Closes the funnel. Before this the product could match two people and then stopped: `quotes`, `bookings` and `reviews` had tables and state machines and nothing that wrote a row, the entry deck's right swipe was wired to an empty handler, and every address resolved to the city centre. Jobs tab and chat - message router: thread, send, markRead, unreadTotal. A thread is a MATCH, not a job — one job with three interested pros is three private conversations. - Current/Past segments derived from ACTIVE_JOB_STATUSES, job detail listing the pros who accepted, and the conversation itself with attachments. Hiring from the deck - A right swipe on the entry deck opened nothing. It now resolves "which job?" through a sheet — sign in, pick an open job, or post one — and calls the same deck.swipe the per-job deck does, so the open-request cap and row lock apply exactly once. Swipes are vetoable so closing the sheet returns the card. Geocoding - ST_Distance and ST_DWithin rank and filter every deck, and both operands were placeholders. Addresses now resolve through Mapbox (permanent=true, which is what licenses storing the coordinates), the server resolves points rather than trusting client-supplied lat/lng, and every stored point records how it was obtained. A `city`-precision base cannot reach the verification queue. Quote -> booking -> review - The commercial chain, minus payments. Accepting a quote is the only place a booking is created; confirming completion is what unlocks reviews and moves the pro's completed_jobs. - Reviews publish double-blind with no sweeper: each is written with published_at already set to its embargo deadline and every read filters published_at <= now(), so it publishes itself. The second review pulls both forward. A silent counterparty cannot bury a bad review by never replying. State machine changes, both deliberate - booked -> matched: a cancelled booking is not a cancelled job. - scheduled -> awaiting_confirmation: in_progress is optional, so a pro who never tapped Start can still say the work is done. Test suite - api tests ran files in parallel against one database and failed roughly one run in three on whichever file lost the race. Serialised, and three fixtures that grabbed "the first client" pinned to the seeded accounts. Also includes work from a parallel session: admin verification queue, pro public profile and reviews read path, notification sending, denormalised stats recompute, search, and observability. 318 tests passing; typecheck and lint clean across 7 packages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
181 lines
6.1 KiB
TypeScript
181 lines
6.1 KiB
TypeScript
/**
|
|
* The geocoding surface, against the live seeded database.
|
|
*
|
|
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
|
*
|
|
* No Mapbox token is set in test, and that is deliberate: the behaviour worth
|
|
* pinning is what happens when the geocoder is NOT available. Every one of these
|
|
* paths used to end with the city centre silently stored as if it were an
|
|
* address, so "degrades honestly" is the property under test.
|
|
*/
|
|
import { config } from 'dotenv';
|
|
import { sql } from 'drizzle-orm';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
|
|
config({ path: '../../.env' });
|
|
|
|
const { closePool, db } = await import('@linkder/db');
|
|
const { appRouter } = await import('../src/root');
|
|
const { createInnerContext } = await import('../src/context');
|
|
const { createCallerFactory } = await import('../src/trpc');
|
|
|
|
const createCaller = createCallerFactory(appRouter);
|
|
type Session = import('../src/context').Session;
|
|
|
|
function callerFor(session: Session | null) {
|
|
return createCaller(createInnerContext({ db, session }));
|
|
}
|
|
|
|
const clientSession = (userId: string): Session => ({
|
|
userId,
|
|
role: 'client',
|
|
name: 'Test Client',
|
|
email: 'client@test',
|
|
phone: null,
|
|
verificationStatus: null,
|
|
});
|
|
|
|
const RUN = Math.random().toString(36).slice(2, 8);
|
|
const CITY = {
|
|
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874),
|
|
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686),
|
|
};
|
|
|
|
let client: string;
|
|
let plumberCat: string;
|
|
|
|
beforeAll(async () => {
|
|
const [row] = await db.execute<{ id: string }>(sql`
|
|
INSERT INTO users (name, email, role)
|
|
VALUES ('Geo Probe', ${`geo-${RUN}@example.com`}, 'client')
|
|
RETURNING id
|
|
`);
|
|
client = row!.id;
|
|
|
|
const [cat] = await db.execute<{ id: string }>(
|
|
sql`SELECT id FROM categories WHERE slug = 'plumber' LIMIT 1`,
|
|
);
|
|
plumberCat = cat!.id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db.execute(sql`DELETE FROM users WHERE id = ${client}`);
|
|
await closePool();
|
|
});
|
|
|
|
describe('geocode.suggest', () => {
|
|
it('is not reachable without a session', async () => {
|
|
// Unlike pro.search this costs money per call, so the session is the first
|
|
// cost bound.
|
|
await expect(callerFor(null).geocode.suggest({ q: 'carrer' })).rejects.toThrow(/signed in/i);
|
|
});
|
|
|
|
it('caps the query length', async () => {
|
|
await expect(
|
|
callerFor(clientSession(client)).geocode.suggest({ q: 'x'.repeat(201) }),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it('caps how many suggestions can be asked for', async () => {
|
|
await expect(
|
|
callerFor(clientSession(client)).geocode.suggest({ q: 'carrer', limit: 50 }),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it('returns an empty list rather than failing when unconfigured', async () => {
|
|
// A provider outage must not take an address field — and therefore a whole
|
|
// form — down with it.
|
|
const result = await callerFor(clientSession(client)).geocode.suggest({ q: 'carrer de sants' });
|
|
expect(result.results).toEqual([]);
|
|
expect(result.configured).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('job.create resolves the point server-side', () => {
|
|
const base = {
|
|
categoryId: '',
|
|
title: 'Tap dripping in the bathroom',
|
|
description: 'The cold tap drips constantly and the washer looks perished.',
|
|
photos: [] as string[],
|
|
urgency: 'flexible' as const,
|
|
};
|
|
|
|
async function readJob(id: string) {
|
|
const [row] = await db.execute<{
|
|
precision: string;
|
|
address_text: string;
|
|
place_id: string | null;
|
|
lat: number;
|
|
lng: number;
|
|
}>(sql`
|
|
SELECT location_precision AS precision,
|
|
address_text,
|
|
location_place_id AS place_id,
|
|
ST_Y(location::geometry) AS lat,
|
|
ST_X(location::geometry) AS lng
|
|
FROM jobs WHERE id = ${id}
|
|
`);
|
|
return row!;
|
|
}
|
|
|
|
it('records an unresolvable address as city precision, and still posts', async () => {
|
|
// The heart of it. This used to store the city centre and label the row an
|
|
// address, so every distance computed from it was a fiction.
|
|
const job = await callerFor(clientSession(client)).job.create({
|
|
...base,
|
|
categoryId: plumberCat,
|
|
place: { source: 'none', label: 'Somewhere near the big roundabout' },
|
|
});
|
|
|
|
const row = await readJob(job.id);
|
|
expect(row.precision).toBe('city');
|
|
expect(row.place_id).toBeNull();
|
|
expect(Number(row.lat)).toBeCloseTo(CITY.lat, 4);
|
|
expect(Number(row.lng)).toBeCloseTo(CITY.lng, 4);
|
|
// What they typed survives — it is a note to the pro, just not a location.
|
|
expect(row.address_text).toBe('Somewhere near the big roundabout');
|
|
});
|
|
|
|
it('takes a device fix at its word but never calls it exact', async () => {
|
|
const job = await callerFor(clientSession(client)).job.create({
|
|
...base,
|
|
categoryId: plumberCat,
|
|
place: { source: 'device', lat: 41.4036, lng: 2.1744, label: 'Gracia' },
|
|
});
|
|
|
|
const row = await readJob(job.id);
|
|
// A handset fix is real, so the coordinates are kept as sent...
|
|
expect(Number(row.lat)).toBeCloseTo(41.4036, 4);
|
|
expect(Number(row.lng)).toBeCloseTo(2.1744, 4);
|
|
// ...but it is metres out on a good day, so it must not rank as a rooftop.
|
|
expect(row.precision).toBe('approximate');
|
|
});
|
|
|
|
it('falls back rather than trusting a placeId it cannot resolve', async () => {
|
|
// With no geocoder there is nothing to verify the id against, and an
|
|
// unverifiable id must not become a coordinate.
|
|
const job = await callerFor(clientSession(client)).job.create({
|
|
...base,
|
|
categoryId: plumberCat,
|
|
place: { source: 'place', placeId: 'made-up-id', label: 'Carrer de Sants 12' },
|
|
});
|
|
|
|
const row = await readJob(job.id);
|
|
expect(row.precision).toBe('city');
|
|
expect(row.place_id).toBeNull();
|
|
});
|
|
|
|
it('no longer accepts raw coordinates at all', async () => {
|
|
// The old shape. Anyone could put a job anywhere on earth with it.
|
|
await expect(
|
|
callerFor(clientSession(client)).job.create({
|
|
...base,
|
|
categoryId: plumberCat,
|
|
// @ts-expect-error — the field is gone from the schema on purpose.
|
|
location: { lat: 0, lng: 0 },
|
|
addressText: 'Null Island',
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
});
|