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>
260 lines
9.7 KiB
TypeScript
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;
|
|
}),
|
|
});
|