/** * 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 '@linkder/shared'; import * as schema from './schema/index'; import { recomputeAllProStats } from './queries/stats'; config({ path: '../../.env' }); const url = process.env.DATABASE_URL; if (!url) throw new Error('DATABASE_URL is not set'); const client = postgres(url, { max: 1 }); const db = drizzle(client, { schema }); const CITY = { name: process.env.NEXT_PUBLIC_CITY_NAME ?? 'Barcelona', lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874), lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686), }; /** 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. */ 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[]; 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: 'Marc Oliveras', cat: 'plumber', distanceM: 800, rating: 4.9, reviews: 47, radius: 15_000, skills: ['Underfloor heating', 'Emergency callouts', 'Boiler swaps'] }, { name: 'Ana Ferrer', cat: 'plumber', distanceM: 2_400, rating: 4.7, reviews: 23, radius: 15_000, skills: ['Bathroom fitting', 'Leak detection'] }, { name: 'Jordi Puig', cat: 'plumber', distanceM: 6_500, rating: 4.5, reviews: 12, radius: 10_000 }, { name: 'Nuria Sala', cat: 'plumber', distanceM: 18_000, rating: 5.0, reviews: 3, radius: 25_000 }, // Further away than they are willing to travel — must NOT appear for a central job. { name: 'Pau Ribas', cat: 'plumber', distanceM: 22_000, rating: 4.8, reviews: 31, radius: 5_000 }, { name: 'Laia Mestre', cat: 'electrician', distanceM: 1_200, rating: 4.8, reviews: 56, radius: 20_000, skills: ['EV chargers', 'Rewiring', 'Fuse boards'] }, { name: 'Oriol Camps', cat: 'electrician', distanceM: 3_900, rating: 4.6, reviews: 18, radius: 15_000 }, { name: 'Marta Vidal', cat: 'electrician', distanceM: 9_100, rating: 4.9, reviews: 71, radius: 30_000 }, { name: 'Sergi Bonet', cat: 'handyman', distanceM: 1_800, rating: 4.4, reviews: 9, radius: 12_000 }, { name: 'Clara Roca', cat: 'handyman', distanceM: 5_200, rating: 4.7, reviews: 34, radius: 18_000 }, { name: 'Ivan Serra', cat: 'handyman', distanceM: 11_500, rating: 4.2, reviews: 6, radius: 15_000 }, { name: 'Elena Prat', cat: 'painter', distanceM: 2_100, rating: 4.9, reviews: 28, radius: 20_000 }, { name: 'Toni Blanch', cat: 'painter', distanceM: 7_800, rating: 4.3, reviews: 15, radius: 15_000 }, { name: 'Rosa Ventura', cat: 'carpenter', distanceM: 3_300, rating: 4.8, reviews: 41, radius: 25_000, skills: ['Fitted wardrobes', 'Listed buildings'] }, { name: 'Guillem Costa', cat: 'carpenter', distanceM: 8_600, rating: 4.6, reviews: 19, radius: 15_000 }, { name: 'Silvia Marti', cat: 'locksmith', distanceM: 950, rating: 4.7, reviews: 62, radius: 25_000 }, { name: 'Xavi Duran', cat: 'locksmith', distanceM: 4_400, rating: 4.5, reviews: 22, radius: 20_000 }, { name: 'Berta Lloret', cat: 'appliance-repair', distanceM: 2_700, rating: 4.8, reviews: 37, radius: 18_000 }, { name: 'Adria Font', cat: 'appliance-repair', distanceM: 10_200, rating: 4.4, reviews: 11, radius: 20_000 }, { name: 'Carla Ripoll', cat: 'hvac', distanceM: 3_100, rating: 4.9, reviews: 44, radius: 22_000, skills: ['Split units', 'Heat pumps'] }, { name: 'Marc Segura', cat: 'hvac', distanceM: 12_800, rating: 4.6, reviews: 27, radius: 25_000 }, // Brand new and unrated — proves the new-pro boost keeps fresh supply visible. { name: 'Nil Bosch', cat: 'plumber', distanceM: 1_500, rating: null, reviews: 0, radius: 15_000, isNew: true }, { name: 'Julia Camps', cat: 'electrician', distanceM: 2_200, rating: null, reviews: 0, radius: 15_000, isNew: true }, // Not verified — must never reach a deck. { name: 'Unverified Ulla', cat: 'plumber', distanceM: 1_000, rating: null, reviews: 0, radius: 15_000, unverified: true }, // Verified but on holiday — must never reach a deck. { name: 'Away Arnau', cat: 'plumber', distanceM: 1_100, rating: 4.9, reviews: 20, radius: 15_000, away: true }, ]; const CLIENTS = ['Sofia Grau', 'Daniel Miro', 'Emma Rovira', 'Lucas Pons', 'Alba Torres']; /** * Finished work, per trade, for the review histories below. * * Written out rather than generated because the profile screen is a reading * surface: "Job 3 completed. Good service." twenty times over tells you nothing * about whether the reviews list works, and nothing about whether a real one * would be worth reading. */ interface SeedWork { title: string; scope: string; amountCents: number; review: string; } const WORK: Record = { plumber: [ { title: 'Replace a leaking kitchen trap', scope: 'Remove and replace the sink trap, test for leaks.', amountCents: 9_000, review: 'Came the same evening, found the leak in about a minute and had it swapped out before I had finished making tea. Left the cupboard drier than he found it.' }, { title: 'New thermostatic shower valve', scope: 'Supply and fit a thermostatic mixer, make good the tiling.', amountCents: 28_500, review: 'Explained the options without pushing me at the expensive one. Tidy work around the tiles and the temperature is finally steady.' }, { title: 'Boiler losing pressure', scope: 'Trace and repair pressure loss, refill and rebalance the system.', amountCents: 14_000, review: 'Took a while to track down but stuck with it and did not charge me for the extra hour. Pressure has held for two months now.' }, { title: 'Fit an outside tap', scope: 'Tee off the rising main, fit an outside tap with an isolator.', amountCents: 12_000, review: 'Quick, clean job and tidied up afterwards. Would have them back.' }, { title: 'Bathroom refit second fix', scope: 'Connect basin, WC and bath after tiling.', amountCents: 46_000, review: 'Turned up when they said they would every single day, which after our last builder felt like a luxury.' }, ], electrician: [ { title: 'Install an EV charger', scope: 'Fit a 7kW charger on its own RCBO, with a certificate.', amountCents: 68_000, review: 'Neat cable run, tested everything in front of me and sent the certificate through the same day. No mess left behind.' }, { title: 'Consumer unit replacement', scope: 'Replace the fuse board, full test and certification.', amountCents: 52_000, review: 'Talked me through what was actually unsafe and what was just old, which I appreciated. Power was only off for the afternoon.' }, { title: 'Kitchen sockets and lighting', scope: 'Add four sockets and two lighting circuits.', amountCents: 39_000, review: 'Good work and a fair price. Chased the walls neatly so the plasterer had an easy job.' }, { title: 'Tripping circuit', scope: 'Fault-find a nuisance trip and repair.', amountCents: 11_000, review: 'Found a nail through a cable in the loft within half an hour. Straightforward and honest about the cost.' }, ], handyman: [ { title: 'Hang six internal doors', scope: 'Hang and adjust six doors with new furniture.', amountCents: 32_000, review: 'All six shut properly for the first time since we moved in. Cleaned up all the shavings too.' }, { title: 'Flat-pack wardrobes', scope: 'Assemble and wall-fix two double wardrobes.', amountCents: 15_000, review: 'Saved my weekend. Fixed them to the wall without being asked, because of the kids.' }, { title: 'Repair a sagging side gate', scope: 'Rehang the gate and fit a new latch.', amountCents: 8_500, review: 'Turned up on time, sorted it in an hour, charged what was quoted.' }, { title: 'Patch and paint a ceiling', scope: 'Fill, sand and repaint a water-damaged ceiling.', amountCents: 18_000, review: 'Cannot tell where the damage was. Very careful with the carpet.' }, ], painter: [ { title: 'Repaint a stairwell', scope: 'Prepare and paint stairwell walls and woodwork.', amountCents: 42_000, review: 'The cutting-in is genuinely straight, which is the whole job really. Dust sheets everywhere and not a mark on the floor.' }, { title: 'Two bedrooms in emulsion', scope: 'Fill, sand and two coats to two bedrooms.', amountCents: 34_000, review: 'Quick and neat, and matched the old colour on the landing so it blends.' }, { title: 'Exterior window frames', scope: 'Sand back, prime and paint six frames.', amountCents: 26_000, review: 'Good preparation, which is where most people cut corners. Looks like new.' }, { title: 'Hallway feature wall', scope: 'Hang wallpaper to one wall and paint the rest.', amountCents: 21_000, review: 'Pattern lines up perfectly at the joins. Very pleased.' }, ], carpenter: [ { title: 'Fitted alcove wardrobes', scope: 'Design, build and fit two alcove wardrobes.', amountCents: 145_000, review: 'Beautiful work. Scribed into a wall that is nowhere near straight and you would never know.' }, { title: 'Replace a rotten sash sill', scope: 'Splice in a new sill section and repaint.', amountCents: 38_000, review: 'Repaired rather than replaced, which on a listed building saved us a small fortune in paperwork.' }, { title: 'Build understairs storage', scope: 'Build and fit understairs drawers.', amountCents: 62_000, review: 'Measured twice, delivered exactly what was drawn. Runs smoothly.' }, { title: 'Loft hatch and ladder', scope: 'Enlarge the hatch and fit a folding ladder.', amountCents: 24_000, review: 'Straightforward and tidy. Explained why the old hatch was too small for the ladder I had bought.' }, ], locksmith: [ { title: 'Locked out at 11pm', scope: 'Non-destructive entry and a new cylinder.', amountCents: 13_500, review: 'Answered the phone at eleven at night and was here in twenty minutes. Opened it without damaging the door.' }, { title: 'Upgrade to anti-snap cylinders', scope: 'Replace three cylinders with anti-snap.', amountCents: 19_000, review: 'Insurance wanted a specific standard and they knew exactly which one without me having to explain.' }, { title: 'New front door lock', scope: 'Supply and fit a mortice lock to BS3621.', amountCents: 16_000, review: 'Clean fit, no splintering, and all the keys work in both locks now.' }, { title: 'Repair a failed uPVC mechanism', scope: 'Replace a multipoint locking mechanism.', amountCents: 17_500, review: 'Had the part on the van. The door finally closes without a shoulder barge.' }, ], 'appliance-repair': [ { title: 'Washing machine not draining', scope: 'Clear the pump and replace the drain hose.', amountCents: 8_000, review: 'Fixed for the price of a takeaway when I had already been told to buy a new machine.' }, { title: 'Oven element replacement', scope: 'Diagnose and replace a failed fan oven element.', amountCents: 11_000, review: 'Diagnosed it over the phone and brought the right part first time. Very efficient.' }, { title: 'Fridge freezer icing up', scope: 'Clear a blocked defrost drain and reseal the door.', amountCents: 9_500, review: 'Honest about whether it was worth repairing at all, which I did not expect.' }, { title: 'Dishwasher leak', scope: 'Replace the door seal and test.', amountCents: 7_500, review: 'In and out in under an hour and no more puddle.' }, ], hvac: [ { title: 'Install two split units', scope: 'Supply and install two wall-mounted split units.', amountCents: 190_000, review: 'Careful with the core drilling and the pipe run outside is genuinely tidy. The whole house is bearable in August now.' }, { title: 'Annual aircon service', scope: 'Clean, regas and service two indoor units.', amountCents: 14_000, review: 'Thorough, and pointed out a filter I could clean myself rather than charging me for a return visit.' }, { title: 'Heat pump commissioning', scope: 'Commission an air-source heat pump and balance the system.', amountCents: 78_000, review: 'Knew the system better than the people who supplied it. Running costs came in where they said they would.' }, { title: 'Noisy outdoor unit', scope: 'Replace worn fan bearings and rebalance.', amountCents: 22_000, review: 'The neighbours have stopped complaining. Fair price for a Saturday.' }, ], }; /** Any trade without its own list still gets a plausible history. */ const GENERIC_WORK: SeedWork[] = [ { title: 'Small job, quoted and completed', scope: 'Agreed scope completed in a single visit.', amountCents: 12_000, review: 'Turned up on time, did what was quoted and cleaned up afterwards. No complaints at all.' }, { title: 'Follow-up visit', scope: 'Second visit to finish the agreed work.', amountCents: 9_000, review: 'Good communication throughout and the price did not move from the quote.' }, { title: 'Half a day on site', scope: 'Half a day of work, materials included.', amountCents: 18_000, review: 'Straightforward, professional and easy to deal with. Would use again.' }, ]; /** * Star ratings for one pro's seeded reviews. * * Built to AVERAGE to the pro's headline figure rather than scattered around * it, because the counters are derived from these rows: deck.test.ts asserts * Marc Oliveras rates 4.9, and that now has to come out of 47 individual * scores rather than being asserted directly on the profile. * * So a 4.9 becomes forty-two 5s and five 4s. Whole stars only — nobody awards * 4.9 — and the mix is what carries the average, which is also what makes the * list look like a real one instead of a wall of fives. */ function seedRatings(avg: number, n: number): number[] { const low = Math.max(1, Math.min(5, Math.floor(avg))); const high = Math.min(5, low + 1); // How many have to be the higher score for the mean to land on `avg`. const highCount = high === low ? n : Math.round((avg - low) * n); return Array.from({ length: n }, (_, k) => (k < highCount ? high : low)); } async function main() { console.log(`Seeding ${CITY.name} (${CITY.lat}, ${CITY.lng})`); // Truncate in FK-safe order — reseeding must be idempotent. await db.execute(sql` TRUNCATE TABLE audit_log, 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 }))) .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((name, i) => ({ name, 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 ? '+34600000000' : `+3460000${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: 'Linkder Admin', email: 'admin@linkder.test', phoneNumber: '+34600009999', 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: `+3461000${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 }); const slug = encodeURIComponent(p.name.toLowerCase().replace(/\s+/g, '-')); // The first photo is the deck card, so it has to be a FACE. picsum returns // landscape stock scenery — a locksmith on a railway track — which makes the // deck unreadable as a people-picker no matter what the layout does. // pravatar serves ~70 portraits; i is 0-based and img is 1-based. const portrait = (i % 70) + 1; await db.insert(schema.proMedia).values([ { proId: user.id, url: `https://i.pravatar.cc/800?img=${portrait}`, position: 0 }, { // The second slot is genuinely for work: scenery is fine here. proId: user.id, url: `https://picsum.photos/seed/${slug}-work/800/1000`, 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: `Carrer Example ${10 + (k % 40)}, ${CITY.name}`, status: 'completed' as const, createdAt: new Date(at(k).getTime() - 6 * 86_400_000), }; }), // The ones nobody answered. Cancelled, because that is what a client // does when a pro never replies. ...Array.from({ length: ignored }, (_, k) => ({ clientId: clientRows[(i + k + 1) % clientRows.length]!.id, categoryId: pro.categoryId, title: work[(k + 1) % work.length]!.title, description: work[(k + 1) % work.length]!.scope, photos: [], urgency: 'this_week' as const, location: { lat: CITY.lat, lng: CITY.lng }, locationPrecision: 'exact' as const, addressText: `Carrer Example ${60 + (k % 20)}, ${CITY.name}`, status: 'cancelled' as const, createdAt: new Date(at(n + k).getTime() - 6 * 86_400_000), })), ]) .returning({ id: schema.jobs.id }); const requestRows = await db .insert(schema.requests) .values( jobRows.map((job, k) => { const answered = k < n; // Sent, then answered `replyMinutes` later. Left to the column // default `created_at` would be now() while `responded_at` sat months // in the past, and every derived response time came out negative. const sentAt = new Date(at(k).getTime() - 5 * 86_400_000); return { jobId: job.id, proId: pro.id, status: answered ? ('accepted' as const) : ('expired' as const), createdAt: sentAt, respondedAt: answered ? new Date(sentAt.getTime() + replyMinutes * 60_000) : null, expiresAt: new Date(sentAt.getTime() + 48 * 3_600_000), }; }), ) .returning({ id: schema.requests.id }); const matchRows = await db .insert(schema.matches) .values( requestRows.slice(0, n).map((req, k) => ({ requestId: req.id, jobId: jobRows[k]!.id, proId: pro.id, clientId: clientRows[(i + k) % clientRows.length]!.id, })), ) .returning({ id: schema.matches.id }); const quoteRows = await db .insert(schema.quotes) .values( matchRows.map((match, k) => ({ matchId: match.id, kind: 'fixed' as const, amountCents: work[k % work.length]!.amountCents, scope: work[k % work.length]!.scope, status: 'accepted' as const, validUntil: new Date(at(k).getTime() - 2 * 86_400_000), respondedAt: new Date(at(k).getTime() - 3 * 86_400_000), })), ) .returning({ id: schema.quotes.id }); const bookingRows = await db .insert(schema.bookings) .values( quoteRows.map((quote, k) => ({ matchId: matchRows[k]!.id, quoteId: quote.id, scheduledStart: new Date(at(k).getTime() - 4 * 3_600_000), scheduledEnd: at(k), status: 'completed' as const, proCompletedAt: at(k), clientConfirmedAt: new Date(at(k).getTime() + 2 * 3_600_000), })), ) .returning({ id: schema.bookings.id }); await db.insert(schema.reviews).values( bookingRows.map((booking, k) => ({ bookingId: booking.id, authorId: clientRows[(i + k) % clientRows.length]!.id, subjectId: pro.id, rating: ratings[k]!, body: work[k % work.length]!.review, // Set, and in the past: `published_at` is the moderation gate that // keeps a review hidden until both sides have written one, and both // `pro.reviews` and the rating counters read nothing without it. publishedAt: new Date(at(k).getTime() + 3 * 86_400_000), createdAt: new Date(at(k).getTime() + 2 * 86_400_000), })), ); reviewCount += n; ignoredCount += ignored; } console.log( ` ${reviewCount} published reviews across completed bookings, ` + `${ignoredCount} requests left to expire`, ); // One open job at the exact city centre — the fixture every deck test uses. const firstClient = clientRows[0]; const plumberCat = catBySlug.get('plumber'); 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: `Carrer Example 12, ${CITY.name}`, }) .returning(); console.log(` 1 open job at the city centre (${job?.id})`); /** * A job with a conversation on it, and one that is already history. * * The jobs tab has three screens — the list, the pros on a job, and the chat * — and none of them can be looked at against a database whose only job is * open with nobody on it. This is the smallest fixture that lights all three * and gives the Current/Past segments something on each side. */ const [chattyPro] = await db .select({ id: schema.proProfiles.userId }) .from(schema.proProfiles) .where(sql`${schema.proProfiles.verificationStatus} = 'verified'`) .limit(1); if (chattyPro) { const conversations = [ { status: 'matched' as const, title: 'Radiator not heating up in the back bedroom', description: 'One radiator stays cold while the rest of the house is fine. Bled it twice, no change. Boiler was serviced in the spring.', messages: [ { fromPro: false, body: 'Hi — are you free to take a look this week?' }, { fromPro: true, body: 'I can do Thursday afternoon. Is the boiler a combi?' }, { fromPro: false, body: 'It is, a Vaillant. Thursday works, any time after 14:00.' }, ], }, { status: 'completed' as const, title: 'Replace the outside tap', description: 'Old garden tap is seized and weeping at the thread. Needs replacing, easy access from the patio.', messages: [ { fromPro: false, body: 'Could you replace an outside tap?' }, { fromPro: true, body: 'Yes — done. New tap fitted and tested, no drips.' }, ], }, ]; for (const c of conversations) { const [j] = await db .insert(schema.jobs) .values({ clientId: firstClient.id, categoryId: plumberCat, title: c.title, description: c.description, photos: [], urgency: 'this_week' as const, location: { lat: CITY.lat, lng: CITY.lng }, locationPrecision: 'exact' as const, addressText: `Carrer Example 12, ${CITY.name}`, status: c.status, }) .returning(); const [req] = await db .insert(schema.requests) .values({ jobId: j!.id, proId: chattyPro.id, status: 'accepted', // Sent two hours ago and answered an hour later. `created_at` has // to be set: left to the column default it is now(), which puts the // response BEFORE the request and drags the pro's derived // avgResponseMinutes negative. createdAt: new Date(now - 2 * 3_600_000), respondedAt: new Date(now - 3_600_000), expiresAt: new Date(now + 86_400_000), }) .returning(); const [match] = await db .insert(schema.matches) .values({ requestId: req!.id, jobId: j!.id, proId: chattyPro.id, clientId: firstClient.id, }) .returning(); // Spaced a minute apart so the thread has a readable order, and the // pro's last message is left UNREAD — that is what puts a badge on the // tab bar, which is the part worth being able to see. const sentAt = (n: number) => new Date(now - (c.messages.length - n) * 60_000); await db.insert(schema.messages).values( c.messages.map((m, n) => ({ matchId: match!.id, senderId: m.fromPro ? chattyPro.id : firstClient.id, body: m.body, createdAt: sentAt(n), readAt: m.fromPro && n === c.messages.length - 1 ? null : sentAt(n), })), ); await db .update(schema.matches) .set({ lastMessageAt: sentAt(c.messages.length - 1) }) .where(sql`${schema.matches.id} = ${match!.id}`); /* * The finished one gets the full commercial trail: quote, booking, * completed. Without it the Past tab has a job in it and nothing to do, * and the review flow — which only opens on a completed booking — is * invisible in the running app. */ if (c.status === 'completed') { const [q] = await db .insert(schema.quotes) .values({ matchId: match!.id, kind: 'fixed', amountCents: 8_500, scope: 'Supply and fit a new outside tap, including the wall plate and sealing.', status: 'accepted', validUntil: new Date(now - 5 * 86_400_000), respondedAt: new Date(now - 6 * 86_400_000), }) .returning(); await db.insert(schema.bookings).values({ matchId: match!.id, quoteId: q!.id, scheduledStart: new Date(now - 4 * 86_400_000), scheduledEnd: new Date(now - 4 * 86_400_000 + 2 * 3_600_000), status: 'completed', proCompletedAt: new Date(now - 4 * 86_400_000 + 2 * 3_600_000), clientConfirmedAt: new Date(now - 4 * 86_400_000 + 3 * 3_600_000), }); } } console.log(` 2 jobs with conversations (1 current, 1 past)`); } } /* * Derive the ranking counters from everything above. * * ratingAvg, ratingCount, completedJobs, responseRate and * avgResponseMinutes used to be written straight onto the profile beside a * history that did not contain them, so the seed asserted a record no query * could reproduce. Deriving them here makes this a fixture the deck ranking * can be tested against, and exercises the same function the app calls on * every accept, decline and review. */ await recomputeAllProStats(db); console.log(' ranking counters derived from the seeded history'); console.log('\nSeed complete. Sign in as client1@linkder.test or pro1@linkder.test'); } await main(); await client.end();