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>
392 lines
14 KiB
TypeScript
392 lines
14 KiB
TypeScript
/**
|
||
* The commercial half of the funnel, end to end, against the live database.
|
||
*
|
||
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
||
*
|
||
* quote → accept → booking → done → confirm → review. Until this existed the
|
||
* chain stopped at "two people are talking": `quotes`, `bookings` and `reviews`
|
||
* had tables and state machines and nothing that wrote a row, so `reviews` was
|
||
* unreachable and `completed_jobs` could never move.
|
||
*
|
||
* The tests are ordered because the lifecycle is. Each `describe` leaves the
|
||
* fixture one step further along, which is also the cheapest way to prove the
|
||
* steps compose rather than merely each working from a hand-built row.
|
||
*/
|
||
import { config } from 'dotenv';
|
||
import { sql } from 'drizzle-orm';
|
||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||
import { REVIEW_EMBARGO_HOURS } from '@linkder/shared';
|
||
|
||
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;
|
||
|
||
const callerFor = (session: Session | null) =>
|
||
createCaller(createInnerContext({ db, session }));
|
||
|
||
const clientSession = (userId: string): Session => ({
|
||
userId,
|
||
role: 'client',
|
||
name: 'Test Client',
|
||
email: 'client@test',
|
||
phone: null,
|
||
verificationStatus: null,
|
||
});
|
||
|
||
const proSession = (userId: string): Session => ({
|
||
userId,
|
||
role: 'pro',
|
||
name: 'Test Pro',
|
||
email: 'pro@test',
|
||
phone: null,
|
||
verificationStatus: 'verified',
|
||
});
|
||
|
||
const RUN = Math.random().toString(36).slice(2, 8);
|
||
|
||
let owner: string;
|
||
let pro: string;
|
||
let stranger: string;
|
||
let jobId: string;
|
||
let matchId: string;
|
||
let quoteId: string;
|
||
let bookingId: string;
|
||
|
||
const slot = () => {
|
||
const start = new Date(Date.now() + 86_400_000);
|
||
return { scheduledStart: start, scheduledEnd: new Date(start.getTime() + 7_200_000) };
|
||
};
|
||
|
||
async function insertUser(name: string, role: 'client' | 'pro'): Promise<string> {
|
||
const [row] = await db.execute<{ id: string }>(sql`
|
||
INSERT INTO users (name, email, role)
|
||
VALUES (${name}, ${`${role}-${RUN}-${Math.random().toString(36).slice(2, 6)}@example.com`}, ${role})
|
||
RETURNING id
|
||
`);
|
||
return row!.id;
|
||
}
|
||
|
||
/*
|
||
* These fixture pros are `is_accepting_jobs = false`.
|
||
*
|
||
* Test files share one database and run concurrently. A verified, accepting pro
|
||
* sitting at the city centre is eligible for the SEEDED job's deck, so creating
|
||
* and deleting one mid-run shifts `deck.list().remaining` underneath
|
||
* deck.router.test.ts. Holiday mode keeps them off every deck and search —
|
||
* `eligibleProAtAnyDistance()` requires the flag — and nothing in the quote →
|
||
* booking → review chain reads it, so the lifecycle is unaffected.
|
||
*/
|
||
beforeAll(async () => {
|
||
const [plumber] = await db.execute<{ id: string }>(
|
||
sql`SELECT id FROM categories WHERE slug = 'plumber' LIMIT 1`,
|
||
);
|
||
|
||
owner = await insertUser(`Life Owner ${RUN}`, 'client');
|
||
stranger = await insertUser(`Life Stranger ${RUN}`, 'client');
|
||
pro = await insertUser(`Life Pro ${RUN}`, 'pro');
|
||
|
||
await db.execute(sql`
|
||
INSERT INTO pro_profiles (
|
||
user_id, headline, bio, hourly_rate_cents, base_location, base_location_precision,
|
||
service_radius_m, verification_status, verified_at, is_accepting_jobs
|
||
)
|
||
VALUES (
|
||
${pro}, 'Lifecycle pro', 'Exists only for the lifecycle tests.', 4000,
|
||
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 'exact',
|
||
15000, 'verified', now(), false
|
||
)
|
||
`);
|
||
|
||
const [job] = await db.execute<{ id: string }>(sql`
|
||
INSERT INTO jobs (client_id, category_id, title, description, location, location_precision,
|
||
address_text, status)
|
||
VALUES (
|
||
${owner}, ${plumber!.id}, 'Lifecycle fixture job',
|
||
'A job that exists to be quoted, booked, completed and reviewed.',
|
||
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 'exact',
|
||
'Carrer de Prova 1', 'matched'
|
||
)
|
||
RETURNING id
|
||
`);
|
||
jobId = job!.id;
|
||
|
||
const [request] = await db.execute<{ id: string }>(sql`
|
||
INSERT INTO requests (job_id, pro_id, status, expires_at, responded_at)
|
||
VALUES (${jobId}, ${pro}, 'accepted', now() + interval '2 days', now())
|
||
RETURNING id
|
||
`);
|
||
|
||
const [match] = await db.execute<{ id: string }>(sql`
|
||
INSERT INTO matches (request_id, job_id, pro_id, client_id)
|
||
VALUES (${request!.id}, ${jobId}, ${pro}, ${owner})
|
||
RETURNING id
|
||
`);
|
||
matchId = match!.id;
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await db.execute(sql`DELETE FROM users WHERE id IN (${owner}, ${stranger}, ${pro})`);
|
||
await closePool();
|
||
});
|
||
|
||
describe('quote', () => {
|
||
it('refuses a stranger, and a client trying to quote themselves', async () => {
|
||
await expect(
|
||
callerFor(clientSession(stranger)).quote.forMatch({ matchId }),
|
||
).rejects.toThrow(/not found/i);
|
||
|
||
await expect(
|
||
callerFor(clientSession(owner)).quote.create({
|
||
matchId,
|
||
kind: 'fixed',
|
||
amountCents: 20_000,
|
||
scope: 'I would like to quote myself, please.',
|
||
}),
|
||
).rejects.toThrow(/only professionals/i);
|
||
});
|
||
|
||
it('lets the pro send one, visible to both sides', async () => {
|
||
const sent = await callerFor(proSession(pro)).quote.create({
|
||
matchId,
|
||
kind: 'fixed',
|
||
amountCents: 24_500,
|
||
scope: 'Replace the trap and reseal the waste under the sink.',
|
||
});
|
||
quoteId = sent.id;
|
||
|
||
expect(sent.status).toBe('sent');
|
||
expect(sent.isLive).toBe(true);
|
||
|
||
const asClient = await callerFor(clientSession(owner)).quote.forMatch({ matchId });
|
||
expect(asClient.map((q) => q.id)).toContain(quoteId);
|
||
});
|
||
|
||
it('withdraws the previous quote when a new one is sent', async () => {
|
||
// Two live offers from one person is not a negotiation, it is a mistake
|
||
// waiting to be accepted.
|
||
const second = await callerFor(proSession(pro)).quote.create({
|
||
matchId,
|
||
kind: 'fixed',
|
||
amountCents: 21_000,
|
||
scope: 'Revised: the trap is fine, it only needs a new washer and a reseal.',
|
||
});
|
||
|
||
const all = await callerFor(proSession(pro)).quote.forMatch({ matchId });
|
||
expect(all.find((q) => q.id === quoteId)!.status).toBe('withdrawn');
|
||
expect(all.find((q) => q.id === second.id)!.status).toBe('sent');
|
||
|
||
quoteId = second.id;
|
||
});
|
||
});
|
||
|
||
describe('booking', () => {
|
||
it('refuses to book on a withdrawn quote', async () => {
|
||
const stale = (await callerFor(proSession(pro)).quote.forMatch({ matchId })).find(
|
||
(q) => q.status === 'withdrawn',
|
||
)!;
|
||
|
||
await expect(
|
||
callerFor(clientSession(owner)).quote.accept({ matchId, quoteId: stale.id, ...slot() }),
|
||
).rejects.toThrow(/no longer open/i);
|
||
});
|
||
|
||
it('refuses a slot in the past', async () => {
|
||
await expect(
|
||
callerFor(clientSession(owner)).quote.accept({
|
||
matchId,
|
||
quoteId,
|
||
scheduledStart: new Date(Date.now() - 86_400_000),
|
||
scheduledEnd: new Date(Date.now() - 82_800_000),
|
||
}),
|
||
).rejects.toThrow();
|
||
});
|
||
|
||
it('accepting creates the booking and closes the job to other pros', async () => {
|
||
// A second pro still waiting on this job would otherwise keep it in their
|
||
// inbox forever and keep it counting against their response rate.
|
||
const other = await insertUser(`Life Other ${RUN}`, 'pro');
|
||
// requests.pro_id references pro_profiles.user_id, not users.id — a pro
|
||
// without a profile is not somebody a job can be sent to.
|
||
await db.execute(sql`
|
||
INSERT INTO pro_profiles (
|
||
user_id, headline, bio, hourly_rate_cents, base_location, base_location_precision,
|
||
service_radius_m, verification_status, verified_at, is_accepting_jobs
|
||
)
|
||
VALUES (
|
||
${other}, 'Also waiting', 'A second pro left hanging on the same job.', 3500,
|
||
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 'exact',
|
||
15000, 'verified', now(), false
|
||
)
|
||
`);
|
||
await db.execute(sql`
|
||
INSERT INTO requests (job_id, pro_id, status, expires_at)
|
||
VALUES (${jobId}, ${other}, 'pending', now() + interval '2 days')
|
||
`);
|
||
|
||
const result = await callerFor(clientSession(owner)).quote.accept({
|
||
matchId,
|
||
quoteId,
|
||
...slot(),
|
||
});
|
||
bookingId = result.bookingId;
|
||
|
||
const [job] = await db.execute<{ status: string }>(
|
||
sql`SELECT status FROM jobs WHERE id = ${jobId}`,
|
||
);
|
||
expect(job!.status).toBe('booked');
|
||
|
||
const [pending] = await db.execute<{ n: number }>(
|
||
sql`SELECT count(*)::int AS n FROM requests
|
||
WHERE job_id = ${jobId} AND status = 'pending'`,
|
||
);
|
||
expect(pending!.n).toBe(0);
|
||
|
||
await db.execute(sql`DELETE FROM users WHERE id = ${other}`);
|
||
});
|
||
|
||
it('lets the pro flag that they have started, but does not require it', async () => {
|
||
await expect(
|
||
callerFor(clientSession(owner)).booking.start({ bookingId }),
|
||
).rejects.toThrow(/only the pro/i);
|
||
|
||
await callerFor(proSession(pro)).booking.start({ bookingId });
|
||
|
||
const [row] = await db.execute<{ status: string }>(
|
||
sql`SELECT status FROM bookings WHERE id = ${bookingId}`,
|
||
);
|
||
expect(row!.status).toBe('in_progress');
|
||
});
|
||
|
||
it('only the pro may mark it done, only the client may confirm', async () => {
|
||
await expect(
|
||
callerFor(clientSession(owner)).booking.markComplete({ bookingId }),
|
||
).rejects.toThrow(/only the pro/i);
|
||
|
||
await callerFor(proSession(pro)).booking.markComplete({ bookingId });
|
||
|
||
await expect(callerFor(proSession(pro)).booking.confirm({ bookingId })).rejects.toThrow(
|
||
/only the customer/i,
|
||
);
|
||
});
|
||
|
||
it('confirming completes the job and moves the pro’s counters', async () => {
|
||
await callerFor(clientSession(owner)).booking.confirm({ bookingId });
|
||
|
||
const [job] = await db.execute<{ status: string }>(
|
||
sql`SELECT status FROM jobs WHERE id = ${jobId}`,
|
||
);
|
||
expect(job!.status).toBe('completed');
|
||
|
||
// completed_jobs is a deck ranking input and was never written before the
|
||
// booking lifecycle existed.
|
||
const [stats] = await db.execute<{ completed_jobs: number }>(
|
||
sql`SELECT completed_jobs FROM pro_profiles WHERE user_id = ${pro}`,
|
||
);
|
||
expect(stats!.completed_jobs).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe('review', () => {
|
||
it('surfaces the finished job as owed a review, on both sides', async () => {
|
||
const mine = await callerFor(clientSession(owner)).review.pending();
|
||
const theirs = await callerFor(proSession(pro)).review.pending();
|
||
|
||
expect(mine.map((r) => r.bookingId)).toContain(bookingId);
|
||
expect(theirs.map((r) => r.bookingId)).toContain(bookingId);
|
||
});
|
||
|
||
it('holds the first review back instead of publishing it', async () => {
|
||
const written = await callerFor(clientSession(owner)).review.create({
|
||
bookingId,
|
||
rating: 5,
|
||
body: 'Turned up on time, fixed it in an hour, tidied up after himself.',
|
||
});
|
||
|
||
// Embargoed, not hidden by a null: `published_at` is dated forward so it
|
||
// surfaces on its own even if the pro never writes anything back.
|
||
expect(written.published).toBe(false);
|
||
expect(written.publishedAt!.getTime()).toBeGreaterThan(Date.now());
|
||
expect(written.publishedAt!.getTime()).toBeLessThanOrEqual(
|
||
Date.now() + REVIEW_EMBARGO_HOURS * 3_600_000 + 5_000,
|
||
);
|
||
|
||
// Not yet counted, and not yet readable.
|
||
const [stats] = await db.execute<{ rating_count: number }>(
|
||
sql`SELECT rating_count FROM pro_profiles WHERE user_id = ${pro}`,
|
||
);
|
||
expect(stats!.rating_count).toBe(0);
|
||
|
||
const seen = await callerFor(proSession(pro)).review.forBooking({ bookingId });
|
||
expect(seen.theyHaveReviewed).toBe(true);
|
||
// Knows one exists, cannot read it — that is what stops a reply in kind.
|
||
expect(seen.theirs).toBeNull();
|
||
});
|
||
|
||
it('refuses a second review from the same author', async () => {
|
||
await expect(
|
||
callerFor(clientSession(owner)).review.create({
|
||
bookingId,
|
||
rating: 1,
|
||
body: 'Actually, on reflection, I would like to change my mind about this.',
|
||
}),
|
||
).rejects.toThrow(/already reviewed/i);
|
||
});
|
||
|
||
it('publishes both the moment the second one lands, and counts it', async () => {
|
||
const second = await callerFor(proSession(pro)).review.create({
|
||
bookingId,
|
||
rating: 5,
|
||
body: 'Clear about the problem, easy access, paid without any fuss.',
|
||
});
|
||
expect(second.published).toBe(true);
|
||
|
||
const [stats] = await db.execute<{ rating_count: number; rating_avg: string | null }>(
|
||
sql`SELECT rating_count, rating_avg FROM pro_profiles WHERE user_id = ${pro}`,
|
||
);
|
||
expect(stats!.rating_count).toBe(1);
|
||
expect(Number(stats!.rating_avg)).toBe(5);
|
||
|
||
// And now each side can read the other's.
|
||
const asPro = await callerFor(proSession(pro)).review.forBooking({ bookingId });
|
||
expect(asPro.theirs?.body).toMatch(/turned up on time/i);
|
||
});
|
||
|
||
it('refuses a review from someone who was not on the booking', async () => {
|
||
await expect(
|
||
callerFor(clientSession(stranger)).review.create({
|
||
bookingId,
|
||
rating: 1,
|
||
body: 'I have never met either of these people but here is my opinion.',
|
||
}),
|
||
).rejects.toThrow(/not found/i);
|
||
});
|
||
|
||
it('refuses a review on work that is not finished', async () => {
|
||
const [fresh] = await db.execute<{ id: string }>(sql`
|
||
INSERT INTO quotes (match_id, kind, amount_cents, scope, valid_until)
|
||
VALUES (${matchId}, 'fixed', 5000, 'Another small job', now() + interval '2 days')
|
||
RETURNING id
|
||
`);
|
||
const [booking] = await db.execute<{ id: string }>(sql`
|
||
INSERT INTO bookings (match_id, quote_id, scheduled_start, scheduled_end, status)
|
||
VALUES (${matchId}, ${fresh!.id}, now() + interval '1 day',
|
||
now() + interval '1 day 2 hours', 'scheduled')
|
||
RETURNING id
|
||
`);
|
||
|
||
await expect(
|
||
callerFor(clientSession(owner)).review.create({
|
||
bookingId: booking!.id,
|
||
rating: 5,
|
||
body: 'Reviewing this before anybody has actually done anything at all.',
|
||
}),
|
||
).rejects.toThrow(/finished and confirmed/i);
|
||
});
|
||
});
|