Files
linkder/packages/api/src/routers/request.ts
T
serfaandClaude Opus 5 1808ad4cba Move the demo market to Mexico City, priced in US dollars
The showcase was a Barcelona market: Catalan names, +34 numbers, euro
rates and "Carrer Example 12" on every job. Presented to a Mexican
client, all of that reads as somebody else's product.

City comes from NEXT_PUBLIC_CITY_* as before, now Ciudad de México at
19.4326/-99.1332, with MAPBOX_COUNTRY=mx. The seed's fallbacks were
Barcelona literals, so an unset env quietly seeded a different city
than the app rendered — they now agree.

Two db tests pinned the Barcelona centre as a hardcoded constant, which
is why the deck returned zero cards on the first run here: every pro was
a continent outside the radius. They read the same env as the seed now,
so the trap cannot recur.

Money: formatCents defaults to USD/en-US, and the nine hardcoded euro
signs across the card, search rows, quote strip and forms are dollars.
The rate NUMBERS are unchanged and still read high for CDMX — that is a
pricing decision, not a currency one, and is left alone deliberately.

Seed people are Mexican, addressed on real Roma/Condesa streets rotated
by index rather than one placeholder repeated. Phones moved to +52 55,
which moves the demo login to +525500000000 / 000000.

Also in here, from the same session:
- Sending a job now confirms. The mutation always succeeded; the sheet
  just closed with no receipt, which from the customer's side is
  indistinguishable from a dead button. Dismissing that receipt resolves
  as 'sent', so the card does not return to the deck.
- Media moves to DigitalOcean Spaces, with the public origin derived
  from bucket and region instead of a second env var to keep in sync.
- Managed-Postgres TLS: DATABASE_CA_CERT takes a path or inline PEM.
- The client-facing project panel beside the running app.
- Two profiles removed and four renamed to match their photos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 10:56:31 -04:00

260 lines
9.7 KiB
TypeScript

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<number>`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;
}),
});