The deck had two answers, which is a lot to hang on a swipe: send this pro a job right now, or lose them. Three more, in one fixed row (DESIGN.md §6.8): rewind · ✗ · watch · ✓ · ask Size is the hierarchy — the two that end the card stay 64px, the three that do not are 44px, never below the §8 floor. Rewind is disabled rather than hidden when there is nothing to undo, so the row never changes length and the big pair never moves out from under a thumb. Rewind is local. The entry deck writes no swipes — a `swipes` row is job-scoped and there is no job there — so the card leaving was only ever an index move. Watch: "tell me when this one is free" - `pro_watches` snapshots the pro's availability AT WATCH TIME, because the trigger is a change, not a state. Without it a sweep would notify every watcher on every run, since "available" stays true for as long as they stay available. - Deliberately the narrow version: a pro with is_accepting_jobs = false is invisible everywhere (eligibleProAtAnyDistance requires it), so a watch can only be placed on somebody already free and fires on the away-and-back cycle. "Free at a time that suits me" needs pro_availability — seeded since M1, read by nothing — to become a real calendar. Flagged rather than faked. Ask: a question, before there is a job - This is the first way to reach a pro who has not agreed to anything. Chat was gated behind message → match → accepted request → job, and that gate is what made a pro's inbox worth opening, so the cap is not decoration: MAX_OPEN_ENQUIRIES unanswered at a time, one thread per pair so it cannot be walked around, answered threads stop counting, stale ones fall out, and the pro can close one. - `enquiries` is its own table, not a match with a null job: a match means a pro said yes to specific work, and collapsing the two would put rows in `matches` that no quote, booking or review could hang off. - `messages` now belongs to a match OR an enquiry, with a CHECK making the illegal state unrepresentable. One message table, so one chat screen. 283 tests passing; typecheck and lint clean across 7 packages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
/**
|
|
* Watching a pro, and asking one a question.
|
|
*
|
|
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
|
*
|
|
* The deck's two new answers. Most of what follows is about the cap on
|
|
* enquiries, because this is the first way in the product to reach a pro who
|
|
* has not agreed to anything — until now chat was gated behind
|
|
* message → match → accepted request → job, and that gate is what made a pro's
|
|
* inbox worth opening.
|
|
*/
|
|
import { config } from 'dotenv';
|
|
import { sql } from 'drizzle-orm';
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
import { MAX_OPEN_ENQUIRIES } 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 client: string;
|
|
let pros: string[] = [];
|
|
let unverifiedPro: string;
|
|
|
|
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}, ${`${RUN}-${Math.random().toString(36).slice(2, 8)}@example.com`}, ${role})
|
|
RETURNING id
|
|
`);
|
|
return row!.id;
|
|
}
|
|
|
|
/**
|
|
* Fixture pros are on holiday (`is_accepting_jobs = false` by default here where
|
|
* it does not matter, true where it does).
|
|
*
|
|
* Test files share one database. A verified, accepting pro at the city centre is
|
|
* eligible for the SEEDED job's deck, so creating and deleting them mid-run
|
|
* shifts `deck.list().remaining` underneath deck.router.test.ts. Parked far
|
|
* outside the city instead, which keeps them off every deck without changing
|
|
* the availability these tests actually assert on.
|
|
*/
|
|
async function insertPro(name: string, accepting = true, verified = true): Promise<string> {
|
|
const id = await insertUser(name, '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 (
|
|
${id}, ${`${name} headline`}, 'Exists only for the discovery tests.', 3000,
|
|
ST_SetSRID(ST_MakePoint(-40.0, -40.0), 4326)::geography, 'exact',
|
|
15000, ${verified ? 'verified' : 'pending'}, now(), ${accepting}
|
|
)
|
|
`);
|
|
return id;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
client = await insertUser(`Discovery Client ${RUN}`, 'client');
|
|
for (let i = 0; i < MAX_OPEN_ENQUIRIES + 2; i += 1) {
|
|
pros.push(await insertPro(`Discovery Pro ${RUN}-${i}`));
|
|
}
|
|
unverifiedPro = await insertPro(`Discovery Unverified ${RUN}`, true, false);
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await db.execute(sql`DELETE FROM enquiries WHERE client_id = ${client}`);
|
|
await db.execute(sql`DELETE FROM pro_watches WHERE watcher_id = ${client}`);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
// One at a time rather than `= ANY(...)`: drizzle passes a JS array through as
|
|
// a scalar parameter, which Postgres rejects.
|
|
for (const id of [client, unverifiedPro, ...pros]) {
|
|
await db.execute(sql`DELETE FROM users WHERE id = ${id}`);
|
|
}
|
|
await closePool();
|
|
});
|
|
|
|
describe('watch', () => {
|
|
it('toggles on and off with one call', async () => {
|
|
const caller = callerFor(clientSession(client));
|
|
|
|
expect((await caller.watch.isWatching({ proId: pros[0]! })).watching).toBe(false);
|
|
|
|
const on = await caller.watch.toggle({ proId: pros[0]! });
|
|
expect(on.watching).toBe(true);
|
|
expect((await caller.watch.isWatching({ proId: pros[0]! })).watching).toBe(true);
|
|
|
|
const off = await caller.watch.toggle({ proId: pros[0]! });
|
|
expect(off.watching).toBe(false);
|
|
expect((await caller.watch.mine()).map((w) => w.proId)).not.toContain(pros[0]);
|
|
});
|
|
|
|
it('refuses an unverified pro, and refuses to watch yourself', async () => {
|
|
const caller = callerFor(clientSession(client));
|
|
await expect(caller.watch.toggle({ proId: unverifiedPro })).rejects.toThrow(/not available/i);
|
|
await expect(caller.watch.toggle({ proId: client })).rejects.toThrow();
|
|
});
|
|
|
|
it('does not fire for a pro who was already free when watched', async () => {
|
|
// The trigger is a CHANGE, not a state. Without the snapshot taken at watch
|
|
// time, every sweep would notify every watcher, because "available" stays
|
|
// true for as long as they stay available.
|
|
const caller = callerFor(clientSession(client));
|
|
await caller.watch.toggle({ proId: pros[0]! });
|
|
|
|
expect(await caller.watch.dueNotification()).toEqual([]);
|
|
});
|
|
|
|
it('fires once a pro who was away comes back', async () => {
|
|
const away = pros[1]!;
|
|
await db.execute(
|
|
sql`UPDATE pro_profiles SET is_accepting_jobs = false WHERE user_id = ${away}`,
|
|
);
|
|
|
|
const caller = callerFor(clientSession(client));
|
|
await caller.watch.toggle({ proId: away });
|
|
expect(await caller.watch.dueNotification()).toEqual([]);
|
|
|
|
await db.execute(
|
|
sql`UPDATE pro_profiles SET is_accepting_jobs = true WHERE user_id = ${away}`,
|
|
);
|
|
|
|
const due = await caller.watch.dueNotification();
|
|
expect(due.map((d) => d.proId)).toContain(away);
|
|
|
|
// Told once: marking it notified takes it out of the queue, so a pro
|
|
// toggling twice does not send two messages.
|
|
await db.execute(
|
|
sql`UPDATE pro_watches SET notified_at = now()
|
|
WHERE watcher_id = ${client} AND pro_id = ${away}`,
|
|
);
|
|
expect(await caller.watch.dueNotification()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('enquiry', () => {
|
|
it('creates a thread and its first message together', async () => {
|
|
// An enquiry with no message is an empty room — a pro opening one would
|
|
// find nothing to answer.
|
|
const caller = callerFor(clientSession(client));
|
|
const { enquiryId } = await caller.enquiry.create({
|
|
proId: pros[0]!,
|
|
body: 'Do you cover replacing a whole bathroom suite, or only repairs?',
|
|
});
|
|
|
|
const [count] = await db.execute<{ n: number }>(
|
|
sql`SELECT count(*)::int AS n FROM messages WHERE enquiry_id = ${enquiryId}`,
|
|
);
|
|
expect(count!.n).toBe(1);
|
|
|
|
const inbox = await callerFor(proSession(pros[0]!)).enquiry.mine();
|
|
expect(inbox.map((e) => e.id)).toContain(enquiryId);
|
|
expect(inbox.find((e) => e.id === enquiryId)!.unreadCount).toBe(1);
|
|
});
|
|
|
|
it('puts a second question in the same thread, not a new one', async () => {
|
|
const caller = callerFor(clientSession(client));
|
|
const first = await caller.enquiry.create({
|
|
proId: pros[0]!,
|
|
body: 'Do you cover replacing a whole bathroom suite?',
|
|
});
|
|
const second = await caller.enquiry.create({
|
|
proId: pros[0]!,
|
|
body: 'And would that include taking the old one away with you?',
|
|
});
|
|
|
|
// Also what stops the cap being walked around by asking one pro repeatedly.
|
|
expect(second.enquiryId).toBe(first.enquiryId);
|
|
});
|
|
|
|
it('caps unanswered enquiries', async () => {
|
|
const caller = callerFor(clientSession(client));
|
|
|
|
for (let i = 0; i < MAX_OPEN_ENQUIRIES; i += 1) {
|
|
await caller.enquiry.create({
|
|
proId: pros[i]!,
|
|
body: `Question number ${i} for a pro who has not agreed to anything yet.`,
|
|
});
|
|
}
|
|
|
|
expect((await caller.enquiry.allowance()).remaining).toBe(0);
|
|
|
|
await expect(
|
|
caller.enquiry.create({
|
|
proId: pros[MAX_OPEN_ENQUIRIES]!,
|
|
body: 'One more question, which should be refused by the open-enquiry cap.',
|
|
}),
|
|
).rejects.toThrow(/still waiting on an answer/i);
|
|
});
|
|
|
|
it('stops counting an enquiry once the pro replies', async () => {
|
|
// Somebody having real conversations should not be throttled; only somebody
|
|
// broadcasting.
|
|
const caller = callerFor(clientSession(client));
|
|
for (let i = 0; i < MAX_OPEN_ENQUIRIES; i += 1) {
|
|
await caller.enquiry.create({
|
|
proId: pros[i]!,
|
|
body: `Question number ${i} for a pro who has not agreed to anything yet.`,
|
|
});
|
|
}
|
|
expect((await caller.enquiry.allowance()).remaining).toBe(0);
|
|
|
|
const answered = (await callerFor(proSession(pros[0]!)).enquiry.mine())[0]!;
|
|
await callerFor(proSession(pros[0]!)).message.send({
|
|
ref: { enquiryId: answered.id },
|
|
body: 'Yes, full bathroom suites are fine — happy to quote if you post the job.',
|
|
attachments: [],
|
|
});
|
|
|
|
expect((await caller.enquiry.allowance()).remaining).toBe(1);
|
|
});
|
|
|
|
it('refuses a pro who is unverified or on holiday', async () => {
|
|
const caller = callerFor(clientSession(client));
|
|
await expect(
|
|
caller.enquiry.create({
|
|
proId: unverifiedPro,
|
|
body: 'Are you able to take on a small job next week at all?',
|
|
}),
|
|
).rejects.toThrow(/not available/i);
|
|
});
|
|
|
|
it('lets the pro close it, after which nothing more can be sent', async () => {
|
|
const caller = callerFor(clientSession(client));
|
|
const { enquiryId } = await caller.enquiry.create({
|
|
proId: pros[0]!,
|
|
body: 'A question that this pro is going to decide not to entertain.',
|
|
});
|
|
|
|
// Only the pro. A customer must not be able to close their own way around
|
|
// the cap.
|
|
await expect(caller.enquiry.close({ enquiryId })).rejects.toThrow(/not found/i);
|
|
|
|
await callerFor(proSession(pros[0]!)).enquiry.close({ enquiryId });
|
|
|
|
await expect(
|
|
caller.message.send({
|
|
ref: { enquiryId },
|
|
body: 'Are you still there? I would really like an answer to this.',
|
|
attachments: [],
|
|
}),
|
|
).rejects.toThrow(/closed/i);
|
|
|
|
// The history stays — a closed thread is still evidence.
|
|
const thread = await caller.message.thread({ ref: { enquiryId } });
|
|
expect(thread.messages.length).toBe(1);
|
|
expect(thread.match.canReply).toBe(false);
|
|
});
|
|
|
|
it('is not readable by anyone who is not in it', async () => {
|
|
const caller = callerFor(clientSession(client));
|
|
const { enquiryId } = await caller.enquiry.create({
|
|
proId: pros[0]!,
|
|
body: 'A private question between me and this particular tradesperson.',
|
|
});
|
|
|
|
const stranger = await insertUser(`Discovery Stranger ${RUN}`, 'client');
|
|
await expect(
|
|
callerFor(clientSession(stranger)).message.thread({ ref: { enquiryId } }),
|
|
).rejects.toThrow(/not found/i);
|
|
await db.execute(sql`DELETE FROM users WHERE id = ${stranger}`);
|
|
});
|
|
});
|