import { sql, type SQL } from 'drizzle-orm'; import type { Db } from '../client'; /** * Recompute the denormalised ranking counters on `pro_profiles`. * * `rating_avg`, `rating_count`, `completed_jobs`, `response_rate` and * `avg_response_minutes` are inputs to `score()` in @linkdr/shared, and until * this existed nothing ever wrote them after the seed. The deck ranked on * numbers that were invented once and never moved, and the card told customers * "usually replies in 25 min" on the strength of it. * * They stay denormalised rather than being computed per query: the deck scores * every candidate pro on every load, and four correlated subqueries per card is * the kind of cost that only shows up once a city is full. The trade is that * they must be refreshed when their inputs change — see the callers. * * Written as one statement over a filtered set so a single pro and a full * backfill cannot drift apart. It is idempotent by construction: it derives * every value from source rows rather than incrementing anything, so running it * twice is the same as running it once, and running it after a missed event * repairs the counter rather than compounding the mistake. */ function recomputeWhere(where: SQL): SQL { return sql` UPDATE pro_profiles p SET -- Multi-column assignment so each source table is scanned once rather -- than once per column. An aggregate with no GROUP BY always returns a -- row, so a pro with no history gets (NULL, 0) and not a failed update. (rating_avg, rating_count) = ( SELECT round(avg(rating)::numeric, 2), count(*)::int FROM reviews -- Published only, and the same predicate pro.reviews reads with. A -- count that included embargoed reviews would put a number in the -- header that the list underneath it can never reach. WHERE subject_id = p.user_id AND published_at IS NOT NULL AND published_at <= now() ), completed_jobs = ( SELECT count(*)::int FROM bookings bk JOIN matches m ON m.id = bk.match_id WHERE m.pro_id = p.user_id AND bk.status = 'completed' ), (response_rate, avg_response_minutes) = ( SELECT /* * Answered over decided — NOT over sent. * * A request still inside its window has not been ignored yet, so * counting it as a miss would punish a pro for work that just * arrived and let them recover only once it expired. The ones that * count against them are those past their expiry with no response, * whether or not the lazy sweeper has relabelled the row yet. */ CASE WHEN count(*) FILTER ( WHERE responded_at IS NOT NULL OR expires_at < now() ) = 0 THEN NULL ELSE round( count(*) FILTER (WHERE responded_at IS NOT NULL)::numeric / count(*) FILTER (WHERE responded_at IS NOT NULL OR expires_at < now()), 3) END, round(avg( EXTRACT(EPOCH FROM (responded_at - created_at)) / 60 ) FILTER (WHERE responded_at IS NOT NULL))::int FROM requests WHERE pro_id = p.user_id ), updated_at = now() WHERE ${where} `; } /** Refresh one pro. Call after anything that changes their history. */ export async function recomputeProStats(db: Db, proId: string): Promise { await db.execute(recomputeWhere(sql`p.user_id = ${proId}`)); } /** * Refresh every pro. For the seed, for a backfill, and for a nightly sweep that * repairs anything a missed event left behind. */ export async function recomputeAllProStats(db: Db): Promise { await db.execute(recomputeWhere(sql`true`)); }