Move the demo market to Mexico City, priced in US dollars

The showcase was a Barcelona market: Catalan names, +34 numbers, euro
rates and "Carrer Example 12" on every job. Presented to a Mexican
client, all of that reads as somebody else's product.

City comes from NEXT_PUBLIC_CITY_* as before, now Ciudad de México at
19.4326/-99.1332, with MAPBOX_COUNTRY=mx. The seed's fallbacks were
Barcelona literals, so an unset env quietly seeded a different city
than the app rendered — they now agree.

Two db tests pinned the Barcelona centre as a hardcoded constant, which
is why the deck returned zero cards on the first run here: every pro was
a continent outside the radius. They read the same env as the seed now,
so the trap cannot recur.

Money: formatCents defaults to USD/en-US, and the nine hardcoded euro
signs across the card, search rows, quote strip and forms are dollars.
The rate NUMBERS are unchanged and still read high for CDMX — that is a
pricing decision, not a currency one, and is left alone deliberately.

Seed people are Mexican, addressed on real Roma/Condesa streets rotated
by index rather than one placeholder repeated. Phones moved to +52 55,
which moves the demo login to +525500000000 / 000000.

Also in here, from the same session:
- Sending a job now confirms. The mutation always succeeded; the sheet
  just closed with no receipt, which from the customer's side is
  indistinguishable from a dead button. Dismissing that receipt resolves
  as 'sent', so the card does not return to the deck.
- Media moves to DigitalOcean Spaces, with the public origin derived
  from bucket and region instead of a second env var to keep in sync.
- Managed-Postgres TLS: DATABASE_CA_CERT takes a path or inline PEM.
- The client-facing project panel beside the running app.
- Two profiles removed and four renamed to match their photos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
serfa
2026-08-23 10:56:31 -04:00
co-authored by Claude Opus 5
parent 5086a238ea
commit 1808ad4cba
113 changed files with 1944 additions and 472 deletions
+17 -1
View File
@@ -1,13 +1,29 @@
import { readFileSync } from 'node:fs';
import { isAbsolute, resolve } from 'node:path';
import { config } from 'dotenv';
import { defineConfig } from 'drizzle-kit';
config({ path: '../../.env' });
/**
* Same CA rule as src/client.ts, restated because drizzle-kit runs this file on
* its own and cannot import from the package it is generating for.
*/
const caEnv = process.env.DATABASE_CA_CERT;
const ca = caEnv
? caEnv.includes('BEGIN CERTIFICATE')
? caEnv
: readFileSync(isAbsolute(caEnv) ? caEnv : resolve(process.cwd(), caEnv), 'utf8')
: undefined;
export default defineConfig({
schema: './src/schema/index.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: { url: process.env.DATABASE_URL! },
dbCredentials: {
url: process.env.DATABASE_URL!,
...(ca ? { ssl: { ca, rejectUnauthorized: true } } : {}),
},
verbose: true,
strict: true,
});
+6 -4
View File
@@ -1,5 +1,5 @@
{
"name": "@linkder/db",
"name": "@linkdr/db",
"version": "0.0.0",
"private": true,
"type": "module",
@@ -17,13 +17,15 @@
"seed": "tsx src/seed.ts",
"recompute-stats": "tsx src/recompute-stats.ts",
"typecheck": "tsc --noEmit",
"test": "vitest run"
"test": "vitest run",
"assets:migrate": "tsx src/migrate-assets.ts"
},
"dependencies": {
"@linkder/shared": "workspace:*",
"@linkdr/shared": "workspace:*",
"@opentelemetry/api": "1.9.1",
"drizzle-orm": "0.38.4",
"postgres": "^3.4.5"
"postgres": "^3.4.5",
"@aws-sdk/client-s3": "^3.717.0"
},
"devDependencies": {
"dotenv": "^16.4.7",
+56 -9
View File
@@ -1,3 +1,5 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, isAbsolute, resolve } from 'node:path';
import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema/index';
@@ -13,10 +15,52 @@ import * as schema from './schema/index';
* pool on every hot reload and exhaust Postgres connections within a minute.
*/
const globalForDb = globalThis as unknown as {
__linkderPool?: postgres.Sql;
__linkderDb?: PostgresJsDatabase<typeof schema>;
__linkdrPool?: postgres.Sql;
__linkdrDb?: PostgresJsDatabase<typeof schema>;
};
/**
* TLS for a managed database.
*
* A hosted Postgres is reached over the public internet, so `sslmode=require`
* alone is not enough: it encrypts the connection but verifies nothing, which
* leaves it open to anyone who can answer for the hostname. Handing the
* provider's CA to the client turns that into a checked identity.
*
* `DATABASE_CA_CERT` takes either a path to the .crt or the certificate inline
* — a path locally where the file is on disk, the PEM itself on a platform
* where secrets are environment variables and there is no filesystem to put it
* on. Unset means a plain connection, which is what local Docker wants.
*/
/**
* Find the certificate file from wherever the caller happens to be.
*
* The scripts run from `packages/db`, Next runs from `apps/web`, and the cert
* sits at the repo root — so a relative path resolved against `process.cwd()`
* alone is wrong for every one of them. Walk up instead, the same way the
* scripts already reach `../../.env`.
*/
function readCaFile(path: string): string {
if (isAbsolute(path)) return readFileSync(path, 'utf8');
let dir = process.cwd();
for (let up = 0; up < 5; up += 1) {
const candidate = resolve(dir, path);
if (existsSync(candidate)) return readFileSync(candidate, 'utf8');
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
throw new Error(`DATABASE_CA_CERT points at ${path}, which was not found from ${process.cwd()}`);
}
export function readSsl(): postgres.Options<Record<string, never>>['ssl'] {
const ca = process.env.DATABASE_CA_CERT;
if (!ca) return undefined;
return { ca: ca.includes('BEGIN CERTIFICATE') ? ca : readCaFile(ca), rejectUnauthorized: true };
}
function createPool(): postgres.Sql {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
@@ -24,25 +68,28 @@ function createPool(): postgres.Sql {
'DATABASE_URL is not set. Copy .env.example to .env and start Postgres with `pnpm services:up`.',
);
}
const ssl = readSsl();
return postgres(connectionString, {
max: Number(process.env.DB_POOL_MAX ?? 10),
idle_timeout: 20,
...(ssl ? { ssl } : {}),
});
}
export function getPool(): postgres.Sql {
const existing = globalForDb.__linkderPool;
const existing = globalForDb.__linkdrPool;
if (existing) return existing;
const created = createPool();
globalForDb.__linkderPool = created;
globalForDb.__linkdrPool = created;
return created;
}
function getDb(): PostgresJsDatabase<typeof schema> {
const existing = globalForDb.__linkderDb;
const existing = globalForDb.__linkdrDb;
if (existing) return existing;
const created = drizzle(getPool(), { schema });
globalForDb.__linkderDb = created;
globalForDb.__linkdrDb = created;
return created;
}
@@ -82,11 +129,11 @@ export const db: Db = new Proxy({} as Db, {
/** Close the pool. For scripts and test teardown — never call this from a request. */
export async function closePool(): Promise<void> {
const existing = globalForDb.__linkderPool;
const existing = globalForDb.__linkdrPool;
if (!existing) return;
await existing.end();
globalForDb.__linkderPool = undefined;
globalForDb.__linkderDb = undefined;
globalForDb.__linkdrPool = undefined;
globalForDb.__linkdrDb = undefined;
}
export { schema };
+175
View File
@@ -0,0 +1,175 @@
/**
* Pull every externally-hosted image into our own bucket.
*
* pnpm assets:migrate
*
* The seed used to point at pravatar, picsum and Unsplash. That is fine for a
* scratch database and wrong for anything anybody is shown: those services rate
* limit, change what a URL returns, and go down — and when they do, a demo is a
* grid of broken images with no way to fix it in the moment.
*
* This fetches each one once, stores it under a DETERMINISTIC key, and rewrites
* the row to our own origin. Idempotent: a key that already exists is left
* alone, so re-running after a reseed costs one HEAD per object rather than
* re-downloading the internet.
*/
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { sql } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import {
HeadObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import * as schema from './schema/index';
import { readSsl } from './client';
for (const line of readFileSync(new URL('../../../.env', import.meta.url), 'utf8').split('\n')) {
const m = /^([A-Z_]+)=(.*)$/.exec(line.trim());
if (m?.[1]) process.env[m[1]] ??= m[2];
}
const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL is not set');
const region = process.env.SPACES_REGION;
const bucket = process.env.SPACES_BUCKET;
if (!region || !bucket || !process.env.SPACES_KEY || !process.env.SPACES_SECRET) {
throw new Error('Spaces is not configured. Set SPACES_REGION / BUCKET / KEY / SECRET.');
}
const origin = process.env.SPACES_CDN_URL || `https://${bucket}.${region}.digitaloceanspaces.com`;
const client = new S3Client({
region,
endpoint: `https://${region}.digitaloceanspaces.com`,
credentials: {
accessKeyId: process.env.SPACES_KEY,
secretAccessKey: process.env.SPACES_SECRET,
},
});
const ssl = readSsl();
const pg = postgres(url, { max: 1, ...(ssl ? { ssl } : {}) });
const db = drizzle(pg, { schema });
const EXT: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/avif': 'avif',
};
/** Already ours? Then there is nothing to fetch. */
function isLocal(u: string): boolean {
return u.startsWith(origin);
}
async function exists(key: string): Promise<boolean> {
try {
await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
return true;
} catch {
return false;
}
}
/**
* Fetch one image and store it, returning the URL it now lives at.
*
* The key is a hash of the SOURCE url, so the same source always lands on the
* same object: reseeding gives every pro a new uuid, and keying on that would
* fill the bucket with a fresh copy of every photo on every run.
*/
async function adopt(source: string, prefix: string): Promise<string | null> {
const hash = createHash('sha1').update(source).digest('hex').slice(0, 16);
const response = await fetch(source, { redirect: 'follow' });
if (!response.ok) {
console.warn(` ! ${response.status} ${source.slice(0, 60)}`);
return null;
}
const type = (response.headers.get('content-type') ?? 'image/jpeg').split(';')[0]!.trim();
const key = `${prefix}/${hash}.${EXT[type] ?? 'jpg'}`;
if (await exists(key)) return `${origin}/${key}`;
const body = Buffer.from(await response.arrayBuffer());
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body,
ContentType: type,
// Public: these are the photos on the deck card. Credential documents are
// a different prefix and are never given an ACL.
ACL: 'public-read',
// A year. The key is content-addressed by source, so a changed image is a
// changed key rather than a stale cache.
CacheControl: 'public, max-age=31536000, immutable',
}),
);
console.log(` + ${(body.length / 1024).toFixed(0)}kb ${key}`);
return `${origin}/${key}`;
}
async function main() {
console.log(`Migrating assets into ${origin}\n`);
const media = await db
.select({ id: schema.proMedia.id, url: schema.proMedia.url, kind: schema.proMedia.kind })
.from(schema.proMedia);
const external = media.filter((m) => !isLocal(m.url));
console.log(`pro_media: ${media.length} rows, ${external.length} still external`);
// Sequential on purpose. Twenty parallel fetches against a free image host is
// how you get rate limited half way through and end up with a bucket that is
// partly migrated and rows that disagree with it.
let moved = 0;
for (const row of external) {
const hosted = await adopt(row.url, row.kind === 'photo' ? 'pro-media' : 'work-samples');
if (!hosted) continue;
await db
.update(schema.proMedia)
.set({ url: hosted })
.where(sql`${schema.proMedia.id} = ${row.id}`);
moved += 1;
}
// Job photos are uploaded by customers and already ours, but a seeded one
// could point outward too.
const jobs = await db
.select({ id: schema.jobs.id, photos: schema.jobs.photos })
.from(schema.jobs);
let jobPhotos = 0;
for (const job of jobs) {
const outward = job.photos.filter((p) => !isLocal(p));
if (outward.length === 0) continue;
const rewritten: string[] = [];
for (const photo of job.photos) {
rewritten.push(isLocal(photo) ? photo : ((await adopt(photo, 'job-photos')) ?? photo));
}
await db
.update(schema.jobs)
.set({ photos: rewritten })
.where(sql`${schema.jobs.id} = ${job.id}`);
jobPhotos += outward.length;
}
console.log(`\n${moved} pro images and ${jobPhotos} job photos now served from ${origin}`);
const left = (
await db.select({ url: schema.proMedia.url }).from(schema.proMedia)
).filter((m) => !isLocal(m.url));
if (left.length) console.warn(`${left.length} still external — rerun to retry.`);
}
await main();
await pg.end();
+5 -1
View File
@@ -3,13 +3,17 @@ import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import { sql } from 'drizzle-orm';
import postgres from 'postgres';
import { readSsl } from './client';
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 });
// Same TLS rule as the app — a managed database is reached over the internet,
// and a migration is the last thing that should run unverified.
const ssl = readSsl();
const client = postgres(url, { max: 1, ...(ssl ? { ssl } : {}) });
const db = drizzle(client);
// PostGIS must exist before any migration that declares a geography column.
+1 -1
View File
@@ -1,5 +1,5 @@
import { sql } from 'drizzle-orm';
import { DECK_PAGE_SIZE, score, type RankingInput } from '@linkder/shared';
import { DECK_PAGE_SIZE, score, type RankingInput } from '@linkdr/shared';
import type { Db } from '../client';
import { eligiblePro } from './eligibility';
+1 -1
View File
@@ -1,5 +1,5 @@
import { sql, type SQL } from 'drizzle-orm';
import { score, type RankingInput } from '@linkder/shared';
import { score, type RankingInput } from '@linkdr/shared';
import type { Db } from '../client';
import { eligiblePro } from './eligibility';
import type { DeckCard } from './deck';
+1 -1
View File
@@ -5,7 +5,7 @@ import type { Db } from '../client';
* Recompute the denormalised ranking counters on `pro_profiles`.
*
* `rating_avg`, `rating_count`, `completed_jobs`, `response_rate` and
* `avg_response_minutes` are inputs to `score()` in @linkder/shared, and until
* `avg_response_minutes` are inputs to `score()` in @linkdr/shared, and until
* this existed nothing ever wrote them after the seed. The deck ranked on
* numbers that were invented once and never moved, and the card told customers
* "usually replies in 25 min" on the strength of it.
+3 -3
View File
@@ -9,7 +9,7 @@ import {
unique,
uuid,
} from 'drizzle-orm/pg-core';
import { DEFAULT_SERVICE_RADIUS_M } from '@linkder/shared';
import { DEFAULT_SERVICE_RADIUS_M } from '@linkdr/shared';
import { point } from '../postgis';
import { locationPrecision, userRole } from './enums';
@@ -38,7 +38,7 @@ export const users = pgTable(
/**
* Required and unique by better-auth. Phone-first users get a synthetic
* address on a domain we control — ALWAYS gate outbound mail on
* `isSyntheticEmail()` from @linkder/shared. Pros must supply a real
* `isSyntheticEmail()` from @linkdr/shared. Pros must supply a real
* address during onboarding; clients may never have one.
*/
email: text('email').notNull().unique(),
@@ -71,7 +71,7 @@ export const users = pgTable(
* "we do not know yet", and callers fall back to the city centre.
*/
location: point('location'),
/** Human label for `location` — "Gràcia, Barcelona". Display only; never matched on. */
/** Human label for `location` — "Condesa, Ciudad de México". Display only; never matched on. */
locationText: text('location_text'),
/**
* See jobs.location_precision. Lowest stakes of the three: this only centres
+3 -3
View File
@@ -7,10 +7,10 @@ import {
QUOTE_STATUSES,
REQUEST_STATUSES,
VERIFICATION_STATUSES,
} from '@linkder/shared';
} from '@linkdr/shared';
/**
* Enums mirror the status unions in @linkder/shared/state-machines.
* Enums mirror the status unions in @linkdr/shared/state-machines.
* Importing them here means a new status cannot be added to the DB without
* also being added to the transition graph.
*/
@@ -22,7 +22,7 @@ export const bookingStatus = pgEnum('booking_status', BOOKING_STATUSES);
export const paymentStatus = pgEnum('payment_status', PAYMENT_STATUSES);
export const verificationStatus = pgEnum('verification_status', VERIFICATION_STATUSES);
export const urgency = pgEnum('urgency', ['now', 'this_week', 'flexible']);
/** How a stored point was obtained — see LOCATION_PRECISIONS in @linkder/shared. */
/** How a stored point was obtained — see LOCATION_PRECISIONS in @linkdr/shared. */
export const locationPrecision = pgEnum('location_precision', LOCATION_PRECISIONS);
export const swipeDirection = pgEnum('swipe_direction', ['left', 'right']);
export const credentialKind = pgEnum('credential_kind', ['id', 'licence', 'insurance']);
+1 -1
View File
@@ -9,7 +9,7 @@ import { users } from './auth';
* where an existing user has no preferences. Every column therefore defaults to
* the value we would use in the absence of a row.
*
* These are read on every send — see `notify()` in @linkder/notify, which maps
* These are read on every send — see `notify()` in @linkdr/notify, which maps
* each notification kind to the column that governs it and drops the message
* when the answer is false.
*/
+100 -49
View File
@@ -10,24 +10,51 @@ 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 { 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');
const client = postgres(url, { max: 1 });
// 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 ?? 'Barcelona',
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874),
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686),
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;
@@ -118,6 +145,19 @@ interface SeedPro {
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;
@@ -129,43 +169,51 @@ interface SeedPro {
/** distanceM is measured from the city centre — deck tests assert against these. */
const PROS: SeedPro[] = [
{ name: 'Marc Oliveras', cat: 'plumber', distanceM: 800, rating: 4.9, reviews: 47, radius: 15_000,
skills: ['Underfloor heating', 'Emergency callouts', 'Boiler swaps'] },
{ name: 'Ana Ferrer', cat: 'plumber', distanceM: 2_400, rating: 4.7, reviews: 23, radius: 15_000,
{ 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: '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 },
{ 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: '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: '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: '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: '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: '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: '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: 'Marc Segura', cat: 'hvac', distanceM: 12_800, rating: 4.6, reviews: 27, radius: 25_000 },
{ 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: '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 },
{ 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 Ulla', cat: 'plumber', distanceM: 1_000, rating: null, reviews: 0, radius: 15_000, unverified: true },
{ 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 Arnau', cat: 'plumber', distanceM: 1_100, rating: 4.9, reviews: 20, radius: 15_000, away: true },
{ name: 'Away Arturo', cat: 'plumber', photo: 'photo-1676210134188-4c05dd172f89', 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'];
const CLIENTS = ['Sofía Guzmán', 'Daniel Miranda', 'Emma Rivera', 'Lucas Ponce', 'Alba Tovar'];
/**
* Finished work, per trade, for the review histories below.
@@ -282,7 +330,7 @@ const GENERIC_WORK: SeedWork[] = [
*
* 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
* 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
@@ -333,7 +381,7 @@ async function main() {
* 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')}`,
phoneNumber: i === 0 ? '+525500000000' : `+52550000${String(i + 1).padStart(4, '0')}`,
role: 'client' as const,
emailVerified: true,
phoneNumberVerified: true,
@@ -344,9 +392,9 @@ async function main() {
console.log(` ${clientRows.length} clients`);
await db.insert(schema.users).values({
name: 'Linkder Admin',
name: 'Linkdr Admin',
email: 'admin@linkder.test',
phoneNumber: '+34600009999',
phoneNumber: '+525500009999',
role: 'admin',
emailVerified: true,
});
@@ -364,7 +412,7 @@ async function main() {
.values({
name: p.name,
email: `pro${i + 1}@linkder.test`,
phoneNumber: `+3461000${String(i + 1).padStart(4, '0')}`,
phoneNumber: `+52551000${String(i + 1).padStart(4, '0')}`,
role: 'pro' as const,
emailVerified: true,
phoneNumberVerified: true,
@@ -406,18 +454,21 @@ async function main() {
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;
/*
* 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: `https://i.pravatar.cc/800?img=${portrait}`, position: 0 },
{ proId: user.id, url: card, 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`,
url: `https://images.unsplash.com/${p.photo}?w=1200&h=900&fit=crop`,
kind: 'work_sample' as const,
position: 1,
},
@@ -494,7 +545,7 @@ async function main() {
urgency: 'flexible' as const,
location: { lat: CITY.lat, lng: CITY.lng },
locationPrecision: 'exact' as const,
addressText: `Carrer Example ${10 + (k % 40)}, ${CITY.name}`,
addressText: streetAddress(10 + (k % 40), k),
status: 'completed' as const,
createdAt: new Date(at(k).getTime() - 6 * 86_400_000),
};
@@ -510,7 +561,7 @@ async function main() {
urgency: 'this_week' as const,
location: { lat: CITY.lat, lng: CITY.lng },
locationPrecision: 'exact' as const,
addressText: `Carrer Example ${60 + (k % 20)}, ${CITY.name}`,
addressText: streetAddress(60 + (k % 20), k + 3),
status: 'cancelled' as const,
createdAt: new Date(at(n + k).getTime() - 6 * 86_400_000),
})),
@@ -623,7 +674,7 @@ async function main() {
budgetMaxCents: 20_000,
location: { lat: CITY.lat, lng: CITY.lng },
locationPrecision: 'exact' as const,
addressText: `Carrer Example 12, ${CITY.name}`,
addressText: streetAddress(12),
})
.returning();
console.log(` 1 open job at the city centre (${job?.id})`);
@@ -679,7 +730,7 @@ async function main() {
urgency: 'this_week' as const,
location: { lat: CITY.lat, lng: CITY.lng },
locationPrecision: 'exact' as const,
addressText: `Carrer Example 12, ${CITY.name}`,
addressText: streetAddress(12),
status: c.status,
})
.returning();
+30 -19
View File
@@ -2,7 +2,7 @@
* Integration test — runs against a live seeded database.
*
* pnpm services:up && pnpm db:migrate && pnpm db:seed
* pnpm --filter @linkder/db test
* pnpm --filter @linkdr/db test
*
* The seed places every pro at a known distance from the city centre, and the
* fixture job sits exactly at the centre, so the expected deck is not "roughly
@@ -52,23 +52,34 @@ describe('getDeck', () => {
const deck = await getDeck(db, { jobId });
const names = deck.map((c) => c.name).sort();
expect(names).toEqual(['Ana Ferrer', 'Jordi Puig', 'Marc Oliveras', 'Nil Bosch', 'Nuria Sala']);
expect(names).toEqual([
'Alejandro Dávila',
'Antón Bautista',
'Gabriel Torres',
'Gerardo Solís',
'Jorge Pineda',
// Sorted by code unit, so accented letters land after plain ASCII ones.
'Norma Salgado',
'Néstor Bosque',
'Rogelio Amaya',
'Sergio Fabela',
]);
});
it('excludes a pro whose service radius does not reach the job', async () => {
// Pau Ribas is 22km away but only travels 5km.
// Pablo Rivas is 22km away but only travels 5km.
const deck = await getDeck(db, { jobId });
expect(deck.map((c) => c.name)).not.toContain('Pau Ribas');
expect(deck.map((c) => c.name)).not.toContain('Pablo Rivas');
});
it('excludes an unverified pro even though they are 1km away', async () => {
const deck = await getDeck(db, { jobId });
expect(deck.map((c) => c.name)).not.toContain('Unverified Ulla');
expect(deck.map((c) => c.name)).not.toContain('Unverified Ulises');
});
it('excludes a verified pro who is not accepting jobs', async () => {
const deck = await getDeck(db, { jobId });
expect(deck.map((c) => c.name)).not.toContain('Away Arnau');
expect(deck.map((c) => c.name)).not.toContain('Away Arturo');
});
it('excludes pros from other trades', async () => {
@@ -80,32 +91,32 @@ describe('getDeck', () => {
it('reports distance in metres, ascending-ish and sane', async () => {
const deck = await getDeck(db, { jobId });
const marc = deck.find((c) => c.name === 'Marc Oliveras');
expect(marc).toBeDefined();
expect(marc!.distanceM).toBeGreaterThan(700);
expect(marc!.distanceM).toBeLessThan(900);
const sergi = deck.find((c) => c.name === 'Sergio Fabela');
expect(sergi).toBeDefined();
expect(sergi!.distanceM).toBeGreaterThan(1_000);
expect(sergi!.distanceM).toBeLessThan(1_200);
});
it('ranks a well-reviewed nearby pro above a distant one with a single review', async () => {
const deck = await getDeck(db, { jobId });
const marc = deck.findIndex((c) => c.name === 'Marc Oliveras'); // 800m, 4.9 x47
const nuria = deck.findIndex((c) => c.name === 'Nuria Sala'); // 18km, 5.0 x3
expect(marc).toBeLessThan(nuria);
const sergi = deck.findIndex((c) => c.name === 'Sergio Fabela'); // 1.1km, 4.8 x62
const nuria = deck.findIndex((c) => c.name === 'Norma Salgado'); // 18km, 5.0 x3
expect(sergi).toBeLessThan(nuria);
});
it('does not bury a brand-new unrated pro at the bottom', async () => {
const deck = await getDeck(db, { jobId });
const nil = deck.findIndex((c) => c.name === 'Nil Bosch');
const nil = deck.findIndex((c) => c.name === 'Néstor Bosque');
expect(nil).toBeGreaterThanOrEqual(0);
expect(nil).toBeLessThan(deck.length - 1);
});
it('carries the media and rating a card needs to render', async () => {
const deck = await getDeck(db, { jobId });
const card = deck.find((c) => c.name === 'Marc Oliveras')!;
const card = deck.find((c) => c.name === 'Sergio Fabela')!;
expect(card.photos.length).toBeGreaterThan(0);
expect(card.ratingAvg).toBeCloseTo(4.9, 1);
expect(card.ratingCount).toBe(47);
expect(card.ratingAvg).toBeCloseTo(4.8, 1);
expect(card.ratingCount).toBe(62);
expect(card.hourlyRateCents).toBeGreaterThan(0);
});
@@ -184,8 +195,8 @@ describe('PostGIS round-trip', () => {
it('reads back the exact coordinates it wrote', async () => {
const rows = await db.select().from(schema.jobs).limit(1);
const job = rows[0]!;
expect(job.location.lat).toBeCloseTo(Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874), 4);
expect(job.location.lng).toBeCloseTo(Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686), 4);
expect(job.location.lat).toBeCloseTo(Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 19.4326), 4);
expect(job.location.lng).toBeCloseTo(Number(process.env.NEXT_PUBLIC_CITY_LNG ?? -99.1332), 4);
});
it('never puts the client on their own deck', async () => {
+16 -13
View File
@@ -2,7 +2,7 @@
* Integration test — runs against a live seeded database.
*
* pnpm services:up && pnpm db:migrate && pnpm db:seed
* pnpm --filter @linkder/db test
* pnpm --filter @linkdr/db test
*
* Search is a second door onto the same supply as the deck, so the test that
* matters most is the parity one: a pro who can be found here must be a pro who
@@ -19,7 +19,10 @@ const { searchPros } = await import('../src/queries/search');
const { getShowcaseDeck } = await import('../src/queries/deck');
/** The seed places every pro relative to this point. */
const CENTRE = { lat: 41.3874, lng: 2.1686 };
const CENTRE = {
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 19.4326),
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? -99.1332),
};
let plumberId: string;
@@ -31,17 +34,17 @@ beforeAll(async () => {
plumberId = plumber.id;
// Seeded skills are empty, so text search has nothing to match until we give
// one pro something to find. Marc Oliveras is 800 m from the centre.
// one pro something to find. Sergio Fabela is 1.1 km from the centre.
await db.execute(sql`
UPDATE pro_profiles SET skills = ARRAY['Underfloor heating', 'Emergency callouts']
WHERE user_id = (SELECT id FROM users WHERE name = 'Marc Oliveras')
WHERE user_id = (SELECT id FROM users WHERE name = 'Sergio Fabela')
`);
});
afterAll(async () => {
await db.execute(sql`
UPDATE pro_profiles SET skills = '{}'::text[]
WHERE user_id = (SELECT id FROM users WHERE name = 'Marc Oliveras')
WHERE user_id = (SELECT id FROM users WHERE name = 'Sergio Fabela')
`);
await closePool();
});
@@ -66,17 +69,17 @@ describe('searchPros', () => {
it('excludes the unverified, the away and the too-far', async () => {
const names = (await searchPros(db, { ...CENTRE, limit: 50 })).map((p) => p.name);
expect(names).not.toContain('Unverified Ulla');
expect(names).not.toContain('Away Arnau');
expect(names).not.toContain('Pau Ribas'); // 22 km out, 5 km radius
expect(names).not.toContain('Unverified Ulises');
expect(names).not.toContain('Away Arturo');
expect(names).not.toContain('Pablo Rivas'); // 22 km out, 5 km radius
});
it('matches a skill', async () => {
const names = (await searchPros(db, { ...CENTRE, q: 'underfloor', limit: 50 })).map(
(p) => p.name,
);
expect(names).toContain('Marc Oliveras');
expect(names).not.toContain('Laia Mestre'); // an electrician with no such skill
expect(names).toContain('Sergio Fabela');
expect(names).not.toContain('Martino Gómez'); // an electrician with no such skill
});
it('matches a trade name', async () => {
@@ -86,8 +89,8 @@ describe('searchPros', () => {
});
it('matches a pro by name', async () => {
const names = (await searchPros(db, { ...CENTRE, q: 'oliveras', limit: 50 })).map((p) => p.name);
expect(names).toEqual(['Marc Oliveras']);
const names = (await searchPros(db, { ...CENTRE, q: 'fabela', limit: 50 })).map((p) => p.name);
expect(names).toEqual(['Sergio Fabela']);
});
it('treats wildcards as literal characters', async () => {
@@ -105,7 +108,7 @@ describe('searchPros', () => {
it("honours the searcher's own distance limit", async () => {
const near = await searchPros(db, { ...CENTRE, maxDistanceM: 3_000, limit: 50 });
expect(near.every((p) => p.distanceM <= 3_000)).toBe(true);
expect(near.map((p) => p.name)).not.toContain('Marta Vidal'); // 9.1 km out
expect(near.map((p) => p.name)).not.toContain('Marta Villanueva'); // 9.1 km out
});
it('sorts by distance, price and rating', async () => {
+19 -16
View File
@@ -2,7 +2,7 @@
* Integration test — runs against a live seeded database.
*
* pnpm services:up && pnpm db:migrate && pnpm db:seed
* pnpm --filter @linkder/db test
* pnpm --filter @linkdr/db test
*
* getShowcaseDeck feeds the entry screen, which is the one deck an anonymous
* visitor sees. Its whole promise is the word "verified": nobody may appear in
@@ -19,7 +19,10 @@ const { closePool, db } = await import('../src/client');
const { getShowcaseDeck } = await import('../src/queries/deck');
/** The seed places the fixture job, and every distance, relative to this point. */
const CENTRE = { lat: 41.3874, lng: 2.1686 };
const CENTRE = {
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 19.4326),
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? -99.1332),
};
let names: (string | null)[];
@@ -38,23 +41,23 @@ describe('getShowcaseDeck', () => {
});
it('excludes a pro who will not travel this far', () => {
// Pau Ribas is seeded 22 km out with a 5 km service radius.
expect(names).not.toContain('Pau Ribas');
// Pablo Rivas is seeded 22 km out with a 5 km service radius.
expect(names).not.toContain('Pablo Rivas');
});
it('excludes a pro whose verification has not passed', () => {
// Unverified Ulla is 1 km away — close enough to prove distance is not
// Unverified Ulises is 1 km away — close enough to prove distance is not
// what is keeping her out.
expect(names).not.toContain('Unverified Ulla');
expect(names).not.toContain('Unverified Ulises');
});
it('excludes a verified pro who is not accepting work', () => {
// Away Arnau is verified and nearby, but on holiday mode.
expect(names).not.toContain('Away Arnau');
// Away Arturo is verified and nearby, but on holiday mode.
expect(names).not.toContain('Away Arturo');
});
it('includes the nearest eligible pro', () => {
expect(names).toContain('Marc Oliveras');
expect(names).toContain('Sergio Fabela');
});
it('honours the limit', async () => {
@@ -63,13 +66,13 @@ describe('getShowcaseDeck', () => {
});
it('honours the searcher own range, not just the pro one', async () => {
// Marta Vidal is seeded 9.1 km out with a 30 km radius: she would travel
// Marta Villanueva is seeded 9.1 km out with a 30 km radius: she would travel
// here happily, but someone who said "within 3 km" did not ask for her.
const near = await getShowcaseDeck(db, { ...CENTRE, maxDistanceM: 3_000, limit: 100 });
const nearNames = near.map((c) => c.name);
expect(nearNames).not.toContain('Marta Vidal');
expect(nearNames).toContain('Marc Oliveras'); // 800 m away
expect(nearNames).not.toContain('Marta Villanueva');
expect(nearNames).toContain('Sergio Fabela'); // 1.1 km away
expect(near.every((c) => c.distanceM <= 3_000)).toBe(true);
});
@@ -97,11 +100,11 @@ describe('getShowcaseDeck', () => {
const cards = await getShowcaseDeck(db, { ...CENTRE, categoryId: plumber.id, limit: 100 });
const filtered = cards.map((c) => c.name);
// Pau Ribas is a plumber — he is kept out by radius, not by trade, so this
// Pablo Rivas is a plumber — he is kept out by radius, not by trade, so this
// proves the category filter did not replace the eligibility rules.
expect(filtered).not.toContain('Pau Ribas');
expect(filtered).not.toContain('Unverified Ulla');
expect(filtered).not.toContain('Away Arnau');
expect(filtered).not.toContain('Pablo Rivas');
expect(filtered).not.toContain('Unverified Ulises');
expect(filtered).not.toContain('Away Arturo');
});
it('never returns a card with a distance beyond that pros own radius', async () => {