CogniPrep asks a paying customer for a Trustpilot review once, and only once, after they have actually used what they bought. The gate is deliberately small:
export const REVIEW_PROMPT_MINIMUMS = { games: 2, exercises: 1, interviews: 1, } as const; Any one of those clearing its own bar is enough. It is an OR and not a weighted sum, because somebody who has done one full assessment centre exercise has a formed opinion and making them play a game first would be arbitrary.
The counts are measured since the purchase, not since signup. So the gate runs one raw aggregate that counts completed games, exercises and interviews twice each: lifetime, and since a timestamp.
That query returned the right numbers. The gate read zero for every customer, every time, from 16 September to 12 November.
One client option, two kinds of result
Our Postgres client sets a column transform:
export const DB_TRANSFORM = { column: { from: postgres.toCamel, to: postgres.fromCamel }, }; Drizzle's query builder is unaffected by that. It reads results positionally and maps them onto the object shape you asked for, so select({ firstPurchaseAt: usersTable.first_purchase_at }) gives you what it says.
db.execute is a different path. It hands back postgres.js's own row objects, and the transform has already rewritten every column name on them. A plain AS games_since arrives as gamesSince.
The gate was reading row['games_since']. That is undefined, and Number(undefined ?? 0) is 0.
Why nothing went wrong loudly
Single-word aliases are not affected by a camelCase transform, which is what made this so quiet. The same query selects paid, games, exercises and interviews, and all four of those arrived under the names the code expected. So:
-
hasPaidwas correct. We identified customers fine. - The lifetime counts were correct. They are what the prompt's own copy and analytics use.
- Only the three "since purchase" counts and
first_purchase_atbroke, and those are exactly the four values the decision is made on.
Three counts of 0 against a bar of 2, 1 and 1 is three false comparisons ORed together. The function returned needsReviewPrompt: false and did it in good faith. There is no error to log, no row to find, no exception, no Sentry issue. A zero is what a brand new customer legitimately looks like.
We had already made this mistake, where it was loud
The nurture cron hit the identical thing earlier, selecting AS user_id and reading row.user_id. There it failed immediately and noisily: the undefined user id meant Drizzle substituted DEFAULT on insert, the NOT NULL column rejected it, and a list of undefineds went into an inArray, which failed every campaign in the same run. That one was found and fixed quickly, and the file now carries a long comment explaining the aliases.
The difference between the two is not the bug. It is that one of them had a destination that complained. Worth remembering next time you are deciding whether something needs a test: the bug you should fear is not the complicated one, it is the one whose failure mode is a plausible number.
The fix is four characters per alias
(SELECT count(*) FROM game_sessions WHERE user_id = $1 AND completed_at >= $2) AS "gamesSince" Quoting the camelCase name outright is stable whether or not the transform is configured, which is the property we actually want. There are 15 raw db.execute call sites in this codebase across five files, and a grep for AS [a-z]+_[a-z_]+ over all of them now returns nothing.
The test asserts the SQL, not just the behaviour
Behaviour tests would have passed with the bug in place if the fixtures were written in snake_case, because the fixtures would have been wrong in exactly the same way the code was. So the fixture rows are written the way the driver really hands them over:
function activityRow(gamesSince: number) { return { games: '6', gamesSince: String(gamesSince), exercises: '0', ... }; } And one test renders the SQL that was actually built and looks at the aliases:
const activity = new PgDialect().sqlToQuery(mocks.execute.mock.calls[1][0]).sql; for (const alias of ['gamesSince', 'exercisesSince', 'interviewsSince']) { expect(activity).toContain(`AS "${alias}"`); } expect(`${purchase} ${activity}`).not.toMatch(/AS [a-z]+_[a-z_]+/); That last line is the one I care about. It fails for a snake_case alias nobody has read yet, which is the only version of this test that would have caught the original.
Nine tests, 152 lines. count() comes back as a string from the driver, so the fixtures are strings too.
What we shipped for the people the prompt missed
Two months of customers passed through the eligible moment and were never asked. Re-arming a full-screen prompt for all of them would be an ambush, so they get a small dismissible card on four dashboard pages instead.
Who sees it is one line:
const sentToTrustpilot = prompt.status === 'submitted' && prompt.rating === null; const showReviewCard = !sentToTrustpilot && (await readPurchaseStatus(userId)).hasPaid; A rating is only ever set by the 1 to 5 star survey this prompt replaced, so "submitted with a rating" means they answered a private survey, not that they wrote a public review. Those people are still asked. Someone who already went to Trustpilot is not, and that branch returns before any purchase read happens, which one of the tests asserts directly with expect(mocks.execute).not.toHaveBeenCalled().
Dismissal lives in one localStorage key and no database column. The card is a passive link rather than an ask we need to account for, so there is nothing to reconcile. It also means the component renders nothing until it has mounted, because rendering on the server would flash it at everyone who closed it.
The copy says "Good or bad, an honest review helps them decide" and nothing else, which is not modesty. Trustpilot's business guidelines forbid steering only happy customers to review or hinting at the rating you want, so the card cannot ask the question it would like to ask.
See it
Open cogniprep.app/pricing and read the star line next to the price. The visual stars are aria-hidden and the score lives in one label, so the fastest way to see it is to search the HTML for Rated:
aria-label="Rated 4.5 out of 5 on Trustpilot. Read our reviews (opens in a new tab)" That number is a hand-maintained constant, not a live fetch, and the comment above it records when it was last read off Trustpilot and what it said:
/** * Read off {@link TRUSTPILOT_URL} on 2026-11-13, when it stood at 4.5 from 14 * reviews. Kept by hand, so check it against that page and update it whenever it * moves: a rating we show has to be the one Trustpilot is showing. */ export const TRUSTPILOT_SCORE = 4.5; The previous version of that same comment, from 2 November, reads 4.3 from 8 reviews. Eleven days, six more reviews, on a prompt that spent the two months before them showing to nobody.