M0: foundation — monorepo, PostGIS schema, deck query, app shell

Greenfield scaffold for Linkder, a swipe-to-hire marketplace for local
professional services.

- pnpm/turbo monorepo: apps/web, packages/{shared,db}
- Postgres 16 + PostGIS via docker compose (ports 5442/6389 to avoid
  clashing with other local stacks)
- Drizzle schema, 23 tables, geography(Point,4326) with GiST indexes
- Domain core in packages/shared: integer-cent money, status transition
  graphs, deck ranking weights, cancellation policy — 46 unit tests
- Deck query: filtering in Postgres on the GiST index, ranking in JS so
  the weights stay tunable — 18 integration tests against a seeded DB
- Deterministic seed placing pros at known distances, including three
  that must NOT appear on a deck (out of radius, unverified, away)
- Next.js 15 app shell with a working swipe deck
- CI: typecheck, lint, test, build against live postgres+redis

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
serfowi
2026-08-20 13:32:35 -04:00
co-authored by Claude Opus 5
commit 19623bcccb
66 changed files with 12412 additions and 0 deletions
+241
View File
@@ -0,0 +1,241 @@
/**
* 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';
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,
};
}
const CATEGORIES = [
{ 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' },
];
interface SeedPro {
name: string;
cat: 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 },
{ name: 'Ana Ferrer', cat: 'plumber', distanceM: 2_400, rating: 4.7, reviews: 23, radius: 15_000 },
{ name: 'Jordi Puig', cat: 'plumber', distanceM: 6_500, rating: 4.5, reviews: 12, radius: 10_000 },
{ name: 'Nuria Sala', cat: 'plumber', distanceM: 18_000, rating: 5.0, reviews: 3, radius: 25_000 },
// Further away than they are willing to travel — must NOT appear for a central job.
{ name: 'Pau Ribas', cat: 'plumber', distanceM: 22_000, rating: 4.8, reviews: 31, radius: 5_000 },
{ name: 'Laia Mestre', cat: 'electrician', distanceM: 1_200, rating: 4.8, reviews: 56, radius: 20_000 },
{ name: '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 },
{ name: 'Guillem Costa', cat: 'carpenter', distanceM: 8_600, rating: 4.6, reviews: 19, radius: 15_000 },
{ name: 'Silvia Marti', cat: 'locksmith', distanceM: 950, rating: 4.7, reviews: 62, radius: 25_000 },
{ name: 'Xavi Duran', cat: 'locksmith', distanceM: 4_400, rating: 4.5, reviews: 22, radius: 20_000 },
{ name: 'Berta Lloret', cat: 'appliance-repair', distanceM: 2_700, rating: 4.8, reviews: 37, radius: 18_000 },
{ name: 'Adria Font', cat: 'appliance-repair', distanceM: 10_200, rating: 4.4, reviews: 11, radius: 20_000 },
{ name: 'Carla Ripoll', cat: 'hvac', distanceM: 3_100, rating: 4.9, reviews: 44, radius: 22_000 },
{ name: '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'];
async function main() {
console.log(`Seeding ${CITY.name} (${CITY.lat}, ${CITY.lng})`);
// Truncate in FK-safe order — reseeding must be idempotent.
await db.execute(sql`
TRUNCATE TABLE
audit_log, reviews, payments, bookings, quotes, messages,
matches, requests, swipes, jobs,
pro_availability, verification_sessions, credentials,
pro_media, pro_categories, pro_profiles,
sessions, accounts, phone_otps, 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`,
phone: `+3460000${String(i + 1).padStart(4, '0')}`,
role: 'client' as const,
emailVerified: new Date(),
phoneVerified: new Date(),
})),
)
.returning();
console.log(` ${clientRows.length} clients`);
await db.insert(schema.users).values({
name: 'Linkder Admin',
email: 'admin@linkder.test',
phone: '+34600009999',
role: 'admin',
emailVerified: new Date(),
});
const now = Date.now();
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`,
phone: `+3461000${String(i + 1).padStart(4, '0')}`,
role: 'pro' as const,
emailVerified: new Date(),
phoneVerified: 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,
serviceRadiusM: p.radius ?? DEFAULT_SERVICE_RADIUS_M,
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 });
const slug = encodeURIComponent(p.name.toLowerCase().replace(/\s+/g, '-'));
await db.insert(schema.proMedia).values([
{ proId: user.id, url: `https://picsum.photos/seed/${slug}-1/800/1000`, position: 0 },
{
proId: user.id,
url: `https://picsum.photos/seed/${slug}-2/800/1000`,
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)`);
// 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 },
addressText: `Carrer Example 12, ${CITY.name}`,
})
.returning();
console.log(` 1 open job at the city centre (${job?.id})`);
}
console.log('\nSeed complete. Sign in as client1@linkder.test or pro1@linkder.test');
}
await main();
await client.end();