DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A one-shot review prompt, and the timestamp that decides when to spend it

CogniPrep asks each customer for a public review exactly once. Once. There is no second ask, no reminder, no "maybe later" that comes back next week. That constraint is the whole engineering problem, because a one-shot ask spent at the wrong moment is spent for good.

The original gate was reasonable on its face: a paying customer who has completed some practice gets asked. It was gated on lifetime activity, which is the obvious thing to count and the wrong thing to count.

Consider the actual sequence. Someone signs up, plays two free games, decides the product is worth paying for, and buys a provider unlock. The payment lands. The next page they load is a full-screen request for a public review, because their lifetime activity already cleared the bar before they paid a penny. They have not opened the thing they just bought. Nobody has an opinion about a product they have not used yet, so the only honest thing that prompt can produce is a reflexive dismissal, and that dismissal consumes the one ask we get.

The window has to start at the purchase

The fix is to measure the activity bar from the purchase rather than from signup, which means the database has to know when the purchase happened. Four different things can be bought here (a provider unlock, scores and reports, interview credits, Assessment Centre access), and the Stripe webhook has a branch for each. All four mean the same thing for this purpose, so there is one write at the end of the grant, not four:

// One write for all four branches above, because they all mean the same
// thing: money arrived from this account. COALESCE makes it a stamp rather
// than an update: the value records when someone BECAME a customer, and a
// second purchase must not push it forward.
if (userId) {
  await tx
    .update(usersTable)
    .set({ first_purchase_at: sql`COALESCE(${usersTable.first_purchase_at}, now())` })
    .where(eq(usersTable.id, userId));
}
Enter fullscreen mode Exit fullscreen mode

COALESCE in the SET clause is the part I would copy into another project. The column is a stamp, not a mutable field, and expressing that in the write rather than in a code comment means no future branch can move it by accident. It is also inside the same transaction as the entitlement grant, so a granted entitlement can never exist without its timestamp.

Backfilling from money, not from guesses

Every existing customer had paid before the column existed. Without a backfill they would all read as "paid, but undated". That state is handled, but it is the weaker one, so the migration dates every account it honestly can from two independent records of money actually arriving:

UPDATE "users_table" u
SET "first_purchase_at" = LEAST(
  (
    SELECT min(t."created_at") FROM "interview_credit_transactions" t
    WHERE t."user_id" = u."id" AND t."type" = 'purchase'
  ),
  (
    SELECT min(f."first_seen_at") FROM "payment_card_fingerprints" f
    WHERE f."user_id" = u."id"
  )
)
WHERE u."first_purchase_at" IS NULL;
Enter fullscreen mode Exit fullscreen mode

Two details decide whether this backfill is trustworthy.

The credit ledger is filtered to type = 'purchase'. There are also grant rows, which are free credits handed out by support or promotions, and treating one of those as a purchase would invent a customer. A backfill is only as honest as its narrowest filter.

LEAST ignores nulls in Postgres, which is what makes a two-source backfill a single expression rather than a CASE ladder. The earliest surviving evidence is the closest we can get to the moment someone became a customer. Accounts with neither trace keep their null, and the gate falls back to lifetime counting for them, so "since the purchase" degrades to "ever" rather than to "never". Degrading a gate towards the old behaviour rather than towards silence is worth deciding on purpose.

The bug that only appears after a data deletion

The gate has three conditions: never asked before, has paid, has practised since paying. The record of "never asked before" is a row, and our "delete all my data" control removes it, because the policy promises it goes.

The other two conditions survive that deletion. Payment records are kept for seven years because they have to be, and practice attempts are retained to enforce a usage cap. So the gate read the missing row as "never asked", both other conditions still held, and a full-screen request for a public review landed on the settings page the instant the user asked us to erase their data.

The suppression reads the one fact a deletion is allowed to leave behind, which is that a deletion happened:

const REVIEW_PROMPT_QUIET_DAYS = 7;
Enter fullscreen mode Exit fullscreen mode

The GDPR audit log already records the deletion action, so a seven-day quiet period needs no new tombstone row and no weakening of the promise about the row that was removed. The suppression lives in the code that decides to render, not in the code that deletes, which is the right place for it: deletion should get simpler over time, not accumulate special cases.

What the prompt shows is not what the gate measured

One last asymmetry that was deliberate. The gate runs on the post-purchase window, but the sentence in the overlay quotes lifetime totals. Telling a customer of six months that they have completed two activities would be false, and keeping the displayed figure unchanged also keeps the analytics funnel measuring the same quantity it measured before the change. A gate and a label are allowed to count different things as long as somebody wrote down which is which.

See it for yourself. Open cogniprep.app/pricing and count the separately purchasable things: Provider Access, Scores and Reports, Interview Access, Assessment Centre. Four products, four webhook branches, and exactly one row write between them, because "when did this person become a customer" is one fact and should be stored once.

Top comments (0)