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:
@@ -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'));
|
||||
}
|
||||
};
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user