M2: the full job lifecycle — chat, hiring, geocoding, quotes, bookings, reviews
Closes the funnel. Before this the product could match two people and then stopped: `quotes`, `bookings` and `reviews` had tables and state machines and nothing that wrote a row, the entry deck's right swipe was wired to an empty handler, and every address resolved to the city centre. Jobs tab and chat - message router: thread, send, markRead, unreadTotal. A thread is a MATCH, not a job — one job with three interested pros is three private conversations. - Current/Past segments derived from ACTIVE_JOB_STATUSES, job detail listing the pros who accepted, and the conversation itself with attachments. Hiring from the deck - A right swipe on the entry deck opened nothing. It now resolves "which job?" through a sheet — sign in, pick an open job, or post one — and calls the same deck.swipe the per-job deck does, so the open-request cap and row lock apply exactly once. Swipes are vetoable so closing the sheet returns the card. Geocoding - ST_Distance and ST_DWithin rank and filter every deck, and both operands were placeholders. Addresses now resolve through Mapbox (permanent=true, which is what licenses storing the coordinates), the server resolves points rather than trusting client-supplied lat/lng, and every stored point records how it was obtained. A `city`-precision base cannot reach the verification queue. Quote -> booking -> review - The commercial chain, minus payments. Accepting a quote is the only place a booking is created; confirming completion is what unlocks reviews and moves the pro's completed_jobs. - Reviews publish double-blind with no sweeper: each is written with published_at already set to its embargo deadline and every read filters published_at <= now(), so it publishes itself. The second review pulls both forward. A silent counterparty cannot bury a bad review by never replying. State machine changes, both deliberate - booked -> matched: a cancelled booking is not a cancelled job. - scheduled -> awaiting_confirmation: in_progress is optional, so a pro who never tapped Start can still say the work is done. Test suite - api tests ran files in parallel against one database and failed roughly one run in three on whichever file lost the race. Serialised, and three fixtures that grabbed "the first client" pinned to the seeded accounts. Also includes work from a parallel session: admin verification queue, pro public profile and reviews read path, notification sending, denormalised stats recompute, search, and observability. 318 tests passing; typecheck and lint clean across 7 packages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
CREATE TYPE "public"."location_precision" AS ENUM('exact', 'approximate', 'city');--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "location_precision" "location_precision" DEFAULT 'city' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "location_place_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "pro_profiles" ADD COLUMN "base_location_precision" "location_precision" DEFAULT 'city' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "pro_profiles" ADD COLUMN "base_location_place_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "jobs" ADD COLUMN "location_precision" "location_precision" DEFAULT 'city' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "jobs" ADD COLUMN "location_place_id" text;
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE "notification_deliveries" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"channel" text NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"detail" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "notification_deliveries" ADD CONSTRAINT "notification_deliveries_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "notification_deliveries_user_idx" ON "notification_deliveries" USING btree ("user_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "notification_deliveries_status_idx" ON "notification_deliveries" USING btree ("status","created_at");
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,20 @@
|
||||
"when": 1787296176203,
|
||||
"tag": "0004_closed_nextwave",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1787305583953,
|
||||
"tag": "0005_stale_human_fly",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1787306631007,
|
||||
"tag": "0006_careful_lilandra",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,11 +15,13 @@
|
||||
"push": "drizzle-kit push",
|
||||
"studio": "drizzle-kit studio",
|
||||
"seed": "tsx src/seed.ts",
|
||||
"recompute-stats": "tsx src/recompute-stats.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@linkder/shared": "workspace:*",
|
||||
"@opentelemetry/api": "1.9.1",
|
||||
"drizzle-orm": "0.38.4",
|
||||
"postgres": "^3.4.5"
|
||||
},
|
||||
@@ -27,7 +29,7 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-kit": "^0.30.1",
|
||||
"tsx": "^4.19.2",
|
||||
"vitest": "^2.1.8",
|
||||
"typescript": "^5.7.3"
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,3 +2,6 @@ export * from './client';
|
||||
export * from './postgis';
|
||||
export * as schema from './schema/index';
|
||||
export * from './queries/deck';
|
||||
export * from './queries/eligibility';
|
||||
export * from './queries/search';
|
||||
export * from './queries/stats';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { DECK_PAGE_SIZE, score, type RankingInput } from '@linkder/shared';
|
||||
import type { Db } from '../client';
|
||||
import { eligiblePro } from './eligibility';
|
||||
|
||||
export interface DeckCard {
|
||||
proId: string;
|
||||
@@ -18,6 +19,8 @@ export interface DeckCard {
|
||||
distanceM: number;
|
||||
photos: string[];
|
||||
categories: string[];
|
||||
/** The pro's own words for what they specialise in. Never matched on by the deck. */
|
||||
skills: string[];
|
||||
/** Debug/tuning aid — surfaced in admin, never in the client UI. */
|
||||
score: number;
|
||||
}
|
||||
@@ -68,6 +71,7 @@ export async function getDeck(
|
||||
created_at: string;
|
||||
photos: string[] | null;
|
||||
categories: string[] | null;
|
||||
skills: string[] | null;
|
||||
}>(sql`
|
||||
SELECT
|
||||
p.user_id AS pro_id,
|
||||
@@ -97,17 +101,14 @@ export async function getDeck(
|
||||
JOIN categories c ON c.id = pc.category_id
|
||||
WHERE pc.pro_id = p.user_id),
|
||||
'{}'
|
||||
) AS categories
|
||||
) AS categories,
|
||||
p.skills
|
||||
FROM jobs j
|
||||
JOIN pro_categories pcat ON pcat.category_id = j.category_id
|
||||
JOIN pro_profiles p ON p.user_id = pcat.pro_id
|
||||
JOIN users u ON u.id = p.user_id
|
||||
WHERE j.id = ${args.jobId}
|
||||
AND p.verification_status = 'verified'
|
||||
AND p.is_accepting_jobs = true
|
||||
AND (u.banned IS NOT TRUE OR (u.ban_expires IS NOT NULL AND u.ban_expires < now()))
|
||||
-- the pro must be willing to travel to this job, index-accelerated
|
||||
AND ST_DWithin(p.base_location, j.location, p.service_radius_m)
|
||||
AND ${eligiblePro(sql`j.location`)}
|
||||
-- never show a card the client has already decided on
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM swipes s
|
||||
@@ -154,6 +155,7 @@ export async function getDeck(
|
||||
distanceM: Math.round(Number(r.distance_m)),
|
||||
photos: r.photos ?? [],
|
||||
categories: r.categories ?? [],
|
||||
skills: r.skills ?? [],
|
||||
score: score(input),
|
||||
} satisfies DeckCard;
|
||||
});
|
||||
@@ -172,7 +174,8 @@ export async function getDeck(
|
||||
* The eligibility rules are deliberately IDENTICAL to getDeck's — verified,
|
||||
* accepting work, not banned, and willing to travel to the point in question.
|
||||
* Nobody should ever appear in the shop window who could not appear on a real
|
||||
* deck. If you change one, change both.
|
||||
* deck, so both share `eligiblePro()` rather than a comment asking you to
|
||||
* remember.
|
||||
*/
|
||||
export async function getShowcaseDeck(
|
||||
db: Db,
|
||||
@@ -229,6 +232,7 @@ export async function getShowcaseDeck(
|
||||
created_at: string;
|
||||
photos: string[] | null;
|
||||
categories: string[] | null;
|
||||
skills: string[] | null;
|
||||
}>(sql`
|
||||
WITH centre AS (
|
||||
SELECT ST_SetSRID(ST_MakePoint(${args.lng}, ${args.lat}), 4326)::geography AS g
|
||||
@@ -261,14 +265,12 @@ export async function getShowcaseDeck(
|
||||
JOIN categories c ON c.id = pc.category_id
|
||||
WHERE pc.pro_id = p.user_id),
|
||||
'{}'
|
||||
) AS categories
|
||||
) AS categories,
|
||||
p.skills
|
||||
FROM pro_profiles p
|
||||
JOIN users u ON u.id = p.user_id
|
||||
CROSS JOIN centre
|
||||
WHERE p.verification_status = 'verified'
|
||||
AND p.is_accepting_jobs = true
|
||||
AND (u.banned IS NOT TRUE OR (u.ban_expires IS NOT NULL AND u.ban_expires < now()))
|
||||
AND ST_DWithin(p.base_location, centre.g, p.service_radius_m)
|
||||
WHERE ${eligiblePro(sql`centre.g`)}
|
||||
${distanceFilter}
|
||||
${categoryFilter}
|
||||
ORDER BY ST_Distance(p.base_location, centre.g) ASC
|
||||
@@ -305,6 +307,7 @@ export async function getShowcaseDeck(
|
||||
distanceM: Math.round(Number(r.distance_m)),
|
||||
photos: r.photos ?? [],
|
||||
categories: r.categories ?? [],
|
||||
skills: r.skills ?? [],
|
||||
score: score(input),
|
||||
} satisfies DeckCard;
|
||||
});
|
||||
@@ -322,10 +325,7 @@ export async function getDeckCount(db: Db, jobId: string): Promise<number> {
|
||||
JOIN pro_profiles p ON p.user_id = pcat.pro_id
|
||||
JOIN users u ON u.id = p.user_id
|
||||
WHERE j.id = ${jobId}
|
||||
AND p.verification_status = 'verified'
|
||||
AND p.is_accepting_jobs = true
|
||||
AND (u.banned IS NOT TRUE OR (u.ban_expires IS NOT NULL AND u.ban_expires < now()))
|
||||
AND ST_DWithin(p.base_location, j.location, p.service_radius_m)
|
||||
AND ${eligiblePro(sql`j.location`)}
|
||||
AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.job_id = j.id AND s.pro_id = p.user_id)
|
||||
AND NOT EXISTS (SELECT 1 FROM requests r WHERE r.job_id = j.id AND r.pro_id = p.user_id)
|
||||
AND p.user_id <> j.client_id
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { sql, type SQL } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* Who may be shown to a customer, anywhere.
|
||||
*
|
||||
* This predicate was copy-pasted into three queries with a comment on each
|
||||
* saying "if you change one, change both" — which is a comment doing a
|
||||
* function's job. The rule is the product's core promise: the word "verified"
|
||||
* has to mean the same thing on the deck, in the shop window and in search, or
|
||||
* one surface becomes the way around the other two.
|
||||
*
|
||||
* Assumes the query aliases pro_profiles as `p` and users as `u`, which all four
|
||||
* callers do.
|
||||
*
|
||||
* @param target a geography point the pro must be willing to travel to
|
||||
*/
|
||||
export function eligiblePro(target: SQL): SQL {
|
||||
return sql`
|
||||
${eligibleProAtAnyDistance()}
|
||||
-- the pro must be willing to travel this far, index-accelerated via GiST
|
||||
AND ST_DWithin(p.base_location, ${target}, p.service_radius_m)
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The non-geographic half — verified, working, not banned.
|
||||
*
|
||||
* Split out for the one caller that has no point to measure from: a profile
|
||||
* looked up by id. Distance is irrelevant there, but "suspended" is not.
|
||||
*/
|
||||
export function eligibleProAtAnyDistance(): SQL {
|
||||
return sql`
|
||||
p.verification_status = 'verified'
|
||||
AND p.is_accepting_jobs = true
|
||||
-- a ban with an expiry that has passed is spent, not active
|
||||
AND (u.banned IS NOT TRUE OR (u.ban_expires IS NOT NULL AND u.ban_expires < now()))
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { sql, type SQL } from 'drizzle-orm';
|
||||
import { score, type RankingInput } from '@linkder/shared';
|
||||
import type { Db } from '../client';
|
||||
import { eligiblePro } from './eligibility';
|
||||
import type { DeckCard } from './deck';
|
||||
|
||||
/**
|
||||
* Search, as opposed to the deck.
|
||||
*
|
||||
* The deck answers "who should I show this customer next?" — one job, ranked,
|
||||
* one card at a time. This answers "show me what is out there", which is a
|
||||
* different question with the same eligibility rules: `eligiblePro()` is shared
|
||||
* with getDeck and getShowcaseDeck precisely so a pro can never be findable here
|
||||
* but unbookable there.
|
||||
*
|
||||
* TEXT MATCHING IS `ILIKE`, DELIBERATELY. There is no pg_trgm, no tsvector and
|
||||
* no GIN index in this database, and at launch there are tens of eligible pros
|
||||
* in one city — the radius filter has already cut the set to a couple of hundred
|
||||
* rows on the GiST index before a single string is compared. A sequential scan
|
||||
* over that is free.
|
||||
*
|
||||
* Replace this with a generated tsvector column + GIN when either becomes true:
|
||||
* - the eligible pool in one city passes ~2,000 pros, or
|
||||
* - someone reports "no results" for an obvious typo (ILIKE cannot fuzzy match).
|
||||
* Not before. A search index over 40 rows is a liability, not an optimisation.
|
||||
*/
|
||||
|
||||
/** Hard ceiling on rows pulled before ranking. Mirrors the deck's CANDIDATE_POOL. */
|
||||
const CANDIDATE_POOL = 200;
|
||||
|
||||
export type SearchSort = 'best' | 'nearest' | 'rating' | 'price';
|
||||
|
||||
export interface SearchArgs {
|
||||
/** Where the searcher is. Their saved pin, or the city centre. */
|
||||
lat: number;
|
||||
lng: number;
|
||||
/** Free text over name, headline, bio, skills and trade names. */
|
||||
q?: string;
|
||||
categoryId?: string;
|
||||
/** How far the searcher will go. Applied on top of each pro's own radius. */
|
||||
maxDistanceM?: number;
|
||||
minRating?: number;
|
||||
maxHourlyRateCents?: number;
|
||||
sort?: SearchSort;
|
||||
limit?: number;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export async function searchPros(db: Db, args: SearchArgs): Promise<DeckCard[]> {
|
||||
const limit = args.limit ?? 50;
|
||||
const now = args.now ?? new Date();
|
||||
const sort = args.sort ?? 'best';
|
||||
|
||||
const q = args.q?.trim();
|
||||
|
||||
/**
|
||||
* One pattern, matched against every text field a pro controls plus their
|
||||
* trade names. `%` and `_` are escaped: without it, a search for "50%" would
|
||||
* match every pro in the city, which reads as a broken filter rather than a
|
||||
* clever query.
|
||||
*/
|
||||
const textFilter: SQL = q
|
||||
? sql`AND (
|
||||
u.name ILIKE ${'%' + escapeLike(q) + '%'} ESCAPE '\\'
|
||||
OR p.headline ILIKE ${'%' + escapeLike(q) + '%'} ESCAPE '\\'
|
||||
OR p.bio ILIKE ${'%' + escapeLike(q) + '%'} ESCAPE '\\'
|
||||
OR array_to_string(p.skills, ' ') ILIKE ${'%' + escapeLike(q) + '%'} ESCAPE '\\'
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM pro_categories pc2
|
||||
JOIN categories c2 ON c2.id = pc2.category_id
|
||||
WHERE pc2.pro_id = p.user_id
|
||||
AND c2.name ILIKE ${'%' + escapeLike(q) + '%'} ESCAPE '\\'
|
||||
)
|
||||
)`
|
||||
: sql``;
|
||||
|
||||
const categoryFilter: SQL = args.categoryId
|
||||
? sql`AND EXISTS (
|
||||
SELECT 1 FROM pro_categories pc
|
||||
WHERE pc.pro_id = p.user_id AND pc.category_id = ${args.categoryId}
|
||||
)`
|
||||
: sql``;
|
||||
|
||||
const distanceFilter: SQL =
|
||||
args.maxDistanceM === undefined
|
||||
? sql``
|
||||
: sql`AND ST_DWithin(p.base_location, centre.g, ${args.maxDistanceM})`;
|
||||
|
||||
// An unrated pro has no average, so a rating floor must exclude them rather
|
||||
// than let NULL slip through — "4 stars and up" cannot include "no stars yet".
|
||||
const ratingFilter: SQL =
|
||||
args.minRating === undefined || args.minRating <= 0
|
||||
? sql``
|
||||
: sql`AND p.rating_avg IS NOT NULL AND p.rating_avg >= ${String(args.minRating)}`;
|
||||
|
||||
const priceFilter: SQL =
|
||||
args.maxHourlyRateCents === undefined
|
||||
? sql``
|
||||
: sql`AND p.hourly_rate_cents <= ${args.maxHourlyRateCents}`;
|
||||
|
||||
/**
|
||||
* Ordering happens in SQL for every sort except `best`, so the cut to
|
||||
* CANDIDATE_POOL keeps the rows that sort asked for. Ordering by distance and
|
||||
* then re-sorting by price in JS would silently drop the cheapest pro in the
|
||||
* city the moment there were more than 200 candidates.
|
||||
*/
|
||||
const orderBy: SQL =
|
||||
sort === 'price'
|
||||
? sql`p.hourly_rate_cents ASC, ST_Distance(p.base_location, centre.g) ASC`
|
||||
: sort === 'rating'
|
||||
? sql`p.rating_avg DESC NULLS LAST, p.rating_count DESC`
|
||||
: sql`ST_Distance(p.base_location, centre.g) ASC`;
|
||||
|
||||
const rows = await db.execute<{
|
||||
pro_id: string;
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
headline: string;
|
||||
bio: string;
|
||||
hourly_rate_cents: number;
|
||||
years_experience: number;
|
||||
rating_avg: string | null;
|
||||
rating_count: number;
|
||||
completed_jobs: number;
|
||||
response_rate: string | null;
|
||||
avg_response_minutes: number | null;
|
||||
distance_m: number;
|
||||
service_radius_m: number;
|
||||
last_active_at: string | null;
|
||||
created_at: string;
|
||||
photos: string[] | null;
|
||||
categories: string[] | null;
|
||||
skills: string[] | null;
|
||||
}>(sql`
|
||||
WITH centre AS (
|
||||
SELECT ST_SetSRID(ST_MakePoint(${args.lng}, ${args.lat}), 4326)::geography AS g
|
||||
)
|
||||
SELECT
|
||||
p.user_id AS pro_id,
|
||||
u.name,
|
||||
u.image,
|
||||
p.headline,
|
||||
p.bio,
|
||||
p.hourly_rate_cents,
|
||||
p.years_experience,
|
||||
p.rating_avg,
|
||||
p.rating_count,
|
||||
p.completed_jobs,
|
||||
p.response_rate,
|
||||
p.avg_response_minutes,
|
||||
ST_Distance(p.base_location, centre.g) AS distance_m,
|
||||
p.service_radius_m,
|
||||
u.last_active_at,
|
||||
p.created_at,
|
||||
COALESCE(
|
||||
(SELECT array_agg(m.url ORDER BY m.position)
|
||||
FROM pro_media m WHERE m.pro_id = p.user_id),
|
||||
'{}'
|
||||
) AS photos,
|
||||
COALESCE(
|
||||
(SELECT array_agg(c.name)
|
||||
FROM pro_categories pc
|
||||
JOIN categories c ON c.id = pc.category_id
|
||||
WHERE pc.pro_id = p.user_id),
|
||||
'{}'
|
||||
) AS categories,
|
||||
p.skills
|
||||
FROM pro_profiles p
|
||||
JOIN users u ON u.id = p.user_id
|
||||
CROSS JOIN centre
|
||||
WHERE ${eligiblePro(sql`centre.g`)}
|
||||
${distanceFilter}
|
||||
${categoryFilter}
|
||||
${textFilter}
|
||||
${ratingFilter}
|
||||
${priceFilter}
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT ${CANDIDATE_POOL}
|
||||
`);
|
||||
|
||||
const results = rows.map((r) => {
|
||||
const ratingAvg = r.rating_avg === null ? null : Number(r.rating_avg);
|
||||
const responseRate = r.response_rate === null ? null : Number(r.response_rate);
|
||||
const input: RankingInput = {
|
||||
ratingAvg,
|
||||
ratingCount: Number(r.rating_count),
|
||||
responseRate,
|
||||
distanceM: Number(r.distance_m),
|
||||
serviceRadiusM: Number(r.service_radius_m),
|
||||
lastActiveAt: r.last_active_at ? new Date(r.last_active_at) : null,
|
||||
createdAt: new Date(r.created_at),
|
||||
now,
|
||||
};
|
||||
|
||||
return {
|
||||
proId: r.pro_id,
|
||||
name: r.name,
|
||||
image: r.image,
|
||||
headline: r.headline,
|
||||
bio: r.bio,
|
||||
hourlyRateCents: Number(r.hourly_rate_cents),
|
||||
yearsExperience: Number(r.years_experience),
|
||||
ratingAvg,
|
||||
ratingCount: Number(r.rating_count),
|
||||
completedJobs: Number(r.completed_jobs),
|
||||
responseRate,
|
||||
avgResponseMinutes: r.avg_response_minutes === null ? null : Number(r.avg_response_minutes),
|
||||
distanceM: Math.round(Number(r.distance_m)),
|
||||
photos: r.photos ?? [],
|
||||
categories: r.categories ?? [],
|
||||
skills: r.skills ?? [],
|
||||
score: score(input),
|
||||
} satisfies DeckCard;
|
||||
});
|
||||
|
||||
// `best` is the only sort SQL cannot express: score() lives in JS so the
|
||||
// weights stay tunable in one file. Every other sort is already ordered.
|
||||
if (sort === 'best') {
|
||||
results.sort((a, b) => b.score - a.score || a.distanceM - b.distanceM);
|
||||
}
|
||||
|
||||
return results.slice(0, limit);
|
||||
}
|
||||
|
||||
/** Neutralise LIKE wildcards in user input so they match literally. */
|
||||
function escapeLike(value: string): string {
|
||||
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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 @linkder/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`));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { config } from 'dotenv';
|
||||
import { closePool, db } from './client';
|
||||
import { recomputeAllProStats } from './queries/stats';
|
||||
|
||||
config({ path: '../../.env' });
|
||||
|
||||
/**
|
||||
* Backfill every pro's ranking counters from their real history.
|
||||
*
|
||||
* Run after a deploy that changes how a counter is derived, or to repair drift
|
||||
* left by an event whose refresh failed. Safe to run at any time and as often as
|
||||
* you like — recomputeAllProStats derives rather than increments.
|
||||
*/
|
||||
const before = await db.execute<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM pro_profiles`,
|
||||
);
|
||||
await recomputeAllProStats(db);
|
||||
console.log(`Recomputed ranking counters for ${before[0]?.n ?? 0} pros.`);
|
||||
|
||||
await closePool();
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { DEFAULT_SERVICE_RADIUS_M } from '@linkder/shared';
|
||||
import { point } from '../postgis';
|
||||
import { userRole } from './enums';
|
||||
import { locationPrecision, userRole } from './enums';
|
||||
|
||||
/**
|
||||
* Tables owned by better-auth, plus the marketplace columns we add on top.
|
||||
@@ -73,6 +73,13 @@ export const users = pgTable(
|
||||
location: point('location'),
|
||||
/** Human label for `location` — "Gràcia, Barcelona". Display only; never matched on. */
|
||||
locationText: text('location_text'),
|
||||
/**
|
||||
* See jobs.location_precision. Lowest stakes of the three: this only centres
|
||||
* a client's own browsing, so a `city` value here is a fine default rather
|
||||
* than something to gate on.
|
||||
*/
|
||||
locationPrecision: locationPrecision('location_precision').notNull().default('city'),
|
||||
locationPlaceId: text('location_place_id'),
|
||||
searchRadiusM: integer('search_radius_m').notNull().default(DEFAULT_SERVICE_RADIUS_M),
|
||||
|
||||
/** Deck ranking penalises dormant pros, so this has to be maintained. */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { pgEnum } from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
BOOKING_STATUSES,
|
||||
LOCATION_PRECISIONS,
|
||||
JOB_STATUSES,
|
||||
PAYMENT_STATUSES,
|
||||
QUOTE_STATUSES,
|
||||
@@ -21,6 +22,8 @@ export const bookingStatus = pgEnum('booking_status', BOOKING_STATUSES);
|
||||
export const paymentStatus = pgEnum('payment_status', PAYMENT_STATUSES);
|
||||
export const verificationStatus = pgEnum('verification_status', VERIFICATION_STATUSES);
|
||||
export const urgency = pgEnum('urgency', ['now', 'this_week', 'flexible']);
|
||||
/** How a stored point was obtained — see LOCATION_PRECISIONS in @linkder/shared. */
|
||||
export const locationPrecision = pgEnum('location_precision', LOCATION_PRECISIONS);
|
||||
export const swipeDirection = pgEnum('swipe_direction', ['left', 'right']);
|
||||
export const credentialKind = pgEnum('credential_kind', ['id', 'licence', 'insurance']);
|
||||
export const reviewStatus = pgEnum('review_status', ['pending', 'approved', 'rejected']);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { index, integer, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-c
|
||||
import { point } from '../postgis';
|
||||
import { users } from './auth';
|
||||
import { categories } from './pros';
|
||||
import { jobStatus, urgency } from './enums';
|
||||
import { jobStatus, locationPrecision, urgency } from './enums';
|
||||
|
||||
export const jobs = pgTable(
|
||||
'jobs',
|
||||
@@ -22,6 +22,15 @@ export const jobs = pgTable(
|
||||
budgetMinCents: integer('budget_min_cents'),
|
||||
budgetMaxCents: integer('budget_max_cents'),
|
||||
location: point('location').notNull(),
|
||||
/**
|
||||
* How `location` was obtained. Defaults to `city` so that every row written
|
||||
* before geocoding existed describes itself honestly: those points ARE the
|
||||
* city centre, and the deck must be able to tell them from a real address
|
||||
* rather than ranking a placeholder as if it were one.
|
||||
*/
|
||||
locationPrecision: locationPrecision('location_precision').notNull().default('city'),
|
||||
/** Geocoder feature id, so the point can be re-resolved without the free text. */
|
||||
locationPlaceId: text('location_place_id'),
|
||||
/** Street-level address, only revealed to the pro once a booking exists. */
|
||||
addressText: text('address_text').notNull(),
|
||||
status: jobStatus('status').notNull().default('open'),
|
||||
|
||||
@@ -12,7 +12,13 @@ import {
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { point } from '../postgis';
|
||||
import { users } from './auth';
|
||||
import { credentialKind, mediaKind, reviewStatus, verificationStatus } from './enums';
|
||||
import {
|
||||
credentialKind,
|
||||
locationPrecision,
|
||||
mediaKind,
|
||||
reviewStatus,
|
||||
verificationStatus,
|
||||
} from './enums';
|
||||
|
||||
export const categories = pgTable('categories', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
@@ -35,6 +41,16 @@ export const proProfiles = pgTable(
|
||||
hourlyRateCents: integer('hourly_rate_cents').notNull(),
|
||||
yearsExperience: integer('years_experience').notNull().default(0),
|
||||
baseLocation: point('base_location').notNull(),
|
||||
/**
|
||||
* See jobs.location_precision. This one carries more weight: base_location is
|
||||
* the LEFT operand of every ST_Distance and ST_DWithin in the deck, so a pro
|
||||
* parked at the city centre silently passes every radius check in the city.
|
||||
* submitForReview refuses a `city` base for that reason.
|
||||
*/
|
||||
baseLocationPrecision: locationPrecision('base_location_precision')
|
||||
.notNull()
|
||||
.default('city'),
|
||||
baseLocationPlaceId: text('base_location_place_id'),
|
||||
serviceRadiusM: integer('service_radius_m').notNull().default(15000),
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { boolean, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||
import { boolean, index, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||
import { users } from './auth';
|
||||
|
||||
/**
|
||||
@@ -9,9 +9,9 @@ import { users } from './auth';
|
||||
* where an existing user has no preferences. Every column therefore defaults to
|
||||
* the value we would use in the absence of a row.
|
||||
*
|
||||
* NOTE: nothing consumes these yet — the worker that sends the messages arrives
|
||||
* in M4. Until then this stores intent only, and the UI must say so rather than
|
||||
* implying a toggle stops an SMS today.
|
||||
* These are read on every send — see `notify()` in @linkder/notify, which maps
|
||||
* each notification kind to the column that governs it and drops the message
|
||||
* when the answer is false.
|
||||
*/
|
||||
export const notificationPreferences = pgTable('notification_preferences', {
|
||||
userId: uuid('user_id')
|
||||
@@ -55,6 +55,46 @@ export const deletionRequests = pgTable('deletion_requests', {
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Every notification we tried to send, and what happened.
|
||||
*
|
||||
* Sends are inline and best-effort — a failed SMS must never roll back the
|
||||
* request it was about — which means a failure has nowhere to surface unless it
|
||||
* is written down. Without this row an outage is invisible: the product looks
|
||||
* like it is notifying people and simply is not, which is the state this table
|
||||
* exists to make impossible to reach unnoticed.
|
||||
*
|
||||
* It is also what a retry would read. When these move into a worker, the queue
|
||||
* consumes `status = 'failed'` from here rather than needing its own store.
|
||||
*/
|
||||
export const notificationDeliveries = pgTable(
|
||||
'notification_deliveries',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** The event, not the copy: 'request.received', 'request.accepted', … */
|
||||
kind: text('kind').notNull(),
|
||||
channel: text('channel').notNull(),
|
||||
/** 'sent' | 'skipped' | 'failed'. Text, not an enum: this list will churn. */
|
||||
status: text('status').notNull(),
|
||||
/** Why it was skipped, or how it failed. Never the message body — that can
|
||||
* carry an address or a phone number, and this table is read casually. */
|
||||
detail: text('detail'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index('notification_deliveries_user_idx').on(t.userId, t.createdAt),
|
||||
// What an operator actually queries: what is broken, most recent first.
|
||||
index('notification_deliveries_status_idx').on(t.status, t.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const notificationDeliveriesRelations = relations(notificationDeliveries, ({ one }) => ({
|
||||
user: one(users, { fields: [notificationDeliveries.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const notificationPreferencesRelations = relations(notificationPreferences, ({ one }) => ({
|
||||
user: one(users, { fields: [notificationPreferences.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
+481
-7
@@ -12,6 +12,7 @@ import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import { DEFAULT_SERVICE_RADIUS_M } from '@linkder/shared';
|
||||
import * as schema from './schema/index';
|
||||
import { recomputeAllProStats } from './queries/stats';
|
||||
|
||||
config({ path: '../../.env' });
|
||||
|
||||
@@ -115,6 +116,8 @@ const CATEGORIES = [
|
||||
interface SeedPro {
|
||||
name: string;
|
||||
cat: string;
|
||||
/** Free-text specialisms. Search matches these, so a few pros must have some. */
|
||||
skills?: string[];
|
||||
distanceM: number;
|
||||
rating: number | null;
|
||||
reviews: number;
|
||||
@@ -126,13 +129,16 @@ interface SeedPro {
|
||||
|
||||
/** distanceM is measured from the city centre — deck tests assert against these. */
|
||||
const PROS: SeedPro[] = [
|
||||
{ name: 'Marc Oliveras', cat: 'plumber', distanceM: 800, rating: 4.9, reviews: 47, radius: 15_000 },
|
||||
{ name: 'Ana Ferrer', cat: 'plumber', distanceM: 2_400, rating: 4.7, reviews: 23, radius: 15_000 },
|
||||
{ name: 'Marc Oliveras', cat: 'plumber', distanceM: 800, rating: 4.9, reviews: 47, radius: 15_000,
|
||||
skills: ['Underfloor heating', 'Emergency callouts', 'Boiler swaps'] },
|
||||
{ name: 'Ana Ferrer', cat: 'plumber', distanceM: 2_400, rating: 4.7, reviews: 23, radius: 15_000,
|
||||
skills: ['Bathroom fitting', 'Leak detection'] },
|
||||
{ name: 'Jordi Puig', cat: 'plumber', distanceM: 6_500, rating: 4.5, reviews: 12, radius: 10_000 },
|
||||
{ name: 'Nuria Sala', cat: 'plumber', distanceM: 18_000, rating: 5.0, reviews: 3, radius: 25_000 },
|
||||
// Further away than they are willing to travel — must NOT appear for a central job.
|
||||
{ name: 'Pau Ribas', cat: 'plumber', distanceM: 22_000, rating: 4.8, reviews: 31, radius: 5_000 },
|
||||
{ name: 'Laia Mestre', cat: 'electrician', distanceM: 1_200, rating: 4.8, reviews: 56, radius: 20_000 },
|
||||
{ name: 'Laia Mestre', cat: 'electrician', distanceM: 1_200, rating: 4.8, reviews: 56, radius: 20_000,
|
||||
skills: ['EV chargers', 'Rewiring', 'Fuse boards'] },
|
||||
{ name: 'Oriol Camps', cat: 'electrician', distanceM: 3_900, rating: 4.6, reviews: 18, radius: 15_000 },
|
||||
{ name: 'Marta Vidal', cat: 'electrician', distanceM: 9_100, rating: 4.9, reviews: 71, radius: 30_000 },
|
||||
{ name: 'Sergi Bonet', cat: 'handyman', distanceM: 1_800, rating: 4.4, reviews: 9, radius: 12_000 },
|
||||
@@ -140,13 +146,15 @@ const PROS: SeedPro[] = [
|
||||
{ name: 'Ivan Serra', cat: 'handyman', distanceM: 11_500, rating: 4.2, reviews: 6, radius: 15_000 },
|
||||
{ name: 'Elena Prat', cat: 'painter', distanceM: 2_100, rating: 4.9, reviews: 28, radius: 20_000 },
|
||||
{ name: 'Toni Blanch', cat: 'painter', distanceM: 7_800, rating: 4.3, reviews: 15, radius: 15_000 },
|
||||
{ name: 'Rosa Ventura', cat: 'carpenter', distanceM: 3_300, rating: 4.8, reviews: 41, radius: 25_000 },
|
||||
{ name: 'Rosa Ventura', cat: 'carpenter', distanceM: 3_300, rating: 4.8, reviews: 41, radius: 25_000,
|
||||
skills: ['Fitted wardrobes', 'Listed buildings'] },
|
||||
{ name: 'Guillem Costa', cat: 'carpenter', distanceM: 8_600, rating: 4.6, reviews: 19, radius: 15_000 },
|
||||
{ name: 'Silvia Marti', cat: 'locksmith', distanceM: 950, rating: 4.7, reviews: 62, radius: 25_000 },
|
||||
{ name: 'Xavi Duran', cat: 'locksmith', distanceM: 4_400, rating: 4.5, reviews: 22, radius: 20_000 },
|
||||
{ name: 'Berta Lloret', cat: 'appliance-repair', distanceM: 2_700, rating: 4.8, reviews: 37, radius: 18_000 },
|
||||
{ name: 'Adria Font', cat: 'appliance-repair', distanceM: 10_200, rating: 4.4, reviews: 11, radius: 20_000 },
|
||||
{ name: 'Carla Ripoll', cat: 'hvac', distanceM: 3_100, rating: 4.9, reviews: 44, radius: 22_000 },
|
||||
{ name: 'Carla Ripoll', cat: 'hvac', distanceM: 3_100, rating: 4.9, reviews: 44, radius: 22_000,
|
||||
skills: ['Split units', 'Heat pumps'] },
|
||||
{ name: 'Marc Segura', cat: 'hvac', distanceM: 12_800, rating: 4.6, reviews: 27, radius: 25_000 },
|
||||
// Brand new and unrated — proves the new-pro boost keeps fresh supply visible.
|
||||
{ name: 'Nil Bosch', cat: 'plumber', distanceM: 1_500, rating: null, reviews: 0, radius: 15_000, isNew: true },
|
||||
@@ -159,13 +167,144 @@ const PROS: SeedPro[] = [
|
||||
|
||||
const CLIENTS = ['Sofia Grau', 'Daniel Miro', 'Emma Rovira', 'Lucas Pons', 'Alba Torres'];
|
||||
|
||||
/**
|
||||
* Finished work, per trade, for the review histories below.
|
||||
*
|
||||
* Written out rather than generated because the profile screen is a reading
|
||||
* surface: "Job 3 completed. Good service." twenty times over tells you nothing
|
||||
* about whether the reviews list works, and nothing about whether a real one
|
||||
* would be worth reading.
|
||||
*/
|
||||
interface SeedWork {
|
||||
title: string;
|
||||
scope: string;
|
||||
amountCents: number;
|
||||
review: string;
|
||||
}
|
||||
|
||||
const WORK: Record<string, SeedWork[]> = {
|
||||
plumber: [
|
||||
{ title: 'Replace a leaking kitchen trap', scope: 'Remove and replace the sink trap, test for leaks.', amountCents: 9_000,
|
||||
review: 'Came the same evening, found the leak in about a minute and had it swapped out before I had finished making tea. Left the cupboard drier than he found it.' },
|
||||
{ title: 'New thermostatic shower valve', scope: 'Supply and fit a thermostatic mixer, make good the tiling.', amountCents: 28_500,
|
||||
review: 'Explained the options without pushing me at the expensive one. Tidy work around the tiles and the temperature is finally steady.' },
|
||||
{ title: 'Boiler losing pressure', scope: 'Trace and repair pressure loss, refill and rebalance the system.', amountCents: 14_000,
|
||||
review: 'Took a while to track down but stuck with it and did not charge me for the extra hour. Pressure has held for two months now.' },
|
||||
{ title: 'Fit an outside tap', scope: 'Tee off the rising main, fit an outside tap with an isolator.', amountCents: 12_000,
|
||||
review: 'Quick, clean job and tidied up afterwards. Would have them back.' },
|
||||
{ title: 'Bathroom refit second fix', scope: 'Connect basin, WC and bath after tiling.', amountCents: 46_000,
|
||||
review: 'Turned up when they said they would every single day, which after our last builder felt like a luxury.' },
|
||||
],
|
||||
electrician: [
|
||||
{ title: 'Install an EV charger', scope: 'Fit a 7kW charger on its own RCBO, with a certificate.', amountCents: 68_000,
|
||||
review: 'Neat cable run, tested everything in front of me and sent the certificate through the same day. No mess left behind.' },
|
||||
{ title: 'Consumer unit replacement', scope: 'Replace the fuse board, full test and certification.', amountCents: 52_000,
|
||||
review: 'Talked me through what was actually unsafe and what was just old, which I appreciated. Power was only off for the afternoon.' },
|
||||
{ title: 'Kitchen sockets and lighting', scope: 'Add four sockets and two lighting circuits.', amountCents: 39_000,
|
||||
review: 'Good work and a fair price. Chased the walls neatly so the plasterer had an easy job.' },
|
||||
{ title: 'Tripping circuit', scope: 'Fault-find a nuisance trip and repair.', amountCents: 11_000,
|
||||
review: 'Found a nail through a cable in the loft within half an hour. Straightforward and honest about the cost.' },
|
||||
],
|
||||
handyman: [
|
||||
{ title: 'Hang six internal doors', scope: 'Hang and adjust six doors with new furniture.', amountCents: 32_000,
|
||||
review: 'All six shut properly for the first time since we moved in. Cleaned up all the shavings too.' },
|
||||
{ title: 'Flat-pack wardrobes', scope: 'Assemble and wall-fix two double wardrobes.', amountCents: 15_000,
|
||||
review: 'Saved my weekend. Fixed them to the wall without being asked, because of the kids.' },
|
||||
{ title: 'Repair a sagging side gate', scope: 'Rehang the gate and fit a new latch.', amountCents: 8_500,
|
||||
review: 'Turned up on time, sorted it in an hour, charged what was quoted.' },
|
||||
{ title: 'Patch and paint a ceiling', scope: 'Fill, sand and repaint a water-damaged ceiling.', amountCents: 18_000,
|
||||
review: 'Cannot tell where the damage was. Very careful with the carpet.' },
|
||||
],
|
||||
painter: [
|
||||
{ title: 'Repaint a stairwell', scope: 'Prepare and paint stairwell walls and woodwork.', amountCents: 42_000,
|
||||
review: 'The cutting-in is genuinely straight, which is the whole job really. Dust sheets everywhere and not a mark on the floor.' },
|
||||
{ title: 'Two bedrooms in emulsion', scope: 'Fill, sand and two coats to two bedrooms.', amountCents: 34_000,
|
||||
review: 'Quick and neat, and matched the old colour on the landing so it blends.' },
|
||||
{ title: 'Exterior window frames', scope: 'Sand back, prime and paint six frames.', amountCents: 26_000,
|
||||
review: 'Good preparation, which is where most people cut corners. Looks like new.' },
|
||||
{ title: 'Hallway feature wall', scope: 'Hang wallpaper to one wall and paint the rest.', amountCents: 21_000,
|
||||
review: 'Pattern lines up perfectly at the joins. Very pleased.' },
|
||||
],
|
||||
carpenter: [
|
||||
{ title: 'Fitted alcove wardrobes', scope: 'Design, build and fit two alcove wardrobes.', amountCents: 145_000,
|
||||
review: 'Beautiful work. Scribed into a wall that is nowhere near straight and you would never know.' },
|
||||
{ title: 'Replace a rotten sash sill', scope: 'Splice in a new sill section and repaint.', amountCents: 38_000,
|
||||
review: 'Repaired rather than replaced, which on a listed building saved us a small fortune in paperwork.' },
|
||||
{ title: 'Build understairs storage', scope: 'Build and fit understairs drawers.', amountCents: 62_000,
|
||||
review: 'Measured twice, delivered exactly what was drawn. Runs smoothly.' },
|
||||
{ title: 'Loft hatch and ladder', scope: 'Enlarge the hatch and fit a folding ladder.', amountCents: 24_000,
|
||||
review: 'Straightforward and tidy. Explained why the old hatch was too small for the ladder I had bought.' },
|
||||
],
|
||||
locksmith: [
|
||||
{ title: 'Locked out at 11pm', scope: 'Non-destructive entry and a new cylinder.', amountCents: 13_500,
|
||||
review: 'Answered the phone at eleven at night and was here in twenty minutes. Opened it without damaging the door.' },
|
||||
{ title: 'Upgrade to anti-snap cylinders', scope: 'Replace three cylinders with anti-snap.', amountCents: 19_000,
|
||||
review: 'Insurance wanted a specific standard and they knew exactly which one without me having to explain.' },
|
||||
{ title: 'New front door lock', scope: 'Supply and fit a mortice lock to BS3621.', amountCents: 16_000,
|
||||
review: 'Clean fit, no splintering, and all the keys work in both locks now.' },
|
||||
{ title: 'Repair a failed uPVC mechanism', scope: 'Replace a multipoint locking mechanism.', amountCents: 17_500,
|
||||
review: 'Had the part on the van. The door finally closes without a shoulder barge.' },
|
||||
],
|
||||
'appliance-repair': [
|
||||
{ title: 'Washing machine not draining', scope: 'Clear the pump and replace the drain hose.', amountCents: 8_000,
|
||||
review: 'Fixed for the price of a takeaway when I had already been told to buy a new machine.' },
|
||||
{ title: 'Oven element replacement', scope: 'Diagnose and replace a failed fan oven element.', amountCents: 11_000,
|
||||
review: 'Diagnosed it over the phone and brought the right part first time. Very efficient.' },
|
||||
{ title: 'Fridge freezer icing up', scope: 'Clear a blocked defrost drain and reseal the door.', amountCents: 9_500,
|
||||
review: 'Honest about whether it was worth repairing at all, which I did not expect.' },
|
||||
{ title: 'Dishwasher leak', scope: 'Replace the door seal and test.', amountCents: 7_500,
|
||||
review: 'In and out in under an hour and no more puddle.' },
|
||||
],
|
||||
hvac: [
|
||||
{ title: 'Install two split units', scope: 'Supply and install two wall-mounted split units.', amountCents: 190_000,
|
||||
review: 'Careful with the core drilling and the pipe run outside is genuinely tidy. The whole house is bearable in August now.' },
|
||||
{ title: 'Annual aircon service', scope: 'Clean, regas and service two indoor units.', amountCents: 14_000,
|
||||
review: 'Thorough, and pointed out a filter I could clean myself rather than charging me for a return visit.' },
|
||||
{ title: 'Heat pump commissioning', scope: 'Commission an air-source heat pump and balance the system.', amountCents: 78_000,
|
||||
review: 'Knew the system better than the people who supplied it. Running costs came in where they said they would.' },
|
||||
{ title: 'Noisy outdoor unit', scope: 'Replace worn fan bearings and rebalance.', amountCents: 22_000,
|
||||
review: 'The neighbours have stopped complaining. Fair price for a Saturday.' },
|
||||
],
|
||||
};
|
||||
|
||||
/** Any trade without its own list still gets a plausible history. */
|
||||
const GENERIC_WORK: SeedWork[] = [
|
||||
{ title: 'Small job, quoted and completed', scope: 'Agreed scope completed in a single visit.', amountCents: 12_000,
|
||||
review: 'Turned up on time, did what was quoted and cleaned up afterwards. No complaints at all.' },
|
||||
{ title: 'Follow-up visit', scope: 'Second visit to finish the agreed work.', amountCents: 9_000,
|
||||
review: 'Good communication throughout and the price did not move from the quote.' },
|
||||
{ title: 'Half a day on site', scope: 'Half a day of work, materials included.', amountCents: 18_000,
|
||||
review: 'Straightforward, professional and easy to deal with. Would use again.' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Star ratings for one pro's seeded reviews.
|
||||
*
|
||||
* Built to AVERAGE to the pro's headline figure rather than scattered around
|
||||
* it, because the counters are derived from these rows: deck.test.ts asserts
|
||||
* Marc Oliveras rates 4.9, and that now has to come out of 47 individual
|
||||
* scores rather than being asserted directly on the profile.
|
||||
*
|
||||
* So a 4.9 becomes forty-two 5s and five 4s. Whole stars only — nobody awards
|
||||
* 4.9 — and the mix is what carries the average, which is also what makes the
|
||||
* list look like a real one instead of a wall of fives.
|
||||
*/
|
||||
function seedRatings(avg: number, n: number): number[] {
|
||||
const low = Math.max(1, Math.min(5, Math.floor(avg)));
|
||||
const high = Math.min(5, low + 1);
|
||||
// How many have to be the higher score for the mean to land on `avg`.
|
||||
const highCount = high === low ? n : Math.round((avg - low) * n);
|
||||
return Array.from({ length: n }, (_, k) => (k < highCount ? high : low));
|
||||
}
|
||||
|
||||
|
||||
async function main() {
|
||||
console.log(`Seeding ${CITY.name} (${CITY.lat}, ${CITY.lng})`);
|
||||
|
||||
// Truncate in FK-safe order — reseeding must be idempotent.
|
||||
await db.execute(sql`
|
||||
TRUNCATE TABLE
|
||||
audit_log, reviews, payments, bookings, quotes, messages,
|
||||
audit_log, notification_deliveries, reviews, payments, bookings, quotes, messages,
|
||||
matches, requests, swipes, jobs,
|
||||
pro_availability, verification_sessions, credentials,
|
||||
pro_media, pro_categories, pro_profiles,
|
||||
@@ -186,7 +325,15 @@ async function main() {
|
||||
CLIENTS.map((name, i) => ({
|
||||
name,
|
||||
email: `client${i + 1}@linkder.test`,
|
||||
phoneNumber: `+3460000${String(i + 1).padStart(4, '0')}`,
|
||||
/**
|
||||
* The first client gets the dev-login number (see web/src/server/dev-login.ts).
|
||||
*
|
||||
* Without this, signing in locally creates a brand-new empty user and
|
||||
* every seeded job, match and conversation belongs to somebody you
|
||||
* cannot log in as — which makes the seed data invisible in the app it
|
||||
* exists to fill.
|
||||
*/
|
||||
phoneNumber: i === 0 ? '+34600000000' : `+3460000${String(i + 1).padStart(4, '0')}`,
|
||||
role: 'client' as const,
|
||||
emailVerified: true,
|
||||
phoneNumberVerified: true,
|
||||
@@ -205,6 +352,9 @@ async function main() {
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
/** Kept so the review pass below can build a history for each pro. */
|
||||
const proRows: { id: string; seed: SeedPro; categoryId: string }[] = [];
|
||||
|
||||
for (const [i, p] of PROS.entries()) {
|
||||
const bearing = (i * 360) / PROS.length;
|
||||
const pos = offset(CITY.lat, CITY.lng, p.distanceM, bearing);
|
||||
@@ -234,7 +384,12 @@ async function main() {
|
||||
hourlyRateCents: 3_500 + (i % 6) * 500,
|
||||
yearsExperience: 2 + (i % 18),
|
||||
baseLocation: pos,
|
||||
// The seed places pros at known bearings and distances, so these ARE real
|
||||
// points — labelling them `city` would make every seeded pro fail the
|
||||
// submitForReview gate and read as unlocatable in tests.
|
||||
baseLocationPrecision: 'exact' as const,
|
||||
serviceRadiusM: p.radius ?? DEFAULT_SERVICE_RADIUS_M,
|
||||
skills: p.skills ?? [],
|
||||
verificationStatus: status,
|
||||
verifiedAt: status === 'verified' ? new Date() : null,
|
||||
isAcceptingJobs: !p.away,
|
||||
@@ -249,6 +404,7 @@ async function main() {
|
||||
const catId = catBySlug.get(p.cat);
|
||||
if (!catId) throw new Error(`unknown category ${p.cat}`);
|
||||
await db.insert(schema.proCategories).values({ proId: user.id, categoryId: catId });
|
||||
proRows.push({ id: user.id, seed: p, categoryId: catId });
|
||||
|
||||
const slug = encodeURIComponent(p.name.toLowerCase().replace(/\s+/g, '-'));
|
||||
// The first photo is the deck card, so it has to be a FACE. picsum returns
|
||||
@@ -281,6 +437,174 @@ async function main() {
|
||||
const eligible = PROS.filter((p) => !p.unverified && !p.away).length;
|
||||
console.log(` ${PROS.length} pros (${eligible} deck-eligible)`);
|
||||
|
||||
/**
|
||||
/**
|
||||
* The work behind every counter on a pro's card.
|
||||
*
|
||||
* A review row cannot exist on its own — it hangs off a booking, which hangs
|
||||
* off a quote, a match, a request and a job. Seeding the whole chain rather
|
||||
* than faking the leaf is the point: `ratingAvg`, `ratingCount`,
|
||||
* `completedJobs`, `responseRate` and `avgResponseMinutes` are DERIVED from
|
||||
* these rows at the end of this file, so a pro credited with 47 reviews has
|
||||
* 47 of them and the deck ranks on a history that exists.
|
||||
*
|
||||
* Inserted a table at a time rather than a row at a time: this is ~600
|
||||
* histories across six tables, and one round trip per row makes the seed take
|
||||
* minutes. Postgres returns a single multi-row INSERT ... RETURNING in the
|
||||
* order the values were given, which is what lets the next table's foreign
|
||||
* keys line up by index.
|
||||
*/
|
||||
let reviewCount = 0;
|
||||
let ignoredCount = 0;
|
||||
for (const [i, pro] of proRows.entries()) {
|
||||
if (pro.seed.reviews === 0 || pro.seed.rating === null) continue;
|
||||
|
||||
const work = WORK[pro.seed.cat] ?? GENERIC_WORK;
|
||||
const n = pro.seed.reviews;
|
||||
const ratings = seedRatings(pro.seed.rating, n);
|
||||
// Spread across pros so the deck has something to rank on. Bodies cycle
|
||||
// past the end of the trade's list; the newest are laid down first, so the
|
||||
// page a profile actually shows stays varied.
|
||||
const replyMinutes = 15 + (i % 8) * 20;
|
||||
|
||||
/*
|
||||
* Requests this pro let expire without answering.
|
||||
*
|
||||
* `responseRate` is answered ÷ decided, so with nothing but accepted
|
||||
* requests every pro scores a flat 1.000 and the ranking weight does
|
||||
* nothing. Capped rather than solved exactly: the ratio only has to vary
|
||||
* and be real, and each one costs a job row.
|
||||
*/
|
||||
const targetRate = Math.min(0.98, 0.6 + (i % 40) / 100);
|
||||
const ignored = Math.min(8, Math.round((n * (1 - targetRate)) / targetRate));
|
||||
|
||||
const at = (k: number) => new Date(now - (k + 1) * 9 * 86_400_000 - i * 3_600_000);
|
||||
|
||||
const jobRows = await db
|
||||
.insert(schema.jobs)
|
||||
.values([
|
||||
...Array.from({ length: n }, (_, k) => {
|
||||
const w = work[k % work.length]!;
|
||||
return {
|
||||
clientId: clientRows[(i + k) % clientRows.length]!.id,
|
||||
categoryId: pro.categoryId,
|
||||
title: w.title,
|
||||
description: w.scope,
|
||||
photos: [],
|
||||
urgency: 'flexible' as const,
|
||||
location: { lat: CITY.lat, lng: CITY.lng },
|
||||
locationPrecision: 'exact' as const,
|
||||
addressText: `Carrer Example ${10 + (k % 40)}, ${CITY.name}`,
|
||||
status: 'completed' as const,
|
||||
createdAt: new Date(at(k).getTime() - 6 * 86_400_000),
|
||||
};
|
||||
}),
|
||||
// The ones nobody answered. Cancelled, because that is what a client
|
||||
// does when a pro never replies.
|
||||
...Array.from({ length: ignored }, (_, k) => ({
|
||||
clientId: clientRows[(i + k + 1) % clientRows.length]!.id,
|
||||
categoryId: pro.categoryId,
|
||||
title: work[(k + 1) % work.length]!.title,
|
||||
description: work[(k + 1) % work.length]!.scope,
|
||||
photos: [],
|
||||
urgency: 'this_week' as const,
|
||||
location: { lat: CITY.lat, lng: CITY.lng },
|
||||
locationPrecision: 'exact' as const,
|
||||
addressText: `Carrer Example ${60 + (k % 20)}, ${CITY.name}`,
|
||||
status: 'cancelled' as const,
|
||||
createdAt: new Date(at(n + k).getTime() - 6 * 86_400_000),
|
||||
})),
|
||||
])
|
||||
.returning({ id: schema.jobs.id });
|
||||
|
||||
const requestRows = await db
|
||||
.insert(schema.requests)
|
||||
.values(
|
||||
jobRows.map((job, k) => {
|
||||
const answered = k < n;
|
||||
// Sent, then answered `replyMinutes` later. Left to the column
|
||||
// default `created_at` would be now() while `responded_at` sat months
|
||||
// in the past, and every derived response time came out negative.
|
||||
const sentAt = new Date(at(k).getTime() - 5 * 86_400_000);
|
||||
return {
|
||||
jobId: job.id,
|
||||
proId: pro.id,
|
||||
status: answered ? ('accepted' as const) : ('expired' as const),
|
||||
createdAt: sentAt,
|
||||
respondedAt: answered
|
||||
? new Date(sentAt.getTime() + replyMinutes * 60_000)
|
||||
: null,
|
||||
expiresAt: new Date(sentAt.getTime() + 48 * 3_600_000),
|
||||
};
|
||||
}),
|
||||
)
|
||||
.returning({ id: schema.requests.id });
|
||||
|
||||
const matchRows = await db
|
||||
.insert(schema.matches)
|
||||
.values(
|
||||
requestRows.slice(0, n).map((req, k) => ({
|
||||
requestId: req.id,
|
||||
jobId: jobRows[k]!.id,
|
||||
proId: pro.id,
|
||||
clientId: clientRows[(i + k) % clientRows.length]!.id,
|
||||
})),
|
||||
)
|
||||
.returning({ id: schema.matches.id });
|
||||
|
||||
const quoteRows = await db
|
||||
.insert(schema.quotes)
|
||||
.values(
|
||||
matchRows.map((match, k) => ({
|
||||
matchId: match.id,
|
||||
kind: 'fixed' as const,
|
||||
amountCents: work[k % work.length]!.amountCents,
|
||||
scope: work[k % work.length]!.scope,
|
||||
status: 'accepted' as const,
|
||||
validUntil: new Date(at(k).getTime() - 2 * 86_400_000),
|
||||
respondedAt: new Date(at(k).getTime() - 3 * 86_400_000),
|
||||
})),
|
||||
)
|
||||
.returning({ id: schema.quotes.id });
|
||||
|
||||
const bookingRows = await db
|
||||
.insert(schema.bookings)
|
||||
.values(
|
||||
quoteRows.map((quote, k) => ({
|
||||
matchId: matchRows[k]!.id,
|
||||
quoteId: quote.id,
|
||||
scheduledStart: new Date(at(k).getTime() - 4 * 3_600_000),
|
||||
scheduledEnd: at(k),
|
||||
status: 'completed' as const,
|
||||
proCompletedAt: at(k),
|
||||
clientConfirmedAt: new Date(at(k).getTime() + 2 * 3_600_000),
|
||||
})),
|
||||
)
|
||||
.returning({ id: schema.bookings.id });
|
||||
|
||||
await db.insert(schema.reviews).values(
|
||||
bookingRows.map((booking, k) => ({
|
||||
bookingId: booking.id,
|
||||
authorId: clientRows[(i + k) % clientRows.length]!.id,
|
||||
subjectId: pro.id,
|
||||
rating: ratings[k]!,
|
||||
body: work[k % work.length]!.review,
|
||||
// Set, and in the past: `published_at` is the moderation gate that
|
||||
// keeps a review hidden until both sides have written one, and both
|
||||
// `pro.reviews` and the rating counters read nothing without it.
|
||||
publishedAt: new Date(at(k).getTime() + 3 * 86_400_000),
|
||||
createdAt: new Date(at(k).getTime() + 2 * 86_400_000),
|
||||
})),
|
||||
);
|
||||
|
||||
reviewCount += n;
|
||||
ignoredCount += ignored;
|
||||
}
|
||||
console.log(
|
||||
` ${reviewCount} published reviews across completed bookings, ` +
|
||||
`${ignoredCount} requests left to expire`,
|
||||
);
|
||||
|
||||
// One open job at the exact city centre — the fixture every deck test uses.
|
||||
const firstClient = clientRows[0];
|
||||
const plumberCat = catBySlug.get('plumber');
|
||||
@@ -298,12 +622,162 @@ async function main() {
|
||||
budgetMinCents: 8_000,
|
||||
budgetMaxCents: 20_000,
|
||||
location: { lat: CITY.lat, lng: CITY.lng },
|
||||
locationPrecision: 'exact' as const,
|
||||
addressText: `Carrer Example 12, ${CITY.name}`,
|
||||
})
|
||||
.returning();
|
||||
console.log(` 1 open job at the city centre (${job?.id})`);
|
||||
|
||||
/**
|
||||
* A job with a conversation on it, and one that is already history.
|
||||
*
|
||||
* The jobs tab has three screens — the list, the pros on a job, and the chat
|
||||
* — and none of them can be looked at against a database whose only job is
|
||||
* open with nobody on it. This is the smallest fixture that lights all three
|
||||
* and gives the Current/Past segments something on each side.
|
||||
*/
|
||||
const [chattyPro] = await db
|
||||
.select({ id: schema.proProfiles.userId })
|
||||
.from(schema.proProfiles)
|
||||
.where(sql`${schema.proProfiles.verificationStatus} = 'verified'`)
|
||||
.limit(1);
|
||||
|
||||
if (chattyPro) {
|
||||
const conversations = [
|
||||
{
|
||||
status: 'matched' as const,
|
||||
title: 'Radiator not heating up in the back bedroom',
|
||||
description:
|
||||
'One radiator stays cold while the rest of the house is fine. Bled it twice, no change. Boiler was serviced in the spring.',
|
||||
messages: [
|
||||
{ fromPro: false, body: 'Hi — are you free to take a look this week?' },
|
||||
{ fromPro: true, body: 'I can do Thursday afternoon. Is the boiler a combi?' },
|
||||
{ fromPro: false, body: 'It is, a Vaillant. Thursday works, any time after 14:00.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
status: 'completed' as const,
|
||||
title: 'Replace the outside tap',
|
||||
description:
|
||||
'Old garden tap is seized and weeping at the thread. Needs replacing, easy access from the patio.',
|
||||
messages: [
|
||||
{ fromPro: false, body: 'Could you replace an outside tap?' },
|
||||
{ fromPro: true, body: 'Yes — done. New tap fitted and tested, no drips.' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const c of conversations) {
|
||||
const [j] = await db
|
||||
.insert(schema.jobs)
|
||||
.values({
|
||||
clientId: firstClient.id,
|
||||
categoryId: plumberCat,
|
||||
title: c.title,
|
||||
description: c.description,
|
||||
photos: [],
|
||||
urgency: 'this_week' as const,
|
||||
location: { lat: CITY.lat, lng: CITY.lng },
|
||||
locationPrecision: 'exact' as const,
|
||||
addressText: `Carrer Example 12, ${CITY.name}`,
|
||||
status: c.status,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const [req] = await db
|
||||
.insert(schema.requests)
|
||||
.values({
|
||||
jobId: j!.id,
|
||||
proId: chattyPro.id,
|
||||
status: 'accepted',
|
||||
// Sent two hours ago and answered an hour later. `created_at` has
|
||||
// to be set: left to the column default it is now(), which puts the
|
||||
// response BEFORE the request and drags the pro's derived
|
||||
// avgResponseMinutes negative.
|
||||
createdAt: new Date(now - 2 * 3_600_000),
|
||||
respondedAt: new Date(now - 3_600_000),
|
||||
expiresAt: new Date(now + 86_400_000),
|
||||
})
|
||||
.returning();
|
||||
|
||||
const [match] = await db
|
||||
.insert(schema.matches)
|
||||
.values({
|
||||
requestId: req!.id,
|
||||
jobId: j!.id,
|
||||
proId: chattyPro.id,
|
||||
clientId: firstClient.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Spaced a minute apart so the thread has a readable order, and the
|
||||
// pro's last message is left UNREAD — that is what puts a badge on the
|
||||
// tab bar, which is the part worth being able to see.
|
||||
const sentAt = (n: number) => new Date(now - (c.messages.length - n) * 60_000);
|
||||
await db.insert(schema.messages).values(
|
||||
c.messages.map((m, n) => ({
|
||||
matchId: match!.id,
|
||||
senderId: m.fromPro ? chattyPro.id : firstClient.id,
|
||||
body: m.body,
|
||||
createdAt: sentAt(n),
|
||||
readAt: m.fromPro && n === c.messages.length - 1 ? null : sentAt(n),
|
||||
})),
|
||||
);
|
||||
|
||||
await db
|
||||
.update(schema.matches)
|
||||
.set({ lastMessageAt: sentAt(c.messages.length - 1) })
|
||||
.where(sql`${schema.matches.id} = ${match!.id}`);
|
||||
|
||||
/*
|
||||
* The finished one gets the full commercial trail: quote, booking,
|
||||
* completed. Without it the Past tab has a job in it and nothing to do,
|
||||
* and the review flow — which only opens on a completed booking — is
|
||||
* invisible in the running app.
|
||||
*/
|
||||
if (c.status === 'completed') {
|
||||
const [q] = await db
|
||||
.insert(schema.quotes)
|
||||
.values({
|
||||
matchId: match!.id,
|
||||
kind: 'fixed',
|
||||
amountCents: 8_500,
|
||||
scope: 'Supply and fit a new outside tap, including the wall plate and sealing.',
|
||||
status: 'accepted',
|
||||
validUntil: new Date(now - 5 * 86_400_000),
|
||||
respondedAt: new Date(now - 6 * 86_400_000),
|
||||
})
|
||||
.returning();
|
||||
|
||||
await db.insert(schema.bookings).values({
|
||||
matchId: match!.id,
|
||||
quoteId: q!.id,
|
||||
scheduledStart: new Date(now - 4 * 86_400_000),
|
||||
scheduledEnd: new Date(now - 4 * 86_400_000 + 2 * 3_600_000),
|
||||
status: 'completed',
|
||||
proCompletedAt: new Date(now - 4 * 86_400_000 + 2 * 3_600_000),
|
||||
clientConfirmedAt: new Date(now - 4 * 86_400_000 + 3 * 3_600_000),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` 2 jobs with conversations (1 current, 1 past)`);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Derive the ranking counters from everything above.
|
||||
*
|
||||
* ratingAvg, ratingCount, completedJobs, responseRate and
|
||||
* avgResponseMinutes used to be written straight onto the profile beside a
|
||||
* history that did not contain them, so the seed asserted a record no query
|
||||
* could reproduce. Deriving them here makes this a fixture the deck ranking
|
||||
* can be tested against, and exercises the same function the app calls on
|
||||
* every accept, decline and review.
|
||||
*/
|
||||
await recomputeAllProStats(db);
|
||||
console.log(' ranking counters derived from the seeded history');
|
||||
|
||||
console.log('\nSeed complete. Sign in as client1@linkder.test or pro1@linkder.test');
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,20 @@ let jobId: string;
|
||||
let clientId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
/*
|
||||
* The fixture job, selected by what makes it the fixture rather than by
|
||||
* position. It used to be "the oldest job", which held only while it was the
|
||||
* only job: the seed now backdates hundreds of completed ones to give pros a
|
||||
* real record, and the oldest row became somebody else's finished electrical
|
||||
* job — so a deck of plumbers was asserted against a deck of electricians.
|
||||
*
|
||||
* It is also the only OPEN job at the centre, and open is the only state a
|
||||
* deck is ever built for.
|
||||
*/
|
||||
const rows = await db.execute<{ id: string; client_id: string }>(
|
||||
sql`SELECT id, client_id FROM jobs ORDER BY created_at LIMIT 1`,
|
||||
sql`SELECT id, client_id FROM jobs
|
||||
WHERE urgency = 'now' AND status = 'open'
|
||||
ORDER BY created_at LIMIT 1`,
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) throw new Error('No seeded job found — run `pnpm db:seed` first');
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Integration test — runs against a live seeded database.
|
||||
*
|
||||
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
||||
* pnpm --filter @linkder/db test
|
||||
*
|
||||
* Search is a second door onto the same supply as the deck, so the test that
|
||||
* matters most is the parity one: a pro who can be found here must be a pro who
|
||||
* could be swiped there. That invariant is why `eligiblePro()` exists.
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
config({ path: '../../.env' });
|
||||
|
||||
const { closePool, db } = await import('../src/client');
|
||||
const { searchPros } = await import('../src/queries/search');
|
||||
const { getShowcaseDeck } = await import('../src/queries/deck');
|
||||
|
||||
/** The seed places every pro relative to this point. */
|
||||
const CENTRE = { lat: 41.3874, lng: 2.1686 };
|
||||
|
||||
let plumberId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const [plumber] = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM categories WHERE slug = 'plumber' LIMIT 1`,
|
||||
);
|
||||
if (!plumber) throw new Error('plumber category missing from seed');
|
||||
plumberId = plumber.id;
|
||||
|
||||
// Seeded skills are empty, so text search has nothing to match until we give
|
||||
// one pro something to find. Marc Oliveras is 800 m from the centre.
|
||||
await db.execute(sql`
|
||||
UPDATE pro_profiles SET skills = ARRAY['Underfloor heating', 'Emergency callouts']
|
||||
WHERE user_id = (SELECT id FROM users WHERE name = 'Marc Oliveras')
|
||||
`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.execute(sql`
|
||||
UPDATE pro_profiles SET skills = '{}'::text[]
|
||||
WHERE user_id = (SELECT id FROM users WHERE name = 'Marc Oliveras')
|
||||
`);
|
||||
await closePool();
|
||||
});
|
||||
|
||||
describe('searchPros', () => {
|
||||
it('returns eligible pros with no query at all', async () => {
|
||||
const results = await searchPros(db, { ...CENTRE, limit: 50 });
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('never returns anyone the shop window would not show', async () => {
|
||||
// The parity invariant. If these two ever disagree, one surface has become
|
||||
// the way around the other.
|
||||
const found = await searchPros(db, { ...CENTRE, limit: 50 });
|
||||
const showcase = await getShowcaseDeck(db, { ...CENTRE, limit: 100 });
|
||||
const shownIds = new Set(showcase.map((c) => c.proId));
|
||||
|
||||
for (const pro of found) {
|
||||
expect(shownIds.has(pro.proId)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('excludes the unverified, the away and the too-far', async () => {
|
||||
const names = (await searchPros(db, { ...CENTRE, limit: 50 })).map((p) => p.name);
|
||||
expect(names).not.toContain('Unverified Ulla');
|
||||
expect(names).not.toContain('Away Arnau');
|
||||
expect(names).not.toContain('Pau Ribas'); // 22 km out, 5 km radius
|
||||
});
|
||||
|
||||
it('matches a skill', async () => {
|
||||
const names = (await searchPros(db, { ...CENTRE, q: 'underfloor', limit: 50 })).map(
|
||||
(p) => p.name,
|
||||
);
|
||||
expect(names).toContain('Marc Oliveras');
|
||||
expect(names).not.toContain('Laia Mestre'); // an electrician with no such skill
|
||||
});
|
||||
|
||||
it('matches a trade name', async () => {
|
||||
const results = await searchPros(db, { ...CENTRE, q: 'electrician', limit: 50 });
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results.every((p) => p.categories.includes('Electrician'))).toBe(true);
|
||||
});
|
||||
|
||||
it('matches a pro by name', async () => {
|
||||
const names = (await searchPros(db, { ...CENTRE, q: 'oliveras', limit: 50 })).map((p) => p.name);
|
||||
expect(names).toEqual(['Marc Oliveras']);
|
||||
});
|
||||
|
||||
it('treats wildcards as literal characters', async () => {
|
||||
// Without escaping, '%' matches everyone and the filter looks broken.
|
||||
const results = await searchPros(db, { ...CENTRE, q: '%', limit: 50 });
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('narrows to one trade', async () => {
|
||||
const results = await searchPros(db, { ...CENTRE, categoryId: plumberId, limit: 50 });
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results.every((p) => p.categories.includes('Plumber'))).toBe(true);
|
||||
});
|
||||
|
||||
it("honours the searcher's own distance limit", async () => {
|
||||
const near = await searchPros(db, { ...CENTRE, maxDistanceM: 3_000, limit: 50 });
|
||||
expect(near.every((p) => p.distanceM <= 3_000)).toBe(true);
|
||||
expect(near.map((p) => p.name)).not.toContain('Marta Vidal'); // 9.1 km out
|
||||
});
|
||||
|
||||
it('sorts by distance, price and rating', async () => {
|
||||
const nearest = await searchPros(db, { ...CENTRE, sort: 'nearest', limit: 50 });
|
||||
for (let i = 1; i < nearest.length; i++) {
|
||||
expect(nearest[i]!.distanceM).toBeGreaterThanOrEqual(nearest[i - 1]!.distanceM);
|
||||
}
|
||||
|
||||
const cheapest = await searchPros(db, { ...CENTRE, sort: 'price', limit: 50 });
|
||||
for (let i = 1; i < cheapest.length; i++) {
|
||||
expect(cheapest[i]!.hourlyRateCents).toBeGreaterThanOrEqual(cheapest[i - 1]!.hourlyRateCents);
|
||||
}
|
||||
|
||||
const rated = await searchPros(db, { ...CENTRE, sort: 'rating', limit: 50 });
|
||||
const scored = rated.filter((p) => p.ratingAvg !== null);
|
||||
for (let i = 1; i < scored.length; i++) {
|
||||
expect(scored[i]!.ratingAvg!).toBeLessThanOrEqual(scored[i - 1]!.ratingAvg!);
|
||||
}
|
||||
// Unrated pros sort last rather than being read as zero.
|
||||
const firstUnrated = rated.findIndex((p) => p.ratingAvg === null);
|
||||
if (firstUnrated !== -1) {
|
||||
expect(rated.slice(firstUnrated).every((p) => p.ratingAvg === null)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('excludes the unrated from a rating floor', async () => {
|
||||
const results = await searchPros(db, { ...CENTRE, minRating: 4.5, limit: 50 });
|
||||
expect(results.every((p) => p.ratingAvg !== null && p.ratingAvg >= 4.5)).toBe(true);
|
||||
});
|
||||
|
||||
it('honours a price ceiling and the limit', async () => {
|
||||
const cheap = await searchPros(db, { ...CENTRE, maxHourlyRateCents: 3_000, limit: 50 });
|
||||
expect(cheap.every((p) => p.hourlyRateCents <= 3_000)).toBe(true);
|
||||
|
||||
const two = await searchPros(db, { ...CENTRE, limit: 2 });
|
||||
expect(two).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Integration test — runs against a live seeded database.
|
||||
*
|
||||
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
||||
*
|
||||
* `recomputeProStats` is the only thing that ever writes the five denormalised
|
||||
* ranking counters, and `score()` reads all five. If it drifts from the rows it
|
||||
* claims to summarise, the deck ranks on fiction and nothing fails — which is
|
||||
* the situation this function was written to end, so it needs a test that
|
||||
* checks the arithmetic rather than that it ran.
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
config({ path: '../../.env' });
|
||||
|
||||
const { closePool, db } = await import('../src/client');
|
||||
const { recomputeProStats } = await import('../src/queries/stats');
|
||||
|
||||
const RUN = Math.random().toString(36).slice(2, 8);
|
||||
|
||||
interface Counters {
|
||||
rating_avg: string | null;
|
||||
rating_count: number;
|
||||
completed_jobs: number;
|
||||
response_rate: string | null;
|
||||
avg_response_minutes: number | null;
|
||||
}
|
||||
|
||||
/** This file's own pro and client, so a parallel test file never sees them. */
|
||||
let pro: string;
|
||||
let client: string;
|
||||
let categoryId: string;
|
||||
|
||||
async function counters(): Promise<Counters> {
|
||||
const [row] = await db.execute<Counters>(sql`
|
||||
SELECT rating_avg, rating_count, completed_jobs, response_rate, avg_response_minutes
|
||||
FROM pro_profiles WHERE user_id = ${pro}
|
||||
`);
|
||||
return row!;
|
||||
}
|
||||
|
||||
/**
|
||||
* One finished, reviewed job.
|
||||
*
|
||||
* The whole chain, because that is what the counters read: a review hangs off a
|
||||
* booking, which hangs off a quote, a match and a request.
|
||||
*/
|
||||
async function completedJob(opts: {
|
||||
rating: number;
|
||||
published: boolean;
|
||||
replyMinutes: number;
|
||||
}): Promise<void> {
|
||||
const [job] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO jobs (client_id, category_id, title, description, urgency, location, address_text, status)
|
||||
VALUES (${client}, ${categoryId}, 'Stats probe', 'Probe job for the stats tests.', 'flexible',
|
||||
ST_SetSRID(ST_MakePoint(0.7, 0.7), 4326)::geography, 'Nowhere', 'completed')
|
||||
RETURNING id
|
||||
`);
|
||||
const [request] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO requests (job_id, pro_id, status, created_at, responded_at, expires_at)
|
||||
VALUES (${job!.id}, ${pro}, 'accepted',
|
||||
now() - interval '10 days',
|
||||
now() - interval '10 days' + ${`${opts.replyMinutes} minutes`}::interval,
|
||||
now() - interval '8 days')
|
||||
RETURNING id
|
||||
`);
|
||||
const [match] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO matches (request_id, job_id, pro_id, client_id)
|
||||
VALUES (${request!.id}, ${job!.id}, ${pro}, ${client}) RETURNING id
|
||||
`);
|
||||
const [quote] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO quotes (match_id, kind, amount_cents, scope, status, valid_until)
|
||||
VALUES (${match!.id}, 'fixed', 10000, 'Probe', 'accepted', now() - interval '7 days')
|
||||
RETURNING id
|
||||
`);
|
||||
const [booking] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO bookings (match_id, quote_id, scheduled_start, scheduled_end, status)
|
||||
VALUES (${match!.id}, ${quote!.id}, now() - interval '6 days',
|
||||
now() - interval '6 days' + interval '2 hours', 'completed')
|
||||
RETURNING id
|
||||
`);
|
||||
await db.execute(sql`
|
||||
INSERT INTO reviews (booking_id, author_id, subject_id, rating, body, published_at)
|
||||
VALUES (${booking!.id}, ${client}, ${pro}, ${opts.rating}, 'Probe review.',
|
||||
${opts.published ? sql`now() - interval '5 days'` : sql`NULL`})
|
||||
`);
|
||||
}
|
||||
|
||||
/** A request the pro let run out. Counts against responseRate, nothing else. */
|
||||
async function ignoredRequest(): Promise<void> {
|
||||
const [job] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO jobs (client_id, category_id, title, description, urgency, location, address_text, status)
|
||||
VALUES (${client}, ${categoryId}, 'Stats probe (ignored)', 'Probe job.', 'flexible',
|
||||
ST_SetSRID(ST_MakePoint(0.7, 0.7), 4326)::geography, 'Nowhere', 'cancelled')
|
||||
RETURNING id
|
||||
`);
|
||||
await db.execute(sql`
|
||||
INSERT INTO requests (job_id, pro_id, status, created_at, expires_at)
|
||||
VALUES (${job!.id}, ${pro}, 'expired', now() - interval '10 days', now() - interval '8 days')
|
||||
`);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const [cat] = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM categories ORDER BY position LIMIT 1`,
|
||||
);
|
||||
categoryId = cat!.id;
|
||||
|
||||
const [p] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO users (name, email, role) VALUES ('Stats Probe', ${`stats-pro-${RUN}@example.com`}, 'pro')
|
||||
RETURNING id
|
||||
`);
|
||||
pro = p!.id;
|
||||
|
||||
const [c] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO users (name, email, role) VALUES ('Stats Client', ${`stats-client-${RUN}@example.com`}, 'client')
|
||||
RETURNING id
|
||||
`);
|
||||
client = c!.id;
|
||||
|
||||
await db.execute(sql`
|
||||
INSERT INTO pro_profiles (
|
||||
user_id, headline, bio, hourly_rate_cents, base_location, service_radius_m,
|
||||
verification_status, verified_at, rating_avg, rating_count, completed_jobs,
|
||||
response_rate, avg_response_minutes
|
||||
)
|
||||
VALUES (
|
||||
${pro}, 'Stats probe', 'Exists only for the stats tests.', 3000,
|
||||
ST_SetSRID(ST_MakePoint(0.7, 0.7), 4326)::geography, 15000, 'verified', now(),
|
||||
-- Deliberate nonsense, so a test that passes proves the recompute WROTE
|
||||
-- rather than that the seeded value happened to be right.
|
||||
'1.00', 999, 999, '0.001', 999
|
||||
)
|
||||
`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.execute(sql`DELETE FROM users WHERE id IN (${pro}, ${client})`);
|
||||
await closePool();
|
||||
});
|
||||
|
||||
describe('recomputeProStats', () => {
|
||||
it('zeroes a pro with no history instead of leaving stale numbers', async () => {
|
||||
await recomputeProStats(db, pro);
|
||||
const c = await counters();
|
||||
|
||||
expect(c.rating_count).toBe(0);
|
||||
expect(c.completed_jobs).toBe(0);
|
||||
// Not 0.0 — an unrated pro has no rating, and "0.0 ★" reads as a bad score
|
||||
// everywhere it is rendered.
|
||||
expect(c.rating_avg).toBeNull();
|
||||
// Likewise: nobody has asked them anything, so there is no rate to report.
|
||||
expect(c.response_rate).toBeNull();
|
||||
expect(c.avg_response_minutes).toBeNull();
|
||||
});
|
||||
|
||||
it('averages the published reviews and counts them', async () => {
|
||||
await completedJob({ rating: 5, published: true, replyMinutes: 10 });
|
||||
await completedJob({ rating: 4, published: true, replyMinutes: 30 });
|
||||
await recomputeProStats(db, pro);
|
||||
|
||||
const c = await counters();
|
||||
expect(c.rating_count).toBe(2);
|
||||
expect(Number(c.rating_avg)).toBeCloseTo(4.5, 2);
|
||||
expect(c.completed_jobs).toBe(2);
|
||||
expect(c.avg_response_minutes).toBe(20);
|
||||
});
|
||||
|
||||
it('ignores an embargoed review, so the count matches what pro.reviews lists', async () => {
|
||||
// Published_at is the moderation gate. A rating counted in the header but
|
||||
// absent from the list underneath is the exact mismatch the seed used to
|
||||
// have, and the reason both read the same predicate.
|
||||
await completedJob({ rating: 1, published: false, replyMinutes: 10 });
|
||||
await recomputeProStats(db, pro);
|
||||
|
||||
const c = await counters();
|
||||
expect(c.rating_count).toBe(2);
|
||||
expect(Number(c.rating_avg)).toBeCloseTo(4.5, 2);
|
||||
// The booking behind it still happened, though — that is not moderated.
|
||||
expect(c.completed_jobs).toBe(3);
|
||||
});
|
||||
|
||||
it('scores response rate over decided requests, not over sent ones', async () => {
|
||||
await ignoredRequest();
|
||||
await recomputeProStats(db, pro);
|
||||
|
||||
// Three answered, one expired unanswered.
|
||||
expect(Number((await counters()).response_rate)).toBeCloseTo(0.75, 3);
|
||||
});
|
||||
|
||||
it('does not count a request that is still inside its window', async () => {
|
||||
const [job] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO jobs (client_id, category_id, title, description, urgency, location, address_text)
|
||||
VALUES (${client}, ${categoryId}, 'Stats probe (live)', 'Probe job.', 'flexible',
|
||||
ST_SetSRID(ST_MakePoint(0.7, 0.7), 4326)::geography, 'Nowhere')
|
||||
RETURNING id
|
||||
`);
|
||||
await db.execute(sql`
|
||||
INSERT INTO requests (job_id, pro_id, status, created_at, expires_at)
|
||||
VALUES (${job!.id}, ${pro}, 'pending', now(), now() + interval '12 hours')
|
||||
`);
|
||||
await recomputeProStats(db, pro);
|
||||
|
||||
// Still 0.75: a request that arrived an hour ago has not been ignored, and
|
||||
// counting it as a miss would punish a pro for work that just came in.
|
||||
expect(Number((await counters()).response_rate)).toBeCloseTo(0.75, 3);
|
||||
});
|
||||
|
||||
it('is idempotent — it derives rather than increments', async () => {
|
||||
await recomputeProStats(db, pro);
|
||||
const once = await counters();
|
||||
await recomputeProStats(db, pro);
|
||||
await recomputeProStats(db, pro);
|
||||
expect(await counters()).toEqual(once);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user