feat(usage): distinguish real edits and template delivery with v5 consent (#1339)

* feat(usage): distinguish real edits and template delivery with v5 consent

* fix(usage): exclude queued test messages and count reorders as edits

- queueEmail carries usageEligible: false into email_data and the queue
  processor passes it on, so the dev tools' send-test-email no longer
  records email_template_delivery once the worker sends it.
- event-types/reorder and categories/reorder-global compare the persisted
  order before and after and record the v5 edit markers only when it
  changed, matching the display_order edit already counted on PUT.
- normalized() builds arrays with Array.from so a row array from the sqlite
  binding compares equal under Jest's separate realm.

* fix(usage): cover per-gallery category order and workflow test runs

- categories/reorder records category_editing when an event's override
  changes; reorder/:eventId records it when an override was actually
  removed.
- send_email and the collections handoff pass usageEligible: false for a
  workflow test run (engine.testRun sets __test), so a non-dry test send is
  not counted as template delivery.

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-09-07 20:03:15 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 69754f8a2c
commit 5c1e38d921
31 changed files with 5398 additions and 292 deletions
+3
View File
@@ -1,3 +1,4 @@
const { changedEvidence } = require('../usage/adoptionEvidence');
const express = require('express');
const path = require('path');
const fs = require('fs').promises;
@@ -144,6 +145,8 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
if (!updated.use_external_url) changedEvidence(res, 'cms_content_editing', page, updated,
['title_en', 'title_de', 'content_en', 'content_de']);
res.json(updated);
} catch (error) {
logger.error('Error updating CMS page:', error);
+14 -2
View File
@@ -1,3 +1,5 @@
const { changedEvidence } = require('../usage/adoptionEvidence');
const { capabilityEvidence } = require('../usage/capabilityEvidence');
const express = require('express');
const { body, validationResult } = require('express-validator');
const { safeValidationErrors } = require('../utils/routeHelpers');
@@ -123,6 +125,7 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
capabilityEvidence(res, 'category_editing');
res.json(category);
} catch (error) {
logger.error('Error creating category:', error);
@@ -206,6 +209,8 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
changedEvidence(res, 'category_editing', category, updated,
['name', 'slug', 'hero_photo_id', 'allow_downloads', 'is_folder']);
res.json(updated);
} catch (error) {
logger.error('Error updating category:', error);
@@ -260,6 +265,7 @@ router.put('/:id/hero', adminAuth, requirePermission('settings.edit'), [
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
changedEvidence(res, 'category_editing', category, updated, ['hero_photo_id']);
res.json(updated);
} catch (error) {
logger.error('Error updating category hero:', error);
@@ -294,6 +300,7 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
capabilityEvidence(res, 'category_editing');
res.json({ message: 'Category deleted successfully' });
} catch (error) {
logger.error('Error deleting category:', error);
@@ -346,12 +353,14 @@ router.post('/reorder', adminAuth, requirePermission('settings.edit'), [
return res.status(400).json({ error: 'One or more categories are not available for this event' });
}
const before = await db('event_category_order').where('event_id', eventId).orderBy('position', 'asc').pluck('category_id');
await db.transaction(async (trx) => {
await trx('event_category_order').where('event_id', eventId).del();
await trx('event_category_order').insert(
orderedIds.map((id, i) => ({ event_id: eventId, category_id: id, position: i + 1 }))
);
});
changedEvidence(res, 'category_editing', { order: before }, { order: orderedIds }, ['order']);
// Log activity after commit (avoids a SQLite in-transaction global write).
await logActivity('event_category_order_set',
@@ -371,7 +380,8 @@ router.post('/reorder', adminAuth, requirePermission('settings.edit'), [
router.delete('/reorder/:eventId', adminAuth, requirePermission('settings.edit'), requireEventOwnership, async (req, res) => {
try {
const eventId = parseInt(req.params.eventId, 10);
await db('event_category_order').where('event_id', eventId).del();
const removed = await db('event_category_order').where('event_id', eventId).del();
if (removed > 0) capabilityEvidence(res, 'category_editing');
await logActivity('event_category_order_reset',
{ eventId },
@@ -400,7 +410,8 @@ router.post('/reorder-global', adminAuth, requirePermission('settings.edit'), [
const orderedIds = req.body.orderedIds.map((id) => parseInt(id, 10));
const globals = await db('photo_categories').where('is_global', formatBoolean(true)).pluck('id');
const globals = await db('photo_categories').where('is_global', formatBoolean(true))
.orderBy('display_order', 'asc').orderBy('name', 'asc').pluck('id');
const globalsSet = new Set(globals);
const invalid = orderedIds.filter((id) => !globalsSet.has(id));
if (invalid.length > 0) {
@@ -423,6 +434,7 @@ router.post('/reorder-global', adminAuth, requirePermission('settings.edit'), [
.where('is_global', formatBoolean(true))
.orderBy('display_order', 'asc')
.orderBy('name', 'asc');
changedEvidence(res, 'category_editing', { order: globals }, { order: categories.map((category) => category.id) }, ['order']);
res.json(categories);
} catch (error) {
logger.error('Error reordering global categories:', error);
+1 -1
View File
@@ -443,7 +443,7 @@ router.post(
const frontendUrl = await getAbsoluteFrontendUrl(req);
const payload = await buildPayloadFor(req.body.templateKey, req.admin.id, frontendUrl);
await emailProcessor.queueEmail(null, admin.email, req.body.templateKey, payload);
await emailProcessor.queueEmail(null, admin.email, req.body.templateKey, payload, { usageEligible: false });
return successResponse(res, {
sent: true,
+6
View File
@@ -1,3 +1,4 @@
const { changedFields } = require('../usage/adoptionEvidence');
const express = require('express');
const { capabilityEvidence } = require('../usage/capabilityEvidence');
const nodemailer = require('nodemailer');
@@ -1029,6 +1030,7 @@ router.put('/templates/:key', [
return res.status(400).json({ error: 'translations object is required' });
}
let contentChanged = false;
// Upsert each language translation
for (const [language, data] of Object.entries(translations)) {
if (!data || typeof data !== 'object') continue;
@@ -1044,6 +1046,7 @@ router.put('/templates/:key', [
updated_at: new Date(),
};
contentChanged ||= changedFields(existing, row, ['subject', 'body_html', 'body_text']);
if (existing) {
await db('email_template_translations')
.where({ template_id: template.id, language })
@@ -1091,6 +1094,7 @@ router.put('/templates/:key', [
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
if (contentChanged) capabilityEvidence(res, 'email_template_editing');
res.json({ message: 'Email template updated successfully' });
} catch (error) {
errorResponse(res, error, 500, 'Failed to update email template');
@@ -1178,6 +1182,8 @@ router.post('/templates', [
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username });
if (Object.values(translations).some(content => content && changedFields({}, content,
['subject', 'body_html', 'body_text']))) capabilityEvidence(res, 'email_template_editing');
return res.status(201).json({ template_key: templateKey, id: templateId });
} catch (error) {
return errorResponse(res, error, 500, 'Failed to create email template');
+9
View File
@@ -1,3 +1,5 @@
const { changedEvidence } = require('../usage/adoptionEvidence');
const { capabilityEvidence } = require('../usage/capabilityEvidence');
/**
* Admin Event Types Routes
* CRUD operations for managing customizable event types
@@ -123,6 +125,7 @@ router.post('/', adminAuth, requirePermission('event_types.manage'), [
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
capabilityEvidence(res, 'event_type_editing');
res.status(201).json(eventType);
} catch (error) {
logger.error('Error creating event type:', { error: error.message });
@@ -163,7 +166,10 @@ router.put('/:id', adminAuth, requirePermission('event_types.manage'), [
const { id } = req.params;
const updates = req.body;
const before = await eventTypeService.getEventTypeById(parseInt(id));
const eventType = await eventTypeService.updateEventType(parseInt(id), updates);
changedEvidence(res, 'event_type_editing', before, eventType,
['name', 'slug_prefix', 'emoji', 'theme_preset', 'theme_config', 'display_order', 'is_active']);
// Log activity
await logActivity('event_type_updated',
@@ -210,6 +216,7 @@ router.delete('/:id', adminAuth, requirePermission('event_types.manage'), [
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
capabilityEvidence(res, 'event_type_editing');
res.json({ message: 'Event type deleted successfully' });
} catch (error) {
logger.error('Error deleting event type:', { error: error.message });
@@ -240,7 +247,9 @@ router.post('/reorder', adminAuth, requirePermission('event_types.manage'), [
}
const { orderedIds } = req.body;
const before = (await eventTypeService.getAllEventTypes()).map((type) => type.id);
const eventTypes = await eventTypeService.reorderEventTypes(orderedIds);
changedEvidence(res, 'event_type_editing', { order: before }, { order: eventTypes.map((type) => type.id) }, ['order']);
// Log activity
await logActivity('event_types_reordered',
+3
View File
@@ -1,3 +1,4 @@
const { capabilityEvidence } = require('../../usage/capabilityEvidence');
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
// Exports a register function; ./index.js calls the sub-routers in the original
// registration order so Express route matching is unchanged.
@@ -89,6 +90,7 @@ module.exports = (router) => {
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
capabilityEvidence(res, 'branding_editing');
res.json({
message: 'Event logo uploaded successfully',
hero_logo_url: logoUrl
@@ -135,6 +137,7 @@ module.exports = (router) => {
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
if (event.hero_logo_url || event.hero_logo_path) capabilityEvidence(res, 'branding_editing');
res.json({ message: 'Event logo removed successfully' });
} catch (error) {
errorResponse(res, error, 500, 'Failed to delete event logo');
+17
View File
@@ -1,3 +1,7 @@
const { settingsChanged } = require('../usage/adoptionEvidence');
const { capabilityEvidence } = require('../usage/capabilityEvidence');
const SEO_USAGE_KEYS = ['seo_allow_indexing', 'seo_block_ai_crawlers', 'seo_block_social_bots',
'seo_blocked_ai_agents', 'seo_custom_rules', 'seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai', 'seo_sitemap_url'];
const express = require('express');
const multer = require('multer');
const path = require('path');
@@ -1036,6 +1040,9 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
...(promo_alignment !== undefined && { promo_alignment: normalizedPromoAlignment })
};
const brandingUpdates = Object.fromEntries(Object.entries(brandingSettings).map(([key, value]) => [`branding_${key}`, value]));
const brandingChanged = await settingsChanged(db, brandingUpdates, Object.keys(brandingUpdates));
// Handle favicon deletion if empty string or null is provided
if (favicon_url === '' || favicon_url === null || favicon_url === undefined) {
// Get current favicon path to delete file
@@ -1125,6 +1132,7 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
metadata: JSON.stringify({ company_name })
});
if (brandingChanged) capabilityEvidence(res, 'branding_editing');
clearPublicSiteCache();
// Check if watermark settings changed and trigger regeneration
@@ -1229,6 +1237,7 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
updated_at: new Date()
});
capabilityEvidence(res, 'branding_editing');
res.json({
message: 'Logo uploaded successfully',
logoUrl: publicPath
@@ -1247,6 +1256,7 @@ router.delete('/logo', adminAuth, requirePermission('settings.edit'), async (req
const pathKey = isDark ? 'branding_logo_path_dark' : 'branding_logo_path';
const urlKey = isDark ? 'branding_logo_url_dark' : 'branding_logo_url';
const logoChanged = await settingsChanged(db, { [pathKey]: '', [urlKey]: '' }, [pathKey, urlKey]);
const pathSetting = await db('app_settings').where('setting_key', pathKey).first();
if (pathSetting && pathSetting.setting_value) {
try {
@@ -1261,6 +1271,7 @@ router.delete('/logo', adminAuth, requirePermission('settings.edit'), async (req
.whereIn('setting_key', [pathKey, urlKey])
.update({ setting_value: JSON.stringify(''), updated_at: new Date() });
if (logoChanged) capabilityEvidence(res, 'branding_editing');
res.json({ message: 'Logo removed' });
} catch (error) {
errorResponse(res, error, 500, 'Failed to remove logo');
@@ -1346,6 +1357,7 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
watermarkRegenerationStarted = true;
}
capabilityEvidence(res, 'branding_editing');
res.json({
message: 'Watermark logo uploaded successfully',
watermarkLogoUrl: publicPath,
@@ -1360,6 +1372,7 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const themeSettings = req.body;
const themeChanged = await settingsChanged(db, { theme_config: themeSettings }, ['theme_config']);
// Save theme settings
await db('app_settings')
@@ -1386,6 +1399,7 @@ router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req,
clearPublicSiteCache();
if (themeChanged) capabilityEvidence(res, 'branding_editing');
res.json({ message: 'Theme settings updated successfully' });
} catch (error) {
errorResponse(res, error, 500, 'Failed to update theme settings');
@@ -1709,6 +1723,7 @@ router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, re
}
}
const seoChanged = await settingsChanged(db, settings, SEO_USAGE_KEYS);
// Update or insert each setting
for (const [key, value] of Object.entries(settings)) {
await db('app_settings')
@@ -1738,6 +1753,7 @@ router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, re
metadata: JSON.stringify({ settings_count: Object.keys(settings).length })
});
if (seoChanged) capabilityEvidence(res, 'seo_editing');
res.json({ message: 'SEO settings updated successfully' });
} catch (error) {
errorResponse(res, error, 500, 'Failed to update SEO settings');
@@ -2035,6 +2051,7 @@ router.post('/favicon', adminAuth, requirePermission('settings.edit'), faviconUp
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
capabilityEvidence(res, 'branding_editing');
res.json({ faviconUrl });
} catch (error) {
errorResponse(res, error, 500, 'Failed to upload favicon');
+1 -1
View File
@@ -82,7 +82,7 @@ router.post(
router.post(
'/consent',
wrap(async (req, res) => {
if (!req.body || Object.keys(req.body).length !== 1 || !['usage.v2', 'usage.v3', 'usage.v4'].includes(schemaForConsent(req.body.consent_version)))
if (!req.body || Object.keys(req.body).length !== 1 || !schemaForConsent(req.body.consent_version) || req.body?.consent_version === 'usage-consent.v1')
throw new ValidationError('Explicit usage consent is required');
res.json(await service.command('consent', { consent_version: req.body.consent_version }));
})
+17 -2
View File
@@ -906,7 +906,7 @@ async function buildSignatureTextFor(language) {
}
}
async function sendTemplateEmail(to, templateKey, variables) {
async function sendTemplateEmail(to, templateKey, variables, { usageEligible = true } = {}) {
try {
// Webhook transport (#1225) replaces SMTP entirely when configured, so an
// instance using it has no SMTP settings to initialise and must not be
@@ -987,6 +987,17 @@ async function sendTemplateEmail(to, templateKey, variables) {
? await emailWebhookTransport.send(mail)
: await transporter.sendMail(mail);
// Transport acceptance is the measured event, not rendering or inbox
// delivery. The service accepts only the fixed bit under confirmed v5
// consent; marker failure must never retry an already-sent message.
if (usageEligible && (viaWebhook || info.accepted?.length > 0)) {
try {
await require('./productUsageService').markUsed(['email_template_delivery']);
} catch {
logger.warn('Product usage mail marker could not be recorded');
}
}
logger.info(`Email sent successfully: ${info.messageId} (${language})`);
// Return the rendered HTML so the queue processor can persist the ACTUAL
// sent body (email_queue.rendered_html) for the Project Overview preview.
@@ -1275,7 +1286,8 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId =
sendResult = await sendTemplateEmail(
email.recipient_email,
email.email_type,
emailData
emailData,
{ usageEligible: emailData.__usageEligible !== false }
);
}
@@ -1436,6 +1448,9 @@ async function queueEmail(eventId, recipientEmail, emailType, emailData, options
try {
// Add eventId to emailData for language detection
emailData.eventId = eventId;
// An explicit test message (dev tools' send-test-email) must not count as
// template delivery when the queue processor sends it later.
if (options.usageEligible === false) emailData.__usageEligible = false;
const row = {
event_id: eventId,
recipient_email: recipientEmail,
@@ -231,7 +231,7 @@ async function sendTestUpdateNotification() {
current_version: updateInfo.current,
channel: channelLabel,
recipient_email: email
});
}, { usageEligible: false });
successCount++;
} catch (error) {
errorCount++;
+3 -2
View File
@@ -53,7 +53,8 @@ registry.registerAction('send_email', async (ctx) => {
// INTERNAL/admin = immediate; EXTERNAL/customer = business-hours floor.
const respectBusinessHours = !isInternal;
await emailProcessor.queueEmail(eventId, to, emailType, emailData, { respectBusinessHours });
// A workflow test run (engine.testRun, __test) is a test message for usage.
await emailProcessor.queueEmail(eventId, to, emailType, emailData, { respectBusinessHours, usageEligible: !ctx.vars?.__test });
return { sent_to: to, recipientClass, respectBusinessHours };
});
@@ -120,7 +121,7 @@ registry.registerAction('escalate_to_collections', async (ctx) => {
due_date: invoice.due_date ? String(invoice.due_date).slice(0, 10) : '',
reminder_level: invoice.reminder_level || 0,
attachments,
}, { respectBusinessHours: false }); // internal/admin → immediate
}, { respectBusinessHours: false, usageEligible: !ctx.vars?.__test }); // internal/admin → immediate
return { collections_handoff_to: adminEmail, outstanding };
});
+3 -3
View File
@@ -16,7 +16,7 @@ const {
verifyEnvelope,
digest,
canonical,
FEATURE_KEYS,
ALL_FEATURE_KEYS,
LEGACY_FEATURE_KEYS,
CATALOG,
CURRENT_SCHEMA_VERSION,
@@ -796,7 +796,7 @@ class UsageService {
async markUsed(features, { destinationBackup = false, legacyFeatures } = {}) {
let allowed = [...new Set([...features, ...(legacyFeatures || [])])].filter((f) =>
FEATURE_KEYS.includes(f)
ALL_FEATURE_KEYS.includes(f)
);
if (!allowed.length) return;
// Single-transaction status check prevents opt-out racing a late marker.
@@ -960,7 +960,7 @@ class UsageService {
generated_at: now,
features: expanded,
gallery_layouts: [...layouts].sort(),
...(['usage.v3', 'usage.v4'].includes(version) ? { inventory: await require('./inventorySnapshot').inventorySnapshot(this.db) } : {})
...(['usage.v3', 'usage.v4', 'usage.v5'].includes(version) ? { inventory: await require('./inventorySnapshot').inventorySnapshot(this.db) } : {})
};
}
+52
View File
@@ -0,0 +1,52 @@
'use strict';
const { isDeepStrictEqual } = require('node:util');
const { capabilityEvidence } = require('./capabilityEvidence');
// Compare values already handled locally by an admin operation. Never retain
// these values, hashes, IDs or a before/after record in the usage subsystem.
function normalized(value) {
if (value === undefined || value === null || value === '') return null;
if (typeof value === 'string' && (value.startsWith('[') || value.startsWith('{'))) {
try { return normalized(JSON.parse(value)); } catch { return value; }
}
// Array.from, not .map: a row array from the sqlite binding belongs to the
// outer realm under Jest and isDeepStrictEqual rejects it on prototype alone.
if (Array.isArray(value)) return Array.from(value, normalized);
if (value && typeof value === 'object') return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, normalized(item)])
);
return value;
}
function changedFields(before, after, fields) {
return fields.some(key => {
if (after[key] === undefined) return false;
let left = before?.[key], right = after[key];
if (typeof left === 'boolean' && (right === 0 || right === 1)) right = Boolean(right);
if (typeof right === 'boolean' && (left === 0 || left === 1)) left = Boolean(left);
return !isDeepStrictEqual(normalized(left), normalized(right));
});
}
function changedEvidence(res, key, before, after, fields) {
if (changedFields(before, after, fields)) capabilityEvidence(res, key);
}
// Restrict comparisons to named product settings. Missing rows are unknown,
// not evidence of customization: persisting a fallback for the first time must
// not turn a default-only installation into an observed customization.
async function settingsChanged(db, updates, allowedKeys) {
const keys = allowedKeys.filter(key => updates[key] !== undefined);
if (!keys.length) return false;
try {
const rows = await db('app_settings').whereIn('setting_key', keys).select('setting_key', 'setting_value');
return rows.some(row => {
let previous = row.setting_value;
try { previous = JSON.parse(previous); } catch { /* legacy plain value */ }
return changedFields({ value: previous }, { value: updates[row.setting_key] }, ['value']);
});
} catch {
// Optional evidence must not prevent the product operation from running.
require('../utils/logger').warn('Product usage settings comparison unavailable');
return false;
}
}
module.exports = { changedFields, changedEvidence, settingsChanged };
+2 -2
View File
@@ -1,5 +1,5 @@
'use strict';
const { FEATURE_KEYS, observesUse } = require('./schema.cjs');
const { ALL_FEATURE_KEYS, ALL_FEATURES } = require('./schema.cjs');
// Trusted route handlers call this AFTER their business operation succeeds.
// Only fixed, allowlisted keys reach finish middleware. It still requires an
@@ -7,7 +7,7 @@ const { FEATURE_KEYS, observesUse } = require('./schema.cjs');
function capabilityEvidence(res, ...keys) {
res.locals.productUsageFeatures = [...new Set([
...(res.locals.productUsageFeatures || []),
...keys.filter((key) => FEATURE_KEYS.includes(key) && observesUse(key))
...keys.filter((key) => ALL_FEATURE_KEYS.includes(key) && ALL_FEATURES[key]?.used)
])];
}
function acceptedUpload(res, { video = false, raw = false, s3 = false } = {}) {
+2 -2
View File
@@ -85,7 +85,7 @@ async function expandSnapshot(db, { features, flags, used, now, version = 'usage
gallery_guest_uploads: 'allow_user_uploads',
gallery_client_access: 'client_access_enabled', gallery_watermarks: 'watermark_downloads'
})) result[key].configured = await enabled('events', column);
if (version === 'usage.v4') {
if (['usage.v4', 'usage.v5'].includes(version)) {
result.gallery_downloads_restricted.configured = await exists('events', ['allow_downloads'], (query) =>
query.where('allow_downloads', formatBoolean(false)));
} else {
@@ -122,7 +122,7 @@ async function expandSnapshot(db, { features, flags, used, now, version = 'usage
query.where({ feedback_enabled: formatBoolean(true), [column]: formatBoolean(true) }));
result.gallery_guest_accounts.configured = await exists('event_feedback_settings', ['feedback_enabled', 'identity_mode'], (query) =>
query.where('feedback_enabled', formatBoolean(true)).whereIn('identity_mode', ['guest', 'shared']));
if (['usage.v3', 'usage.v4'].includes(version)) {
if (['usage.v3', 'usage.v4', 'usage.v5'].includes(version)) {
result.gallery_folders.configured = await exists('photo_categories', ['is_folder', 'event_id'], (query) =>
query.where('is_folder', formatBoolean(true)).where((q) => q.whereNull('event_id').orWhereIn('event_id', db('events').select('id'))));
result.transfer_upload_links.configured = Boolean(effective.transfers) && await exists('transfers',
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -2,10 +2,10 @@
// Vendored byte-identical in PicPeak. Existing wire versions stay immutable;
// every expansion requires explicit consent to its own version.
const CATALOG = require("./features.v4.json");
const CATALOGS = { "usage.v2": require("./features.v2.json"), "usage.v3": require("./features.v3.json"), "usage.v4": CATALOG };
const CONSENT_VERSIONS = { "usage.v1": "usage-consent.v1", "usage.v2": "usage-consent.v2", "usage.v3": "usage-consent.v3", "usage.v4": "usage-consent.v4" };
const CURRENT_SCHEMA_VERSION = "usage.v4";
const CATALOG = require("./features.v5.json");
const CATALOGS = { "usage.v2": require("./features.v2.json"), "usage.v3": require("./features.v3.json"), "usage.v4": require("./features.v4.json"), "usage.v5": CATALOG };
const CONSENT_VERSIONS = { "usage.v1": "usage-consent.v1", "usage.v2": "usage-consent.v2", "usage.v3": "usage-consent.v3", "usage.v4": "usage-consent.v4", "usage.v5": "usage-consent.v5" };
const CURRENT_SCHEMA_VERSION = "usage.v5";
const CURRENT_CONSENT_VERSION = CONSENT_VERSIONS[CURRENT_SCHEMA_VERSION];
const schemaForConsent = (consent) => Object.keys(CONSENT_VERSIONS).find((version) => CONSENT_VERSIONS[version] === consent);
const schemaRank = (version) => Object.keys(CONSENT_VERSIONS).indexOf(version);
@@ -49,7 +49,7 @@ const report = (version) => object({
key, object({ configured: boolean, ...(observesUse(key, version) ? { used: boolean } : {}) })
]))),
gallery_layouts: { type: "array", uniqueItems: true, maxItems: LAYOUTS.length, items: { enum: LAYOUTS } },
...(["usage.v3", "usage.v4"].includes(version) ? { inventory: object(Object.fromEntries(INVENTORY_KEYS.map(key => [key,
...(["usage.v3", "usage.v4", "usage.v5"].includes(version) ? { inventory: object(Object.fromEntries(INVENTORY_KEYS.map(key => [key,
{ type: "integer", minimum: 0, maximum: MAX_INVENTORY_COUNT }
]))) } : {}),
});