/** * 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 = { 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, }, ]); // Mon–Fri, 08:00–18: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();