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:
@@ -45,16 +45,30 @@ let verifiedProId: string;
|
||||
let unverifiedProId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
/**
|
||||
* The seeded city-centre job, chosen by the properties these tests depend on
|
||||
* rather than by being the oldest row.
|
||||
*
|
||||
* "Oldest" stopped meaning "the fixture" the moment the seed grew backdated
|
||||
* jobs for other features, and the TTL assertion below silently started
|
||||
* measuring somebody else's `flexible` job.
|
||||
*/
|
||||
const jobs = await db.execute<{ id: string; client_id: string }>(
|
||||
sql`SELECT id, client_id FROM jobs ORDER BY created_at LIMIT 1`,
|
||||
sql`SELECT id, client_id FROM jobs
|
||||
WHERE urgency = 'now' AND status = 'open'
|
||||
ORDER BY created_at LIMIT 1`,
|
||||
);
|
||||
const job = jobs[0];
|
||||
if (!job) throw new Error('No seeded job — run `pnpm db:seed`');
|
||||
if (!job) throw new Error('No seeded open "now" job — run `pnpm db:seed`');
|
||||
jobId = job.id;
|
||||
ownerId = job.client_id;
|
||||
|
||||
const others = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM users WHERE role = 'client' AND id <> ${ownerId} LIMIT 1`,
|
||||
// Seeded only — see the note on the job lookup above. A probe client from
|
||||
// another test file could otherwise land here and be deleted mid-run.
|
||||
sql`SELECT id FROM users
|
||||
WHERE role = 'client' AND email LIKE '%@linkder.test' AND id <> ${ownerId}
|
||||
LIMIT 1`,
|
||||
);
|
||||
strangerId = others[0]!.id;
|
||||
|
||||
@@ -327,11 +341,106 @@ describe('undo', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The entry deck has no job in context, so a right swipe there has to ask which
|
||||
* job it means. These are the states that question can be in.
|
||||
*/
|
||||
describe('deck.sendable', () => {
|
||||
it('lists the jobs a pro could be sent, and flags the ones they already have', async () => {
|
||||
const caller = callerFor(clientSession(ownerId));
|
||||
|
||||
const before = await caller.deck.sendable({ proId: verifiedProId });
|
||||
const target = before.jobs.find((j) => j.id === jobId);
|
||||
expect(target).toBeDefined();
|
||||
expect(target!.alreadySent).toBe(false);
|
||||
expect(target!.atCap).toBe(false);
|
||||
expect(before.proAvailable).toBe(true);
|
||||
|
||||
await caller.deck.swipe({ jobId, proId: verifiedProId, direction: 'right' });
|
||||
|
||||
const after = await caller.deck.sendable({ proId: verifiedProId });
|
||||
const sent = after.jobs.find((j) => j.id === jobId);
|
||||
// The sheet offers "send" only where this is false — without it a second
|
||||
// swipe would silently no-op against the unique constraint.
|
||||
expect(sent!.alreadySent).toBe(true);
|
||||
expect(sent!.requestStatus).toBe('pending');
|
||||
expect(sent!.pendingCount).toBe(1);
|
||||
});
|
||||
|
||||
it('only ever lists jobs that are still taking offers', async () => {
|
||||
const caller = callerFor(clientSession(ownerId));
|
||||
|
||||
await db.execute(sql`UPDATE jobs SET status = 'completed' WHERE id = ${jobId}`);
|
||||
const closed = await caller.deck.sendable({ proId: verifiedProId });
|
||||
expect(closed.jobs.map((j) => j.id)).not.toContain(jobId);
|
||||
|
||||
await db.execute(sql`UPDATE jobs SET status = 'open' WHERE id = ${jobId}`);
|
||||
const open = await caller.deck.sendable({ proId: verifiedProId });
|
||||
expect(open.jobs.map((j) => j.id)).toContain(jobId);
|
||||
});
|
||||
|
||||
it("never lists somebody else's jobs", async () => {
|
||||
const theirs = await callerFor(clientSession(strangerId)).deck.sendable({
|
||||
proId: verifiedProId,
|
||||
});
|
||||
expect(theirs.jobs.map((j) => j.id)).not.toContain(jobId);
|
||||
});
|
||||
|
||||
it('reports a pro who cannot be sent anything rather than failing later', async () => {
|
||||
const result = await callerFor(clientSession(ownerId)).deck.sendable({
|
||||
proId: unverifiedProId,
|
||||
});
|
||||
// swipe would throw NOT_FOUND for this pro. Knowing up front is what lets
|
||||
// the sheet say so instead of opening and then erroring.
|
||||
expect(result.proAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('says whether the pro actually works the trade, without hiding the job', async () => {
|
||||
const caller = callerFor(clientSession(ownerId));
|
||||
|
||||
// verifiedProId was picked precisely because they cover this job's category.
|
||||
const matching = await caller.deck.sendable({ proId: verifiedProId });
|
||||
expect(matching.jobs.find((j) => j.id === jobId)!.tradeMatches).toBe(true);
|
||||
|
||||
const [offTrade] = await db.execute<{ id: string }>(sql`
|
||||
SELECT p.user_id AS id FROM pro_profiles p
|
||||
WHERE p.verification_status = 'verified'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM pro_categories pc
|
||||
JOIN jobs j ON j.category_id = pc.category_id AND j.id = ${jobId}
|
||||
WHERE pc.pro_id = p.user_id
|
||||
)
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
if (offTrade) {
|
||||
const other = await caller.deck.sendable({ proId: offTrade.id });
|
||||
const row = other.jobs.find((j) => j.id === jobId);
|
||||
// Flagged, still offered — the client picked this person on purpose.
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.tradeMatches).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a pro asking who they could hire', async () => {
|
||||
const asPro: Session = {
|
||||
...clientSession(verifiedProId),
|
||||
role: 'pro',
|
||||
verificationStatus: 'verified',
|
||||
};
|
||||
await expect(callerFor(asPro).deck.sendable({ proId: verifiedProId })).rejects.toThrow(
|
||||
/only clients/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('job router', () => {
|
||||
it("lists only the caller's own jobs", async () => {
|
||||
const mine = await callerFor(clientSession(ownerId)).job.mine();
|
||||
expect(mine.length).toBeGreaterThan(0);
|
||||
for (const job of mine) expect(job.clientId).toBe(ownerId);
|
||||
// `mine` no longer returns client_id — it is scoped by it in the WHERE
|
||||
// clause, so the column would only be a chance for the two to disagree.
|
||||
// Ownership is asserted by what the list does and does not contain.
|
||||
expect(mine.map((j) => j.id)).toContain(jobId);
|
||||
|
||||
const theirs = await callerFor(clientSession(strangerId)).job.mine();
|
||||
expect(theirs.map((j) => j.id)).not.toContain(jobId);
|
||||
|
||||
Reference in New Issue
Block a user