Files
linkder/packages/db/src/seed.ts
T
serfaandClaude Opus 5 0192585727 Stock the trades we serve, and stop offering the ones we don't
A demo hit "That's everyone nearby" on Electrician after four swipes.
That was the deck working — there were exactly four — but the shape of
the catalogue was worse than it looked: 42 of the 50 trades had NO pros
at all, so tapping Roofer or Cleaner hit the dead end immediately rather
than after seven swipes.

Two halves to the fix, and the second matters more.

Depth: 56 more pros, so the fifteen live trades now run 3–15 deep
instead of 1–12. Every photo was picked by reading Unsplash's written
description and keeping only images that show the trade being DONE, then
checking each URL resolves — one of 33 was a 404 and was dropped rather
than seeded broken. Where a photo already belonged to someone in this
file the new pro takes a name of the same gender: the face and the name
on a card have to agree.

Honesty: the other 35 trades are seeded isActive:false. A category with
nobody behind it is not a feature, it is the product claiming to do
something it cannot — and no amount of invented supply fixes that, it
just moves the lie one screen later. LIVE_TRADES is the list, and
widening it means recruiting pros first and adding the slug second.

Locksmith is called out in the file: photo searches return padlocks, not
locksmiths, so two of its cards carry door hardware. A lock is at least
a locksmith's work. A stock portrait of somebody who is plainly not one
is not.

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

964 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Deterministic seed for the launch city.
*
* Pros are placed at KNOWN bearings and distances from the city centre so the
* PostGIS radius filter has an assertable expected result — e.g. a job at the
* centre with pros at 1/3/8/20km lets a test say exactly which cards must appear.
* Nothing here is random; reseeding twice gives the same database.
*/
import { config } from 'dotenv';
import { sql } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { DEFAULT_SERVICE_RADIUS_M } from '@linkdr/shared';
import * as schema from './schema/index';
import { recomputeAllProStats } from './queries/stats';
import { readSsl } from './client';
config({ path: '../../.env' });
const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL is not set');
// Same TLS rule as the app — the seed truncates and rewrites everything, so
// it is the last thing that should reach a managed database unverified.
const ssl = readSsl();
const client = postgres(url, { max: 1, ...(ssl ? { ssl } : {}) });
const db = drizzle(client, { schema });
const CITY = {
name: process.env.NEXT_PUBLIC_CITY_NAME ?? 'Ciudad de México',
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 19.4326),
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? -99.1332),
};
/**
* Real CDMX streets, rotated by index.
*
* A demo where every job is at "Example Street 12" reads as filler on the very
* screen — the job list — that is meant to look like real work. These are all
* in Roma/Condesa, which is inside the seeded pros' service radii.
*/
const STREETS = [
'Av. Álvaro Obregón',
'Calle Durango',
'Av. Ámsterdam',
'Calle Colima',
'Av. Michoacán',
'Calle Orizaba',
'Av. Nuevo León',
'Calle Tonalá',
];
/** "Calle Colima 34, Ciudad de México" — a house number and a street, per index. */
function streetAddress(number: number, k = 0): string {
return `${STREETS[k % STREETS.length]} ${number}, ${CITY.name}`;
}
/** Move a known distance along a bearing from an origin. Accurate enough at city scale. */
function offset(lat: number, lng: number, metres: number, bearingDeg: number) {
const R = 6_371_000;
const br = (bearingDeg * Math.PI) / 180;
const dLat = (metres * Math.cos(br)) / R;
const dLng = (metres * Math.sin(br)) / (R * Math.cos((lat * Math.PI) / 180));
return {
lat: lat + (dLat * 180) / Math.PI,
lng: lng + (dLng * 180) / Math.PI,
};
}
/**
* The trade taxonomy, in display order — the picker and the landing-page trade
* strip both render it top-to-bottom, so the order is the demand order: the
* trades a city marketplace sees most first, the long tail after. Slugs are the
* stable key (pros, jobs and tests reference them); names and icons are cosmetic.
* Icons are lucide names, kebab-case.
*/
/**
* The trades this marketplace can actually serve.
*
* A category with no pros behind it is not a feature, it is a dead end: the
* customer picks it, the deck is empty, and the product has told them it does
* something it does not do. Everything outside this set is seeded `isActive:
* false` and never reaches the trade strip or search.
*
* This is the honest shape of a launch market. Widening it means recruiting
* supply first, then adding the slug here — in that order.
*/
const LIVE_TRADES = new Set([
'plumber', 'electrician', 'handyman', 'painter', 'carpenter', 'locksmith',
'appliance-repair', 'hvac', 'cleaner', 'gardener', 'mover', 'builder',
'roofer', 'window-cleaner', 'pest-control',
]);
const CATEGORIES = [
// Core trades — the eight that carry most of the volume.
{ slug: 'plumber', name: 'Plumber', icon: 'shower-head' },
{ slug: 'electrician', name: 'Electrician', icon: 'zap' },
{ slug: 'handyman', name: 'Handyman', icon: 'wrench' },
{ slug: 'painter', name: 'Painter', icon: 'paint-roller' },
{ slug: 'carpenter', name: 'Carpenter', icon: 'hammer' },
{ slug: 'locksmith', name: 'Locksmith', icon: 'key-round' },
{ slug: 'appliance-repair', name: 'Appliance Repair', icon: 'washing-machine' },
{ slug: 'hvac', name: 'Heating & Cooling', icon: 'thermometer' },
// Home upkeep and renovation.
{ slug: 'cleaner', name: 'House Cleaning', icon: 'sparkles' },
{ slug: 'gardener', name: 'Gardening & Landscaping', icon: 'sprout' },
{ slug: 'mover', name: 'Removals & Moving', icon: 'truck' },
{ slug: 'builder', name: 'Builder & Renovation', icon: 'brick-wall' },
{ slug: 'tiler', name: 'Tiling', icon: 'grid-2x2' },
{ slug: 'plasterer', name: 'Plastering & Drywall', icon: 'layers' },
{ slug: 'roofer', name: 'Roofing', icon: 'house' },
{ slug: 'flooring', name: 'Flooring & Parquet', icon: 'grid-3x3' },
{ slug: 'window-fitter', name: 'Windows & Glazing', icon: 'app-window' },
{ slug: 'blinds-curtains', name: 'Blinds & Curtains', icon: 'blinds' },
{ slug: 'kitchen-fitter', name: 'Kitchen Fitting', icon: 'cooking-pot' },
{ slug: 'bathroom-fitter', name: 'Bathroom Fitting', icon: 'bath' },
{ slug: 'gas-engineer', name: 'Gas & Boilers', icon: 'flame' },
{ slug: 'solar', name: 'Solar & Batteries', icon: 'sun' },
{ slug: 'drain-unblocking', name: 'Drains & Unblocking', icon: 'droplets' },
{ slug: 'pest-control', name: 'Pest Control', icon: 'bug' },
{ slug: 'waste-removal', name: 'Waste & Junk Removal', icon: 'trash-2' },
{ slug: 'window-cleaner', name: 'Window Cleaning', icon: 'spray-can' },
{ slug: 'pool-maintenance', name: 'Pool Maintenance', icon: 'waves' },
{ slug: 'upholstery', name: 'Upholstery & Furniture Repair', icon: 'sofa' },
{ slug: 'alarms-cctv', name: 'Alarms & CCTV', icon: 'cctv' },
// Devices and vehicles.
{ slug: 'it-support', name: 'Computer & IT Support', icon: 'laptop' },
{ slug: 'phone-repair', name: 'Phone & Tablet Repair', icon: 'smartphone' },
{ slug: 'car-mechanic', name: 'Car Mechanic', icon: 'car' },
{ slug: 'car-detailing', name: 'Car Wash & Detailing', icon: 'car-front' },
// People care.
{ slug: 'babysitter', name: 'Childcare & Nannies', icon: 'baby' },
{ slug: 'elderly-care', name: 'Elderly Care', icon: 'heart-handshake' },
{ slug: 'pet-care', name: 'Pet Care & Dog Walking', icon: 'dog' },
{ slug: 'massage', name: 'Massage & Physio', icon: 'hand-heart' },
{ slug: 'hairdresser', name: 'Hairdresser & Barber', icon: 'scissors' },
{ slug: 'beautician', name: 'Beauty & Nails', icon: 'gem' },
{ slug: 'personal-trainer', name: 'Personal Trainer', icon: 'dumbbell' },
// Lessons.
{ slug: 'tutor', name: 'Private Tutor', icon: 'graduation-cap' },
{ slug: 'music-teacher', name: 'Music Lessons', icon: 'music' },
// Events and creative.
{ slug: 'photographer', name: 'Photographer', icon: 'camera' },
{ slug: 'videographer', name: 'Video & Drone', icon: 'video' },
{ slug: 'dj', name: 'DJ & Live Music', icon: 'disc-3' },
{ slug: 'catering', name: 'Catering & Private Chefs', icon: 'chef-hat' },
{ slug: 'event-planner', name: 'Events & Parties', icon: 'party-popper' },
// Professional services.
{ slug: 'accountant', name: 'Accounting & Tax', icon: 'calculator' },
{ slug: 'lawyer', name: 'Legal Services', icon: 'scale' },
{ slug: 'architect', name: 'Architect & Surveyor', icon: 'drafting-compass' },
];
interface SeedPro {
name: string;
cat: string;
/** Free-text specialisms. Search matches these, so a few pros must have some. */
skills?: string[];
/**
* REQUIRED. The Unsplash photo shown on this pro's card.
*
* The deck is a people-picker: a card is somebody you are deciding whether to
* let into your home, so a stock portrait of a stranger under the word
* "Electrician" reads as a dating app, and a keyword search for "painter"
* returns oil paintings. Both were tried and both were wrong.
*
* Every id below was chosen by reading Unsplash's own written description and
* keeping only those that say a PERSON is doing THAT trade. Verified by text,
* not by luck — so if one looks wrong, the fix is to swap the id here.
*/
photo: string;
distanceM: number;
rating: number | null;
reviews: number;
radius: number;
isNew?: boolean;
unverified?: boolean;
away?: boolean;
}
/** distanceM is measured from the city centre — deck tests assert against these. */
const PROS: SeedPro[] = [
{ name: 'Antón Bautista', cat: 'plumber', photo: 'photo-1621905252507-b35492cc74b4', distanceM: 2_400, rating: 4.7, reviews: 23, radius: 15_000,
skills: ['Bathroom fitting', 'Leak detection'] },
{ name: 'Jorge Pineda', cat: 'plumber', photo: 'photo-1659353588842-891391e6fcd4', distanceM: 6_500, rating: 4.5, reviews: 12, radius: 10_000 },
{ name: 'Norma Salgado', cat: 'plumber', photo: 'photo-1558618666-fcd25c85cd64', 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: 'Pablo Rivas', cat: 'plumber', photo: 'photo-1749532125405-70950966b0e5', distanceM: 22_000, rating: 4.8, reviews: 31, radius: 5_000 },
{ name: 'Martino Gómez', cat: 'electrician', photo: 'photo-1621905251189-08b45d6a269e', distanceM: 1_200, rating: 4.8, reviews: 56, radius: 20_000,
skills: ['EV chargers', 'Rewiring', 'Fuse boards'] },
{ name: 'Omar Campos', cat: 'electrician', photo: 'photo-1660330589693-99889d60181e', distanceM: 3_900, rating: 4.6, reviews: 18, radius: 15_000 },
{ name: 'Marta Villanueva', cat: 'electrician', photo: 'photo-1646640381839-02748ae8ddf0', distanceM: 9_100, rating: 4.9, reviews: 71, radius: 30_000 },
{ name: 'Sergio Bonilla', cat: 'handyman', photo: 'photo-1621905251918-48416bd8575a', distanceM: 1_800, rating: 4.4, reviews: 9, radius: 12_000 },
{ name: 'Iván Serrano', cat: 'handyman', photo: 'photo-1698998882494-57c3e043f340', distanceM: 11_500, rating: 4.2, reviews: 6, radius: 15_000 },
{ name: 'Elena Prado', cat: 'painter', photo: 'photo-1717281234297-3def5ae3eee1', distanceM: 2_100, rating: 4.9, reviews: 28, radius: 20_000 },
{ name: 'Antonio Blanco', cat: 'painter', photo: 'photo-1652829069834-2c05031199c5', distanceM: 7_800, rating: 4.3, reviews: 15, radius: 15_000 },
{ name: 'Rosa Ventura', cat: 'carpenter', photo: 'photo-1659930087003-2d64e33181f7', distanceM: 3_300, rating: 4.8, reviews: 41, radius: 25_000,
skills: ['Fitted wardrobes', 'Listed buildings'] },
{ name: 'Guillermo Cortés', cat: 'carpenter', photo: 'photo-1544164560-adac3045edb2', distanceM: 8_600, rating: 4.6, reviews: 19, radius: 15_000 },
{ name: 'Javier Durán', cat: 'locksmith', photo: 'photo-1676630656246-3047520adfdf', distanceM: 4_400, rating: 4.5, reviews: 22, radius: 20_000 },
{ name: 'Berta Loera', cat: 'appliance-repair', photo: 'photo-1698998882494-57c3e043f340', distanceM: 2_700, rating: 4.8, reviews: 37, radius: 18_000 },
{ name: 'Adrián Fonseca', cat: 'appliance-repair', photo: 'photo-1621905251918-48416bd8575a', distanceM: 10_200, rating: 4.4, reviews: 11, radius: 20_000 },
{ name: 'Carlo Rincón', cat: 'hvac', photo: 'photo-1642749776312-aa42ce20c9f5', distanceM: 3_100, rating: 4.9, reviews: 44, radius: 22_000,
skills: ['Split units', 'Heat pumps'] },
{ name: 'Marco Segura', cat: 'hvac', photo: 'photo-1705579605238-24a90c8799c5', 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: 'Néstor Bosque', cat: 'plumber', photo: 'photo-1676210134188-4c05dd172f89', distanceM: 1_500, rating: null, reviews: 0, radius: 15_000, isNew: true },
// Five more plumbers with real trade photography, so the first deck a client
// sees is deep and looks like the job it is for.
{ name: 'Sergio Fabela', cat: 'plumber', photo: 'photo-1676210133055-eab6ef033ce3', distanceM: 1_100, rating: 4.8, reviews: 62, radius: 18_000,
skills: ['Underfloor heating', 'Emergency callouts', 'Blocked drains', 'Pipe relining'] },
{ name: 'Rogelio Amaya', cat: 'plumber', photo: 'photo-1676210134190-3f2c0d5cf58d', distanceM: 2_900, rating: 4.6, reviews: 38, radius: 20_000,
skills: ['Boiler servicing', 'Radiator installs'] },
{ name: 'Gerardo Solís', cat: 'plumber', photo: 'photo-1621905252507-b35492cc74b4', distanceM: 4_200, rating: 4.9, reviews: 84, radius: 15_000,
skills: ['Bathroom refits', 'Underfloor heating', 'Wet rooms'] },
{ name: 'Alejandro Dávila', cat: 'plumber', photo: 'photo-1659353588842-891391e6fcd4', distanceM: 5_600, rating: 4.4, reviews: 17, radius: 12_000,
skills: ['Leak detection', 'Tap and valve repairs'] },
{ name: 'Gabriel Torres', cat: 'plumber', photo: 'photo-1558618666-fcd25c85cd64', distanceM: 7_300, rating: 4.7, reviews: 29, radius: 25_000,
skills: ['Water heaters', 'Kitchen plumbing', 'Emergency callouts'] },
{ name: 'Julia Cázares', cat: 'electrician', photo: 'photo-1758101755915-462eddc23f57', distanceM: 2_200, rating: null, reviews: 0, radius: 15_000, isNew: true },
// Not verified — must never reach a deck.
{ name: 'Unverified Ulises', cat: 'plumber', photo: 'photo-1749532125405-70950966b0e5', distanceM: 1_000, rating: null, reviews: 0, radius: 15_000, unverified: true },
// Verified but on holiday — must never reach a deck.
{ name: 'Away Arturo', cat: 'plumber', photo: 'photo-1676210134188-4c05dd172f89', distanceM: 1_100, rating: 4.9, reviews: 20, radius: 15_000, away: true },
/*
* Depth, added after a demo ran a trade dry in four swipes.
*
* Every photo below was chosen by reading Unsplash's written description and
* keeping only images that show the trade being done, then checking that each
* URL resolves. Where a trade reuses a photo already in this file, the new pro
* takes a name of the same gender as the existing one — the face and the name
* on a card have to agree.
*/
// ---- more of the trades that already had supply ----
{ name: 'Ramiro Escalante', cat: 'plumber', photo: 'photo-1676210133055-eab6ef033ce3', distanceM: 3_700, rating: 4.6, reviews: 26, radius: 18_000 },
{ name: 'Efraín Nava', cat: 'plumber', photo: 'photo-1749532125405-70950966b0e5', distanceM: 6_200, rating: 4.8, reviews: 51, radius: 22_000 },
{ name: 'Ismael Zúñiga', cat: 'plumber', photo: 'photo-1676210134190-3f2c0d5cf58d', distanceM: 1_300, rating: 4.7, reviews: 34, radius: 15_000 },
{ name: 'Rubén Alcántara', cat: 'electrician', photo: 'photo-1660330589693-99889d60181e', distanceM: 5_400, rating: 4.7, reviews: 39, radius: 22_000 },
{ name: 'Teresa Alcalá', cat: 'electrician', photo: 'photo-1646640381839-02748ae8ddf0', distanceM: 3_100, rating: 4.8, reviews: 45, radius: 20_000 },
{ name: 'Hugo Peralta', cat: 'electrician', photo: 'photo-1621905251189-08b45d6a269e', distanceM: 7_600, rating: 4.5, reviews: 18, radius: 25_000 },
{ name: 'Fidel Barrera', cat: 'handyman', photo: 'photo-1621905251918-48416bd8575a', distanceM: 2_700, rating: 4.6, reviews: 30, radius: 15_000 },
{ name: 'Ramón Ocampo', cat: 'handyman', photo: 'photo-1698998882494-57c3e043f340', distanceM: 5_800, rating: 4.7, reviews: 43, radius: 20_000 },
{ name: 'Ernesto Lira', cat: 'handyman', photo: 'photo-1621905251918-48416bd8575a', distanceM: 8_900, rating: 4.4, reviews: 13, radius: 25_000 },
{ name: 'Verónica Pacheco', cat: 'painter', photo: 'photo-1717281234297-3def5ae3eee1', distanceM: 4_200, rating: 4.8, reviews: 48, radius: 20_000 },
{ name: 'Salvador Ibarra', cat: 'painter', photo: 'photo-1652829069834-2c05031199c5', distanceM: 6_700, rating: 4.6, reviews: 25, radius: 22_000 },
{ name: 'Lorena Quintero', cat: 'painter', photo: 'photo-1717281234297-3def5ae3eee1', distanceM: 1_700, rating: 4.9, reviews: 59, radius: 15_000 },
{ name: 'Emilio Cervantes', cat: 'carpenter', photo: 'photo-1544164560-adac3045edb2', distanceM: 3_800, rating: 4.7, reviews: 36, radius: 20_000 },
{ name: 'Patricia Aguirre', cat: 'carpenter', photo: 'photo-1659930087003-2d64e33181f7', distanceM: 6_300, rating: 4.8, reviews: 54, radius: 25_000 },
{ name: 'Raúl Mejía', cat: 'carpenter', photo: 'photo-1544164560-adac3045edb2', distanceM: 2_200, rating: 4.5, reviews: 20, radius: 18_000 },
// Locksmith is the thinnest trade for photography — the searches return
// padlocks, not locksmiths. Two door-hardware shots carry the extra pros
// rather than a stock portrait of somebody plainly not a locksmith.
{ name: 'Aarón Valdés', cat: 'locksmith', photo: 'photo-1579382311054-0701b08da226', distanceM: 2_900, rating: 4.7, reviews: 33, radius: 22_000 },
{ name: 'Óscar Lozano', cat: 'locksmith', photo: 'photo-1616358284394-acded77dcea5', distanceM: 6_100, rating: 4.6, reviews: 24, radius: 25_000 },
{ name: 'Felipe Carrillo', cat: 'locksmith', photo: 'photo-1676630656246-3047520adfdf', distanceM: 1_400, rating: 4.9, reviews: 58, radius: 18_000 },
{ name: 'Alfonso Arriaga', cat: 'appliance-repair', photo: 'photo-1621905251918-48416bd8575a', distanceM: 4_600, rating: 4.7, reviews: 32, radius: 20_000 },
{ name: 'Mauricio Tapia', cat: 'appliance-repair', photo: 'photo-1698998882494-57c3e043f340', distanceM: 7_200, rating: 4.6, reviews: 22, radius: 25_000 },
{ name: 'Octavio Lugo', cat: 'appliance-repair', photo: 'photo-1621905251918-48416bd8575a', distanceM: 2_500, rating: 4.8, reviews: 46, radius: 18_000 },
{ name: 'Damián Solórzano', cat: 'hvac', photo: 'photo-1642749776312-aa42ce20c9f5', distanceM: 3_300, rating: 4.7, reviews: 37, radius: 22_000 },
{ name: 'Fermín Villaseñor', cat: 'hvac', photo: 'photo-1705579605238-24a90c8799c5', distanceM: 6_900, rating: 4.8, reviews: 50, radius: 25_000 },
{ name: 'Joaquín Bañuelos', cat: 'hvac', photo: 'photo-1642749776312-aa42ce20c9f5', distanceM: 1_100, rating: 4.5, reviews: 21, radius: 15_000 },
// ---- trades that had no supply at all until now ----
{ name: 'Guadalupe Rentería', cat: 'cleaner', photo: 'photo-1646980241033-cd7abda2ee88', distanceM: 1_400, rating: 4.8, reviews: 63, radius: 15_000,
skills: ['Deep cleans', 'Move-out cleans'] },
{ name: 'Alma Trejo', cat: 'cleaner', photo: 'photo-1647381518264-97ff1835026f', distanceM: 3_200, rating: 4.7, reviews: 41, radius: 18_000 },
{ name: 'Rocío Delgado', cat: 'cleaner', photo: 'photo-1758273238415-01ec03d9ef27', distanceM: 5_600, rating: 4.9, reviews: 88, radius: 20_000 },
{ name: 'Isabel Mora', cat: 'cleaner', photo: 'photo-1758523670739-0d26a3ee976d', distanceM: 2_100, rating: 4.6, reviews: 24, radius: 12_000 },
{ name: 'Yolanda Cárdenas', cat: 'cleaner', photo: 'photo-1758272421751-963195322eaa', distanceM: 7_300, rating: 4.5, reviews: 17, radius: 25_000 },
{ name: 'Tomás Vega', cat: 'gardener', photo: 'photo-1637531347055-4fa8aa80c111', distanceM: 2_300, rating: 4.8, reviews: 47, radius: 20_000,
skills: ['Tree surgery', 'Irrigation'] },
{ name: 'Beatriz Olvera', cat: 'gardener', photo: 'photo-1555955208-94f6fafea771', distanceM: 4_700, rating: 4.7, reviews: 29, radius: 18_000 },
{ name: 'Aureliano Sosa', cat: 'gardener', photo: 'photo-1605117882932-f9e32b03fea9', distanceM: 6_100, rating: 4.5, reviews: 15, radius: 25_000 },
{ name: 'Margarita Nieto', cat: 'gardener', photo: 'photo-1728706613021-e447801e1ea6', distanceM: 1_900, rating: 4.9, reviews: 66, radius: 15_000 },
{ name: 'Everardo Rangel', cat: 'gardener', photo: 'photo-1621460249485-4e4f92c9de5d', distanceM: 8_200, rating: 4.3, reviews: 9, radius: 30_000 },
{ name: 'Baltazar Nájera', cat: 'mover', photo: 'photo-1698917414969-feade59e3343', distanceM: 3_400, rating: 4.6, reviews: 38, radius: 40_000 },
{ name: 'Rigoberto Cuevas', cat: 'mover', photo: 'photo-1694715669993-ea0022b470f7', distanceM: 5_900, rating: 4.8, reviews: 55, radius: 45_000,
skills: ['Piano moving', 'Packing service'] },
{ name: 'Nicolás Frías', cat: 'mover', photo: 'photo-1523543659209-5c57c05834aa', distanceM: 2_600, rating: 4.5, reviews: 19, radius: 35_000 },
{ name: 'Hilario Ordaz', cat: 'mover', photo: 'photo-1642756457381-930fdc1e2e2e', distanceM: 7_700, rating: 4.7, reviews: 44, radius: 50_000 },
{ name: 'Damián Bravo', cat: 'mover', photo: 'photo-1554620158-d8d5c2f3a27b', distanceM: 1_800, rating: 4.4, reviews: 11, radius: 30_000 },
{ name: 'Heriberto Alanís', cat: 'builder', photo: 'photo-1587582423116-ec07293f0395', distanceM: 4_300, rating: 4.6, reviews: 27, radius: 35_000 },
{ name: 'Anselmo Padilla', cat: 'builder', photo: 'photo-1563166423-482a8c14b2d6', distanceM: 7_100, rating: 4.8, reviews: 61, radius: 40_000,
skills: ['Extensions', 'Structural work'] },
{ name: 'Ezequiel Maldonado', cat: 'builder', photo: 'photo-1593313637552-29c2c0dacd35', distanceM: 2_400, rating: 4.7, reviews: 35, radius: 25_000 },
{ name: 'Leonardo Gallardo', cat: 'builder', photo: 'photo-1667207591431-0366faabde1a', distanceM: 10_300, rating: 4.5, reviews: 20, radius: 45_000 },
{ name: 'Porfirio Escobar', cat: 'builder', photo: 'photo-1530639834082-05bafb67fbbe', distanceM: 1_500, rating: 4.9, reviews: 73, radius: 30_000 },
{ name: 'Abel Montoya', cat: 'roofer', photo: 'photo-1635424824849-1b09bdcc55b1', distanceM: 4_100, rating: 4.7, reviews: 33, radius: 25_000 },
{ name: 'Cristóbal Ruelas', cat: 'roofer', photo: 'photo-1726589004565-bedfba94d3a2', distanceM: 6_800, rating: 4.6, reviews: 21, radius: 30_000 },
{ name: 'Genaro Ibáñez', cat: 'roofer', photo: 'photo-1633759593085-1eaeb724fc88', distanceM: 2_900, rating: 4.9, reviews: 52, radius: 20_000,
skills: ['Flat roofs', 'Leak tracing'] },
{ name: 'Moisés Contreras', cat: 'roofer', photo: 'photo-1635424709961-f3a150459ad4', distanceM: 9_400, rating: 4.4, reviews: 12, radius: 35_000 },
{ name: 'Ulises Barrios', cat: 'roofer', photo: 'photo-1635424824800-692767998d07', distanceM: 1_600, rating: 4.8, reviews: 40, radius: 18_000 },
{ name: 'Efrén Aparicio', cat: 'window-cleaner', photo: 'photo-1635445818409-64a0ff92eb39', distanceM: 2_800, rating: 4.7, reviews: 31, radius: 20_000 },
{ name: 'Salomón Terán', cat: 'window-cleaner', photo: 'photo-1775654063422-54e0a71578ad', distanceM: 5_100, rating: 4.8, reviews: 49, radius: 25_000 },
{ name: 'Bernardo Quiroz', cat: 'window-cleaner', photo: 'photo-1719499757876-346424e8f67d', distanceM: 8_600, rating: 4.5, reviews: 16, radius: 30_000 },
{ name: 'Rodolfo Zavala', cat: 'pest-control', photo: 'photo-1747659629851-a92bd71149f6', distanceM: 3_600, rating: 4.7, reviews: 42, radius: 25_000 },
{ name: 'Ignacio Bermúdez', cat: 'pest-control', photo: 'photo-1674485135526-b5a686b33dfe', distanceM: 6_400, rating: 4.6, reviews: 23, radius: 30_000 },
{ name: 'Faustino Ayala', cat: 'pest-control', photo: 'photo-1749030415358-f533ad412767', distanceM: 2_000, rating: 4.8, reviews: 57, radius: 20_000 },
{ name: 'Gustavo Meraz', cat: 'pest-control', photo: 'photo-1670989292166-8b20b9530438', distanceM: 9_100, rating: 4.4, reviews: 14, radius: 35_000 },
];
/**
* Customers.
*
* The avatar is not decoration: `users.image` is the face on every review a pro
* has (`pro.reviews` selects it as `authorImage`) and on the chat header. Left
* null, every review on every profile in the demo renders faceless, which is
* the one screen meant to look like other people have used this.
*/
const CLIENTS: { name: string; photo: string }[] = [
// The first one is the dev-login account — see the phone note below.
{ name: 'Robert Pérez', photo: 'photo-1500648767791-00dcc994a43e' },
{ name: 'Daniel Miranda', photo: 'photo-1507003211169-0a1dd7228f2d' },
{ name: 'Emma Rivera', photo: 'photo-1494790108377-be9c29b29330' },
{ name: 'Lucas Ponce', photo: 'photo-1506794778202-cad84cf45f1d' },
{ name: 'Alba Tovar', photo: 'photo-1438761681033-6461ffad8d80' },
];
/**
* 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
* Sergi Fabra rates 4.8, and that now has to come out of 62 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, notification_deliveries, reviews, payments, bookings, quotes, messages,
matches, requests, swipes, jobs,
pro_availability, verification_sessions, credentials,
pro_media, pro_categories, pro_profiles,
sessions, accounts, verifications, users, categories
RESTART IDENTITY CASCADE
`);
const cats = await db
.insert(schema.categories)
.values(CATEGORIES.map((c, i) => ({ ...c, position: i, isActive: LIVE_TRADES.has(c.slug) })))
.returning();
const catBySlug = new Map(cats.map((c) => [c.slug, c.id]));
console.log(` ${cats.length} categories`);
const clientRows = await db
.insert(schema.users)
.values(
CLIENTS.map((c, i) => ({
name: c.name,
// Seeded with the source url and rewritten to our bucket by
// `pnpm assets:migrate`, exactly as pro_media is.
image: `https://images.unsplash.com/${c.photo}?w=200&h=200&fit=crop`,
email: `client${i + 1}@linkder.test`,
/**
* 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 ? '+525500000000' : `+52550000${String(i + 1).padStart(4, '0')}`,
role: 'client' as const,
emailVerified: true,
phoneNumberVerified: true,
phoneVerifiedAt: new Date(),
})),
)
.returning();
console.log(` ${clientRows.length} clients`);
await db.insert(schema.users).values({
name: 'Linkdr Admin',
email: 'admin@linkder.test',
phoneNumber: '+525500009999',
role: 'admin',
emailVerified: true,
});
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);
const [user] = await db
.insert(schema.users)
.values({
name: p.name,
email: `pro${i + 1}@linkder.test`,
phoneNumber: `+52551000${String(i + 1).padStart(4, '0')}`,
role: 'pro' as const,
emailVerified: true,
phoneNumberVerified: true,
phoneVerifiedAt: new Date(),
lastActiveAt: new Date(now - (i % 5) * 86_400_000),
})
.returning();
if (!user) throw new Error('failed to insert pro user');
const catName = CATEGORIES.find((c) => c.slug === p.cat)?.name ?? 'Pro';
const status = p.unverified ? ('pending' as const) : ('verified' as const);
await db.insert(schema.proProfiles).values({
userId: user.id,
headline: `${catName} in ${CITY.name}`,
bio: `${p.name} has been working across ${CITY.name} for years. Reliable, tidy, and turns up when they say they will. Fixed-price quotes agreed before any work starts.`,
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,
ratingAvg: p.rating === null ? null : String(p.rating),
ratingCount: p.reviews,
completedJobs: p.reviews,
responseRate: p.reviews === 0 ? null : String(Math.min(0.99, 0.6 + (i % 40) / 100)),
avgResponseMinutes: 15 + (i % 8) * 20,
createdAt: p.isNew ? new Date(now - 3 * 86_400_000) : new Date(now - 400 * 86_400_000),
});
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 });
/*
* The card photo, and a wider crop of the same shot as the work sample.
*
* Seeded with the ORIGINAL Unsplash url, then rewritten to our own bucket by
* `pnpm assets:migrate`. Keeping the source here rather than a hardcoded
* Spaces url means the seed still works on a machine with no bucket, and the
* migration stays the one place that knows where assets live.
*/
const card = `https://images.unsplash.com/${p.photo}?w=800&h=1000&fit=crop`;
await db.insert(schema.proMedia).values([
{ proId: user.id, url: card, position: 0 },
{
// The second slot is genuinely for work: scenery is fine here.
proId: user.id,
url: `https://images.unsplash.com/${p.photo}?w=1200&h=900&fit=crop`,
kind: 'work_sample' as const,
position: 1,
},
]);
// MonFri, 08:0018:00
await db.insert(schema.proAvailability).values(
[1, 2, 3, 4, 5].map((weekday) => ({
proId: user.id,
weekday,
startMinute: 8 * 60,
endMinute: 18 * 60,
})),
);
}
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: streetAddress(10 + (k % 40), k),
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: streetAddress(60 + (k % 20), k + 3),
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');
if (firstClient && plumberCat) {
const [job] = await db
.insert(schema.jobs)
.values({
clientId: firstClient.id,
categoryId: plumberCat,
title: 'Kitchen sink leaking under the cupboard',
description:
'Water pooling under the kitchen sink, seems to be coming from the trap. The cupboard floor is starting to swell. Available most evenings this week.',
photos: [],
urgency: 'now' as const,
budgetMinCents: 8_000,
budgetMaxCents: 20_000,
location: { lat: CITY.lat, lng: CITY.lng },
locationPrecision: 'exact' as const,
addressText: streetAddress(12),
})
.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: streetAddress(12),
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');
}
await main();
await client.end();