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:
+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');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user