Merge pull request #1361 from PicPeak/feat/usage-reporting-update-prompt

feat(usage): prompt existing admins once for usage reporting after an update
This commit is contained in:
Paul Nothaft
2026-09-08 19:23:54 +02:00
committed by GitHub
20 changed files with 470 additions and 27 deletions
@@ -54,6 +54,7 @@ maybe('product usage on Postgres', () => {
await require('../../migrations/core/204_product_usage_privacy_receipts').up(db);
await require('../../migrations/core/205_product_usage_consent_version').up(db);
await require('../../migrations/core/206_product_usage_delivery_backoff').up(db);
await require('../../migrations/core/212_product_usage_prompt_shown').up(db);
await db.schema.createTable('app_settings', (t) => {
t.string('setting_key').primary(); t.text('setting_value'); t.string('setting_type');
@@ -120,6 +121,24 @@ maybe('product usage on Postgres', () => {
// back as a STRING — the tick() gate compares it against a number.
expect(cols.attempts).toBeDefined();
expect(cols.next_attempt_at).toBeDefined();
expect(cols.prompt_shown).toBeDefined();
});
it('backfills the prompt for existing participation using PostgreSQL booleans', async () => {
const migration = require('../../migrations/core/212_product_usage_prompt_shown');
await migration.down(db);
await db('product_usage_state').where({ id: 1 }).update({ status: 'active', consent_version: 'usage-consent.v2' });
await migration.up(db);
await migration.up(db);
expect(await service().status()).toMatchObject({ status: 'active', prompt_shown: true, consent_version: 'usage-consent.v2' });
await db('product_usage_state').where({ id: 1 }).update({ status: 'disabled' });
expect(await service().status()).toMatchObject({ status: 'disabled', prompt_shown: true });
});
it('persists a fresh installation declining without opting in on PostgreSQL', async () => {
expect(await service().status()).toMatchObject({ status: 'disabled', prompt_shown: false });
await service().markPromptShown();
expect(await service().status()).toMatchObject({ status: 'disabled', prompt_shown: true, notice_dismissed: false });
});
it('reruns the backoff migration safely', async () => {
@@ -0,0 +1,80 @@
const knex = require('knex');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { UsageService } = require('../../src/usage/UsageService');
const { generateIdentity, digest, canonical } = require('../../src/usage/protocol.cjs');
const migration = require('../../migrations/core/212_product_usage_prompt_shown');
let db;
let directory;
beforeEach(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-prompt-test-'));
for (const name of [
'201_product_usage', '202_product_usage_cancel_requested', '203_product_usage_cancel_seq',
'204_product_usage_privacy_receipts', '205_product_usage_consent_version', '206_product_usage_delivery_backoff'
]) await require(`../../migrations/core/${name}`).up(db);
});
afterEach(async () => {
await db.destroy();
fs.rmSync(directory, { recursive: true, force: true });
});
test.each(['active', 'activation_pending', 'deletion_pending', 'identity_conflict'])(
'preserves an existing %s participation without altering its consent or pending packet', async (status) => {
await db('product_usage_state').where({ id: 1 }).update({
status, consent_version: 'usage-consent.v2', pending_packet: 'retained-packet',
});
await migration.up(db);
await migration.up(db);
const state = await db('product_usage_state').where({ id: 1 }).first();
expect(state).toMatchObject({ status, consent_version: 'usage-consent.v2', pending_packet: 'retained-packet', prompt_shown: 1 });
}
);
test('a previously participating installation stays acknowledged after a confirmed withdrawal', async () => {
const actions = [];
const service = new UsageService(db, {
secret: 'test-only-prompt-encryption-secret-32-characters',
endpoint: 'https://collector.example.test',
bindingPath: path.join(directory, 'instance.key'),
fetch: async (_url, init) => {
const { packet } = JSON.parse(init.body);
actions.push(packet.action);
return new Response(JSON.stringify({
packet_id: packet.packet_id, installation_id: packet.installation_id,
packet_digest: digest(canonical(packet)), action: packet.action,
sequence: packet.sequence, status: 'deleted',
}));
},
});
const identity = generateIdentity();
await db('product_usage_state').where({ id: 1 }).update({
status: 'active', notice_dismissed: 1, consent_version: 'usage-consent.v5', sequence: 1,
installation_id: identity.installation_id, public_key: identity.public_key,
private_key_encrypted: service.encrypt(identity.private_key), instance_binding: await service.binding(true),
});
await migration.up(db);
const state = await service.disable();
expect(actions).toEqual(['delete']);
expect(state).toMatchObject({ status: 'disabled', prompt_shown: true, installation_id: null });
expect(state.privacy_receipts.last_deletion.status).toBe('collector-confirmed');
});
test('a fresh installation can decline once without changing consent or the separate banner', async () => {
await migration.up(db);
const fetch = jest.fn();
const service = new UsageService(db, { fetch });
expect(await service.status()).toMatchObject({ status: 'disabled', prompt_shown: false, notice_dismissed: false });
await service.markPromptShown();
await migration.up(db);
expect(await service.status()).toMatchObject({ status: 'disabled', prompt_shown: true, notice_dismissed: false });
expect(fetch).not.toHaveBeenCalled();
});
test('migration guards tolerate a missing table', async () => {
await db.schema.dropTable('product_usage_state');
await expect(migration.up(db)).resolves.toBeUndefined();
await expect(migration.down(db)).resolves.toBeUndefined();
});
@@ -29,6 +29,7 @@ jest.mock('../../src/services/productUsageService', () =>
'tick',
'status',
'dismiss',
'markPromptShown',
'enable',
'disable',
'abandon',
@@ -129,6 +130,7 @@ const ROUTES = [
['post', '/abandon'],
['post', '/retry'],
['post', '/dismiss'],
['post', '/prompt-seen'],
['get', '/preview'],
['get', '/export'],
['put', '/feedback-preferences'],
@@ -178,6 +180,15 @@ test('owner sees no-store status and supplies consent to the service', async ()
.expect(200);
expect(service.enable).toHaveBeenCalledWith('usage-consent.v1');
});
test('only a settings editor can acknowledge the prompt without opting in', async () => {
await request(app)
.post('/api/admin/usage/prompt-seen')
.set('Authorization', `Bearer ${token('admin')}`)
.expect('Cache-Control', 'no-store')
.expect(200);
expect(service.markPromptShown).toHaveBeenCalledTimes(1);
expect(service.enable).not.toHaveBeenCalled();
});
test('public/gallery paths and failed/unauthenticated admin operations never set feature markers', async () => {
const { EventEmitter } = require('events');
const simulate = (path, admin, statusCode) => {
@@ -42,6 +42,7 @@ async function bootDb() {
t.string('status', 30).notNullable().defaultTo('disabled');
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
t.boolean('notice_dismissed').notNullable().defaultTo(false);
t.boolean('prompt_shown').notNullable().defaultTo(false);
t.string('installation_id', 64);
t.string('public_key', 59);
t.text('private_key_encrypted');
@@ -32,6 +32,7 @@ async function bootDb() {
t.string('status', 30).notNullable().defaultTo('disabled');
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v2');
t.boolean('notice_dismissed').notNullable().defaultTo(false);
t.boolean('prompt_shown').notNullable().defaultTo(false);
t.string('installation_id', 64);
t.string('public_key', 59);
t.text('private_key_encrypted');
@@ -26,6 +26,7 @@ async function bootDb() {
t.string('status', 30).notNullable().defaultTo('disabled');
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
t.boolean('notice_dismissed').notNullable().defaultTo(false);
t.boolean('prompt_shown').notNullable().defaultTo(false);
t.string('installation_id', 64);
t.string('public_key', 59);
t.text('private_key_encrypted');
@@ -25,6 +25,7 @@ async function bootDb() {
t.string('status', 30).notNullable().defaultTo('disabled');
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
t.boolean('notice_dismissed').notNullable().defaultTo(false);
t.boolean('prompt_shown').notNullable().defaultTo(false);
t.string('installation_id', 64);
t.string('public_key', 59);
t.text('private_key_encrypted');
@@ -0,0 +1,31 @@
// Tracks whether this installation has ever been offered the one-time
// usage-reporting opt-in prompt shown to an existing admin on their first
// login after an update (see UsageService.markPromptShown()). A fresh
// install that went through the setup wizard's own opt-in step sets this
// too, so upgraded and brand-new installs share one "already asked" marker
// and neither gets asked twice. Separate from `notice_dismissed`, which
// governs the persistent, re-visitable dashboard banner instead.
const { formatBoolean } = require('../../src/utils/dbCompat');
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('product_usage_state'))) return;
if (!(await knex.schema.hasColumn('product_usage_state', 'prompt_shown'))) {
await knex.schema.alterTable('product_usage_state', (t) => {
t.boolean('prompt_shown').notNullable().defaultTo(false);
});
}
// Existing participants already made their choice before this marker
// existed. Preserve it through withdrawal, pending delivery and identity
// recovery; none of those transitions should produce a fresh invitation.
await knex('product_usage_state')
.whereNot('status', 'disabled')
.update({ prompt_shown: formatBoolean(true) });
};
exports.down = async function (knex) {
if (
(await knex.schema.hasTable('product_usage_state')) &&
(await knex.schema.hasColumn('product_usage_state', 'prompt_shown'))
) {
await knex.schema.alterTable('product_usage_state', (t) => t.dropColumn('prompt_shown'));
}
};
+7
View File
@@ -73,6 +73,13 @@ router.post(
'/dismiss',
wrap(async (_req, res) => res.json(await service.dismiss()))
);
// Acknowledges the one-time opt-in prompt (setup wizard or the post-update
// modal) regardless of whether the admin enabled or declined — either way it
// must not ask this installation again.
router.post(
'/prompt-seen',
wrap(async (_req, res) => res.json(await service.markPromptShown()))
);
router.post(
'/enable',
wrap(async (req, res) =>
+14
View File
@@ -263,6 +263,7 @@ class UsageService {
return {
status: state.status,
notice_dismissed: Boolean(state.notice_dismissed),
prompt_shown: Boolean(state.prompt_shown),
installation_id: state.installation_id,
collector_url: collectorUrl,
collector_error: collectorError,
@@ -343,6 +344,18 @@ class UsageService {
.update({ notice_dismissed: formatBoolean(true) });
return this.status();
}
// The one-time opt-in prompt (setup wizard for a new install, a modal shown
// once to an existing admin after an update) calls this on either outcome —
// enable or decline — so it never asks the same installation twice. Kept
// separate from `notice_dismissed`: that one only silences the persistent,
// re-visitable dashboard banner and is unrelated to whether this one-time
// prompt has already been shown.
async markPromptShown() {
await this.db('product_usage_state')
.where({ id: 1 })
.update({ prompt_shown: formatBoolean(true) });
return this.status();
}
async enable(consent) {
if (!Object.values(CONSENT_VERSIONS).includes(consent))
throw new ValidationError('Explicit usage consent is required');
@@ -382,6 +395,7 @@ class UsageService {
status: 'activation_pending',
consent_version: consent,
notice_dismissed: formatBoolean(true),
prompt_shown: formatBoolean(true),
installation_id: identity.installation_id,
public_key: identity.public_key,
private_key_encrypted: this.encrypt(identity.private_key),