From 578b6e55a8b899cb1a94aa0736333efcb04865be Mon Sep 17 00:00:00 2001 From: Drumee Dev Date: Mon, 24 Aug 2026 15:40:18 -0700 Subject: [PATCH] feat(funnel): report the onboarded activation milestone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One call in update_profile, right after drumate_update_profile. HERE AND NOT IN mark_complete(). mark_complete only VALIDATES that the mandatory steps are stored — it can succeed and then be followed by a failed update_profile, leaving a user who never got `onboarded = 1` and meets the wizard again on next login. The profile write is the line that actually ends onboarding, so the milestone belongs after it. This is the only timestamp the stage will ever have. drumate.profile.$.onboarded is a boolean and nothing else records when the wizard was finished — which is why the funnel needs a row at all rather than a derived query. Accounts that completed before this ships are backfilled with their signup time and flagged approx=1. Not awaited and never throws, exactly like _pushReferralLive beside it: onboarding completion is the user's flow and an analytics row is a bystander. Idempotent at the database — yp.funnel_milestone is keyed (uid, milestone) — so a user who reruns the wizard keeps the timestamp of their first completion. Co-Authored-By: Claude Opus 5 (1M context) --- service/onboarding.js | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/service/onboarding.js b/service/onboarding.js index b6d2a76..81a7b62 100644 --- a/service/onboarding.js +++ b/service/onboarding.js @@ -472,6 +472,20 @@ class Onboarding extends Entity { if (team_size) profile.team_size = team_size; if (intent) profile.intent = intent; await this.yp.await_proc('drumate_update_profile', this.uid, profile); + // Activation funnel -> "Onboarded". + // + // HERE AND NOT IN mark_complete(). mark_complete only VALIDATES that the + // mandatory steps are stored — it can be reached and then followed by a + // failed update_profile, leaving a user who never got `onboarded = 1` and + // will meet the wizard again next login. The line above is the one that + // actually ends onboarding, so the milestone belongs after it. + // + // THIS IS THE ONLY TIMESTAMP THE STAGE WILL EVER HAVE. + // drumate.profile.$.onboarded is a boolean and nothing else records when + // the wizard was finished, which is why the funnel needs a row at all; + // accounts that completed before this shipped are backfilled with their + // signup time and flagged approx=1. + this._markFunnelMilestone(this.uid, 'onboarded'); // AFTER the write, never before: the dashboard re-reads the row from the // database, so publishing first would race its own commit and push the old // status. Not awaited — see _pushReferralLive. @@ -535,6 +549,33 @@ class Onboarding extends Entity { } } + /** + * Record an activation-funnel milestone for the analytics Funnel page. + * + * NOT AWAITED AND NEVER THROWS, exactly like _pushReferralLive above and for + * the same reason: onboarding completion is the user's flow, and an + * analytics row is a bystander. The profile write it follows has already + * committed, so a failure here must not turn a finished onboarding into an + * error the user sees. + * + * Idempotent at the database: yp.funnel_milestone is keyed (uid, milestone) + * and funnel_mark is INSERT IGNORE, so a user who reruns the wizard keeps + * the timestamp of the first completion. + * + * server-team has its own copy of this call (service/lib/funnel-milestone) + * for the folder and upload legs — this is a separate plugin and cannot + * share that file. + * + * @param {String} uid + * @param {String} milestone 'onboarded' + */ + _markFunnelMilestone(uid, milestone) { + if (!uid) return; + this.yp.await_proc('funnel_mark', uid, milestone).catch((e) => { + this.warn('[onboarding] funnel mark failed', milestone, e && e.message); + }); + } + /** * * @returns