feat(usage): prompt existing admins once for usage reporting after an update

An admin who already had PicPeak installed before the opt-in reporting
feature existed never gets asked — the setup wizard only runs once, on
a brand-new instance. Adds a one-time modal, shown on the admin's next
dashboard visit after updating, offering the same choice the wizard
gives a new install.

- New `product_usage_state.prompt_shown` column (migration 211) and
  UsageService.markPromptShown(), set on either outcome (enable or
  decline) from both this modal and the wizard step, so an
  installation is never asked twice regardless of which path it took.
- New POST /admin/usage/prompt-seen endpoint.
- Extracted the wizard's three-point pitch (UsageReportingPitch.tsx)
  so the modal and the wizard step share identical copy instead of
  drifting apart.
- The modal never shows once participation is already active, and
  never shows a second time after either the wizard or the modal has
  been through it once.

Depends on #1360 (the setup wizard step this reuses).
This commit is contained in:
Paul Nothaft
2026-09-08 16:25:51 +02:00
parent 8d0c32902d
commit d20f80112f
15 changed files with 223 additions and 28 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/211_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');
@@ -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,25 @@
// 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.
exports.up = 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.boolean('prompt_shown').notNullable().defaultTo(false);
});
}
};
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),