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>
90 lines
3.7 KiB
TypeScript
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`));
|
|
}
|