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:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
@@ -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,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,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';
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 pro’s own radius', async () => {
|
||||
|
||||
Reference in New Issue
Block a user