import { TRPCError } from '@trpc/server'; import { and, eq, gt, sql } from 'drizzle-orm'; import { z } from 'zod'; import { recomputeProStats, schema } from '@linkdr/db'; import { notify } from '@linkdr/notify'; import { assertTransition } from '@linkdr/shared'; import { proProcedure, router, verifiedProProcedure } from '../trpc'; /** * The missing middle of the funnel. * * A right swipe writes a `pending` request and stops (`deck.swipe`). Until * something accepts one, `matches` stays empty forever — which means no chat, * no quote, no booking, and a pro whose inbox does not exist. This router is * that step: the pro answers, and a match is the answer being yes. * * Expiry is lazy on purpose. A request past `expiresAt` is treated as expired * wherever it is read and refused wherever it is acted on, rather than being * swept by a cron that does not exist yet. The sweeper belongs with the M4 * worker; correctness must not wait for it. */ export const requestRouter = router({ /** * The pro's inbox: jobs waiting on their answer. * * Not `verifiedProProcedure` — an unverified pro should be able to SEE what * they are missing, which is the strongest argument for finishing * verification. Acting on one is what needs the badge. */ mine: proProcedure.query(async ({ ctx }) => { const rows = await ctx.db .select({ requestId: schema.requests.id, expiresAt: schema.requests.expiresAt, createdAt: schema.requests.createdAt, jobId: schema.jobs.id, title: schema.jobs.title, description: schema.jobs.description, urgency: schema.jobs.urgency, photos: schema.jobs.photos, budgetMinCents: schema.jobs.budgetMinCents, budgetMaxCents: schema.jobs.budgetMaxCents, categoryName: schema.categories.name, // The pro needs to know how far it is before they answer. Metres from // their own base, on the GiST index. distanceM: sql`ST_Distance(${schema.proProfiles.baseLocation}, ${schema.jobs.location})`, }) .from(schema.requests) .innerJoin(schema.jobs, eq(schema.jobs.id, schema.requests.jobId)) .innerJoin(schema.categories, eq(schema.categories.id, schema.jobs.categoryId)) .innerJoin(schema.proProfiles, eq(schema.proProfiles.userId, schema.requests.proId)) .where( and( eq(schema.requests.proId, ctx.session.userId), eq(schema.requests.status, 'pending'), // Lazy expiry: an unanswered request that ran out is not in the inbox. gt(schema.requests.expiresAt, new Date()), // A job the client has since cancelled is not worth answering. eq(schema.jobs.status, 'open'), ), ) .orderBy(schema.requests.expiresAt); return rows.map((r) => ({ ...r, distanceM: Math.round(Number(r.distanceM)) })); }), /** * "Yes, I want this job." * * Creates the match, which is what opens chat. Verified only: this is the * first point where a pro touches a real customer, and `verifiedProProcedure` * exists for exactly this. * * Everything happens under a lock on the request row. Two taps on a flaky * connection are a read-then-write race, and the second one must not produce a * second match — `matches.request_id` is UNIQUE, so the database would refuse * it anyway, but a 500 from a constraint is not an answer a UI can render. */ accept: verifiedProProcedure .input(z.object({ requestId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { const result = await ctx.db.transaction(async (tx) => { const [request] = await tx .select() .from(schema.requests) .where(eq(schema.requests.id, input.requestId)) .for('update'); // 404 rather than 403 for someone else's request: a stranger must not be // able to confirm it exists. Same rule as requireOwnedJob. if (!request || request.proId !== ctx.session.userId) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Request not found' }); } if (request.status !== 'pending') { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: request.status === 'accepted' ? 'You already accepted this job.' : 'This request is no longer open.', }); } if (request.expiresAt <= new Date()) { // Record the expiry rather than leaving a stale `pending` row behind. await tx .update(schema.requests) .set({ status: 'expired', respondedAt: new Date() }) .where(eq(schema.requests.id, request.id)); throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'This request expired. The customer has moved on.', }); } const [job] = await tx .select() .from(schema.jobs) .where(eq(schema.jobs.id, request.jobId)) .for('update'); if (!job) throw new TRPCError({ code: 'NOT_FOUND', message: 'Job not found' }); if (job.status !== 'open' && job.status !== 'matched') { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'This job is no longer taking offers.', }); } await tx .update(schema.requests) .set({ status: 'accepted', respondedAt: new Date() }) .where(eq(schema.requests.id, request.id)); const [match] = await tx .insert(schema.matches) .values({ requestId: request.id, jobId: request.jobId, proId: request.proId, clientId: job.clientId, }) .returning(); // A job with several interested pros is already `matched`; only the // first acceptance moves it, and the graph is the authority on whether // that move is legal. if (job.status === 'open') { assertTransition('job', 'open', 'matched'); await tx .update(schema.jobs) .set({ status: 'matched', updatedAt: new Date() }) .where(eq(schema.jobs.id, job.id)); } await tx.insert(schema.auditLog).values({ actorId: ctx.session.userId, action: 'request.accepted', entity: 'request', entityId: request.id, metadata: { jobId: job.id, matchId: match!.id }, ip: ctx.ip, }); return { matchId: match!.id, jobId: job.id, clientId: job.clientId, jobTitle: job.title, }; }); /* * Answering a request is what moves this pro's response rate, so the * counters the deck ranks on are stale until this runs. * * AFTER the transaction, and swallowed: a failed stats refresh must never * roll back an acceptance. The pro said yes, the match exists, and the * next accept — or the nightly backfill — recomputes from source rows and * repairs the number anyway, because recomputeProStats derives rather * than increments. */ await recomputeProStats(ctx.db, ctx.session.userId).catch(() => {}); /* * Tell the client somebody said yes. * * This is the message the whole funnel turns on: a client who posted a * job and closed the app had no way of learning a pro was waiting, and * the request expired while both sides assumed the other was thinking * about it. * * Same placement and same reasoning as the stats refresh above — after * the commit, and it cannot throw. */ await notify(ctx.db, result.clientId, { kind: 'request.accepted', proName: ctx.session.name ?? 'A pro', jobTitle: result.jobTitle, }); return { matchId: result.matchId, jobId: result.jobId }; }), /** * "No thanks." * * No match, no job transition — the client's other requests are unaffected and * the job stays open for them. Deliberately allowed for an unverified pro: * declining is how a pro keeps their inbox honest, and blocking it would just * leave stale requests hanging until they expire. */ decline: proProcedure .input(z.object({ requestId: z.string().uuid(), reason: z.string().max(500).optional() })) .mutation(async ({ ctx, input }) => { const result = await ctx.db.transaction(async (tx) => { const [request] = await tx .select() .from(schema.requests) .where(eq(schema.requests.id, input.requestId)) .for('update'); if (!request || request.proId !== ctx.session.userId) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Request not found' }); } if (request.status !== 'pending') { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'This request is no longer open.', }); } await tx .update(schema.requests) .set({ status: 'declined', respondedAt: new Date() }) .where(eq(schema.requests.id, request.id)); await tx.insert(schema.auditLog).values({ actorId: ctx.session.userId, action: 'request.declined', entity: 'request', entityId: request.id, metadata: { jobId: request.jobId, reason: input.reason ?? null }, ip: ctx.ip, }); return { declined: true as const }; }); // A decline is an answer too — it counts toward the response rate exactly // as an acceptance does. Same placement and same reasoning as `accept`. await recomputeProStats(ctx.db, ctx.session.userId).catch(() => {}); return result; }), });