Files
linkder/packages/db/src/queries/stats.ts
T
serfaandClaude Opus 5 1808ad4cba Move the demo market to Mexico City, priced in US dollars
The showcase was a Barcelona market: Catalan names, +34 numbers, euro
rates and "Carrer Example 12" on every job. Presented to a Mexican
client, all of that reads as somebody else's product.

City comes from NEXT_PUBLIC_CITY_* as before, now Ciudad de México at
19.4326/-99.1332, with MAPBOX_COUNTRY=mx. The seed's fallbacks were
Barcelona literals, so an unset env quietly seeded a different city
than the app rendered — they now agree.

Two db tests pinned the Barcelona centre as a hardcoded constant, which
is why the deck returned zero cards on the first run here: every pro was
a continent outside the radius. They read the same env as the seed now,
so the trap cannot recur.

Money: formatCents defaults to USD/en-US, and the nine hardcoded euro
signs across the card, search rows, quote strip and forms are dollars.
The rate NUMBERS are unchanged and still read high for CDMX — that is a
pricing decision, not a currency one, and is left alone deliberately.

Seed people are Mexican, addressed on real Roma/Condesa streets rotated
by index rather than one placeholder repeated. Phones moved to +52 55,
which moves the demo login to +525500000000 / 000000.

Also in here, from the same session:
- Sending a job now confirms. The mutation always succeeded; the sheet
  just closed with no receipt, which from the customer's side is
  indistinguishable from a dead button. Dismissing that receipt resolves
  as 'sent', so the card does not return to the deck.
- Media moves to DigitalOcean Spaces, with the public origin derived
  from bucket and region instead of a second env var to keep in sync.
- Managed-Postgres TLS: DATABASE_CA_CERT takes a path or inline PEM.
- The client-facing project panel beside the running app.
- Two profiles removed and four renamed to match their photos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 10:56:31 -04:00

90 lines
3.7 KiB
TypeScript

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<void> {
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<void> {
await db.execute(recomputeWhere(sql`true`));
}