feat(usage): add consented beta capabilities and gallery photo totals

This commit is contained in:
Paul Nothaft
2026-09-06 19:23:14 +02:00
parent e40bc474bc
commit c358bc65f7
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,
};