M2: the full job lifecycle — chat, hiring, geocoding, quotes, bookings, reviews
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>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@linkder/geocode",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@linkder/shared": "workspace:*",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { z } from 'zod';
|
||||
import type { LatLng, LocationPrecision } from '@linkder/shared';
|
||||
|
||||
/**
|
||||
* Address → coordinates.
|
||||
*
|
||||
* Linkder matches on distance: `ST_Distance(p.base_location, j.location)` ranks
|
||||
* every deck and `ST_DWithin(..., p.service_radius_m)` decides who is eligible
|
||||
* at all. Before this package both operands were the city centre for any user
|
||||
* who declined the browser's location prompt, so the ranking was ordering by
|
||||
* noise. Everything here exists to make a stored point mean something.
|
||||
*
|
||||
* Mapbox Geocoding v6, always with `permanent=true`. That flag is not a tuning
|
||||
* knob — it is the licence to store the coordinates we get back, and storing
|
||||
* them is the entire point. The temporary endpoint forbids persistence, so a
|
||||
* request without it would make every row in the database a licence breach.
|
||||
*/
|
||||
|
||||
export class GeocodeError extends Error {}
|
||||
|
||||
const ENDPOINT = 'https://api.mapbox.com/search/geocode/v6';
|
||||
|
||||
/** Mapbox caps `limit` at 10 for forward geocoding. */
|
||||
export const MAX_SUGGESTIONS = 10;
|
||||
export const MAX_QUERY_LENGTH = 200;
|
||||
|
||||
export interface GeocodeResult {
|
||||
/** Mapbox feature id. Kept so a point can be re-resolved without the free text. */
|
||||
providerId: string;
|
||||
/** What to show the user, and what lands in `addressText`. */
|
||||
label: string;
|
||||
coordinates: LatLng;
|
||||
precision: LocationPrecision;
|
||||
}
|
||||
|
||||
interface GeocodeConfig {
|
||||
token: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
function readConfig(): GeocodeConfig {
|
||||
const token = process.env.MAPBOX_TOKEN;
|
||||
if (!token) {
|
||||
throw new GeocodeError('Geocoding is not configured. Missing: MAPBOX_TOKEN');
|
||||
}
|
||||
// Bounding results to one country is a quality decision, not a security one:
|
||||
// "Carrer de Sants" matches in several places and the wrong continent is a
|
||||
// worse answer than no answer.
|
||||
return { token, country: process.env.MAPBOX_COUNTRY ?? 'es' };
|
||||
}
|
||||
|
||||
export function isConfigured(): boolean {
|
||||
return Boolean(process.env.MAPBOX_TOKEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* How precise a Mapbox feature actually is.
|
||||
*
|
||||
* Derived from `feature_type`, never from "did we get a result" — a query for a
|
||||
* misspelled street still returns the city, and treating that as a located
|
||||
* address is exactly the bug this package was written to remove.
|
||||
*
|
||||
* Anything unrecognised falls to `city`, the least-claiming value. A new Mapbox
|
||||
* feature type should degrade to "we are not sure", not silently rank as exact.
|
||||
*/
|
||||
export function precisionOf(featureType: string | undefined): LocationPrecision {
|
||||
switch (featureType) {
|
||||
case 'address':
|
||||
case 'secondary_address':
|
||||
return 'exact';
|
||||
case 'street':
|
||||
case 'block':
|
||||
case 'postcode':
|
||||
case 'neighborhood':
|
||||
case 'locality':
|
||||
return 'approximate';
|
||||
default:
|
||||
return 'city';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice of Mapbox's response we depend on.
|
||||
*
|
||||
* Parsed rather than cast: this is a third-party payload crossing into code that
|
||||
* writes coordinates to the database, and a silently-missing `coordinates` would
|
||||
* become a NaN point rather than a loud failure.
|
||||
*/
|
||||
const featureSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
properties: z.object({
|
||||
mapbox_id: z.string().optional(),
|
||||
feature_type: z.string().optional(),
|
||||
full_address: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
place_formatted: z.string().optional(),
|
||||
coordinates: z.object({
|
||||
longitude: z.number(),
|
||||
latitude: z.number(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const responseSchema = z.object({
|
||||
features: z.array(featureSchema).default([]),
|
||||
});
|
||||
|
||||
function toResult(feature: z.infer<typeof featureSchema>): GeocodeResult | null {
|
||||
const p = feature.properties;
|
||||
const providerId = p.mapbox_id ?? feature.id;
|
||||
if (!providerId) return null;
|
||||
|
||||
const label =
|
||||
p.full_address ??
|
||||
(p.name && p.place_formatted ? `${p.name}, ${p.place_formatted}` : (p.name ?? null)) ??
|
||||
null;
|
||||
if (!label) return null;
|
||||
|
||||
return {
|
||||
providerId,
|
||||
label,
|
||||
coordinates: { lat: p.coordinates.latitude, lng: p.coordinates.longitude },
|
||||
precision: precisionOf(p.feature_type),
|
||||
};
|
||||
}
|
||||
|
||||
/** Shape a Mapbox payload into our results. Exported for the tests, which run offline. */
|
||||
export function parseResponse(payload: unknown): GeocodeResult[] {
|
||||
const parsed = responseSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
throw new GeocodeError('Geocoder returned a response we do not understand');
|
||||
}
|
||||
return parsed.data.features.map(toResult).filter((r): r is GeocodeResult => r !== null);
|
||||
}
|
||||
|
||||
async function call(path: string, params: URLSearchParams): Promise<GeocodeResult[]> {
|
||||
const { token } = readConfig();
|
||||
params.set('access_token', token);
|
||||
// See the note at the top of this file. Never make this conditional.
|
||||
params.set('permanent', 'true');
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${ENDPOINT}/${path}?${params.toString()}`);
|
||||
} catch (cause) {
|
||||
throw new GeocodeError('Could not reach the geocoder', { cause });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// 401/403 here almost always means the token lacks the permanent-geocoding
|
||||
// entitlement rather than that it is invalid — worth saying, because the two
|
||||
// look identical from the outside and only one is a billing-plan problem.
|
||||
throw new GeocodeError(
|
||||
response.status === 401 || response.status === 403
|
||||
? `Geocoder rejected the token (${response.status}). Permanent geocoding requires an entitled Mapbox plan.`
|
||||
: `Geocoder failed (${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
return parseResponse(await response.json());
|
||||
}
|
||||
|
||||
export const forwardInputSchema = z.object({
|
||||
q: z.string().trim().min(1).max(MAX_QUERY_LENGTH),
|
||||
/** Bias results toward here. The city centre, or the user's own pin. */
|
||||
proximity: z
|
||||
.object({ lat: z.number().min(-90).max(90), lng: z.number().min(-180).max(180) })
|
||||
.optional(),
|
||||
limit: z.number().int().min(1).max(MAX_SUGGESTIONS).optional(),
|
||||
});
|
||||
export type ForwardInput = z.infer<typeof forwardInputSchema>;
|
||||
|
||||
/** Address text → ranked candidates, nearest to `proximity` first. */
|
||||
export async function forward(input: ForwardInput): Promise<GeocodeResult[]> {
|
||||
const { country } = readConfig();
|
||||
const params = new URLSearchParams({
|
||||
q: input.q,
|
||||
country,
|
||||
limit: String(input.limit ?? 5),
|
||||
// Suggestions are typed a character at a time; without this Mapbox only
|
||||
// matches complete addresses and the list stays empty until the last letter.
|
||||
autocomplete: 'true',
|
||||
});
|
||||
if (input.proximity) {
|
||||
params.set('proximity', `${input.proximity.lng},${input.proximity.lat}`);
|
||||
}
|
||||
return call('forward', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates → the nearest address.
|
||||
*
|
||||
* This is what makes "Use my current location" honest. That button used to set a
|
||||
* point with no label, leaving the user looking at a form that had silently
|
||||
* decided where they live. Now they get an address back and can see whether it
|
||||
* is right.
|
||||
*
|
||||
* The result is downgraded to at most `approximate`: a phone fix is metres out
|
||||
* on a good day and a street away on a bad one, so it must never be recorded
|
||||
* with the same confidence as a chosen address.
|
||||
*/
|
||||
export async function reverse(point: LatLng): Promise<GeocodeResult | null> {
|
||||
const params = new URLSearchParams({
|
||||
longitude: String(point.lng),
|
||||
latitude: String(point.lat),
|
||||
limit: '1',
|
||||
});
|
||||
const [nearest] = await call('reverse', params);
|
||||
if (!nearest) return null;
|
||||
|
||||
return {
|
||||
...nearest,
|
||||
// Keep the DEVICE's coordinates, not the matched address's — the user is
|
||||
// where they are, and snapping them to a building centroid moves them.
|
||||
coordinates: point,
|
||||
precision: nearest.precision === 'exact' ? 'approximate' : nearest.precision,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Unit tests. No network, no credentials — everything here runs offline against
|
||||
* recorded Mapbox payloads.
|
||||
*
|
||||
* The thing worth testing is the precision mapping. A geocoder always returns
|
||||
* *something*: ask it for a misspelled street and it hands back the city, very
|
||||
* confidently. Treating that as a located address is precisely the bug this
|
||||
* package exists to remove, so "did we get a result" must never be the signal.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { GeocodeError, parseResponse, precisionOf } from '../src/index';
|
||||
|
||||
/** Shaped like a real v6 feature, trimmed to the fields we read. */
|
||||
function feature(overrides: {
|
||||
featureType?: string;
|
||||
id?: string;
|
||||
fullAddress?: string;
|
||||
name?: string;
|
||||
placeFormatted?: string;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
}) {
|
||||
return {
|
||||
properties: {
|
||||
mapbox_id: overrides.id ?? 'dXJuOm1ieGFkcjo=',
|
||||
feature_type: overrides.featureType,
|
||||
full_address: overrides.fullAddress,
|
||||
name: overrides.name,
|
||||
place_formatted: overrides.placeFormatted,
|
||||
coordinates: { longitude: overrides.lng ?? 2.1686, latitude: overrides.lat ?? 41.3874 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('precisionOf', () => {
|
||||
it('calls a street address exact', () => {
|
||||
expect(precisionOf('address')).toBe('exact');
|
||||
expect(precisionOf('secondary_address')).toBe('exact');
|
||||
});
|
||||
|
||||
it('calls anything coarser than a building approximate', () => {
|
||||
for (const t of ['street', 'block', 'postcode', 'neighborhood', 'locality']) {
|
||||
expect(precisionOf(t)).toBe('approximate');
|
||||
}
|
||||
});
|
||||
|
||||
it('calls a city-or-wider result what it is', () => {
|
||||
// The failure mode that matters: a bad query returns the city, and if that
|
||||
// ranked as a location every distance in the product would be a lie.
|
||||
expect(precisionOf('place')).toBe('city');
|
||||
expect(precisionOf('region')).toBe('city');
|
||||
expect(precisionOf('country')).toBe('city');
|
||||
});
|
||||
|
||||
it('degrades an unknown feature type rather than trusting it', () => {
|
||||
// Mapbox can add types. A new one must not silently rank as exact.
|
||||
expect(precisionOf('something_new')).toBe('city');
|
||||
expect(precisionOf(undefined)).toBe('city');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseResponse', () => {
|
||||
it('maps a full address feature', () => {
|
||||
const [result] = parseResponse({
|
||||
features: [
|
||||
feature({
|
||||
featureType: 'address',
|
||||
id: 'addr-1',
|
||||
fullAddress: 'Carrer de Sants 12, 08014 Barcelona, Spain',
|
||||
lat: 41.3751,
|
||||
lng: 2.1339,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
providerId: 'addr-1',
|
||||
label: 'Carrer de Sants 12, 08014 Barcelona, Spain',
|
||||
coordinates: { lat: 41.3751, lng: 2.1339 },
|
||||
precision: 'exact',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a label from name and place when there is no full address', () => {
|
||||
const [result] = parseResponse({
|
||||
features: [feature({ featureType: 'street', name: 'Carrer de Sants', placeFormatted: 'Barcelona, Spain' })],
|
||||
});
|
||||
expect(result?.label).toBe('Carrer de Sants, Barcelona, Spain');
|
||||
expect(result?.precision).toBe('approximate');
|
||||
});
|
||||
|
||||
it('drops a feature with nothing to show or nothing to re-resolve by', () => {
|
||||
// A row we cannot label is a row the user cannot choose between; a row with
|
||||
// no id is one we could never re-resolve. Both are dropped rather than
|
||||
// rendered as a blank line.
|
||||
const results = parseResponse({
|
||||
features: [
|
||||
feature({ featureType: 'address', fullAddress: undefined, name: undefined }),
|
||||
{ properties: { feature_type: 'address', coordinates: { longitude: 2, latitude: 41 } } },
|
||||
],
|
||||
});
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns nothing for an empty result set rather than throwing', () => {
|
||||
expect(parseResponse({ features: [] })).toEqual([]);
|
||||
expect(parseResponse({})).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses a payload whose coordinates are missing or malformed', () => {
|
||||
// This is the guard that keeps a NaN out of a geography column.
|
||||
expect(() => parseResponse({ features: [{ properties: { feature_type: 'address' } }] })).toThrow(
|
||||
GeocodeError,
|
||||
);
|
||||
expect(() =>
|
||||
parseResponse({
|
||||
features: [{ properties: { coordinates: { longitude: 'two', latitude: 41 } } }],
|
||||
}),
|
||||
).toThrow(GeocodeError);
|
||||
expect(() => parseResponse('not json at all')).toThrow(GeocodeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "noEmit": true },
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: { environment: 'node', include: ['test/**/*.test.ts'] },
|
||||
});
|
||||
Reference in New Issue
Block a user