Merge pull request #1310 from PicPeak/codex/usage-v3-features

feat(usage): add beta capabilities and gallery/photo totals with explicit consent
This commit is contained in:
Paul Nothaft
2026-09-06 19:33:47 +02:00
committed by GitHub
25 changed files with 5497 additions and 309 deletions
@@ -0,0 +1,42 @@
const express = require('express');
const request = require('supertest');
const mockExport = jest.fn();
const mockMarkUsed = jest.fn().mockResolvedValue();
jest.mock('../../src/database/db', () => ({
db: jest.fn(() => ({ where: jest.fn().mockReturnThis(), first: jest.fn().mockResolvedValue({ id: 1 }) })),
withRetry: fn => fn()
}));
jest.mock('../../src/middleware/auth', () => ({ adminAuth: (req, res, next) => {
if (!req.headers.authorization) return res.sendStatus(401);
req.admin = { id: 1 }; next();
} }));
jest.mock('../../src/middleware/permissions', () => ({ requirePermission: () => (_req, _res, next) => next() }));
jest.mock('../../src/middleware/ownership', () => ({ requireEventOwnership: (_req, _res, next) => next() }));
jest.mock('../../src/services/photoExportService', () => ({ PhotoExportService: jest.fn().mockImplementation(() => ({ exportPhotos: mockExport })) }));
jest.mock('../../src/services/photoAdminMarksService', () => ({}));
jest.mock('../../src/services/feedbackService', () => ({}));
jest.mock('../../src/services/productUsageService', () => ({ markUsed: (...args) => mockMarkUsed(...args) }));
const { productUsage } = require('../../src/middleware/productUsage');
const router = require('../../src/routes/adminPhotoExport');
const app = express();
app.use(express.json());
app.use('/admin', productUsage);
app.use('/admin/photo-export', router);
beforeEach(() => { mockMarkUsed.mockClear(); mockExport.mockReset(); });
test('only a successful authenticated XMP export produces the new bit, without request or exported content', async () => {
mockExport.mockResolvedValue({ type: 'content', contentType: 'text/plain', filename: 'PRIVATE-export.txt', content: 'PRIVATE-content' });
await request(app).post('/admin/photo-export/1/export').set('Authorization', 'test').send({ photo_ids: [9], format: 'xmp' }).expect(200);
expect(mockMarkUsed).toHaveBeenCalledWith(['photo_exports', 'photo_xmp_export'], { legacyFeatures: [], destinationBackup: false });
expect(JSON.stringify(mockMarkUsed.mock.calls)).not.toContain('PRIVATE');
mockMarkUsed.mockClear();
await request(app).post('/admin/photo-export/1/export').set('Authorization', 'test').send({ photo_ids: [9], format: 'csv' }).expect(200);
expect(mockMarkUsed.mock.calls[0][0]).toEqual(['photo_exports']);
});
test('failed, invalid and unauthenticated exports never produce an XMP-use marker', async () => {
mockExport.mockRejectedValue(new Error('Synthetic export failure'));
await request(app).post('/admin/photo-export/1/export').set('Authorization', 'test').send({ photo_ids: [9], format: 'xmp' }).expect(500);
await request(app).post('/admin/photo-export/1/export').set('Authorization', 'test').send({ photo_ids: [9], format: 'unknown' }).expect(400);
await request(app).post('/admin/photo-export/1/export').send({ photo_ids: [9], format: 'xmp' }).expect(401);
expect(mockMarkUsed).not.toHaveBeenCalled();
});
@@ -1,8 +1,8 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const catalog = require('../../src/usage/features.v2.json');
const inventory = require('../../../docs/usage-coverage.v2.json');
const catalog = require('../../src/usage/features.v3.json');
const inventory = require('../../../docs/usage-coverage.v3.json');
const protocol = require('../../src/usage/schema.cjs');
const { RULES_V2, capabilityKeys } = require('../../src/usage/capabilityRules');
const { acceptedUpload, capabilityEvidence } = require('../../src/usage/capabilityEvidence');
@@ -55,11 +55,13 @@ test('all current settings tabs have an explicit scope decision', () => {
test('v1 wire validation is immutable; catalog, UI and translated descriptions agree', () => {
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v1'].properties)).digest('hex'))
.toBe('cc8d0a865d21e36d2b24d23ca6aa8dd8d48000cb17aef83996786f70755bc922');
expect(protocol.FEATURE_KEYS).toHaveLength(73);
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v2'].properties)).digest('hex'))
.toBe('159821cf45c1951016d33a4ed9ca55a0a7ee1b60dd715b803fcfed33e5c8a846');
expect(protocol.FEATURE_KEYS).toHaveLength(86);
expect(protocol.LEGACY_FEATURE_KEYS).toHaveLength(19);
expect(inventory.configuration_only).toHaveLength(17);
expect(inventory.configuration_only).toHaveLength(23);
const frontend = path.resolve(__dirname, '../../../frontend');
expect(JSON.parse(fs.readFileSync(path.join(frontend, 'src/features/settings/usageFeatures.v2.json')))).toEqual(catalog);
expect(JSON.parse(fs.readFileSync(path.join(frontend, 'src/features/settings/usageFeatures.v3.json')))).toEqual(catalog);
for (const lang of ['en', 'de']) {
const translated = JSON.parse(fs.readFileSync(path.join(frontend, `src/i18n/locales/${lang}.json`))).productUsage.catalog;
for (const [key, value] of Object.entries(catalog.features)) {
@@ -70,9 +72,9 @@ test('v1 wire validation is immutable; catalog, UI and translated descriptions a
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',
'video_uploads', 'camera_raw_uploads', 's3_storage', 's3_photo_storage', 's3_backups', 'api_integration'];
'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(protocol.observesUse).filter((key) => !covered.has(key))).toEqual([]);
expect(protocol.FEATURE_KEYS.filter((key) => protocol.observesUse(key)).filter((key) => !covered.has(key))).toEqual([]);
for (const key of covered) expect(protocol.observesUse(key)).toBe(true);
});
@@ -10,7 +10,9 @@
*/
const knex = require('knex');
const { UsageService } = require('../../src/usage/UsageService');
const { FEATURE_KEYS, CATALOG, generateIdentity, makePacket, signPacket, verifyEnvelope } = require('../../src/usage/protocol.cjs');
const { featureKeysFor, CATALOGS, generateIdentity, makePacket, signPacket, verifyEnvelope } = require('../../src/usage/protocol.cjs');
const FEATURE_KEYS = featureKeysFor('usage.v2');
const CATALOG = CATALOGS['usage.v2'];
async function bootDb() {
const db = knex({
@@ -369,7 +371,7 @@ describe('v2 technical configuration and privacy boundaries', () => {
expect(await db('product_usage_markers').pluck('feature')).toHaveLength(56);
expect(JSON.stringify(report)).not.toContain('PRIVATE');
const identity = generateIdentity();
const envelope = signPacket(makePacket(identity, 'report', 1, report), identity, new Date(report.generated_at));
const envelope = signPacket(makePacket(identity, 'report', 1, report, 'usage.v2'), identity, new Date(report.generated_at));
expect(verifyEnvelope(envelope, Date.parse(report.generated_at))).toEqual(envelope.packet);
});
+138
View File
@@ -0,0 +1,138 @@
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const knex = require('knex');
const { UsageService } = require('../../src/usage/UsageService');
const p = require('../../src/usage/protocol.cjs');
const { expandSnapshot } = require('../../src/usage/expandedSnapshot');
const { capabilityEvidence } = require('../../src/usage/capabilityEvidence');
for (const engine of ['sqlite3', ...(process.env.PICPEAK_PG_TEST_URL ? ['pg'] : [])]) {
describe(`usage.v3 on ${engine}`, () => {
let db, admin, schema, client;
const now = Date.parse('2026-09-06T12:00:00.000Z');
const savedEnv = { ...process.env };
beforeEach(async () => {
if (engine === 'pg') {
admin = knex({ client: 'pg', connection: process.env.PICPEAK_PG_TEST_URL });
schema = `usage_v3_${crypto.randomUUID().replaceAll('-', '')}`;
await admin.schema.createSchema(schema);
db = knex({ client: 'pg', connection: process.env.PICPEAK_PG_TEST_URL, searchPath: [schema] });
} else db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
const migrations = path.resolve(__dirname, '../../migrations/core');
for (const file of fs.readdirSync(migrations).filter(name => /^20[1-6]_product_usage/.test(name)).sort())
await require(path.join(migrations, file)).up(db);
await db('product_usage_state').where({ id: 1 }).update({ status: 'active', consent_version: 'usage-consent.v3' });
await db.schema.createTable('app_settings', t => { t.string('setting_key').primary(); t.text('setting_value'); });
await db.schema.createTable('feature_flags', t => { t.string('key').primary(); t.boolean('value'); });
await db.schema.createTable('events', t => {
t.increments('id'); t.text('color_theme'); t.string('external_path'); t.integer('css_template_id');
t.string('default_photo_sort'); t.boolean('is_archived'); t.boolean('is_draft');
});
await db.schema.createTable('photos', t => { t.increments('id'); t.integer('event_id'); t.string('media_type'); t.string('filename'); });
await db.schema.createTable('css_templates', t => { t.increments('id'); t.boolean('is_enabled'); t.text('css_content'); });
await db.schema.createTable('photo_categories', t => { t.increments('id'); t.integer('event_id'); t.boolean('is_folder'); });
await db.schema.createTable('workflows', t => { t.increments('id'); t.boolean('enabled'); });
await db.schema.createTable('transfers', t => {
t.increments('id'); t.string('upload_token'); t.boolean('allow_uploads'); t.timestamp('deleted_at'); t.timestamp('upload_expires_at'); t.timestamp('expires_at');
});
for (const table of ['email_configs', 'mail_accounts'])
await db.schema.createTable(table, t => { t.increments('id'); t.string('smtp_host'); });
await db.schema.createTable('whatsapp_configs', t => { t.increments('id'); t.boolean('enabled'); t.string('phone_number_id'); t.string('access_token'); });
client = new UsageService(db, { now: () => now, secret: 'v3-test-only-secret'.repeat(3) });
});
afterEach(async () => {
process.env = { ...savedEnv };
await db?.destroy();
if (admin) { await admin.schema.dropSchema(schema, true); await admin.destroy(); admin = null; }
});
test('counts retained gallery/photo records, excluding videos, without loading entities', async () => {
await db('events').insert([{ is_draft: true }, { is_archived: true }, { is_archived: false }]);
await db('photos').insert([
{ event_id: 1, media_type: 'image', filename: 'PRIVATE-original.dng' },
{ event_id: 2, media_type: null, filename: 'PRIVATE-archive.jpg' },
{ event_id: 3, media_type: 'video', filename: 'PRIVATE-video.mov' },
]);
const queries = [];
db.on('query', q => queries.push(q.sql));
const report = await client.snapshot();
expect(report.inventory).toEqual({ galleries: 3, photos: 2 });
expect(Object.keys(report.features)).toHaveLength(86);
expect(queries.filter(sql => /from ["`]photos["`]/.test(sql))).toEqual([expect.stringMatching(/select count\(\*\)/)]);
expect(JSON.stringify(report)).not.toContain('PRIVATE');
const identity = p.generateIdentity();
const envelope = p.signPacket(p.makePacket(identity, 'report', 1, report), identity, new Date(now));
expect(p.verifyEnvelope(envelope, now).payload).toEqual(report);
await db('photos').where({ id: 1 }).delete();
await db('events').where({ id: 1 }).delete();
expect((await client.snapshot()).inventory).toEqual({ galleries: 2, photos: 1 });
});
test.each(['usage.v1', 'usage.v2'])('%s consent never collects v3 markers or counts', async (version) => {
await db('product_usage_state').where({ id: 1 }).update({ consent_version: p.CONSENT_VERSIONS[version] });
const queries = [];
db.on('query', q => queries.push(q.sql));
await client.markUsed(['crm_invoice_import', 'photo_admin_marks', 'face_recognition']);
const report = await client.preview();
expect(report).not.toHaveProperty('inventory');
expect(report.features).not.toHaveProperty('crm_invoice_import');
expect(await db('product_usage_markers').pluck('feature')).toEqual(['face_recognition']);
expect(queries.filter(sql => /from ["`]photos["`]/.test(sql))).toEqual([]);
expect(queries.some(sql => /count\(\*\)/.test(sql))).toBe(false);
expect((await client.status()).consent_update_available).toBe(true);
});
test('only allowed successful-capability bits survive and preview is read-only', async () => {
const res = { locals: {} };
capabilityEvidence(res, 'photo_xmp_export', 'photo_replacement', 'photo_admin_marks', 'crm_invoice_import',
'crm_combined_billing', 'crm_monthly_billing_manual', 'crm_document_conversion', '[email protected]', 'gallery_folders');
await client.markUsed(res.locals.productUsageFeatures);
expect(await db('product_usage_markers').pluck('feature')).toHaveLength(7);
const before = await db('product_usage_markers').orderBy('feature');
const report = await client.preview();
expect(report.inventory).toEqual({ galleries: 0, photos: 0 });
expect(report.features.crm_invoice_import.used).toBe(true);
expect(report.features.gallery_folders).not.toHaveProperty('used');
expect(await db('product_usage_markers').orderBy('feature')).toEqual(before);
await db('product_usage_state').where({ id: 1 }).update({ status: 'deletion_pending' });
await client.markUsed(['face_recognition']);
expect(await db('product_usage_markers').where({ feature: 'face_recognition' })).toHaveLength(0);
});
test('configuration reflects effective modules, applicable folders and unexpired upload permission', async () => {
await db('events').insert({ default_photo_sort: 'capture_date_asc' });
await db('photo_categories').insert({ event_id: 1, is_folder: true });
await db('workflows').insert({ enabled: true });
await db('transfers').insert({ upload_token: 'PRIVATE', allow_uploads: true, upload_expires_at: '2026-09-07T00:00:00.000Z' });
await db('app_settings').insert({ setting_key: 'general_use_original_filenames_for_downloads', setting_value: 'true' });
process.env.STORAGE_BACKEND = 's3'; process.env.STORAGE_AUTO_IMPORT = 'true';
process.env.STORAGE_S3_BUCKET = 'PRIVATE'; process.env.STORAGE_S3_ACCESS_KEY = 'PRIVATE'; process.env.STORAGE_S3_SECRET_KEY = 'PRIVATE';
const snap = flags => expandSnapshot(db, { features: p.emptyFeatures('usage.v1'), flags, used: new Set(), now, version: 'usage.v3' });
const enabled = await snap({ transfers: true, workflows: true, quotes: true, bills: true, incomingInvoices: true });
for (const key of ['gallery_folders', 'transfer_upload_links', 'workflow_automation_enabled', 's3_auto_import', 'gallery_capture_date_sort', 'download_original_filenames', 'crm_invoice_import', 'crm_combined_billing'])
expect(enabled[key].configured).toBe(true);
expect(JSON.stringify(enabled)).not.toContain('PRIVATE');
const disabled = await snap({ transfers: false, workflows: false, quotes: false, bills: true });
for (const key of ['transfer_upload_links', 'workflow_automation_enabled', 'crm_invoice_import', 'crm_combined_billing'])
expect(disabled[key].configured).toBe(false);
await db('transfers').update({ upload_expires_at: '2026-09-06T12:00:00.000Z' });
expect((await snap({ transfers: true })).transfer_upload_links.configured).toBe(false);
await db('transfers').update({ upload_expires_at: null, expires_at: '2026-09-07T00:00:00.000Z' });
expect((await snap({ transfers: true })).transfer_upload_links.configured).toBe(true);
await db('transfers').update({ deleted_at: '2026-09-06T11:00:00.000Z' });
expect((await snap({ transfers: true })).transfer_upload_links.configured).toBe(false);
await db('photo_categories').update({ event_id: 999 });
expect((await snap({})).gallery_folders.configured).toBe(false);
});
test('ML recognition is already represented without querying faces or results', async () => {
await db('feature_flags').insert({ key: 'faces', value: true });
await client.markUsed(['face_recognition']);
expect((await client.snapshot()).features.face_recognition).toEqual({ configured: true, used: true });
process.env.PICPEAK_SINGLE_CONTAINER = 'true';
expect((await client.snapshot()).features.face_recognition).toEqual({ configured: false, used: true });
// No faces, people, embeddings or recognition-result tables exist in this fixture.
});
});
}
+3
View File
@@ -23,6 +23,7 @@
*/
const express = require('express');
const { capabilityEvidence } = require('../usage/capabilityEvidence');
const fs = require('fs');
const path = require('path');
const multer = require('multer');
@@ -414,6 +415,7 @@ router.post(
handleAsync(async (req, res) => {
validateRequest(req);
const result = await contractService.convertToEvent(parseInt(req.params.id, 10), req.admin?.id);
if (!result.alreadyConverted) capabilityEvidence(res, 'crm_document_conversion');
return successResponse(res, result, 200,
result.alreadyConverted ? 'Already converted to event' : 'Contract converted to event');
}),
@@ -427,6 +429,7 @@ router.post(
handleAsync(async (req, res) => {
validateRequest(req);
const result = await contractService.convertToInvoiceOnly(parseInt(req.params.id, 10), req.admin?.id);
if (!result.alreadyConverted) capabilityEvidence(res, 'crm_document_conversion');
return successResponse(res, result, 200, 'Invoices created from contract');
}),
);
+3
View File
@@ -7,6 +7,7 @@
*/
const express = require('express');
const { capabilityEvidence } = require('../usage/capabilityEvidence');
const { body, param, query } = require('express-validator');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
@@ -719,6 +720,7 @@ router.post('/:id/bill-combined', [
{ includeHours: req.body.includeHours !== false, includeRebills: req.body.includeRebills !== false },
req.admin.id,
);
if (result.invoiceId) capabilityEvidence(res, 'crm_combined_billing');
successResponse(res, result, 201);
}));
@@ -769,6 +771,7 @@ router.post('/:id/trigger-monthly-bill', [
parseInt(req.params.id, 10),
req.admin.id,
);
if (result.invoiceId) capabilityEvidence(res, 'crm_monthly_billing_manual');
successResponse(res, result, 201);
}));
+2
View File
@@ -19,6 +19,7 @@
*/
const express = require('express');
const { capabilityEvidence } = require('../usage/capabilityEvidence');
const { body, param, query } = require('express-validator');
const multer = require('multer');
const path = require('path');
@@ -563,6 +564,7 @@ router.post(
const inserted = await db('invoices').insert(row).returning('id');
const invoiceId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
capabilityEvidence(res, 'crm_invoice_import');
return successResponse(res, {
invoice: transformInvoice(await db('invoices').where({ id: invoiceId }).first()),
+3
View File
@@ -4,6 +4,7 @@
*/
const express = require('express');
const { capabilityEvidence } = require('../usage/capabilityEvidence');
const router = express.Router();
const { body, query, validationResult } = require('express-validator');
const { db, withRetry } = require('../database/db');
@@ -228,6 +229,8 @@ router.post('/:eventId/export', adminAuth, requirePermission('photos.download'),
admin_id: req.admin.id,
});
if (format === 'xmp') capabilityEvidence(res, 'photo_xmp_export');
if (result.type === 'stream') {
res.setHeader('Content-Type', result.contentType);
res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`);
+3 -1
View File
@@ -7,7 +7,7 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { ensureThumbnail } = require('../services/imageProcessor');
const { isVideoMimeType } = require('../services/videoProcessor');
const { acceptedUpload } = require('../usage/capabilityEvidence');
const { acceptedUpload, capabilityEvidence } = require('../usage/capabilityEvidence');
const { generatePhotoFilename, buildContentDisposition } = require('../utils/filenameSanitizer');
const {
getUseOriginalFilenames,
@@ -358,6 +358,7 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
event,
});
if (result.success) {
capabilityEvidence(res, 'photo_replacement');
acceptedUpload(res, {
video: isVideoMimeType(file.mimetype),
raw: path.extname(file.originalname).toLowerCase() === '.dng',
@@ -861,6 +862,7 @@ router.put('/:eventId/photos/:photoId/mark', adminAuth, requirePermission('photo
parseInt(eventId, 10), photoId, req.admin.id, mark,
);
capabilityEvidence(res, 'photo_admin_marks');
res.json({ success: true, mark: result });
} catch (error) {
// Validation errors from the service are the caller's fault, not a 500.
+4
View File
@@ -27,6 +27,7 @@
*/
const express = require('express');
const { capabilityEvidence } = require('../usage/capabilityEvidence');
const { body, param, query } = require('express-validator');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
@@ -484,6 +485,7 @@ router.post(
validateRequest(req);
const id = parseInt(req.params.id, 10);
const result = await quoteService.convertToEvent(id, req.admin.id);
if (!result.alreadyConverted) capabilityEvidence(res, 'crm_document_conversion');
return successResponse(res, result, 200, result.alreadyConverted ? 'Already converted' : 'Quote converted');
})
);
@@ -499,6 +501,7 @@ router.post(
validateRequest(req);
const id = parseInt(req.params.id, 10);
const result = await quoteService.convertToInvoiceOnly(id, req.admin.id);
if (!result.alreadyConverted) capabilityEvidence(res, 'crm_document_conversion');
return successResponse(res, result, 200, 'Invoices created from quote');
})
);
@@ -519,6 +522,7 @@ router.post(
const contractService = require('../services/contractService');
const id = parseInt(req.params.id, 10);
const result = await contractService.createFromQuote(id, req.admin.id);
if (!result.alreadyConverted) capabilityEvidence(res, 'crm_document_conversion');
return successResponse(res, result, 200,
result.alreadyConverted ? 'Already linked to a contract' : 'Contract drafted from quote');
})
+4 -4
View File
@@ -5,7 +5,7 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { ValidationError } = require('../utils/errors');
const service = require('../services/productUsageService');
const { ProtocolError } = require('../usage/protocol.cjs');
const { ProtocolError, schemaForConsent } = require('../usage/protocol.cjs');
const router = express.Router();
const wrap = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res)).catch((error) => {
@@ -82,9 +82,9 @@ router.post(
router.post(
'/consent',
wrap(async (req, res) => {
if (!req.body || Object.keys(req.body).length !== 1 || req.body.consent_version !== 'usage-consent.v2')
throw new ValidationError('Explicit usage v2 consent is required');
res.json(await service.command('consent', { consent_version: 'usage-consent.v2' }));
if (!req.body || Object.keys(req.body).length !== 1 || !['usage.v2', 'usage.v3'].includes(schemaForConsent(req.body.consent_version)))
throw new ValidationError('Explicit usage consent is required');
res.json(await service.command('consent', { consent_version: req.body.consent_version }));
})
);
router.post(
+17 -12
View File
@@ -20,7 +20,9 @@ const {
LEGACY_FEATURE_KEYS,
CATALOG,
CURRENT_SCHEMA_VERSION,
CURRENT_CONSENT_VERSION,
CONSENT_VERSIONS,
schemaForConsent,
schemaRank,
featureKeysFor,
observesUse,
LAYOUTS
@@ -130,8 +132,7 @@ class UsageService {
}
schemaVersion(state) {
return state?.consent_version === CURRENT_CONSENT_VERSION
? CURRENT_SCHEMA_VERSION : 'usage.v1';
return schemaForConsent(state?.consent_version) || 'usage.v1';
}
constructor(db, options = {}) {
this.db = db;
@@ -343,7 +344,7 @@ class UsageService {
return this.status();
}
async enable(consent) {
if (!['usage-consent.v1', CURRENT_CONSENT_VERSION].includes(consent))
if (!Object.values(CONSENT_VERSIONS).includes(consent))
throw new ValidationError('Explicit usage consent is required');
// Read BEFORE the lease, deliberately. locked() claims the lease and then
// reads the row in a second statement; a /disable completing between
@@ -670,7 +671,7 @@ class UsageService {
await this.db.transaction(async (tx) => {
const upgraded = await tx('product_usage_state')
.where({ id: 1, status: 'active', installation_id: packet.installation_id })
.update({ ...update, consent_version: CURRENT_CONSENT_VERSION });
.update({ ...update, consent_version: packet.payload.consent_version });
if (upgraded) await tx('product_usage_markers').delete();
});
} else {
@@ -816,7 +817,7 @@ class UsageService {
.first();
if (destination && parse(destination.setting_value) === 's3') {
allowed.push('s3_storage');
if (version === CURRENT_SCHEMA_VERSION) allowed.push('s3_backups');
if (version !== 'usage.v1') allowed.push('s3_backups');
}
}
await tx('product_usage_markers')
@@ -841,7 +842,7 @@ class UsageService {
rows.map((r) => [r.setting_key, parse(r.setting_value)])
);
const flagRows = await this.db('feature_flags')
.whereIn('key', version === CURRENT_SCHEMA_VERSION
.whereIn('key', version !== 'usage.v1'
? [...new Set([...Object.values(FLAG_MAP), 'incomingMail', ...Object.values(CATALOG.features).map((f) => f.flag).filter(Boolean)])]
: Object.values(FLAG_MAP))
.select('key', 'value');
@@ -947,15 +948,16 @@ class UsageService {
features.custom_css.used = true;
}
const now = new Date(this.now()).toISOString();
const expanded = version === CURRENT_SCHEMA_VERSION
? await require('./expandedSnapshot').expandSnapshot(this.db, { features, flags, used, now: this.now() })
const expanded = version !== 'usage.v1'
? await require('./expandedSnapshot').expandSnapshot(this.db, { features, flags, used, now: this.now(), version })
: features;
return {
picpeak_version: this.version,
report_date: now.slice(0, 10),
generated_at: now,
features: expanded,
gallery_layouts: [...layouts].sort()
gallery_layouts: [...layouts].sort(),
...(version === 'usage.v3' ? { inventory: await require('./inventorySnapshot').inventorySnapshot(this.db) } : {})
};
}
@@ -974,14 +976,17 @@ class UsageService {
throw new ConflictError('Retry the pending usage operation first');
if (!['feedback', 'vote', 'session', 'consent'].includes(action))
throw new ValidationError('Invalid usage action');
if (action === 'consent' && state.consent_version === CURRENT_CONSENT_VERSION)
const targetVersion = action === 'consent' ? schemaForConsent(payload?.consent_version) : null;
if (action === 'consent' && (!targetVersion || targetVersion === 'usage.v1'))
throw new ValidationError('Explicit usage consent is required');
if (action === 'consent' && schemaRank(targetVersion) <= schemaRank(this.schemaVersion(state)))
throw new ConflictError('Usage consent is already current');
const packet = makePacket(
state,
action,
Number(state.sequence) + 1,
payload,
action === 'consent' ? CURRENT_SCHEMA_VERSION : this.schemaVersion(state)
action === 'consent' ? targetVersion : this.schemaVersion(state)
);
// Validate the complete packet before storing an un-sendable operation.
verifyEnvelope(
+22 -4
View File
@@ -1,5 +1,5 @@
'use strict';
const { CATALOG, emptyFeatures } = require('./schema.cjs');
const { CATALOGS, emptyFeatures } = require('./schema.cjs');
const { formatBoolean } = require('../utils/dbCompat');
const truth = (value) => value === true || value === 1 || value === '1';
@@ -14,8 +14,8 @@ const parse = (value) => {
// Technical configuration only. Never read photos, feedback contents, guest /
// customer / admin profiles, messages, audit logs, delivery logs or counts.
// Presence queries return a literal 1, not even a row's identifying primary key.
async function expandSnapshot(db, { features, flags, used, now }) {
const result = { ...emptyFeatures('usage.v2'), ...features };
async function expandSnapshot(db, { features, flags, used, now, version = 'usage.v2' }) {
const result = { ...emptyFeatures(version), ...features };
const effective = { analytics: true, userManagement: true, ...flags };
if (!effective.quotes) effective.bills = false;
if (effective.bills) effective.accounting = true;
@@ -27,7 +27,7 @@ async function expandSnapshot(db, { features, flags, used, now }) {
effective.clients = ['customerPortal', 'quotes', 'bills', 'contracts', 'projects', 'calendar', 'hoursLogging', 'newsletters']
.some((flag) => effective[flag]);
if (['1', 'true', 'yes'].includes(String(process.env.PICPEAK_SINGLE_CONTAINER || '').toLowerCase())) effective.faces = false;
for (const [key, definition] of Object.entries(CATALOG.features)) {
for (const [key, definition] of Object.entries(CATALOGS[version].features)) {
if (definition.configuration === 'builtin') result[key].configured = true;
if (definition.flag) result[key].configured = Boolean(effective[definition.flag]);
if (definition.used && key !== 'custom_css') result[key].used = used.has(key);
@@ -116,6 +116,24 @@ async function expandSnapshot(db, { features, flags, used, now }) {
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 (version === 'usage.v3') {
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',
['allow_uploads', 'deleted_at', 'upload_expires_at', 'expires_at', 'upload_token'], (query) =>
query.where('allow_uploads', formatBoolean(true)).whereNull('deleted_at').whereNotNull('upload_token').whereNot('upload_token', '')
.where((q) => q.where('upload_expires_at', '>', new Date(now).toISOString())
.orWhere((fallback) => fallback.whereNull('upload_expires_at').where((expiry) =>
expiry.whereNull('expires_at').orWhere('expires_at', '>', new Date(now).toISOString())))));
result.workflow_automation_enabled.configured = Boolean(effective.workflows) && await enabled('workflows', 'enabled');
result.s3_auto_import.configured = result.s3_photo_storage.configured && process.env.STORAGE_AUTO_IMPORT === 'true';
result.crm_combined_billing.configured = Boolean(effective.bills && effective.incomingInvoices);
result.crm_document_conversion.configured = Boolean(effective.quotes || effective.contracts);
result.gallery_capture_date_sort.configured = await exists('events', ['default_photo_sort'], (query) =>
query.whereIn('default_photo_sort', ['capture_date_asc', 'capture_date_desc']));
const originalNames = await db('app_settings').where({ setting_key: 'general_use_original_filenames_for_downloads' }).first('setting_value');
result.download_original_filenames.configured = truth(parse(originalNames?.setting_value));
}
return result;
}
module.exports = { expandSnapshot };
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
'use strict';
const { MAX_INVENTORY_COUNT } = require('./schema.cjs');
// Two installation totals only. No entity rows, IDs, names, file metadata,
// groupings, logs, processing results or visitor actions reach this layer.
async function inventorySnapshot(db) {
return db.transaction(async (tx) => {
const [{ count: galleries }] = await tx('events').count('* as count');
const photosQuery = tx('photos');
if (await tx.schema.hasColumn('photos', 'media_type'))
photosQuery.where((q) => q.whereNull('media_type').orWhereNot('media_type', 'video'));
const [{ count: photos }] = await photosQuery.count('* as count');
const inventory = { galleries: Number(galleries), photos: Number(photos) };
for (const count of Object.values(inventory)) {
if (!Number.isSafeInteger(count) || count < 0 || count > MAX_INVENTORY_COUNT)
throw new Error('Usage inventory total is outside the supported range');
}
return inventory;
}, db.client.config.client === 'pg' ? { isolationLevel: 'repeatable read', readOnly: true } : {});
}
module.exports = { inventorySnapshot };
+22 -11
View File
@@ -1,10 +1,17 @@
"use strict";
// Vendored byte-identical in PicPeak. v1 stays immutable; a larger allowlist
// has a new wire version and requires explicit, signed v2 consent.
const CATALOG = require("./features.v2.json");
const CURRENT_SCHEMA_VERSION = "usage.v2";
const CURRENT_CONSENT_VERSION = "usage-consent.v2";
// Vendored byte-identical in PicPeak. Existing wire versions stay immutable;
// every expansion requires explicit consent to its own version.
const CATALOG = require("./features.v3.json");
const CATALOGS = { "usage.v2": require("./features.v2.json"), "usage.v3": CATALOG };
const CONSENT_VERSIONS = { "usage.v1": "usage-consent.v1", "usage.v2": "usage-consent.v2", "usage.v3": "usage-consent.v3" };
const CURRENT_SCHEMA_VERSION = "usage.v3";
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);
const INVENTORY_KEYS = ["galleries", "photos"];
// At the collector's 100,000-reporter limit, sums remain safe JS integers.
const MAX_INVENTORY_COUNT = 1000000000;
const LEGACY_FEATURE_KEYS = [
"crm", "crm_quotes", "crm_invoices", "crm_contracts", "crm_projects",
"crm_calendar", "crm_hours", "customer_portal", "accounting", "workflows",
@@ -22,9 +29,9 @@ const timestamp = { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2
const text = (maxLength, minLength = 1) => ({ type: "string", minLength, maxLength });
const boolean = { type: "boolean" };
const featureKeysFor = (version = CURRENT_SCHEMA_VERSION) =>
version === "usage.v1" ? LEGACY_FEATURE_KEYS : version === "usage.v2" ? FEATURE_KEYS : [];
version === "usage.v1" ? LEGACY_FEATURE_KEYS : Object.keys(CATALOGS[version]?.features || {});
const observesUse = (key, version = CURRENT_SCHEMA_VERSION) =>
version === "usage.v1" || CATALOG.features[key]?.measurement === "configuration_and_use";
version === "usage.v1" || CATALOGS[version]?.features[key]?.measurement === "configuration_and_use";
const emptyFeatures = (version = CURRENT_SCHEMA_VERSION) => Object.fromEntries(
featureKeysFor(version).map(key => [key, {
configured: false, ...(observesUse(key, version) ? { used: false } : {})
@@ -38,6 +45,9 @@ 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 } },
...(version === "usage.v3" ? { inventory: object(Object.fromEntries(INVENTORY_KEYS.map(key => [key,
{ type: "integer", minimum: 0, maximum: MAX_INVENTORY_COUNT }
]))) } : {}),
});
const feedback = object({
feedback_id: uuid, kind: { enum: ["feedback", "feature_request", "testimonial"] },
@@ -45,14 +55,14 @@ const feedback = object({
allow_public: boolean, allow_marketing: boolean,
});
const makePayloads = (version) => ({
register: object({ consent_version: { const: version === "usage.v1" ? "usage-consent.v1" : CURRENT_CONSENT_VERSION } }),
register: object({ consent_version: { const: CONSENT_VERSIONS[version] } }),
report: report(version),
delete: object({}), feedback,
vote: object({ feedback_id: uuid, voted: boolean }),
session: object({}),
...(version === "usage.v2" ? { consent: object({ consent_version: { const: CURRENT_CONSENT_VERSION } }) } : {}),
...(version !== "usage.v1" ? { consent: object({ consent_version: { const: CONSENT_VERSIONS[version] } }) } : {}),
});
const payloadsByVersion = Object.fromEntries(["usage.v1", "usage.v2"].map(version => [version, makePayloads(version)]));
const payloadsByVersion = Object.fromEntries(Object.keys(CONSENT_VERSIONS).map(version => [version, makePayloads(version)]));
const envelopeSchemas = Object.fromEntries(Object.entries(payloadsByVersion).map(([version, actions]) => [version, {
$schema: "http://json-schema.org/draft-07/schema#",
$id: `https://usage.picpeak.app/schema/${version}.json`,
@@ -73,7 +83,8 @@ const envelopeSchemas = Object.fromEntries(Object.entries(payloadsByVersion).map
const envelopeSchema = envelopeSchemas[CURRENT_SCHEMA_VERSION];
const payloads = payloadsByVersion[CURRENT_SCHEMA_VERSION];
module.exports = {
FEATURE_KEYS, LEGACY_FEATURE_KEYS, LAYOUTS, CATALOG, CURRENT_SCHEMA_VERSION,
FEATURE_KEYS, LEGACY_FEATURE_KEYS, LAYOUTS, CATALOG, CATALOGS, CONSENT_VERSIONS,
schemaForConsent, schemaRank, INVENTORY_KEYS, MAX_INVENTORY_COUNT, CURRENT_SCHEMA_VERSION,
CURRENT_CONSENT_VERSION, featureKeysFor, observesUse, emptyFeatures,
envelopeSchema, envelopeSchemas, payloads, payloadsByVersion,
};
+131 -240
View File
@@ -1,65 +1,65 @@
# Product-usage coverage: usage.v2
# Product-usage coverage: usage.v3
Reviewed PicPeak baseline: a5ff9264 (3.124.1-beta.0). Review scope:
all 81 current backend route families,
all 26 feature flags, admin routes/settings
and runtime/public boundaries. This is capability coverage, not instrumentation
of every UI field. Source of truth: `usage-coverage.v2.json`; the PicPeak inventory
test fails on an added/removed route family, literal route declaration or feature flag.
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.v3.json`.
The prior `usage-coverage.v2.json` and v2 wire catalog remain available unchanged.
## Privacy decision
## Data scope
The purpose remains feature prioritization, fixes and maintenance from #1110.
Only **installation-wide booleans** and the existing fixed gallery-layout enums.
No user/customer/guest identity, business values, documents, photos, messages,
IP/domain/URL, per-action time, event IDs, frequencies or user-level history.
A stable installation fingerprint remains pseudonymous (not anonymous); rare
combinations can be distinctive. Participant-only dataset access and opt-out
deletion therefore remain mandatory.
There are 86 capabilities: the original 19 in v1, 54 added in v2, and 13 added in
v3. 63 have configured/used booleans; 23 are configuration-only and omit `used`.
ML face recognition was already included: only effective availability and a
successful authenticated admin capability operation, never biometric results.
Of 73 capabilities, 19 were already present in v1 and 54 are new in v2:
56 configured/used pairs and 17 **configuration-only** signals. Configuration-only
signals omit `used` entirely; this is deliberately not a false “unused” value.
Guest-facing capabilities are measured from technical configuration only, never
from actual likes, comments, uploads, downloads, newsletter interactions or views.
v3 additionally reports 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.
`configured` = current technical availability/configuration. Built-in means
available, not evidence of use. `used` = one monotonic yes/no bit since consent
to the current schema (v1: since joining; v2: since joining or explicit upgrade).
It means successful allowlisted **admin capability operation**, not necessarily
completion of a queued job. It is not an event log. Repeated operations do not
store anything more. The marker table contains only constant capability keys.
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.
`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.
## Consent and version transition
- Existing participation and migration default to `usage-consent.v1`. A client
update alone does not collect any of the 54 new local markers or report fields.
- The settings page presents the full local EN/DE catalog before v2 opt-in or
upgrade; an unchecked checkbox requires an explicit decision.
- A signed `usage.v2 / consent` command updates the same installation, after all
prior queued operations have finished. It preserves its raw history and lookup
identity. No downgrade or automatic expansion occurs.
- Only a matching collector receipt upgrades local consent and atomically resets
local usage markers. Until confirmation, collection remains v1, even if a
receipt is lost. A pending consent is durable/retryable; opt-out always wins.
- No second report on the same UTC day. The first expanded report may be on the
next day of admin activity. API integration use alone does not trigger a report.
- Collector must be deployed first. Old collectors reject the new schema;
the client shows delivery pending instead of assuming consent or sending v2.
- v1 validation remains unchanged and old envelopes remain exportable exactly as
first received. Raw history contains the original schema version on each packet.
- Aggregate projections include their schema version. Absent v2 fields in v1
projections are **unknown**, never false. `reported` and `used_reported`
supply each metric's real denominator. Configuration-only use has denominator
zero and is displayed as “Not collected”, not 0% adoption.
- v1 and v2 keep their exact wire schemas and feature allowlists. Updating code
does not grant consent or collect v3 markers/inventory for an older participant.
- The local EN/DE dialog lists all 86 capabilities and both inventory definitions.
An unchecked checkbox requires explicit consent to `usage-consent.v3`.
- A signed v3 consent command upgrades v1 or v2 without changing identity/history.
Prior queued operations finish first. Only a matching accepted receipt changes
local consent and atomically clears previous local usage markers. Lost receipts
remain retryable; a withdrawal always wins over a late upgrade receipt.
- Consent cannot downgrade. Older clients may continue sending their already
consented older report schema. Old reports retain their original raw envelopes.
- Collector first, client second. Older collectors reject v3 rather than accepting
undisclosed fields. No second report on the same UTC day; the first v3 report
may be on the next day of admin activity.
- Summary/history count the latest report per reporter (per period for history).
Missing older fields are unknown. Feature denominators use only supplied fields.
Inventory has `{ total, reported }` per key; zero with `reported=0` means unknown,
while zero with a positive denominator is a reported empty inventory. Never sum
every daily report as if it were a different installation. Opt-out removes
current and historical contributions, including these totals.
## Every reported capability
The static bilingual definitions below are also shipped as
`features.v2.json` in both applications, exposed at
`/schema/features.v2.json`, and displayed in both usage interfaces.
“Since” is the schema in which a key was introduced; definitions here describe v2.
Legacy v1 semantics remain documented separately in the protocol reference.
Definitions are shipped byte-identically in both applications as
`features.v3.json`, served at `/schema/features.v3.json`, and shown in EN and DE.
| Key (EN / DE) | Since | Configured | Used |
| --- | --- | --- | --- |
@@ -74,7 +74,7 @@ Legacy v1 semantics remain documented separately in the protocol reference.
| `accounting` — Accounting / Buchhaltung | 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 / 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 / Newsletter | 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`Face recognition / Gesichtserkennung | 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. |
| `face_recognition`ML face recognition / ML-Gesichtserkennung | 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 / Eigenes 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 / 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 / SMTP-Versand | 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. |
@@ -97,7 +97,7 @@ Legacy v1 semantics remain documented separately in the protocol reference.
| `camera_raw_uploads` — Admin camera RAW uploads / Admin-Kamera-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 / Nachrichtenwerkzeuge | 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 / IMAP-Empfang | 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 / Automatische Ereigniserinnerungen | usage.v2 | The reminderEmails capability switch is effectively enabled; only a boolean. | **Not collected. Configuration only.** |
| `reminder_emails` — Automatic event reminders / Automatische Ereigniserinnerungen | usage.v2 | The reminderEmails capability switch is effectively enabled; only a boolean. | Not collected: configuration only. |
| `email_templates` — Email templates / E-Mail-Vorlagen | 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 / E-Mail-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 / Eingangsrechnungen | 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. |
@@ -107,7 +107,7 @@ Legacy v1 semantics remain documented separately in the protocol reference.
| `crm_installments` — Installment-plan tools / Ratenplan-Werkzeuge | 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 / Dokumentvorlagen und Bausteine | 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 / CMS-Seiten | 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 / Öffentliche Startseite | usage.v2 | The public landing-page setting is enabled; no page HTML, texts, domains or visitors. | **Not collected. Configuration only.** |
| `public_site` — Public landing page / Öffentliche Startseite | usage.v2 | The public landing-page setting is enabled; no page HTML, texts, domains or visitors. | Not collected: configuration only. |
| `branding` — Branding settings / Branding-Einstellungen | 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 / SEO-Einstellungen | 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 / Admin- und Rollenverwaltung | 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. |
@@ -121,88 +121,69 @@ Legacy v1 semantics remain documented separately in the protocol reference.
| `analytics_dashboard` — Existing analytics module / Bestehendes Analytics-Modul | 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 / 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 / Gastverwaltungswerkzeuge | 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 / Galerie-Likes aktiviert | 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 / Galerie-Sternebewertungen aktiviert | 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 / Galerie-Kommentare aktiviert | 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 / Galerie-Favoriten aktiviert | 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 / Galerie-Reaktionen aktiviert | 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 / Galerie-Farblabels aktiviert | 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 / Gastidentitäten aktiviert | 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 / Gast-Uploads aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** |
| `gallery_downloads` — Gallery downloads allowed / Galerie-Downloads erlaubt | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** |
| `download_resolution_picker` — Download resolution picker enabled / Download-Auflösungswahl aktiviert | 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 / Client-Zugang aktiviert | 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 / Wasserzeichen aktiviert | 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 / Bildschutz aktiviert | 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 / Galerie-Enthüllung aktiviert | 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 / Galerieablauf konfiguriert | usage.v2 | At least one gallery has an expiry configured; no dates, gallery IDs or counts. | **Not collected. Configuration only.** |
| `gallery_feedback_likes` — Gallery likes enabled / Galerie-Likes aktiviert | 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 / Galerie-Sternebewertungen aktiviert | 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 / Galerie-Kommentare aktiviert | 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 / Galerie-Favoriten aktiviert | 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 / Galerie-Reaktionen aktiviert | 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 / Galerie-Farblabels aktiviert | 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 / Gastidentitäten aktiviert | 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 / Gast-Uploads aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. |
| `gallery_downloads` — Gallery downloads allowed / Galerie-Downloads erlaubt | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. |
| `download_resolution_picker` — Download resolution picker enabled / Download-Auflösungswahl aktiviert | 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 / Client-Zugang aktiviert | 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 / Wasserzeichen aktiviert | 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 / Bildschutz aktiviert | 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 / Galerie-Enthüllung aktiviert | 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 / Galerieablauf konfiguriert | 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 / 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 / Fotoersetzung | 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 / Fotografenmarkierungen | 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 / Galerieordner eingerichtet | 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 / PicTransfer-Uploadlinks aktiviert | 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 / Workflow-Automation aktiviert | 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 / Rechnungsimport | 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 / Kombinierte Abrechnung | 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 / Manuelle Monatsabrechnung | 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 / Dokumentumwandlung | 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 / Sortierung nach Aufnahmezeit eingerichtet | 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 / Originaldateinamen für Downloads aktiviert | usage.v3 | The original-download-filenames switch is enabled; no filenames or downloads are read or sent. | Not collected: configuration only. |
Gallery layouts (unchanged): `grid`, `masonry`, `carousel`, `timeline`,
`mosaic`, `gallery-premium`, `gallery-story`, `other`. Only set membership,
not how many galleries use a layout. Unknown names are normalized to other.
## Inventory totals
## Exact observation sources
- `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.
PicPeak `backend/src/usage/capabilityRules.js` is the fixed method/path
allowlist; request paths, query/body/response values never leave the middleware.
Only the resulting constant keys reach `markUsed`, with active schema consent,
authenticated admin and 2xx response checks. Status/health polls are excluded.
## Route-family decisions
Additional trusted success evidence in `capabilityEvidence.js`:
accepted admin file storage (video / DNG / S3 booleans only, not chunk
initialization), successful manual SMTP or email-webhook send/test, non-skipped
manual IMAP poll/connection test, successful manual WhatsApp test, and successful
S3 backup roundtrip test. SMTP vs webhook uses the actual selected transport
(including per-account SMTP overrides), not just environment presence.
Webhook test/replay means **accepted enqueue**, never remote delivery tracking.
`UsageService.snapshot` and `expandedSnapshot.js` inspect allowlisted settings,
effective flags and technical configuration existence. They do not query
customer/guest profiles, financial records, photos/EXIF, message/feedback bodies,
audit/security logs or delivery histories. Inherited technical defaults count as
configuration; disabled feature dependencies cannot be inferred as active.
Optional-module tables/columns are guarded. CSS/layout inspection maps locally
to presence/enums; no free-form CSS/theme content is sent.
OAuth is marked only by the successful **admin** OIDC callback, without claims
or provider metadata. S3 backup use is inferred only for backup operations
writing to the configured destination; a local DB/portable export is not S3 use.
Background jobs and public/customer/visitor handlers never record product use.
## Complete route-family decision matrix
Paths below are relative to PicPeak `backend/src/routes/`. “Partial” means only
the disclosed allowlist/evidence, not every endpoint in that file. All literal
route declarations are captured in the companion inventory, with excluded
methods remaining unobserved.
| Source | Decision / signals | Reason / limits |
| Family | Coverage | Boundary |
| --- | --- | --- |
| `acceptInvite.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
| `admin.js` | composition | Router composition / helpers; decisions are recorded for each mounted family. |
| `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 | Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose. |
| `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 | 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. |
| `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` | Admin category CRUD; no names, descriptions, colors or ordering values. |
| `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.v3.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` | Admin contract/block operations only; no legal text, signatures, signing parties or customer signing events. |
| `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.v3.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` | Successful admin CRM/hour-entry/invitation operations only. No customer/account names, IDs, rates, billed hours, payment state or portal behavior. |
| `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.v3.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 | Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose. |
| `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`, `gallery_client_access`, `gallery_watermarks`, `gallery_reveal`, `gallery_expiration`, `gallery_sharing`, `custom_css` | 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. |
| `adminEvents/crud.js` | partial: `galleries`, `gallery_guest_uploads`, `gallery_downloads`, `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.v3.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 | Router composition / helpers; decisions are recorded for each mounted family. |
| `adminEvents/index.js` | composition | Router composition / helpers; decisions are recorded for each mounted family. |
| `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. |
@@ -214,148 +195,58 @@ methods remaining unobserved.
| `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` | Admin invoice operations only; no amounts, VAT/customer/payment values or payment-check responses. |
| `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.v3.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 | Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose. |
| `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` | Admin export initiation only; export filters, selected files, sizes and contents excluded. |
| `adminPhotos.js` | partial: `photo_management`, `photo_exports`, `photo_processing`, `video_uploads`, `camera_raw_uploads`, `s3_storage`, `s3_photo_storage` | 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. |
| `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.v3.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.v3.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` | Admin quote/preset operations only; no quote content, prices, customer acceptance or signatures. |
| `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.v3.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` | 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. |
| `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.v3.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 | Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose. |
| `adminSystemHealth.js` | excluded | Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose. |
| `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` | Admin transfer CRUD/files/link management/download only. Public recipients, received-file data, upload and download statistics excluded. |
| `adminUsage.js` | excluded | Consent, inspection, export, feedback, voting and deletion are explicit protocol operations; not product-use signals. Activity only triggers a due fixed report. |
| `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.v3.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 | 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. |
| `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` | Admin workflow authoring/approval/test initiation only. Runtime triggers, payloads, execution frequency/results and public approvals excluded. |
| `analyticsTrackerProxy.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
| `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.v3.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 | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
| `customerAuth.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
| `gallery.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
| `galleryFeedback.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
| `galleryGuests.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
| `protectedImages.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
| `publicCMS.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
| `publicContracts.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
| `publicFonts.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
| `publicNewsletter.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
| `publicPaymentCheck.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
| `publicQuotes.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
| `publicSettings.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
| `publicTransfer.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
| `publicTransferUpload.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
| `publicWorkflowApprovals.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
| `secureImages.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
| `setup.js` | excluded | Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose. |
| `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. |
## Every admin settings tab
These 29 current SettingsPage tabs are also inventoried and tested against
the frontend TabType. Page navigation itself is not tracked.
| Tab | Capability / exclusion |
| --- | --- |
| `usage` | Explicit consent/report inspection/feedback is not itself adoption telemetry. |
| `features` | Only the allowlisted effective feature booleans; no settings visit/save marker. |
| `general` | `video_uploads`, `camera_raw_uploads`, `public_site`, `custom_css`. General technical upload/public-site/CSS configuration only; no title, URLs, limits, times, HTML or identity. |
| `events` | `galleries`, `gallery_guest_uploads`, `gallery_downloads`, `gallery_client_access`, `gallery_watermarks`, `gallery_image_protection`, `gallery_reveal`, `gallery_expiration`. Gallery operations and disclosed configuration only; no event/customer values or visitor use. |
| `eventTypes` | `event_types`. General admin event-type capability; no names or preset contents. |
| `branding` | `branding`, `gallery_watermarks`. Branding operation and watermark configuration only; no branding text, logos or colors. |
| `categories` | `gallery_categories`. Category management capability only; no names/order/category membership. |
| `thumbnails` | `photo_processing`. Admin processing settings/regeneration initiation only; no image data or progress. |
| `downloads` | `download_resolution_picker`. Configuration boolean only; no actual download/selection behavior or resolution values. |
| `styling` | `custom_css`. Presence/application only plus controlled gallery-layout enums, never CSS/theme values. |
| `cms` | `cms`, `public_site`. Admin page editing capability/public-site enabled only; no HTML, slugs or traffic. |
| `email` | `smtp`, `incoming_mail`, `messaging`, `email_templates`, `email_webhook`. Configuration and documented manual admin capability operations only; messages, recipients, automatic activity and mailbox values excluded. |
| `moderation` | `feedback_moderation`. Admin moderation/word-filter capability, never feedback content or visitor behavior. |
| `security` | Excluded password/MFA/session/rate-limit/security profiles and operations. |
| `sso` | `oauth`. Enabled/config-present and successful admin callback only; no claims/provider details. |
| `imageSecurity` | `gallery_image_protection`. Configuration presence only; no blocked-IP/security analytics or monitoring history. |
| `seo` | `seo_customization`. Admin SEO configuration operation only; no meta tags, URLs, robots or verification tokens. |
| `apiTokens` | `api_integration`. Valid credential presence and one successful scoped API capability bit; no tokens/scopes/owner metadata. |
| `webhooks` | `webhooks`. Active configuration and manual test/replay enqueue only; no delivery data. |
| `status` | Excluded operational health, diagnostics, resource data, update and storage polling. |
| `analytics` | `analytics_dashboard`. Analytics capability and admin aggregate-view use only; no embedded analytics results/tracker IDs or visitors. |
| `backup` | `backup`, `database_backup`, `portable_backup`, `restore`, `s3_backups`. Schedule presence/manual capability initiation only, no histories, sizes, paths or files. |
| `businessProfile` | Excluded business identity, bank accounts and addresses. |
| `crm` | `crm`, `crm_quotes`, `crm_invoices`, `crm_projects`, `crm_hours`, `customer_portal`, `crm_installments`. Only coarse module capabilities; no policies/amounts/customer/payment values. |
| `contracts` | `crm_contracts`, `document_templates`. Admin contract/template capability only; no legal text or signatures. |
| `reminderTemplates` | `reminder_emails`, `email_templates`. Reminder flag configuration and admin template editing only; no automatic reminder sends/recipients/content. |
| `accounting` | `accounting`, `accounting_incoming_invoices`, `accounting_expenses`, `accounting_tax_report`, `accounting_ledger`. Only module capabilities, no tax codes, rates, balances or business identity. |
| `whatsapp` | `whatsapp`. Configured integration plus manual test only; no phone numbers, tokens or automatic delivery. |
| `slideshow` | `slideshow`. Admin setup capability only; no kiosk viewers, slide progress or photos. |
## Every feature flag (configuration decisions)
| Flag | Signal / exclusion |
| --- | --- |
| `accounting` | `accounting`, `accounting_ledger`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
| `analytics` | `analytics_dashboard`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
| `bills` | `crm_invoices`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
| `calendar` | `crm_calendar`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
| `calendarBooking` | Excluded: disabled roadmap placeholder, not an implemented booking capability. |
| `clients` | `crm`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
| `contracts` | `crm_contracts`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
| `crmDevelopment` | Excluded: internal development/test helpers, not product adoption. |
| `customerPortal` | `customer_portal`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
| `expenses` | `accounting_expenses`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
| `faces` | `face_recognition`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
| `galleries` | `galleries`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
| `hoursLogging` | `crm_hours`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
| `incomingInvoices` | `accounting_incoming_invoices`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
| `incomingMail` | `incoming_mail`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
| `messaging` | `messaging`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
| `newsletters` | `newsletters`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
| `projects` | `crm_projects`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
| `quotes` | `crm_quotes`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
| `reminderEmails` | `reminder_emails`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
| `slideshow` | `slideshow`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
| `taxReport` | `accounting_tax_report`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
| `transfers` | `transfers`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
| `userManagement` | `admin_management`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
| `whatsapp` | `whatsapp`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
| `workflows` | `workflows`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
## Deliberately excluded runtime and future features
## 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, amounts and document contents
- Business/customer/user identities, geography, financial amounts and document contents; only explicit v3 inventory totals are permitted.
- Disabled calendarBooking and internal crmDevelopment; hosted future product #1111
- Image fragmentation: removed from current PicPeak, not a live capability
The Messages and Reminder Emails implementations were reviewed as real features,
despite stale placeholder comments. Reminder Emails remains configuration-only.
Calendar booking is still a disabled placeholder and is not presented as a
working capability. This review does not approve any public visitor tracking,
even if another optional analytics integration is configured.
All exclusion decisions still permit the existing product functions themselves.
They restrict this usage program; they do not disable galleries, email or jobs.
Adding capabilities requires a documented scope review, updated inventory,
closed schema, both UI disclosures/docs and tests; a wider collection scope
requires renewed explicit consent, not a silent catalog expansion.
## Verification obligations
Required checks include unchanged v1 validation, closed v2 fields, all 73
configuration signals and privacy canaries, all route/flag decisions, no
configuration-only use, disabled/pending/upgrade/opt-out boundaries, mixed-version
denominators, byte-identical protocol/catalogs, EN/DE UI catalog consistency,
raw export and deletion, SQLite/PostgreSQL and paired local Docker/browser tests.
Test outcomes are recorded separately; this document is not a claim of legal
certification or proof that modified self-hosted clients report truthfully.
+17
View File
@@ -1,5 +1,22 @@
# Optional product usage and feedback (#1110)
Current scope: **usage.v3**. The expanded catalog contains 86 capabilities
(including ML face recognition and invoice import) and exactly two inventory
totals: stored gallery records and non-video photo records, including drafts
and retained archive records. No content, identifiers, per-gallery breakdowns,
biometric results, financial values or visitor actions.
Existing v1/v2 participants retain their previous scope until explicit signed
v3 consent is confirmed. New count queries and markers do not run before that
confirmation. Collector must be deployed first. v1/v2 wire schemas and raw
history remain unchanged. See [current coverage](FEATURE_COVERAGE.md) for all
definitions and [v3 inventory](usage-coverage.v3.json) for code boundaries.
The sections below also document the historical v1/v2 implementation. Any
statements excluding all gallery/photo counts describe those earlier versions;
v3 adds only the two installation totals above.
Tracking is disabled by default. After updating, settings editors see a
dismissible invitation in the admin shell. Only explicit consent in Settings →
Product usage & feedback registers an installation. Public galleries never
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.v2.json';
import catalog from './usageFeatures.v3.json';
/** Local, static disclosure: opening it never contacts the collector. */
export function UsageCatalog() {
@@ -12,6 +12,13 @@ export function UsageCatalog() {
<details className="rounded border border-theme p-3">
<summary className="cursor-pointer font-semibold">{t('productUsage.catalogTitle')}</summary>
<p className="my-3 text-sm">{t('productUsage.catalogExplanation')}</p>
<section className="my-3 space-y-2 text-sm">
<h4 className="font-semibold">{t('productUsage.inventoryTitle')}</h4>
<p>{t('productUsage.inventoryExplanation')}</p>
{Object.keys(catalog.inventory).map((key) => <p key={key}>
<strong>{t(`productUsage.inventory.${key}.name`)}</strong>: {t(`productUsage.inventory.${key}.description`)}
</p>)}
</section>
<label className="block text-sm">
{t('productUsage.catalogSearch')}
<input type="search" value={search} onChange={(e) => setSearch(e.target.value)}
@@ -72,7 +72,7 @@ afterEach(cleanup);
it('shows every v2 signal locally before participation, without collector calls', async () => {
mount();
await screen.findByText('productUsage.catalogTitle');
expect(screen.getAllByRole('heading', { level: 4, hidden: true })).toHaveLength(73);
expect(screen.getAllByRole('heading', { level: 4, hidden: true })).toHaveLength(87);
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
+83 -12
View File
@@ -1,19 +1,19 @@
{
"productUsage": {
"fields": "usage.v2-Berichte enthalten einen Installationsfingerabdruck, PicPeak-Version, UTC-Berichtstag und Erstellungszeit, Schema-/Signaturmetadaten, feste Galerie-Layouts und 73 fest definierte Funktionssignale: 56 Konfiguriert/Genutzt-Paare und 17 reine Konfigurationswerte. Der vollständige Katalog unten erklärt jedes Feld. Keine Aktionsanzahlen und keine Besucherbeobachtung.",
"catalogTitle": "Vollständiger Katalog: alle 73 Funktionssignale (usage.v2)",
"catalogExplanation": "Konfiguriert beschreibt technische Verfügbarkeit oder Konfiguration, nicht Beliebtheit. Integrierte Funktionen sind immer verfügbar. Genutzt ist ein einziges installationsweites Ja/Nein seit Zustimmung zum aktuellen Schema (v1: seit Teilnahme; v2: seit Teilnahme oder ausdrücklicher Erweiterung). Angenommene Aufträge gelten als gestartet, nicht zwingend abgeschlossen. Reine Konfigurationssignale haben kein Genutzt-Feld. Die Marker speichern weder Person noch Ereignis-ID, Aktionszeit oder Häufigkeit.",
"fields": "usage.v3-Berichte enthalten einen Installationsfingerabdruck, PicPeak-Version, UTC-Berichtsdatum und Erstellungszeit, Schema-/Signaturmetadaten, feste Galerie-Layouts, 86 Funktionssignale (63 Konfiguriert/Genutzt-Paare und 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 Aktionsanzahlen oder Besucherbeobachtung.",
"catalogTitle": "Vollständiger Katalog: 86 Funktionssignale und 2 Bestandszahlen (usage.v3)",
"catalogExplanation": "Konfiguriert beschreibt die aktuelle technische Verfügbarkeit oder Einrichtung. Integriert bedeutet verfügbar, nicht genutzt. Genutzt ist ein installationsweites Ja/Nein seit Zustimmung zum Berichtsschema; angenommene Aufträge gelten als gestartet, nicht zwingend abgeschlossen. Reine Konfigurationssignale enthalten kein Genutzt-Feld. Der Bestand enthält nur aktuelle Gesamtzahlen der Galerie-/Fotoeinträge, ohne Aufschlüsselung nach Galerien. Nutzungsmarker speichern keine Personen, Objektkennungen, Aktionszeiten oder Häufigkeiten.",
"catalogSearch": "Funktionsname oder Schlüssel suchen",
"catalogEmpty": "Keine passenden Funktionen.",
"configuredLabel": "Konfiguriert",
"usedLabel": "Genutzt",
"configurationOnly": "Nur Konfiguration — tatsächliche Nutzung wird nicht erfasst.",
"versionDisclosure": "Diese Zustimmung gilt für usage.v2 / usage-consent.v2. Bestehende v1-Teilnehmer teilen weiterhin nur die bisherigen 19 Funktionen, bis sie der Erweiterung ausdrücklich zustimmen. Vertrauliche Identität und Rohberichtshistorie bleiben erhalten; der lokale Beobachtungszeitraum der Nutzungsmarker beginnt mit der Collector-Bestätigung neu. Pro UTC-Tag wird höchstens ein Bericht angenommen; der erste v2-Bericht kann daher am nächsten aktiven Tag erfolgen. Vor Bestätigung werden keine neuen lokalen Marker erfasst.",
"versionDisclosure": "Diese Zustimmung gilt für usage.v3 / usage-consent.v3. Bestehende v1- und v2-Teilnahmen behalten ihre bisherigen 19 bzw. 73 Fähigkeiten ohne Bestandszahlen bis zur ausdrücklichen Erweiterung. Identität und Rohhistorie bleiben erhalten; lokale Nutzungsmarker beginnen erst nach Bestätigung durch den Collector neu. Höchstens ein Bericht pro UTC-Tag wird angenommen, der erste v3-Bericht kann daher am nächsten aktiven Tag folgen. Neue Marker und Bestandszahlen werden vor Bestätigung nicht erfasst.",
"currentSchema": "Aktuelles Berichtsschema: {{schema}}",
"reviewUpgrade": "Erweiterten Umfang von usage.v2 prüfen",
"upgrade": "usage.v2 ausdrücklich zustimmen",
"upgradeExplanation": "Ihre bestehende usage.v1-Teilnahme bleibt unverändert. Prüfen Sie den vollständigen erweiterten Katalog, bevor Sie über das Upgrade entscheiden. Eine Ablehnung beendet Ihre bisherige Teilnahme nicht.",
"upgradePending": "Die signierte Erweiterung der Zustimmung wartet auf Bestätigung. Es wird nur der bisherige v1-Umfang erfasst. Versuchen Sie es erneut, sobald der Collector erreichbar ist, oder deaktivieren Sie die Teilnahme zum Stoppen und Löschen.",
"reviewUpgrade": "Erweiterten Umfang von usage.v3 prüfen",
"upgrade": "usage.v3 ausdrücklich zustimmen",
"upgradeExplanation": "Deine bestehende Teilnahme behält ihren bisherigen Umfang. Prüfe den erweiterten Katalog und die beiden Bestandszahlen, bevor du dich entscheidest. Ablehnen beendet die Teilnahme nicht.",
"upgradePending": "Die signierte Erweiterung wartet auf Bestätigung. Es wird nur der bisher bestätigte Umfang erfasst. Wiederhole den Versuch, wenn der Collector erreichbar ist, oder deaktiviere die Teilnahme zum Stoppen und Löschen.",
"catalog": {
"crm": {
"name": "Kundenverwaltung",
@@ -71,7 +71,7 @@
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
},
"face_recognition": {
"name": "Gesichtserkennung",
"name": "ML-Gesichtserkennung",
"configured": "Der Funktionsschalter faces ist effektiv aktiviert; nur ein Wahrheitswert.",
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
},
@@ -362,6 +362,65 @@
"gallery_expiration": {
"name": "Galerieablauf konfiguriert",
"configured": "Mindestens eine Galerie hat einen Ablauf konfiguriert; keine Daten, Galeriekennungen oder Anzahlen."
},
"photo_xmp_export": {
"name": "XMP-Export",
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
"used": "Ein Admin hat erfolgreich einen XMP-Export erstellt; keine Sidecars, Dateinamen, Bewertungen, Auswahlen oder Anzahlen."
},
"photo_replacement": {
"name": "Fotoersetzung",
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
"used": "Ein Admin-Upload hat tatsächlich erfolgreich ein Foto ersetzt; keine Dateinamen, Abgleichwerte, Kennungen oder Anzahlen."
},
"photo_admin_marks": {
"name": "Fotografenmarkierungen",
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
"used": "Ein Admin hat eine eigene Fotomarkierung erfolgreich gespeichert; keine Bewertung, Farbe, Foto- oder Admin-Identität."
},
"gallery_folders": {
"name": "Galerieordner eingerichtet",
"configured": "Eine anwendbare globale oder Galerie-Kategorie ist als Ordner eingerichtet; keine Namen, Inhalte, Anzahlen oder Besucheraktivität."
},
"transfer_upload_links": {
"name": "PicTransfer-Uploadlinks aktiviert",
"configured": "PicTransfer ist aktiviert und ein nicht gelöschter Transfer erlaubt noch gültige Uploads; keine Links, Tokens, Daten, Empfänger oder Uploads."
},
"workflow_automation_enabled": {
"name": "Workflow-Automation aktiviert",
"configured": "Das Workflow-Modul und mindestens ein Workflow sind aktiviert; keine Namen, Graphen, Auslöser, Entscheidungen oder Durchläufe."
},
"s3_auto_import": {
"name": "Automatischer S3-Import aktiviert",
"configured": "S3-Medienspeicher ist eingerichtet und STORAGE_AUTO_IMPORT aktiviert; keine Buckets, Präfixe, Zugangsdaten, Abfragen oder importierten Objekte."
},
"crm_invoice_import": {
"name": "Rechnungsimport",
"configured": "Die erforderlichen Produktfunktionen sind effektiv aktiviert; nur ein Wahrheitswert.",
"used": "Ein Admin hat eine bestehende Rechnung erfolgreich importiert; keine PDF, Rechnungsnummer, Beträge, Währung, Kunden oder Zahlungsstände."
},
"crm_combined_billing": {
"name": "Kombinierte Abrechnung",
"configured": "Die erforderlichen Produktfunktionen sind effektiv aktiviert; nur ein Wahrheitswert.",
"used": "Ein Admin hat erfolgreich eine kombinierte Abrechnung erstellt; keine Stunden, Ausgaben, Kunden, Dokumente oder Finanzwerte."
},
"crm_monthly_billing_manual": {
"name": "Manuelle Monatsabrechnung",
"configured": "Die erforderlichen Produktfunktionen sind effektiv aktiviert; nur ein Wahrheitswert.",
"used": "Ein Admin hat einen Monatsentwurf erfolgreich zum Versand freigegeben; die tatsächliche E-Mail-Zustellung wird nicht gemessen. Keine Scheduler-Aktivität, Kunden, Intervalle oder Rechnungswerte."
},
"crm_document_conversion": {
"name": "Dokumentumwandlung",
"configured": "Die erforderlichen Produktfunktionen sind effektiv aktiviert; nur ein Wahrheitswert.",
"used": "Ein Admin hat ein Angebot oder einen Vertrag erfolgreich in ein Dokument oder eine Galerie umgewandelt; keine Inhalte, Verknüpfungen, Annahmestände oder automatischen Workflows."
},
"gallery_capture_date_sort": {
"name": "Sortierung nach Aufnahmezeit eingerichtet",
"configured": "Eine Galerie sortiert standardmäßig nach Aufnahmezeit; keine Aufnahmedaten, EXIF oder Sortieraktionen von Besuchern."
},
"download_original_filenames": {
"name": "Originaldateinamen für Downloads aktiviert",
"configured": "Der Schalter für Originaldateinamen beim Download ist aktiviert; keine Dateinamen oder Downloads werden gelesen oder gesendet."
}
},
"auditTitle": "Export- und Löschquittungen",
@@ -384,11 +443,11 @@
"sectionVisibility": "Wo er sichtbar ist",
"sectionDeletion": "Beenden und löschen",
"sectionFeedback": "Feedback ist getrennt",
"excluded": "Automatische Berichte enthalten keine Galeriebesucher, Klickverläufe, Foto- oder Galerieanzahlen, Namen, E-Mail-Adressen, Domains, Dateinamen oder Zugangsdaten.",
"excluded": "Automatische Berichte enthalten keine Besucheraktivität, Klickverläufe, Aufschlüsselungen nach Galerien oder Fotos, Namen, E-Mails, Domains, Dateinamen, Bildinhalte, biometrischen Ergebnisse, Finanzwerte oder Konfigurationsgeheimnisse.",
"transport": "Ihr PicPeak-Backend verwahrt den Signaturschlüssel und sendet einmal pro UTC-Tag bei Admin-Nutzung signierte Nutzungsberichte an {{collector}}. Sie können Berichte vorab ansehen und jeden eindeutig angenommenen Bericht genau wie beim ersten Empfang herunterladen; Übertragungswiederholungen werden zusammengeführt. Abgelehnte Versuche und getrennt gesendetes Feedback gehören nicht zu diesem Berichtsexport.",
"sectionOneWay": "Nur senden — kein Rückkanal",
"oneWay": "PicPeak sendet ausschließlich. Es ruft beim Collector nichts ab, holt sich keine Anweisungen und stellt ihm keinen Endpunkt bereit, den er aufrufen könnte — auf diesem Weg gibt es weder einen geplanten Job noch eine eingehende Route. Aus einer Antwort wird nur die Bestätigung für das eben gesendete Paket gelesen, und jedes ihrer Felder wird gegen dieses Paket geprüft, bevor sie angenommen wird; alles andere wird verworfen. Ein von Ihnen angeforderter Datenexport wird Ihnen als Datei übergeben und niemals ausgewertet oder ausgeführt. Über diesen Kanal können also weder Code noch Konfiguration oder Inhalte in Ihre Installation gelangen — auch nicht von einem übernommenen Collector.",
"visibility": "Nur teilnehmende Installationen können den Funktionsdatensatz und aggregierte Ergebnisse einsehen, auch Gruppen mit nur einer Installation. Schema und Quellcode sind öffentlich; geprüfte Funktionswünsche und Empfehlungen werden nur mit Erlaubnis ihrer Verfasser veröffentlicht. Ihr Fingerabdruck ist pseudonym, nicht anonym. Bewahren Sie Ihren Abfrage-Hash vertraulich auf: Er ermöglicht lesenden Zugriff auf Ihre eigenen Berichte und den Teilnehmerdatensatz.",
"visibility": "Teilnehmende Installationen können den gemeinsamen Datensatz der neuesten Funktionskombinationen und Bestandszahlen einschließlich Einzelgruppen sowie aggregierte Zeitverläufe einsehen. Maintainer können alle aufbewahrten Originalberichte und privates Feedback einsehen und exportieren. Dies erlaubt keine Veröffentlichung privaten Feedbacks. Schema und Quellcode sind öffentlich. Dein Fingerabdruck und die Daten sind pseudonym, nicht anonym. Halte deinen Lookup-Hash geheim: Er gewährt Lesezugriff auf deine Berichte und den Teilnehmerdatensatz.",
"deletion": "Deaktivieren stoppt die Erfassung sofort und fordert die Löschung der Berichte, Aggregatbeiträge, Rückmeldungen, Veröffentlichungen, Stimmen und Sitzungen an. Bei einem Ausfall bleiben nur die zur Löschung nötigen Zugangsdaten erhalten; die Oberfläche zeigt die ausstehende Löschung. Nach Bestätigung werden Hash und Schlüssel lokal gelöscht; eine erneute Teilnahme erzeugt eine neue Identität. Der Collector behält einen Einweg-Sperrwert und kurzlebige Missbrauchszähler ohne Installationsbezug. PicPeak speichert eine herunterladbare lokale Löschquittung ohne den alten Hash, Schlüssel oder Inhalte.",
"feedbackDisclosure": "Feedback wird getrennt von automatischen Berichten und nur beim Absenden übertragen. Jeder Beitrag ist anonym, sofern Sie keinen Namen angeben, und nur für Betreuer sichtbar, sofern Sie die Veröffentlichung nicht ausdrücklich erlauben. Öffentliche Beiträge werden geprüft. Die Verwendung einer Empfehlung für Marketing benötigt eine zusätzliche Erlaubnis.",
"consentCheck": "Ich habe diese Hinweise gelesen und stimme der Teilnahme ausdrücklich zu.",
@@ -447,7 +506,19 @@
"abandonExplanationUnregistered": "Diese Teilnahme wurde vom Collector nie angenommen, dort ist also nichts gespeichert und es gibt nichts zu löschen. Sie können sie hier verwerfen und jederzeit neu beginnen.",
"abandonConfirm": "Installationsidentität, Schlüsselmaterial und alle lokalen Marker werden gelöscht. Der Collector wird nicht benachrichtigt und behält die bisher gesendeten Berichte — die Quittung hält das als unbestätigt fest. Danach ist eine neue Teilnahme wieder möglich.",
"abandonConfirmUnregistered": "Installationsidentität, Schlüsselmaterial und alle lokalen Marker werden gelöscht. Der Collector hat diese Teilnahme nie angenommen, es wird also nirgendwo sonst etwas entfernt. Danach ist eine neue Teilnahme wieder möglich.",
"auditPreviousParticipation": "Löschbestätigungen beziehen sich auf eine frühere Teilnahme, nicht auf die aktuelle."
"auditPreviousParticipation": "Löschbestätigungen beziehen sich auf eine frühere Teilnahme, nicht auf die aktuelle.",
"inventoryTitle": "Zwei Bestandszahlen",
"inventoryExplanation": "Die Gesamtzahlen beschreiben aktuell gespeicherte Datenbankeinträge, keine Upload-Aktivität oder eindeutigen Dateien. Vorschaubilder und Videos zählen nicht zur Fotoanzahl. Nur diese beiden Zahlen verlassen die Installation.",
"inventory": {
"galleries": {
"name": "Gespeicherte Galerien",
"description": "Aktuelle Anzahl gespeicherter Galerien einschließlich Entwürfen, inaktiven und archivierten Galerien. Gelöschte Galerien zählen nicht. Eine Gesamtzahl der Installation, ohne Aufschlüsselung oder Kennungen."
},
"photos": {
"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."
}
}
},
"userManagement": {
"title": "Benutzerverwaltung",
+83 -12
View File
@@ -1,19 +1,19 @@
{
"productUsage": {
"fields": "usage.v2 reports contain an installation fingerprint, PicPeak version, UTC report date and generation time, schema/signing metadata, controlled gallery layouts, and 73 fixed capability signals: 56 configured/used pairs and 17 configuration-only booleans. The complete catalog below defines every field. There are no action counts or visitor observations.",
"catalogTitle": "Full catalog: all 73 capability signals (usage.v2)",
"catalogExplanation": "Configured describes current technical availability or configuration, not popularity. Built-in capabilities are always available. Used is a single installation-wide yes/no bit since consent to the current schema (v1: since joining; v2: since joining or explicit upgrade). Accepted jobs count as initiated, not necessarily finished. Configuration-only capabilities have no used field. No actor, event identifier, action time or frequency is stored in these markers.",
"fields": "usage.v3 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.v3)",
"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",
"usedLabel": "Used",
"configurationOnly": "Configuration only — actual use is not collected.",
"versionDisclosure": "This consent covers usage.v2 / usage-consent.v2. Existing v1 participants continue sharing only the previous 19 capabilities unless they explicitly upgrade. The same private identity and raw history remain; the local usage-marker observation period restarts when the collector confirms the upgrade. At most one report per UTC day is accepted, so the first v2 report can be on the next active day. New local markers are not recorded before confirmation.",
"versionDisclosure": "This consent covers usage.v3 / usage-consent.v3. Existing v1 and v2 participants retain their previous 19 or 73 capabilities without inventory totals until they explicitly upgrade. Identity and raw history remain; local usage markers restart only after the collector confirms the upgrade. At most one report per UTC day is accepted, so the first v3 report may be on the next active day. New markers and totals are not collected before confirmation.",
"currentSchema": "Current reporting schema: {{schema}}",
"reviewUpgrade": "Review expanded usage.v2 scope",
"upgrade": "Explicitly agree to usage.v2",
"upgradeExplanation": "Your existing usage.v1 participation is unchanged. Review the complete expanded catalog before deciding whether to upgrade. Declining does not end your current participation.",
"upgradePending": "The signed consent upgrade is pending confirmation. Only the existing v1 scope is collected. Retry when the collector is available, or disable participation to stop and delete.",
"reviewUpgrade": "Review expanded usage.v3 scope",
"upgrade": "Explicitly agree to usage.v3",
"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": {
"crm": {
"name": "Client management",
@@ -71,7 +71,7 @@
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
},
"face_recognition": {
"name": "Face recognition",
"name": "ML face recognition",
"configured": "The faces capability switch is effectively enabled; only a boolean.",
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
},
@@ -362,6 +362,65 @@
"gallery_expiration": {
"name": "Gallery expiration configured",
"configured": "At least one gallery has an expiry configured; no dates, gallery IDs or counts."
},
"photo_xmp_export": {
"name": "XMP export",
"configured": "Built-in capability is available; this is not evidence of use.",
"used": "An admin successfully generated an XMP export; no sidecars, filenames, ratings, selections or counts."
},
"photo_replacement": {
"name": "Photo replacement",
"configured": "Built-in capability is available; this is not evidence of use.",
"used": "An admin upload actually replaced a photo successfully; no filenames, matching values, IDs or counts."
},
"photo_admin_marks": {
"name": "Photographer marks",
"configured": "Built-in capability is available; this is not evidence of use.",
"used": "An admin successfully saved their own photo mark; no rating, color, photo or admin identity."
},
"gallery_folders": {
"name": "Gallery folders configured",
"configured": "An applicable global or gallery category is configured as a folder; no names, contents, counts or visitor activity."
},
"transfer_upload_links": {
"name": "PicTransfer upload links enabled",
"configured": "PicTransfer is enabled and a non-deleted transfer allows unexpired uploads; no links, tokens, dates, recipients or uploads."
},
"workflow_automation_enabled": {
"name": "Workflow automation enabled",
"configured": "The workflows module and at least one workflow are enabled; no names, graphs, triggers, decisions or runs."
},
"s3_auto_import": {
"name": "S3 automatic import enabled",
"configured": "S3 media storage is configured and STORAGE_AUTO_IMPORT is enabled; no bucket, prefix, credentials, polling or imported objects."
},
"crm_invoice_import": {
"name": "Invoice import",
"configured": "The required product capabilities are effectively enabled; only a boolean.",
"used": "An admin successfully imported an existing invoice; no PDF, invoice number, amount, currency, customer or payment status."
},
"crm_combined_billing": {
"name": "Combined billing",
"configured": "The required product capabilities are effectively enabled; only a boolean.",
"used": "An admin successfully created a combined bill; no hours, expenses, customer, documents or financial values."
},
"crm_monthly_billing_manual": {
"name": "Manual monthly billing",
"configured": "The required product capabilities are effectively enabled; only a boolean.",
"used": "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": {
"name": "Document conversion",
"configured": "The required product capabilities are effectively enabled; only a boolean.",
"used": "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": {
"name": "Capture-date sorting configured",
"configured": "A gallery defaults to sorting by capture date; no capture dates, EXIF or visitor sorting actions."
},
"download_original_filenames": {
"name": "Original download filenames enabled",
"configured": "The original-download-filenames switch is enabled; no filenames or downloads are read or sent."
}
},
"auditTitle": "Export and deletion receipts",
@@ -384,11 +443,11 @@
"sectionVisibility": "Where it is visible",
"sectionDeletion": "Leaving and deleting",
"sectionFeedback": "Feedback is separate",
"excluded": "No gallery visitors, clickstreams, photo or gallery counts, names, emails, domains, filenames, or configuration secrets are included in automatic usage reports.",
"excluded": "No visitor activity, clickstreams, per-gallery or per-photo breakdowns, names, emails, domains, filenames, image contents, biometric results, financial values or configuration secrets are included in automatic reports.",
"transport": "Your PicPeak backend keeps the signing key and sends signed usage reports to {{collector}} once per UTC day during admin use. Preview reports and download each unique accepted report exactly as first received; transport retries are deduplicated. Rejected attempts and separately submitted feedback are not part of this report export.",
"sectionOneWay": "Sending only — no return channel",
"oneWay": "PicPeak only sends. It never fetches anything from the collector, never asks it for instructions, and exposes no endpoint the collector could call — there is no scheduled job and no inbound route on this path. From a reply it reads only the acknowledgement for the packet it just sent, and checks every field of that acknowledgement against the packet before accepting it; anything else is discarded. A data export you request yourself is handed to you as a file and is never interpreted or executed. So this channel cannot deliver code, configuration or content into your installation — not even from a collector that has been taken over.",
"visibility": "Only participating installations can inspect the feature dataset and aggregate results, including groups of one. The schema and source are public; approved feature requests and testimonials are public only with their authors permission. Your fingerprint is pseudonymous, not anonymous. Keep your lookup hash private: it grants read-only access to your own reports and the participant dataset.",
"visibility": "Participating installations can inspect the shared dataset of latest feature combinations and inventory totals, including groups of one, and shared aggregate history. Maintainers can inspect and export all retained original reports and private feedback. This does not authorize publication of private feedback. The schema and source are public. Your fingerprint and data are pseudonymous, not anonymous. Keep your lookup hash private: it grants read-only access to your own reports and the participant dataset.",
"deletion": "Disabling immediately stops collection and requests deletion of reports, aggregate contributions, feedback, published items, votes and sessions. During an outage, only credentials needed to finish deletion remain and the UI shows deletion pending. After confirmation, the local hash and key are erased; rejoining creates a new identity. The collector retains a one-way revocation digest and short-lived identity-free abuse counters. PicPeak keeps a downloadable local deletion receipt without the old hash, key or payloads.",
"feedbackDisclosure": "Feedback is separate from automatic reports and is sent only when you submit it. Each item is anonymous unless you include a name, and private to maintainers unless you explicitly permit publication. Public items require maintainer review. Marketing use of a testimonial requires separate permission.",
"consentCheck": "I have read this disclosure and explicitly agree to participate.",
@@ -447,7 +506,19 @@
"abandonExplanationUnregistered": "This participation was never accepted by the collector, so nothing is stored there and there is nothing to delete. You can discard it here and start again at any time.",
"abandonConfirm": "This deletes the installation identity, the key material and every local marker. The collector is not notified and keeps the reports already sent — the receipt records that as unconfirmed. You can join again afterwards.",
"abandonConfirmUnregistered": "This deletes the local installation identity, the key material and every local marker. The collector never accepted this participation, so nothing is removed anywhere else. You can join again afterwards.",
"auditPreviousParticipation": "Deletion confirmations refer to an earlier participation, not the current one."
"auditPreviousParticipation": "Deletion confirmations refer to an earlier participation, not the current one.",
"inventoryTitle": "Two inventory totals",
"inventoryExplanation": "Totals describe currently stored database records, not upload activity or unique files. Thumbnails and videos are excluded from the photo total. Only these two numbers leave the installation.",
"inventory": {
"galleries": {
"name": "Stored galleries",
"description": "Current number of gallery records, including drafts, inactive and archived galleries. Deleted galleries are excluded. One total for the installation, no breakdown or identifiers."
},
"photos": {
"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."
}
}
},
"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.v2'
consent_version: 'usage-consent.v3'
})
).data;
},
async upgradeConsent(): Promise<{ delivered: boolean; queued: boolean; state: UsageStatus }> {
return (await api.post('/admin/usage/consent', { consent_version: 'usage-consent.v2' })).data;
return (await api.post('/admin/usage/consent', { consent_version: 'usage-consent.v3' })).data;
},
async disable(): Promise<UsageStatus> {
return (await api.post('/admin/usage/disable')).data;