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:
@@ -0,0 +1,106 @@
|
||||
import { forward, GeocodeError, isConfigured, type GeocodeResult } from '@linkder/geocode';
|
||||
import type { LatLng, LocationInput, LocationPrecision } from '@linkder/shared';
|
||||
|
||||
/**
|
||||
* The one place a stored coordinate is decided.
|
||||
*
|
||||
* `job.create`, `pro.upsertProfile` and `user.updateLocation` all route through
|
||||
* here, because a point that means one thing on a job and another on a pro
|
||||
* profile makes `ST_Distance(p.base_location, j.location)` meaningless — and
|
||||
* that expression is how this product ranks every deck.
|
||||
*
|
||||
* The important property: for a picked suggestion the SERVER resolves the
|
||||
* coordinates. The client sends an id and a label, never a lat/lng. Before this,
|
||||
* `job.create` wrote `input.location` straight through, so a crafted payload
|
||||
* could put a job anywhere and an ordinary form could — and routinely did — put
|
||||
* it at the city centre while the row claimed to be an address.
|
||||
*/
|
||||
|
||||
export interface ResolvedLocation {
|
||||
location: LatLng;
|
||||
/** What the geocoder called this point. Written to the row's address column. */
|
||||
addressText: string;
|
||||
precision: LocationPrecision;
|
||||
placeId: string | null;
|
||||
}
|
||||
|
||||
export interface CityCentre {
|
||||
lat: number;
|
||||
lng: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** The fallback point, read once from env. Throws rather than guessing a city. */
|
||||
export function cityCentre(): CityCentre {
|
||||
const lat = Number(process.env.NEXT_PUBLIC_CITY_LAT);
|
||||
const lng = Number(process.env.NEXT_PUBLIC_CITY_LNG);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
|
||||
// Same reasoning as deck.showcase: an unset city silently makes every pro
|
||||
// "out of radius", which looks like having no supply rather than a config
|
||||
// mistake.
|
||||
throw new Error('NEXT_PUBLIC_CITY_LAT / NEXT_PUBLIC_CITY_LNG are not set.');
|
||||
}
|
||||
return { lat, lng, name: process.env.NEXT_PUBLIC_CITY_NAME ?? 'the city centre' };
|
||||
}
|
||||
|
||||
function centreFallback(label?: string): ResolvedLocation {
|
||||
const centre = cityCentre();
|
||||
return {
|
||||
location: { lat: centre.lat, lng: centre.lng },
|
||||
// Keep whatever the person typed. It is not a location, but it is a note to
|
||||
// themselves and to the pro who eventually turns up.
|
||||
addressText: label?.trim() || centre.name,
|
||||
precision: 'city',
|
||||
placeId: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn what the caller reported into a point we are willing to store.
|
||||
*
|
||||
* Never throws for a geocoding failure. A provider outage must not stop someone
|
||||
* posting a job — it downgrades them to `city` precision, which every read
|
||||
* surface already knows how to treat as "we do not really know where this is".
|
||||
*/
|
||||
export async function resolveLocation(input: LocationInput): Promise<ResolvedLocation> {
|
||||
if (input.source === 'none') return centreFallback(input.label);
|
||||
|
||||
if (input.source === 'device') {
|
||||
return {
|
||||
location: { lat: input.lat, lng: input.lng },
|
||||
addressText: input.label?.trim() || 'Current location',
|
||||
// Never `exact`. A handset fix is metres out on a good day and a street
|
||||
// away on a bad one.
|
||||
precision: 'approximate',
|
||||
placeId: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isConfigured()) return centreFallback(input.label);
|
||||
|
||||
try {
|
||||
/*
|
||||
* Re-resolve from the label and keep the candidate whose id the caller
|
||||
* picked.
|
||||
*
|
||||
* Mapbox Geocoding v6 has no retrieve-by-id, so a forward call on the same
|
||||
* text is how the id gets turned back into a point. Costs one extra request
|
||||
* per SAVE — not per keystroke — which is the right place to spend it: the
|
||||
* alternative is trusting coordinates from the browser, and the whole reason
|
||||
* this file exists is that we did that and the data was wrong.
|
||||
*/
|
||||
const candidates = await forward({ q: input.label, limit: 10 });
|
||||
const match = candidates.find((c: GeocodeResult) => c.providerId === input.placeId);
|
||||
if (!match) return centreFallback(input.label);
|
||||
|
||||
return {
|
||||
location: match.coordinates,
|
||||
addressText: match.label,
|
||||
precision: match.precision,
|
||||
placeId: match.providerId,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof GeocodeError) return centreFallback(input.label);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import { router } from './trpc';
|
||||
import { adminRouter } from './routers/admin';
|
||||
import { deckRouter } from './routers/deck';
|
||||
import { bookingRouter } from './routers/booking';
|
||||
import { geocodeRouter } from './routers/geocode';
|
||||
import { jobRouter } from './routers/job';
|
||||
import { messageRouter } from './routers/message';
|
||||
import { proRouter } from './routers/pro';
|
||||
import { quoteRouter } from './routers/quote';
|
||||
import { requestRouter } from './routers/request';
|
||||
import { reviewRouter } from './routers/review';
|
||||
import { uploadRouter } from './routers/upload';
|
||||
import { notificationRouter } from './routers/notification';
|
||||
import { userRouter } from './routers/user';
|
||||
@@ -12,8 +19,15 @@ import { userRouter } from './routers/user';
|
||||
*/
|
||||
export const appRouter = router({
|
||||
job: jobRouter,
|
||||
admin: adminRouter,
|
||||
deck: deckRouter,
|
||||
pro: proRouter,
|
||||
request: requestRouter,
|
||||
message: messageRouter,
|
||||
quote: quoteRouter,
|
||||
booking: bookingRouter,
|
||||
review: reviewRouter,
|
||||
geocode: geocodeRouter,
|
||||
upload: uploadRouter,
|
||||
user: userRouter,
|
||||
notification: notificationRouter,
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { and, asc, count, desc, eq, inArray } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { recomputeProStats, schema } from '@linkder/db';
|
||||
import { notify } from '@linkder/notify';
|
||||
import { createPresignedDownload } from '@linkder/storage';
|
||||
import { assertTransition, VERIFICATION_STATUSES } from '@linkder/shared';
|
||||
import { adminProcedure, router } from '../trpc';
|
||||
|
||||
/**
|
||||
* The back office.
|
||||
*
|
||||
* `pro.submitForReview` moved a profile to `pending` and nothing on earth moved
|
||||
* it to `verified` — there were no admin procedures at all, so the only way to
|
||||
* put a tradesperson in front of a customer was to edit the row by hand. That
|
||||
* made "verified", the single claim this marketplace sells, an assertion nobody
|
||||
* could act on.
|
||||
*
|
||||
* Every procedure here is `adminProcedure`, which 404s rather than 403s for
|
||||
* everyone else: an admin surface that announces itself is a target.
|
||||
*
|
||||
* Nothing in this router trusts a status it was handed. Each transition goes
|
||||
* through the graph in @linkder/shared, and each writes an `audit_log` row —
|
||||
* these are the decisions that a regulator, an insurer or a court would ask us
|
||||
* to account for.
|
||||
*/
|
||||
|
||||
/** Which documents a reviewer must have seen before approving. */
|
||||
const REQUIRED_KINDS = ['id', 'insurance'] as const;
|
||||
|
||||
export const adminRouter = router({
|
||||
/**
|
||||
* The review queue.
|
||||
*
|
||||
* Oldest first, deliberately: a pro waiting four days to start earning is the
|
||||
* one who gives up on us, and a newest-first queue starves exactly them.
|
||||
*/
|
||||
queue: adminProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
status: z.enum(VERIFICATION_STATUSES).default('pending'),
|
||||
limit: z.number().int().min(1).max(100).default(50),
|
||||
})
|
||||
.default({ status: 'pending', limit: 50 }),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const rows = await ctx.db
|
||||
.select({
|
||||
proId: schema.proProfiles.userId,
|
||||
name: schema.users.name,
|
||||
email: schema.users.email,
|
||||
phone: schema.users.phoneNumber,
|
||||
headline: schema.proProfiles.headline,
|
||||
verificationStatus: schema.proProfiles.verificationStatus,
|
||||
submittedAt: schema.proProfiles.updatedAt,
|
||||
banned: schema.users.banned,
|
||||
banExpires: schema.users.banExpires,
|
||||
})
|
||||
.from(schema.proProfiles)
|
||||
.innerJoin(schema.users, eq(schema.users.id, schema.proProfiles.userId))
|
||||
.where(eq(schema.proProfiles.verificationStatus, input.status))
|
||||
.orderBy(asc(schema.proProfiles.updatedAt))
|
||||
.limit(input.limit);
|
||||
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
// Enough to triage the list without opening every one: a profile missing
|
||||
// its insurance certificate can be skipped in the list rather than
|
||||
// opened, read and closed again.
|
||||
const ids = rows.map((r) => r.proId);
|
||||
const [credentials, media, categories] = await Promise.all([
|
||||
ctx.db
|
||||
.select({ proId: schema.credentials.proId, kind: schema.credentials.kind })
|
||||
.from(schema.credentials)
|
||||
.where(inArray(schema.credentials.proId, ids)),
|
||||
ctx.db
|
||||
.select({ proId: schema.proMedia.proId, id: schema.proMedia.id })
|
||||
.from(schema.proMedia)
|
||||
.where(inArray(schema.proMedia.proId, ids)),
|
||||
ctx.db
|
||||
.select({ proId: schema.proCategories.proId, name: schema.categories.name })
|
||||
.from(schema.proCategories)
|
||||
.innerJoin(schema.categories, eq(schema.categories.id, schema.proCategories.categoryId))
|
||||
.where(inArray(schema.proCategories.proId, ids)),
|
||||
]);
|
||||
|
||||
return rows.map((row) => {
|
||||
const kinds = credentials.filter((c) => c.proId === row.proId).map((c) => c.kind);
|
||||
return {
|
||||
...row,
|
||||
credentialKinds: kinds,
|
||||
missing: REQUIRED_KINDS.filter((k) => !kinds.includes(k)),
|
||||
photoCount: media.filter((m) => m.proId === row.proId).length,
|
||||
categories: categories.filter((c) => c.proId === row.proId).map((c) => c.name),
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
/** How many are waiting, per status. Drives the badge on the queue. */
|
||||
counts: adminProcedure.query(async ({ ctx }) => {
|
||||
const rows = await ctx.db
|
||||
.select({ status: schema.proProfiles.verificationStatus, n: count() })
|
||||
.from(schema.proProfiles)
|
||||
.groupBy(schema.proProfiles.verificationStatus);
|
||||
|
||||
return Object.fromEntries(rows.map((r) => [r.status, Number(r.n)])) as Partial<
|
||||
Record<(typeof VERIFICATION_STATUSES)[number], number>
|
||||
>;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Everything a reviewer needs to decide, including the documents.
|
||||
*
|
||||
* Credential `fileKey`s are private R2 object keys with no public URL — see
|
||||
* `isPrivateKind`. They are resolved here into signed GETs that expire in
|
||||
* minutes, so a passport scan is readable by the reviewer looking at it and
|
||||
* not by anyone they forward the page to.
|
||||
*/
|
||||
proDetail: adminProcedure
|
||||
.input(z.object({ proId: z.string().uuid() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [row] = await ctx.db
|
||||
.select({ profile: schema.proProfiles, user: schema.users })
|
||||
.from(schema.proProfiles)
|
||||
.innerJoin(schema.users, eq(schema.users.id, schema.proProfiles.userId))
|
||||
.where(eq(schema.proProfiles.userId, input.proId));
|
||||
|
||||
if (!row) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
|
||||
const [credentials, media, categories, sessions, history] = await Promise.all([
|
||||
ctx.db
|
||||
.select()
|
||||
.from(schema.credentials)
|
||||
.where(eq(schema.credentials.proId, input.proId)),
|
||||
ctx.db
|
||||
.select()
|
||||
.from(schema.proMedia)
|
||||
.where(eq(schema.proMedia.proId, input.proId))
|
||||
.orderBy(schema.proMedia.position),
|
||||
ctx.db
|
||||
.select({ name: schema.categories.name })
|
||||
.from(schema.proCategories)
|
||||
.innerJoin(schema.categories, eq(schema.categories.id, schema.proCategories.categoryId))
|
||||
.where(eq(schema.proCategories.proId, input.proId)),
|
||||
ctx.db
|
||||
.select()
|
||||
.from(schema.verificationSessions)
|
||||
.where(eq(schema.verificationSessions.proId, input.proId))
|
||||
.orderBy(desc(schema.verificationSessions.createdAt)),
|
||||
// What has already been decided about this pro, and by whom.
|
||||
ctx.db
|
||||
.select()
|
||||
.from(schema.auditLog)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.auditLog.entity, 'pro_profile'),
|
||||
eq(schema.auditLog.entityId, input.proId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(schema.auditLog.createdAt))
|
||||
.limit(20),
|
||||
]);
|
||||
|
||||
const documents = await Promise.all(
|
||||
credentials.map(async (c) => ({
|
||||
id: c.id,
|
||||
kind: c.kind,
|
||||
issuer: c.issuer,
|
||||
expiresAt: c.expiresAt,
|
||||
reviewStatus: c.reviewStatus,
|
||||
reviewNotes: c.reviewNotes,
|
||||
// Never the key itself: it is the one durable handle on the object.
|
||||
url: await createPresignedDownload(c.fileKey).catch(() => null),
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
proId: row.profile.userId,
|
||||
name: row.user.name,
|
||||
email: row.user.email,
|
||||
phone: row.user.phoneNumber,
|
||||
banned: row.user.banned,
|
||||
banExpires: row.user.banExpires,
|
||||
profile: {
|
||||
headline: row.profile.headline,
|
||||
bio: row.profile.bio,
|
||||
hourlyRateCents: row.profile.hourlyRateCents,
|
||||
yearsExperience: row.profile.yearsExperience,
|
||||
serviceRadiusM: row.profile.serviceRadiusM,
|
||||
skills: row.profile.skills,
|
||||
verificationStatus: row.profile.verificationStatus,
|
||||
verifiedAt: row.profile.verifiedAt,
|
||||
suspendedReason: row.profile.suspendedReason,
|
||||
isAcceptingJobs: row.profile.isAcceptingJobs,
|
||||
},
|
||||
categories: categories.map((c) => c.name),
|
||||
photos: media.map((m) => m.url),
|
||||
documents,
|
||||
missing: REQUIRED_KINDS.filter((k) => !credentials.some((c) => c.kind === k)),
|
||||
sessions,
|
||||
history,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Approve or reject. The moment a pro becomes real, or does not.
|
||||
*
|
||||
* `assertTransition` is the authority on whether the move is legal, so a
|
||||
* double-submitted approval or a decision on an already-rejected profile
|
||||
* fails loudly here rather than silently overwriting a status.
|
||||
*/
|
||||
decide: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
proId: z.string().uuid(),
|
||||
decision: z.enum(['verified', 'rejected']),
|
||||
/** Shown to the pro when rejected, so it has to say what to fix. */
|
||||
notes: z.string().trim().max(1000).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await ctx.db.transaction(async (tx) => {
|
||||
const [profile] = await tx
|
||||
.select()
|
||||
.from(schema.proProfiles)
|
||||
.where(eq(schema.proProfiles.userId, input.proId))
|
||||
.for('update');
|
||||
|
||||
if (!profile) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
|
||||
assertTransition('verification', profile.verificationStatus, input.decision);
|
||||
|
||||
if (input.decision === 'rejected' && !input.notes) {
|
||||
// A rejection with no reason is one the pro cannot act on, and it
|
||||
// becomes a support ticket instead of a fixed profile.
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Say what was wrong — the pro sees this and has to be able to fix it.',
|
||||
});
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(schema.proProfiles)
|
||||
.set({
|
||||
verificationStatus: input.decision,
|
||||
verifiedAt: input.decision === 'verified' ? new Date() : profile.verifiedAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.proProfiles.userId, input.proId));
|
||||
|
||||
// Record who looked at the documents. Approving a pro is a statement
|
||||
// about somebody's licence and insurance; it needs a name against it.
|
||||
await tx
|
||||
.update(schema.credentials)
|
||||
.set({
|
||||
reviewStatus: input.decision === 'verified' ? 'approved' : 'rejected',
|
||||
reviewedBy: ctx.session.userId,
|
||||
reviewedAt: new Date(),
|
||||
reviewNotes: input.notes ?? null,
|
||||
})
|
||||
.where(eq(schema.credentials.proId, input.proId));
|
||||
|
||||
await tx.insert(schema.auditLog).values({
|
||||
actorId: ctx.session.userId,
|
||||
action: `verification.${input.decision === 'verified' ? 'approved' : 'rejected'}`,
|
||||
entity: 'pro_profile',
|
||||
entityId: input.proId,
|
||||
metadata: { from: profile.verificationStatus, notes: input.notes ?? null },
|
||||
ip: ctx.ip,
|
||||
});
|
||||
|
||||
return { status: input.decision, previous: profile.verificationStatus };
|
||||
});
|
||||
|
||||
// A newly verified pro joins the deck this instant, and the deck ranks on
|
||||
// counters. Outside the transaction and swallowed — see request.accept.
|
||||
if (result.status === 'verified') {
|
||||
await recomputeProStats(ctx.db, input.proId).catch(() => {});
|
||||
}
|
||||
|
||||
/*
|
||||
* Tell the pro either way.
|
||||
*
|
||||
* Both are transactional — the outcome of something they did — so neither
|
||||
* is governed by a preference toggle. A rejection carries the notes,
|
||||
* which is why `decide` refuses one without them: this message is the
|
||||
* only place most pros will read what went wrong.
|
||||
*/
|
||||
await notify(
|
||||
ctx.db,
|
||||
input.proId,
|
||||
result.status === 'verified'
|
||||
? { kind: 'verification.approved' }
|
||||
: { kind: 'verification.rejected', notes: input.notes ?? '' },
|
||||
);
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Take a pro off every surface without unverifying them.
|
||||
*
|
||||
* Writes the ban on the USER, not the profile: `eligibleProAtAnyDistance()`
|
||||
* already honours `banned`/`ban_expires` in the deck, the showcase, search
|
||||
* and the public profile, so one write closes all four. Suspending by
|
||||
* flipping `verificationStatus` instead would lose the reason and the expiry,
|
||||
* and a later re-review would silently reinstate them.
|
||||
*/
|
||||
suspend: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
proId: z.string().uuid(),
|
||||
reason: z.string().trim().min(1).max(1000),
|
||||
/** Omitted means indefinite. */
|
||||
until: z.coerce.date().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.proId === ctx.session.userId) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'You cannot suspend yourself.' });
|
||||
}
|
||||
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(schema.users)
|
||||
.set({ banned: true, banReason: input.reason, banExpires: input.until ?? null })
|
||||
.where(eq(schema.users.id, input.proId));
|
||||
|
||||
await tx
|
||||
.update(schema.proProfiles)
|
||||
.set({ suspendedReason: input.reason, updatedAt: new Date() })
|
||||
.where(eq(schema.proProfiles.userId, input.proId));
|
||||
|
||||
await tx.insert(schema.auditLog).values({
|
||||
actorId: ctx.session.userId,
|
||||
action: 'verification.suspended',
|
||||
entity: 'pro_profile',
|
||||
entityId: input.proId,
|
||||
metadata: { reason: input.reason, until: input.until?.toISOString() ?? null },
|
||||
ip: ctx.ip,
|
||||
});
|
||||
});
|
||||
|
||||
return { suspended: true as const };
|
||||
}),
|
||||
|
||||
unsuspend: adminProcedure
|
||||
.input(z.object({ proId: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(schema.users)
|
||||
.set({ banned: false, banReason: null, banExpires: null })
|
||||
.where(eq(schema.users.id, input.proId));
|
||||
|
||||
await tx
|
||||
.update(schema.proProfiles)
|
||||
.set({ suspendedReason: null, updatedAt: new Date() })
|
||||
.where(eq(schema.proProfiles.userId, input.proId));
|
||||
|
||||
await tx.insert(schema.auditLog).values({
|
||||
actorId: ctx.session.userId,
|
||||
action: 'verification.unsuspended',
|
||||
entity: 'pro_profile',
|
||||
entityId: input.proId,
|
||||
ip: ctx.ip,
|
||||
});
|
||||
});
|
||||
|
||||
return { suspended: false as const };
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { recomputeProStats, schema, type Db } from '@linkder/db';
|
||||
import { assertTransition, cancellationOutcome, type BookingStatus } from '@linkder/shared';
|
||||
import { requireMatchParticipant } from './message';
|
||||
import { protectedProcedure, router } from '../trpc';
|
||||
|
||||
/**
|
||||
* A slot, agreed.
|
||||
*
|
||||
* The half of the lifecycle that happens after money is discussed and before it
|
||||
* moves: the work gets done, the pro says so, the client confirms. Completion is
|
||||
* what unlocks reviews, and — once escrow lands — what releases the payout, so
|
||||
* the transitions here are the ones a dispute would be argued over. Every one
|
||||
* routes through `assertTransition`; nothing sets a status by hand.
|
||||
*
|
||||
* No money yet. `cancellationOutcome` is called on cancel so the split is
|
||||
* RECORDED at the moment the facts are known, rather than reconstructed months
|
||||
* later from a scheduled time that has long since passed.
|
||||
*/
|
||||
|
||||
async function loadBooking(db: Db, bookingId: string, userId: string) {
|
||||
const booking = await db.query.bookings.findFirst({
|
||||
where: eq(schema.bookings.id, bookingId),
|
||||
});
|
||||
if (!booking) throw new TRPCError({ code: 'NOT_FOUND', message: 'Booking not found' });
|
||||
|
||||
// Authorization is the match's, not the booking's — one rule for "are these
|
||||
// two people in this conversation", and it lives in the message router.
|
||||
const match = await requireMatchParticipant(db, booking.matchId, userId);
|
||||
return { booking, match };
|
||||
}
|
||||
|
||||
export const bookingRouter = router({
|
||||
/** Every booking on one thread. Both sides see the same rows. */
|
||||
forMatch: protectedProcedure
|
||||
.input(z.object({ matchId: z.string().uuid() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await requireMatchParticipant(ctx.db, input.matchId, ctx.session.userId);
|
||||
return await ctx.db
|
||||
.select()
|
||||
.from(schema.bookings)
|
||||
.where(eq(schema.bookings.matchId, input.matchId))
|
||||
.orderBy(desc(schema.bookings.createdAt));
|
||||
}),
|
||||
|
||||
/**
|
||||
* "I am on my way / I have started."
|
||||
*
|
||||
* Only the pro, and only once the slot is real. Separate from `markComplete`
|
||||
* because `in_progress` is what a client checks when somebody has not turned
|
||||
* up, and collapsing the two would lose that.
|
||||
*/
|
||||
start: protectedProcedure
|
||||
.input(z.object({ bookingId: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { booking, match } = await loadBooking(ctx.db, input.bookingId, ctx.session.userId);
|
||||
if (match.proId !== ctx.session.userId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the pro can start a job' });
|
||||
}
|
||||
|
||||
assertTransition('booking', booking.status, 'in_progress');
|
||||
await ctx.db
|
||||
.update(schema.bookings)
|
||||
.set({ status: 'in_progress', updatedAt: new Date() })
|
||||
.where(eq(schema.bookings.id, booking.id));
|
||||
|
||||
return { status: 'in_progress' as const };
|
||||
}),
|
||||
|
||||
/**
|
||||
* "Done."
|
||||
*
|
||||
* The pro's claim, not the truth yet — it starts the client's confirmation
|
||||
* clock rather than completing anything. `pro_completed_at` is the timestamp
|
||||
* the auto-confirm sweeper will read (AUTO_CONFIRM_HOURS) once the M4 worker
|
||||
* exists; until then the client confirms by hand and nothing auto-releases,
|
||||
* which is the safe direction to be wrong in.
|
||||
*/
|
||||
markComplete: protectedProcedure
|
||||
.input(z.object({ bookingId: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { booking, match } = await loadBooking(ctx.db, input.bookingId, ctx.session.userId);
|
||||
if (match.proId !== ctx.session.userId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the pro can mark work done' });
|
||||
}
|
||||
|
||||
assertTransition('booking', booking.status, 'awaiting_confirmation');
|
||||
await ctx.db
|
||||
.update(schema.bookings)
|
||||
.set({
|
||||
status: 'awaiting_confirmation',
|
||||
proCompletedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.bookings.id, booking.id));
|
||||
|
||||
return { status: 'awaiting_confirmation' as const };
|
||||
}),
|
||||
|
||||
/**
|
||||
* "Yes, it is done."
|
||||
*
|
||||
* The client's confirmation, and the end of the job. Three things move
|
||||
* together and so share a transaction: the booking completes, the job
|
||||
* completes, and the pro's `completed_jobs` counter — a deck ranking input —
|
||||
* is recomputed from source rows.
|
||||
*/
|
||||
confirm: protectedProcedure
|
||||
.input(z.object({ bookingId: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { booking, match } = await loadBooking(ctx.db, input.bookingId, ctx.session.userId);
|
||||
if (match.clientId !== ctx.session.userId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the customer can confirm' });
|
||||
}
|
||||
|
||||
assertTransition('booking', booking.status, 'completed');
|
||||
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(schema.bookings)
|
||||
.set({ status: 'completed', clientConfirmedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(schema.bookings.id, booking.id));
|
||||
|
||||
const [job] = await tx
|
||||
.select()
|
||||
.from(schema.jobs)
|
||||
.where(eq(schema.jobs.id, match.jobId))
|
||||
.for('update');
|
||||
|
||||
// A job can only be completed from `booked`. If it is already there —
|
||||
// a second confirm, or an admin got here first — leave it alone rather
|
||||
// than throwing a transition error at a client doing nothing wrong.
|
||||
if (job && job.status === 'booked') {
|
||||
assertTransition('job', job.status, 'completed');
|
||||
await tx
|
||||
.update(schema.jobs)
|
||||
.set({ status: 'completed', updatedAt: new Date() })
|
||||
.where(eq(schema.jobs.id, job.id));
|
||||
}
|
||||
|
||||
await tx.insert(schema.auditLog).values({
|
||||
actorId: ctx.session.userId,
|
||||
action: 'booking.completed',
|
||||
entity: 'booking',
|
||||
entityId: booking.id,
|
||||
metadata: { jobId: match.jobId, proId: match.proId },
|
||||
ip: ctx.ip,
|
||||
});
|
||||
});
|
||||
|
||||
// After the transaction and swallowed, same as request.accept: a failed
|
||||
// stats refresh must never roll back a completion. recomputeProStats
|
||||
// derives rather than increments, so the next run repairs it.
|
||||
await recomputeProStats(ctx.db, match.proId).catch(() => {});
|
||||
|
||||
return { status: 'completed' as const };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Call it off.
|
||||
*
|
||||
* Either side may, and who cancelled decides who pays — `cancellationOutcome`
|
||||
* in @linkder/shared owns that rule and is already tested. The result is
|
||||
* written into the audit log now, while the scheduled time and the quote are
|
||||
* still the facts they were; recomputing it later from a slot that has since
|
||||
* passed would give a different answer.
|
||||
*
|
||||
* The job goes back to `matched`, not `cancelled`: the customer still needs
|
||||
* the work done, and their other conversations are untouched.
|
||||
*/
|
||||
cancel: protectedProcedure
|
||||
.input(z.object({ bookingId: z.string().uuid(), reason: z.string().max(500).optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { booking, match } = await loadBooking(ctx.db, input.bookingId, ctx.session.userId);
|
||||
|
||||
assertTransition('booking', booking.status, 'cancelled');
|
||||
|
||||
const quote = await ctx.db.query.quotes.findFirst({
|
||||
where: eq(schema.quotes.id, booking.quoteId),
|
||||
});
|
||||
|
||||
const cancelledBy =
|
||||
ctx.session.role === 'admin'
|
||||
? 'admin'
|
||||
: ctx.session.userId === match.proId
|
||||
? 'pro'
|
||||
: 'client';
|
||||
|
||||
const outcome = cancellationOutcome({
|
||||
amountCents: quote?.amountCents ?? 0,
|
||||
scheduledStart: booking.scheduledStart,
|
||||
cancelledBy,
|
||||
});
|
||||
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(schema.bookings)
|
||||
.set({
|
||||
status: 'cancelled',
|
||||
cancelledAt: new Date(),
|
||||
cancelledBy: ctx.session.userId,
|
||||
cancellationReason: input.reason ?? outcome.reason,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.bookings.id, booking.id));
|
||||
|
||||
const [job] = await tx
|
||||
.select()
|
||||
.from(schema.jobs)
|
||||
.where(eq(schema.jobs.id, match.jobId))
|
||||
.for('update');
|
||||
|
||||
if (job && job.status === 'booked') {
|
||||
// Back to the market, not dead. The customer still wants the work —
|
||||
// killing the job because one slot fell through would make them post
|
||||
// it again from scratch. See JOB_GRAPH.
|
||||
assertTransition('job', 'booked', '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: 'booking.cancelled',
|
||||
entity: 'booking',
|
||||
entityId: booking.id,
|
||||
metadata: {
|
||||
cancelledBy,
|
||||
refundCents: outcome.refundCents,
|
||||
feeCents: outcome.feeCents,
|
||||
reason: outcome.reason,
|
||||
},
|
||||
ip: ctx.ip,
|
||||
});
|
||||
});
|
||||
|
||||
return { status: 'cancelled' as BookingStatus, outcome };
|
||||
}),
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { and, count, eq } from 'drizzle-orm';
|
||||
import { and, count, desc, eq, inArray, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { getDeck, getDeckCount, getShowcaseDeck, schema } from '@linkder/db';
|
||||
import { notify } from '@linkder/notify';
|
||||
import {
|
||||
DECK_PAGE_SIZE,
|
||||
MAX_OPEN_REQUESTS_PER_JOB,
|
||||
@@ -85,6 +86,86 @@ export const deckRouter = router({
|
||||
return { cards };
|
||||
}),
|
||||
|
||||
/**
|
||||
* "I want this pro — which of my jobs do I send them?"
|
||||
*
|
||||
* The entry deck has no job in context, and a request cannot exist without
|
||||
* one. Rather than making the client ask three questions to find that out,
|
||||
* this answers all of them at once: which jobs are still taking offers, which
|
||||
* of them this pro already has, and which are at the cap.
|
||||
*
|
||||
* Read-only. Nothing here contacts anyone — `deck.swipe` is still the only
|
||||
* thing that writes a request, and the sheet calls it once the job is picked.
|
||||
*/
|
||||
sendable: clientProcedure
|
||||
.input(z.object({ proId: z.string().uuid() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const uid = ctx.session.userId;
|
||||
|
||||
// Same availability rule `swipe` enforces. Answering it here means the
|
||||
// sheet can say "not taking work" instead of failing on send.
|
||||
const pro = await ctx.db.query.proProfiles.findFirst({
|
||||
where: eq(schema.proProfiles.userId, input.proId),
|
||||
columns: { verificationStatus: true, isAcceptingJobs: true },
|
||||
});
|
||||
const proAvailable =
|
||||
Boolean(pro) && pro!.verificationStatus === 'verified' && pro!.isAcceptingJobs;
|
||||
|
||||
const rows = await ctx.db
|
||||
.select({
|
||||
id: schema.jobs.id,
|
||||
title: schema.jobs.title,
|
||||
status: schema.jobs.status,
|
||||
categoryId: schema.jobs.categoryId,
|
||||
categoryName: schema.categories.name,
|
||||
createdAt: schema.jobs.createdAt,
|
||||
pendingCount: sql<number>`(
|
||||
SELECT count(*)::int FROM requests r
|
||||
WHERE r.job_id = ${schema.jobs.id}
|
||||
AND r.status = 'pending' AND r.expires_at > now()
|
||||
)`,
|
||||
// Null when this pro has never been sent this job. Any other value
|
||||
// means the card should say so rather than offer to send again.
|
||||
requestStatus: sql<string | null>`(
|
||||
SELECT r.status FROM requests r
|
||||
WHERE r.job_id = ${schema.jobs.id} AND r.pro_id = ${input.proId}
|
||||
)`,
|
||||
/**
|
||||
* Whether the pro actually works this trade.
|
||||
*
|
||||
* Not a filter. `swipe` never checked it either — a client who picked
|
||||
* this person deliberately may know something the categories do not.
|
||||
* But sending a plumber a rewiring job wastes both sides' time, so the
|
||||
* sheet warns.
|
||||
*/
|
||||
tradeMatches: sql<boolean>`EXISTS (
|
||||
SELECT 1 FROM pro_categories pc
|
||||
WHERE pc.pro_id = ${input.proId} AND pc.category_id = ${schema.jobs.categoryId}
|
||||
)`,
|
||||
})
|
||||
.from(schema.jobs)
|
||||
.innerJoin(schema.categories, eq(schema.categories.id, schema.jobs.categoryId))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.jobs.clientId, uid),
|
||||
// The two statuses `swipe` accepts. A booked or finished job is not
|
||||
// somewhere to add another pro.
|
||||
inArray(schema.jobs.status, ['open', 'matched']),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(schema.jobs.createdAt));
|
||||
|
||||
return {
|
||||
proAvailable,
|
||||
cap: MAX_OPEN_REQUESTS_PER_JOB,
|
||||
jobs: rows.map((r) => ({
|
||||
...r,
|
||||
alreadySent: r.requestStatus !== null,
|
||||
atCap: r.pendingCount >= MAX_OPEN_REQUESTS_PER_JOB,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
|
||||
/** The next cards for one of the caller's own jobs. */
|
||||
list: clientProcedure
|
||||
.input(z.object({ jobId: z.string().uuid(), limit: z.number().int().min(1).max(50).optional() }))
|
||||
@@ -154,7 +235,7 @@ export const deckRouter = router({
|
||||
* (Writing the tombstone first and rolling back would make a dismissed card
|
||||
* reappear, which contradicts how the deck client is meant to behave.)
|
||||
*/
|
||||
return await ctx.db.transaction(async (tx) => {
|
||||
const result = await ctx.db.transaction(async (tx) => {
|
||||
await tx
|
||||
.select({ id: schema.jobs.id })
|
||||
.from(schema.jobs)
|
||||
@@ -190,14 +271,51 @@ export const deckRouter = router({
|
||||
.onConflictDoNothing({ target: [schema.requests.jobId, schema.requests.proId] })
|
||||
.returning();
|
||||
|
||||
// TODO(M3): enqueue a notification to the pro (web push + email) via BullMQ.
|
||||
|
||||
return {
|
||||
requested: true as const,
|
||||
requestId: request?.id ?? null,
|
||||
expiresInHours: ttlHours,
|
||||
/** Only for the notification below; never returned to the caller. */
|
||||
newRequest: Boolean(request),
|
||||
};
|
||||
});
|
||||
|
||||
/*
|
||||
* Tell the pro a job is waiting.
|
||||
*
|
||||
* Only for a request that was actually created — the insert is
|
||||
* onConflictDoNothing, so a second right swipe on the same pro returns the
|
||||
* existing request and must not text them again.
|
||||
*
|
||||
* Outside the transaction, awaited but never allowed to throw: the request
|
||||
* is the thing that matters and it has already committed. `notify` records
|
||||
* its own failures to notification_deliveries, so a silent outage is
|
||||
* visible without this call site having to care.
|
||||
*/
|
||||
if (result.newRequest) {
|
||||
const [job] = await ctx.db
|
||||
.select({
|
||||
title: schema.jobs.title,
|
||||
trade: schema.categories.name,
|
||||
distanceM: sql<number>`ST_Distance(${schema.proProfiles.baseLocation}, ${schema.jobs.location})`,
|
||||
})
|
||||
.from(schema.jobs)
|
||||
.innerJoin(schema.categories, eq(schema.categories.id, schema.jobs.categoryId))
|
||||
.innerJoin(schema.proProfiles, eq(schema.proProfiles.userId, input.proId))
|
||||
.where(eq(schema.jobs.id, input.jobId));
|
||||
|
||||
if (job) {
|
||||
await notify(ctx.db, input.proId, {
|
||||
kind: 'request.received',
|
||||
trade: job.trade,
|
||||
distanceM: Math.round(Number(job.distanceM)),
|
||||
expiresInHours: ttlHours,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { newRequest: _newRequest, ...response } = result;
|
||||
return response;
|
||||
}),
|
||||
|
||||
/** Undo the last swipe on a job, as long as it has not become a live request. */
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
forward,
|
||||
GeocodeError,
|
||||
isConfigured,
|
||||
MAX_SUGGESTIONS,
|
||||
reverse,
|
||||
type GeocodeResult,
|
||||
} from '@linkder/geocode';
|
||||
import { latLngSchema } from '@linkder/shared';
|
||||
import { protectedProcedure, router } from '../trpc';
|
||||
|
||||
/**
|
||||
* Turning what someone typed into a point we can match on.
|
||||
*
|
||||
* `protectedProcedure`, not public: every address surface in the product is
|
||||
* already behind sign-in, and unlike `pro.search` this one costs money per
|
||||
* keystroke. An anonymous caller with a loop would be spending our Mapbox
|
||||
* budget, so the session is the first cost bound and the throttle below is the
|
||||
* second.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A per-process throttle, same shape as the one in `message.ts` and with the
|
||||
* same caveat: it resets on deploy and does not span instances. It is a spend
|
||||
* ceiling on a runaway client, not a rate limiter — the real one arrives with
|
||||
* the shared Redis in M4.
|
||||
*
|
||||
* Sized for typing rather than for sending: the client debounces at 250ms, so a
|
||||
* person filling in one address costs a handful of calls and this only bites a
|
||||
* loop.
|
||||
*/
|
||||
const WINDOW_MS = 60_000;
|
||||
const LIMIT = 60;
|
||||
const recent = new Map<string, number[]>();
|
||||
|
||||
function assertRate(userId: string): void {
|
||||
const now = Date.now();
|
||||
const window = (recent.get(userId) ?? []).filter((at) => now - at < WINDOW_MS);
|
||||
if (window.length >= LIMIT) {
|
||||
throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: 'Too many lookups. Pause a moment.' });
|
||||
}
|
||||
window.push(now);
|
||||
recent.set(userId, window);
|
||||
}
|
||||
|
||||
/**
|
||||
* A geocoder that is down, or not configured, must not take a form down with it.
|
||||
*
|
||||
* Callers get an empty list and the UI says "we could not look that up" — the
|
||||
* user can still submit, and the point lands as `city` precision, which is
|
||||
* exactly what the flag is for. The alternative, a 500 out of an address field,
|
||||
* would block posting a job because a third party had a bad minute.
|
||||
*/
|
||||
async function tolerant(work: () => Promise<GeocodeResult[]>): Promise<GeocodeResult[]> {
|
||||
if (!isConfigured()) return [];
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
if (error instanceof GeocodeError) return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const geocodeRouter = router({
|
||||
/** Address text → candidates, for the address field's suggestion list. */
|
||||
suggest: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
q: z.string().trim().min(1).max(200),
|
||||
/** Bias toward here — the city centre, or a pin the user already has. */
|
||||
proximity: latLngSchema.optional(),
|
||||
limit: z.number().int().min(1).max(MAX_SUGGESTIONS).optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
assertRate(ctx.session.userId);
|
||||
const results = await tolerant(() => forward(input));
|
||||
return { results, configured: isConfigured() };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Coordinates → the nearest address.
|
||||
*
|
||||
* What makes "Use my current location" honest: the button used to set a point
|
||||
* with no label, so the form had silently decided where you live and shown you
|
||||
* nothing about it.
|
||||
*/
|
||||
reverse: protectedProcedure.input(latLngSchema).mutation(async ({ ctx, input }) => {
|
||||
assertRate(ctx.session.userId);
|
||||
if (!isConfigured()) return { result: null, configured: false };
|
||||
|
||||
try {
|
||||
return { result: await reverse(input), configured: true };
|
||||
} catch (error) {
|
||||
if (error instanceof GeocodeError) return { result: null, configured: true };
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
});
|
||||
+203
-11
@@ -2,8 +2,9 @@ import { TRPCError } from '@trpc/server';
|
||||
import { and, desc, eq, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { schema } from '@linkder/db';
|
||||
import { assertTransition, createJobSchema } from '@linkder/shared';
|
||||
import { clientProcedure, publicProcedure, router } from '../trpc';
|
||||
import { ACTIVE_JOB_STATUSES, assertTransition, createJobSchema } from '@linkder/shared';
|
||||
import { resolveLocation } from '../location';
|
||||
import { clientProcedure, proProcedure, publicProcedure, router } from '../trpc';
|
||||
|
||||
export const jobRouter = router({
|
||||
categories: publicProcedure.query(({ ctx }) =>
|
||||
@@ -22,6 +23,9 @@ export const jobRouter = router({
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Unknown trade' });
|
||||
}
|
||||
|
||||
// The server decides the point, not the caller. See src/location.ts.
|
||||
const resolved = await resolveLocation(input.place);
|
||||
|
||||
const [job] = await ctx.db
|
||||
.insert(schema.jobs)
|
||||
.values({
|
||||
@@ -33,8 +37,10 @@ export const jobRouter = router({
|
||||
urgency: input.urgency,
|
||||
budgetMinCents: input.budgetMinCents ?? null,
|
||||
budgetMaxCents: input.budgetMaxCents ?? null,
|
||||
location: input.location,
|
||||
addressText: input.addressText,
|
||||
location: resolved.location,
|
||||
locationPrecision: resolved.precision,
|
||||
locationPlaceId: resolved.placeId,
|
||||
addressText: resolved.addressText,
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -42,14 +48,125 @@ export const jobRouter = router({
|
||||
return job;
|
||||
}),
|
||||
|
||||
/** The caller's own jobs, newest first. */
|
||||
mine: clientProcedure.query(({ ctx }) =>
|
||||
ctx.db
|
||||
.select()
|
||||
/**
|
||||
* The caller's own jobs, newest first, with everything a list row shows.
|
||||
*
|
||||
* The counts are correlated subqueries rather than joins: a job with three
|
||||
* matches and forty messages would otherwise multiply into 120 rows that then
|
||||
* have to be folded back up in JS. Each subquery hits an index that already
|
||||
* exists (`requests_job_idx`, `matches_job_idx`, `messages_unread_idx`).
|
||||
*
|
||||
* ACTIVE vs PAST is derived from `status`, never stored: `open | matched |
|
||||
* booked` are live, `completed | cancelled` are history. A second boolean
|
||||
* column would be a second thing to keep true.
|
||||
*/
|
||||
mine: clientProcedure.query(async ({ ctx }) => {
|
||||
const uid = ctx.session.userId;
|
||||
|
||||
const rows = await ctx.db
|
||||
.select({
|
||||
id: schema.jobs.id,
|
||||
title: schema.jobs.title,
|
||||
status: schema.jobs.status,
|
||||
urgency: schema.jobs.urgency,
|
||||
addressText: schema.jobs.addressText,
|
||||
photos: schema.jobs.photos,
|
||||
createdAt: schema.jobs.createdAt,
|
||||
categoryName: schema.categories.name,
|
||||
pendingCount: sql<number>`(
|
||||
SELECT count(*)::int FROM requests r
|
||||
WHERE r.job_id = ${schema.jobs.id} AND r.status = 'pending' AND r.expires_at > now()
|
||||
)`,
|
||||
matchCount: sql<number>`(
|
||||
SELECT count(*)::int FROM matches m WHERE m.job_id = ${schema.jobs.id}
|
||||
)`,
|
||||
// Unread means "sent to me and not yet read" — messages I sent do not
|
||||
// count, or every thread I start would badge itself.
|
||||
unreadCount: sql<number>`(
|
||||
SELECT count(*)::int
|
||||
FROM messages msg
|
||||
JOIN matches m ON m.id = msg.match_id
|
||||
WHERE m.job_id = ${schema.jobs.id}
|
||||
AND msg.sender_id <> ${uid}
|
||||
AND msg.read_at IS NULL
|
||||
)`,
|
||||
lastMessageAt: sql<string | null>`(
|
||||
SELECT max(m.last_message_at) FROM matches m WHERE m.job_id = ${schema.jobs.id}
|
||||
)`,
|
||||
nextBookingAt: sql<string | null>`(
|
||||
SELECT min(b.scheduled_start)
|
||||
FROM bookings b
|
||||
JOIN matches m ON m.id = b.match_id
|
||||
WHERE m.job_id = ${schema.jobs.id}
|
||||
AND b.status IN ('scheduled', 'in_progress')
|
||||
)`,
|
||||
})
|
||||
.from(schema.jobs)
|
||||
.where(eq(schema.jobs.clientId, ctx.session.userId))
|
||||
.orderBy(desc(schema.jobs.createdAt)),
|
||||
),
|
||||
.innerJoin(schema.categories, eq(schema.categories.id, schema.jobs.categoryId))
|
||||
.where(eq(schema.jobs.clientId, uid))
|
||||
.orderBy(desc(schema.jobs.createdAt));
|
||||
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
lastMessageAt: r.lastMessageAt ? new Date(r.lastMessageAt) : null,
|
||||
nextBookingAt: r.nextBookingAt ? new Date(r.nextBookingAt) : null,
|
||||
isActive: ACTIVE_JOB_STATUSES.includes(r.status),
|
||||
}));
|
||||
}),
|
||||
|
||||
/**
|
||||
* The same list from the other side of the market: jobs this pro was accepted
|
||||
* on. A pro has no `jobs` of their own — their working history IS the client's
|
||||
* jobs, seen through the matches they hold.
|
||||
*/
|
||||
mineForPro: proProcedure.query(async ({ ctx }) => {
|
||||
const uid = ctx.session.userId;
|
||||
|
||||
const rows = await ctx.db
|
||||
.select({
|
||||
id: schema.jobs.id,
|
||||
matchId: schema.matches.id,
|
||||
title: schema.jobs.title,
|
||||
status: schema.jobs.status,
|
||||
urgency: schema.jobs.urgency,
|
||||
photos: schema.jobs.photos,
|
||||
createdAt: schema.jobs.createdAt,
|
||||
categoryName: schema.categories.name,
|
||||
clientName: schema.users.name,
|
||||
// The street address is the client's to give. It is theirs to withhold
|
||||
// until money and a slot are agreed — see schema/jobs.ts.
|
||||
addressText: sql<string | null>`(
|
||||
SELECT CASE WHEN EXISTS (
|
||||
SELECT 1 FROM bookings b
|
||||
WHERE b.match_id = ${schema.matches.id} AND b.status <> 'cancelled'
|
||||
) THEN ${schema.jobs.addressText} ELSE NULL END
|
||||
)`,
|
||||
unreadCount: sql<number>`(
|
||||
SELECT count(*)::int FROM messages msg
|
||||
WHERE msg.match_id = ${schema.matches.id}
|
||||
AND msg.sender_id <> ${uid}
|
||||
AND msg.read_at IS NULL
|
||||
)`,
|
||||
lastMessageAt: schema.matches.lastMessageAt,
|
||||
nextBookingAt: sql<string | null>`(
|
||||
SELECT min(b.scheduled_start) FROM bookings b
|
||||
WHERE b.match_id = ${schema.matches.id}
|
||||
AND b.status IN ('scheduled', 'in_progress')
|
||||
)`,
|
||||
})
|
||||
.from(schema.matches)
|
||||
.innerJoin(schema.jobs, eq(schema.jobs.id, schema.matches.jobId))
|
||||
.innerJoin(schema.categories, eq(schema.categories.id, schema.jobs.categoryId))
|
||||
.innerJoin(schema.users, eq(schema.users.id, schema.matches.clientId))
|
||||
.where(eq(schema.matches.proId, uid))
|
||||
.orderBy(desc(schema.matches.createdAt));
|
||||
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
nextBookingAt: r.nextBookingAt ? new Date(r.nextBookingAt) : null,
|
||||
isActive: ACTIVE_JOB_STATUSES.includes(r.status),
|
||||
}));
|
||||
}),
|
||||
|
||||
byId: clientProcedure.input(z.object({ id: z.string().uuid() })).query(async ({ ctx, input }) => {
|
||||
const job = await ctx.db.query.jobs.findFirst({
|
||||
@@ -69,6 +186,81 @@ export const jobRouter = router({
|
||||
return { ...job, pendingRequests: pending?.n ?? 0 };
|
||||
}),
|
||||
|
||||
/**
|
||||
* The pros who accepted this job — one row per conversation.
|
||||
*
|
||||
* This is the middle screen of the jobs tab: a job with three interested pros
|
||||
* is three private threads, not one. `matches.id` is the thread id, so a row
|
||||
* here taps straight into `message.thread`.
|
||||
*
|
||||
* Ordered by "most recently spoken to", falling back to when the pro accepted,
|
||||
* so a thread with a new message rises to the top of the screen.
|
||||
*/
|
||||
matches: clientProcedure
|
||||
.input(z.object({ jobId: z.string().uuid() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const uid = ctx.session.userId;
|
||||
|
||||
const job = await ctx.db.query.jobs.findFirst({ where: eq(schema.jobs.id, input.jobId) });
|
||||
// Same rule as byId — never confirm someone else's job exists.
|
||||
if (!job || (job.clientId !== uid && ctx.session.role !== 'admin')) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Job not found' });
|
||||
}
|
||||
|
||||
const rows = await ctx.db
|
||||
.select({
|
||||
matchId: schema.matches.id,
|
||||
proId: schema.matches.proId,
|
||||
proName: schema.users.name,
|
||||
headline: schema.proProfiles.headline,
|
||||
ratingAvg: schema.proProfiles.ratingAvg,
|
||||
ratingCount: schema.proProfiles.ratingCount,
|
||||
acceptedAt: schema.matches.createdAt,
|
||||
lastMessageAt: schema.matches.lastMessageAt,
|
||||
// The face the client swiped, not `users.image` — a pro's account
|
||||
// avatar is usually empty, while their first card photo never is.
|
||||
photo: sql<string | null>`(
|
||||
SELECT pm.url FROM pro_media pm
|
||||
WHERE pm.pro_id = ${schema.matches.proId} AND pm.kind = 'photo'
|
||||
ORDER BY pm.position LIMIT 1
|
||||
)`,
|
||||
lastMessage: sql<string | null>`(
|
||||
SELECT msg.body FROM messages msg
|
||||
WHERE msg.match_id = ${schema.matches.id}
|
||||
ORDER BY msg.created_at DESC LIMIT 1
|
||||
)`,
|
||||
// A message can be a photo with no caption, which would otherwise
|
||||
// preview as an empty line. The count is what lets the row say so.
|
||||
lastMessageAttachments: sql<number>`(
|
||||
SELECT coalesce(array_length(msg.attachments, 1), 0) FROM messages msg
|
||||
WHERE msg.match_id = ${schema.matches.id}
|
||||
ORDER BY msg.created_at DESC LIMIT 1
|
||||
)`,
|
||||
unreadCount: sql<number>`(
|
||||
SELECT count(*)::int FROM messages msg
|
||||
WHERE msg.match_id = ${schema.matches.id}
|
||||
AND msg.sender_id <> ${uid}
|
||||
AND msg.read_at IS NULL
|
||||
)`,
|
||||
nextBookingAt: sql<string | null>`(
|
||||
SELECT min(b.scheduled_start) FROM bookings b
|
||||
WHERE b.match_id = ${schema.matches.id}
|
||||
AND b.status IN ('scheduled', 'in_progress')
|
||||
)`,
|
||||
})
|
||||
.from(schema.matches)
|
||||
.innerJoin(schema.users, eq(schema.users.id, schema.matches.proId))
|
||||
.innerJoin(schema.proProfiles, eq(schema.proProfiles.userId, schema.matches.proId))
|
||||
.where(eq(schema.matches.jobId, input.jobId))
|
||||
.orderBy(desc(sql`coalesce(${schema.matches.lastMessageAt}, ${schema.matches.createdAt})`));
|
||||
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
ratingAvg: r.ratingAvg === null ? null : Number(r.ratingAvg),
|
||||
nextBookingAt: r.nextBookingAt ? new Date(r.nextBookingAt) : null,
|
||||
}));
|
||||
}),
|
||||
|
||||
cancel: clientProcedure
|
||||
.input(z.object({ id: z.string().uuid(), reason: z.string().max(500).optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { and, desc, eq, isNull, ne, or, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { schema, type Db } from '@linkder/db';
|
||||
import { PAST_JOB_STATUSES, sendMessageSchema, type JobStatus } from '@linkder/shared';
|
||||
import { protectedProcedure, router } from '../trpc';
|
||||
|
||||
/**
|
||||
* Chat between the two parties on a job.
|
||||
*
|
||||
* A thread is a MATCH, not a job. One job can have several pros accept it, and
|
||||
* each of those is a separate private conversation — keying chat on the job
|
||||
* would put three tradespeople in one room with the customer and each other.
|
||||
*
|
||||
* `matches` already carries `lastMessageAt` and `messages` is already indexed
|
||||
* for both "this thread, newest last" and the unread badge. This router is the
|
||||
* first thing to read or write either.
|
||||
*/
|
||||
|
||||
/** A page of history. Thirty is about two phone screens. */
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
/**
|
||||
* A crude per-process send throttle.
|
||||
*
|
||||
* It is deliberately not a real rate limiter: it resets on deploy and does not
|
||||
* span instances. What it does buy is that a runaway client or a held-down send
|
||||
* button cannot write ten thousand rows before anyone notices. The real limiter
|
||||
* belongs with the shared Redis in M4 — this is the floor until then.
|
||||
*/
|
||||
const SEND_WINDOW_MS = 60_000;
|
||||
const SEND_LIMIT = 30;
|
||||
const recentSends = new Map<string, number[]>();
|
||||
|
||||
function assertSendRate(userId: string): void {
|
||||
const now = Date.now();
|
||||
const window = (recentSends.get(userId) ?? []).filter((at) => now - at < SEND_WINDOW_MS);
|
||||
if (window.length >= SEND_LIMIT) {
|
||||
throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: 'Slow down a moment.' });
|
||||
}
|
||||
window.push(now);
|
||||
recentSends.set(userId, window);
|
||||
}
|
||||
|
||||
type Tx = Parameters<Parameters<Db['transaction']>[0]>[0];
|
||||
type Executor = Db | Tx;
|
||||
|
||||
export interface MatchContext {
|
||||
matchId: string;
|
||||
clientId: string;
|
||||
proId: string;
|
||||
jobId: string;
|
||||
jobTitle: string;
|
||||
jobStatus: JobStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* The authorization gate for everything in this file.
|
||||
*
|
||||
* Throws NOT_FOUND rather than FORBIDDEN for a match the caller is not part of —
|
||||
* same rule as `job.byId` and `request.accept`: a stranger must not be able to
|
||||
* probe whether a conversation exists.
|
||||
*
|
||||
* There is no admin bypass, unlike `job.byId`. An admin can already see the job,
|
||||
* the booking and the money; reading a private conversation is a different kind
|
||||
* of access and belongs behind a support flow that leaves an audit trail, not
|
||||
* behind the same procedure the participants use.
|
||||
*/
|
||||
export async function requireMatchParticipant(
|
||||
exec: Executor,
|
||||
matchId: string,
|
||||
userId: string,
|
||||
): Promise<MatchContext> {
|
||||
const [row] = await exec
|
||||
.select({
|
||||
matchId: schema.matches.id,
|
||||
clientId: schema.matches.clientId,
|
||||
proId: schema.matches.proId,
|
||||
jobId: schema.jobs.id,
|
||||
jobTitle: schema.jobs.title,
|
||||
jobStatus: schema.jobs.status,
|
||||
})
|
||||
.from(schema.matches)
|
||||
.innerJoin(schema.jobs, eq(schema.jobs.id, schema.matches.jobId))
|
||||
.where(eq(schema.matches.id, matchId));
|
||||
|
||||
if (!row || (row.clientId !== userId && row.proId !== userId)) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Conversation not found' });
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/** History is closed once the job is. See PAST_JOB_STATUSES. */
|
||||
function canReply(status: JobStatus): boolean {
|
||||
return !PAST_JOB_STATUSES.includes(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyset pagination, oldest-ward.
|
||||
*
|
||||
* The cursor is the id of the oldest message on the page, and the comparison
|
||||
* resolves that row's `created_at` inside the query. Two reasons it is not a
|
||||
* timestamp the caller carries:
|
||||
*
|
||||
* - Postgres stores microseconds and a JS `Date` holds milliseconds, so a
|
||||
* round-tripped timestamp is truncated — and every message that landed later
|
||||
* in the same millisecond then falls the wrong side of `<` and is skipped.
|
||||
* - A cursor from another conversation resolves to NULL here, which yields an
|
||||
* empty page rather than a row from a thread the caller cannot see.
|
||||
*
|
||||
* `(created_at, id)` rather than `created_at` alone because a tie on the
|
||||
* timestamp would otherwise drop a message or repeat one.
|
||||
*/
|
||||
const cursorSchema = z.string().uuid();
|
||||
|
||||
export const messageRouter = router({
|
||||
/**
|
||||
* One thread: the header the screen needs, plus a page of messages oldest-first.
|
||||
*
|
||||
* Header and page come back together because the first render needs both, and
|
||||
* a second round trip to learn whose conversation this is would leave the
|
||||
* screen titleless for a beat.
|
||||
*/
|
||||
thread: protectedProcedure
|
||||
.input(z.object({ matchId: z.string().uuid(), cursor: cursorSchema.optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const uid = ctx.session.userId;
|
||||
const match = await requireMatchParticipant(ctx.db, input.matchId, uid);
|
||||
|
||||
const peerId = match.clientId === uid ? match.proId : match.clientId;
|
||||
const [peer] = await ctx.db
|
||||
.select({
|
||||
id: schema.users.id,
|
||||
name: schema.users.name,
|
||||
image: schema.users.image,
|
||||
role: schema.users.role,
|
||||
})
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.id, peerId));
|
||||
|
||||
const rows = await ctx.db
|
||||
.select({
|
||||
id: schema.messages.id,
|
||||
senderId: schema.messages.senderId,
|
||||
body: schema.messages.body,
|
||||
attachments: schema.messages.attachments,
|
||||
readAt: schema.messages.readAt,
|
||||
createdAt: schema.messages.createdAt,
|
||||
})
|
||||
.from(schema.messages)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.messages.matchId, input.matchId),
|
||||
input.cursor
|
||||
? sql`(${schema.messages.createdAt}, ${schema.messages.id}) < (
|
||||
SELECT anchor.created_at, anchor.id FROM messages anchor
|
||||
WHERE anchor.id = ${input.cursor}::uuid
|
||||
AND anchor.match_id = ${input.matchId}::uuid
|
||||
)`
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(schema.messages.createdAt), desc(schema.messages.id))
|
||||
.limit(PAGE_SIZE + 1);
|
||||
|
||||
const hasMore = rows.length > PAGE_SIZE;
|
||||
const page = hasMore ? rows.slice(0, PAGE_SIZE) : rows;
|
||||
const oldest = page[page.length - 1];
|
||||
|
||||
return {
|
||||
match: {
|
||||
id: match.matchId,
|
||||
jobId: match.jobId,
|
||||
jobTitle: match.jobTitle,
|
||||
jobStatus: match.jobStatus,
|
||||
canReply: canReply(match.jobStatus),
|
||||
peer: peer ?? null,
|
||||
},
|
||||
// Newest last, the way a chat reads.
|
||||
messages: page.reverse().map((m) => ({ ...m, isMine: m.senderId === uid })),
|
||||
nextCursor: hasMore && oldest ? oldest.id : null,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Say something.
|
||||
*
|
||||
* Insert and `lastMessageAt` move together in one transaction: the jobs list
|
||||
* sorts and previews on that column, so a message that lands without it is a
|
||||
* conversation that silently stops surfacing.
|
||||
*/
|
||||
send: protectedProcedure.input(sendMessageSchema).mutation(async ({ ctx, input }) => {
|
||||
const uid = ctx.session.userId;
|
||||
assertSendRate(uid);
|
||||
|
||||
return await ctx.db.transaction(async (tx) => {
|
||||
const match = await requireMatchParticipant(tx, input.matchId, uid);
|
||||
|
||||
if (!canReply(match.jobStatus)) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message:
|
||||
match.jobStatus === 'cancelled'
|
||||
? 'This job was cancelled. The conversation is closed.'
|
||||
: 'This job is finished. The conversation is closed.',
|
||||
});
|
||||
}
|
||||
|
||||
const [message] = await tx
|
||||
.insert(schema.messages)
|
||||
.values({
|
||||
matchId: input.matchId,
|
||||
senderId: uid,
|
||||
body: input.body,
|
||||
attachments: input.attachments,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!message) {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Could not send' });
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(schema.matches)
|
||||
.set({ lastMessageAt: message.createdAt })
|
||||
.where(eq(schema.matches.id, input.matchId));
|
||||
|
||||
return { ...message, isMine: true as const };
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* Mark everything the other side sent as read.
|
||||
*
|
||||
* Idempotent by construction — the `read_at IS NULL` predicate is also the
|
||||
* partial index (`messages_unread_idx`), so a second call touches no rows.
|
||||
*/
|
||||
markRead: protectedProcedure
|
||||
.input(z.object({ matchId: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const uid = ctx.session.userId;
|
||||
await requireMatchParticipant(ctx.db, input.matchId, uid);
|
||||
|
||||
const updated = await ctx.db
|
||||
.update(schema.messages)
|
||||
.set({ readAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(schema.messages.matchId, input.matchId),
|
||||
ne(schema.messages.senderId, uid),
|
||||
isNull(schema.messages.readAt),
|
||||
),
|
||||
)
|
||||
.returning({ id: schema.messages.id });
|
||||
|
||||
return { read: updated.length };
|
||||
}),
|
||||
|
||||
/**
|
||||
* One number for the tab badge: everything unread across every conversation
|
||||
* this person is part of, on either side of the market.
|
||||
*/
|
||||
unreadTotal: protectedProcedure.query(async ({ ctx }) => {
|
||||
const uid = ctx.session.userId;
|
||||
|
||||
const [row] = await ctx.db
|
||||
.select({ n: sql<number>`count(*)::int` })
|
||||
.from(schema.messages)
|
||||
.innerJoin(schema.matches, eq(schema.matches.id, schema.messages.matchId))
|
||||
.where(
|
||||
and(
|
||||
or(eq(schema.matches.clientId, uid), eq(schema.matches.proId, uid)),
|
||||
ne(schema.messages.senderId, uid),
|
||||
isNull(schema.messages.readAt),
|
||||
),
|
||||
);
|
||||
|
||||
return { unread: row?.n ?? 0 };
|
||||
}),
|
||||
});
|
||||
+209
-26
@@ -1,14 +1,28 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { and, desc, eq, inArray, lt } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import { z } from 'zod';
|
||||
import { schema } from '@linkder/db';
|
||||
import { eligibleProAtAnyDistance, schema, searchPros } from '@linkder/db';
|
||||
import { notify } from '@linkder/notify';
|
||||
import {
|
||||
assertTransition,
|
||||
credentialSchema,
|
||||
proProfileSchema,
|
||||
proReviewsSchema,
|
||||
REVIEWS_PAGE_SIZE,
|
||||
searchProsSchema,
|
||||
updateSkillsSchema,
|
||||
} from '@linkder/shared';
|
||||
import { protectedProcedure, proProcedure, router } from '../trpc';
|
||||
import { resolveLocation } from '../location';
|
||||
import { proProcedure, publicProcedure, router } from '../trpc';
|
||||
|
||||
/**
|
||||
* Aliased to `p` and `u` because `eligibleProAtAnyDistance()` is raw SQL written
|
||||
* against those names — the same aliases the deck and search queries use. This
|
||||
* is what lets a drizzle query share the rule instead of restating it.
|
||||
*/
|
||||
const p = alias(schema.proProfiles, 'p');
|
||||
const u = alias(schema.users, 'u');
|
||||
|
||||
/**
|
||||
* A pro is only shown to clients once verification passes. These procedures
|
||||
@@ -17,6 +31,60 @@ import { protectedProcedure, proProcedure, router } from '../trpc';
|
||||
* onboarding impossible.
|
||||
*/
|
||||
export const proRouter = router({
|
||||
/**
|
||||
* Find pros directly, instead of posting a job and swiping.
|
||||
*
|
||||
* Public, like `deck.showcase`: a shop window behind a login wall is not a
|
||||
* shop window. The eligibility rules are the deck's own — `searchPros` shares
|
||||
* `eligiblePro()` with it — so nothing findable here is unbookable there.
|
||||
*
|
||||
* ABUSE: this is the first procedure taking an unbounded caller-supplied
|
||||
* string with no session. `searchProsSchema` caps the query length, the page
|
||||
* size and the radius, and the query caps its own candidate pool — which
|
||||
* bounds the cost of ONE call, not the number of calls. A per-IP limiter
|
||||
* belongs here before public launch; `ctx.ip` is already plumbed for it.
|
||||
*/
|
||||
search: publicProcedure.input(searchProsSchema.optional()).query(async ({ ctx, input }) => {
|
||||
const cityLat = Number(process.env.NEXT_PUBLIC_CITY_LAT);
|
||||
const cityLng = Number(process.env.NEXT_PUBLIC_CITY_LNG);
|
||||
if (!Number.isFinite(cityLat) || !Number.isFinite(cityLng)) {
|
||||
// Without a centre every pro is "out of radius" and the screen would look
|
||||
// like an empty marketplace rather than a broken config.
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'NEXT_PUBLIC_CITY_LAT / NEXT_PUBLIC_CITY_LNG are not set.',
|
||||
});
|
||||
}
|
||||
|
||||
// Same rule as the showcase deck: search from where the caller told us they
|
||||
// are, and fall back to the city for everyone else.
|
||||
const me = ctx.session
|
||||
? await ctx.db.query.users.findFirst({
|
||||
where: eq(schema.users.id, ctx.session.userId),
|
||||
columns: { location: true, searchRadiusM: true },
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const centredOnYou = Boolean(me?.location);
|
||||
|
||||
const results = await searchPros(ctx.db, {
|
||||
lat: me?.location?.lat ?? cityLat,
|
||||
lng: me?.location?.lng ?? cityLng,
|
||||
q: input?.q,
|
||||
categoryId: input?.categoryId,
|
||||
// An explicit filter wins; otherwise fall back to the radius they saved in
|
||||
// settings, but only where we know where they are.
|
||||
maxDistanceM: input?.maxDistanceM ?? (centredOnYou ? me?.searchRadiusM : undefined),
|
||||
minRating: input?.minRating,
|
||||
maxHourlyRateCents: input?.maxHourlyRateCents,
|
||||
sort: input?.sort ?? 'best',
|
||||
limit: input?.limit,
|
||||
});
|
||||
|
||||
return { results, total: results.length, centredOnYou };
|
||||
}),
|
||||
|
||||
|
||||
/** The caller's own pro profile, with everything the onboarding wizard needs. */
|
||||
me: proProcedure.query(async ({ ctx }) => {
|
||||
const profile = await ctx.db.query.proProfiles.findFirst({
|
||||
@@ -88,11 +156,16 @@ export const proRouter = router({
|
||||
: [];
|
||||
const nextCategoryIds = [...input.categoryIds].sort();
|
||||
|
||||
// Resolved before the comparison below, because "did the base move?" has to
|
||||
// be asked about the point we are actually going to store, not the one the
|
||||
// client claimed. See src/location.ts.
|
||||
const resolved = await resolveLocation(input.place);
|
||||
|
||||
const materiallyChanged = Boolean(
|
||||
existing &&
|
||||
(existing.serviceRadiusM !== input.serviceRadiusM ||
|
||||
existing.baseLocation.lat !== input.location.lat ||
|
||||
existing.baseLocation.lng !== input.location.lng ||
|
||||
existing.baseLocation.lat !== resolved.location.lat ||
|
||||
existing.baseLocation.lng !== resolved.location.lng ||
|
||||
previousCategoryIds.length !== nextCategoryIds.length ||
|
||||
previousCategoryIds.some((id, i) => id !== nextCategoryIds[i])),
|
||||
);
|
||||
@@ -118,7 +191,9 @@ export const proRouter = router({
|
||||
bio: input.bio,
|
||||
hourlyRateCents: input.hourlyRateCents,
|
||||
yearsExperience: input.yearsExperience,
|
||||
baseLocation: input.location,
|
||||
baseLocation: resolved.location,
|
||||
baseLocationPrecision: resolved.precision,
|
||||
baseLocationPlaceId: resolved.placeId,
|
||||
serviceRadiusM: input.serviceRadiusM,
|
||||
updatedAt: new Date(),
|
||||
...(sendBackForReview ? { verificationStatus: 'pending' as const } : {}),
|
||||
@@ -234,6 +309,7 @@ export const proRouter = router({
|
||||
distanceM: 2_400,
|
||||
photos: media.map((m) => m.url),
|
||||
categories: categories.map((c) => c.name),
|
||||
skills: profile.skills,
|
||||
score: 0,
|
||||
verificationStatus: profile.verificationStatus,
|
||||
isAcceptingJobs: profile.isAcceptingJobs,
|
||||
@@ -371,6 +447,17 @@ export const proRouter = router({
|
||||
const missing: string[] = [];
|
||||
if (categories.length === 0) missing.push('at least one trade');
|
||||
if (media.length === 0) missing.push('at least one photo');
|
||||
/*
|
||||
* A base that is really just the city centre is not a working area.
|
||||
*
|
||||
* `base_location` is the left operand of every ST_Distance and ST_DWithin in
|
||||
* the deck, so a pro parked on the centroid passes every radius check in the
|
||||
* city and ranks first for every job. Letting that into the review queue
|
||||
* would put an unlocatable pro in front of customers with a verified badge —
|
||||
* and the reviewer, who is the expensive part of this pipeline, has no way
|
||||
* to see it from the documents.
|
||||
*/
|
||||
if (profile.baseLocationPrecision === 'city') missing.push('a real base address');
|
||||
if (!credentials.some((c) => c.kind === 'id')) missing.push('a photo ID');
|
||||
if (!credentials.some((c) => c.kind === 'insurance')) missing.push('proof of insurance');
|
||||
|
||||
@@ -396,7 +483,28 @@ export const proRouter = router({
|
||||
ip: ctx.ip,
|
||||
});
|
||||
|
||||
// TODO(M2): kick off the Didit identity session and notify the admin queue.
|
||||
/*
|
||||
* Tell the reviewers somebody is waiting.
|
||||
*
|
||||
* Every admin, because there is no assignment model yet and a queue nobody
|
||||
* is told about is a queue that grows. Sequential rather than parallel: this
|
||||
* is a handful of people, and a burst of provider calls to save a few
|
||||
* milliseconds on a once-per-onboarding event is not a trade worth making.
|
||||
*/
|
||||
const admins = await ctx.db
|
||||
.select({ id: schema.users.id })
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.role, 'admin'));
|
||||
|
||||
for (const admin of admins) {
|
||||
await notify(ctx.db, admin.id, {
|
||||
kind: 'verification.submitted',
|
||||
proName: ctx.session.name ?? 'A pro',
|
||||
});
|
||||
}
|
||||
|
||||
// TODO(M2): kick off the Didit identity session. The manual queue in
|
||||
// `admin.decide` is what actually moves this profile on today.
|
||||
|
||||
return { status: 'pending' as const };
|
||||
}),
|
||||
@@ -416,32 +524,38 @@ export const proRouter = router({
|
||||
* Public profile, for the card detail view. Only ever returns a verified pro,
|
||||
* and deliberately omits anything private — no phone, no documents, no address.
|
||||
*/
|
||||
publicProfile: protectedProcedure
|
||||
publicProfile: publicProcedure
|
||||
.input(z.object({ proId: z.string().uuid() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const profile = await ctx.db.query.proProfiles.findFirst({
|
||||
where: and(
|
||||
eq(schema.proProfiles.userId, input.proId),
|
||||
eq(schema.proProfiles.verificationStatus, 'verified'),
|
||||
),
|
||||
});
|
||||
if (!profile) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
const [row] = await ctx.db
|
||||
.select({ profile: p, name: u.name, image: u.image })
|
||||
.from(p)
|
||||
.innerJoin(u, eq(u.id, p.userId))
|
||||
// A suspended pro stays off every surface, including a direct link, and
|
||||
// "verified" has to mean the same thing here as on the deck — so this
|
||||
// shares the rule rather than restating it.
|
||||
.where(and(eq(p.userId, input.proId), eligibleProAtAnyDistance()));
|
||||
|
||||
const [user] = await ctx.db
|
||||
.select({ name: schema.users.name, image: schema.users.image })
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.id, input.proId));
|
||||
if (!row) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
const { profile } = row;
|
||||
|
||||
const media = await ctx.db
|
||||
.select()
|
||||
.from(schema.proMedia)
|
||||
.where(eq(schema.proMedia.proId, input.proId))
|
||||
.orderBy(schema.proMedia.position);
|
||||
const [media, categories] = await Promise.all([
|
||||
ctx.db
|
||||
.select()
|
||||
.from(schema.proMedia)
|
||||
.where(eq(schema.proMedia.proId, input.proId))
|
||||
.orderBy(schema.proMedia.position),
|
||||
ctx.db
|
||||
.select({ name: schema.categories.name })
|
||||
.from(schema.proCategories)
|
||||
.innerJoin(schema.categories, eq(schema.categories.id, schema.proCategories.categoryId))
|
||||
.where(eq(schema.proCategories.proId, input.proId)),
|
||||
]);
|
||||
|
||||
return {
|
||||
proId: profile.userId,
|
||||
name: user?.name ?? null,
|
||||
image: user?.image ?? null,
|
||||
name: row.name,
|
||||
image: row.image,
|
||||
headline: profile.headline,
|
||||
bio: profile.bio,
|
||||
hourlyRateCents: profile.hourlyRateCents,
|
||||
@@ -449,7 +563,76 @@ export const proRouter = router({
|
||||
ratingAvg: profile.ratingAvg === null ? null : Number(profile.ratingAvg),
|
||||
ratingCount: profile.ratingCount,
|
||||
completedJobs: profile.completedJobs,
|
||||
responseRate: profile.responseRate === null ? null : Number(profile.responseRate),
|
||||
avgResponseMinutes: profile.avgResponseMinutes,
|
||||
categories: categories.map((c) => c.name),
|
||||
skills: profile.skills,
|
||||
media,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* One page of a pro's written reviews, newest first.
|
||||
*
|
||||
* Public, like the profile it sits under — reviews are the single most useful
|
||||
* thing a customer reads before hiring, and putting them behind a login would
|
||||
* make the shop window useless.
|
||||
*
|
||||
* Two rules this must not break:
|
||||
*
|
||||
* 1. `published_at` is a moderation gate, not a timestamp. A review stays
|
||||
* hidden until both sides have written one or the window closes, which is
|
||||
* what stops a pro retaliating against a bad review with a bad one back.
|
||||
* Reading unpublished rows here would quietly defeat that.
|
||||
* 2. Nothing identifying the booking leaves — no `bookingId`, no `authorId`.
|
||||
* An author's display name is already public on a review; their user id is
|
||||
* a join key into everything else they have ever done.
|
||||
*/
|
||||
reviews: publicProcedure.input(proReviewsSchema).query(async ({ ctx, input }) => {
|
||||
// Reviews are not a way around the profile: if the pro cannot be looked up,
|
||||
// neither can what people said about them.
|
||||
const [subject] = await ctx.db
|
||||
.select({ id: p.userId })
|
||||
.from(p)
|
||||
.innerJoin(u, eq(u.id, p.userId))
|
||||
.where(and(eq(p.userId, input.proId), eligibleProAtAnyDistance()));
|
||||
|
||||
if (!subject) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
|
||||
const limit = input.limit ?? REVIEWS_PAGE_SIZE;
|
||||
|
||||
const author = alias(schema.users, 'author');
|
||||
const rows = await ctx.db
|
||||
.select({
|
||||
id: schema.reviews.id,
|
||||
rating: schema.reviews.rating,
|
||||
body: schema.reviews.body,
|
||||
publishedAt: schema.reviews.publishedAt,
|
||||
authorName: author.name,
|
||||
authorImage: author.image,
|
||||
})
|
||||
.from(schema.reviews)
|
||||
.innerJoin(author, eq(author.id, schema.reviews.authorId))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.reviews.subjectId, input.proId),
|
||||
// This one predicate is the moderation gate. An unpublished review has
|
||||
// publishedAt NULL, and `NULL < now()` is NULL, not true — so it is
|
||||
// excluded here for the same reason a future embargo date is.
|
||||
lt(schema.reviews.publishedAt, new Date()),
|
||||
input.cursor ? lt(schema.reviews.publishedAt, input.cursor) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(schema.reviews.publishedAt), desc(schema.reviews.id))
|
||||
// One extra row is how we know there is a next page without a second COUNT.
|
||||
.limit(limit + 1);
|
||||
|
||||
const page = rows.slice(0, limit);
|
||||
const last = page[page.length - 1];
|
||||
|
||||
return {
|
||||
reviews: page.map((r) => ({ ...r, publishedAt: r.publishedAt! })),
|
||||
nextCursor: rows.length > limit && last?.publishedAt ? last.publishedAt : null,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { schema } from '@linkder/db';
|
||||
import {
|
||||
assertTransition,
|
||||
createBookingSchema,
|
||||
createQuoteSchema,
|
||||
QUOTE_VALIDITY_HOURS,
|
||||
} from '@linkder/shared';
|
||||
import { requireMatchParticipant } from './message';
|
||||
import { clientProcedure, protectedProcedure, router, verifiedProProcedure } from '../trpc';
|
||||
|
||||
/**
|
||||
* "Here is what it will cost."
|
||||
*
|
||||
* The step between a conversation and a commitment. A match means two people are
|
||||
* talking; a quote is the pro putting a number and a scope in writing, and an
|
||||
* accepted one is what a booking — and later a dispute — is judged against.
|
||||
*
|
||||
* Everything hangs off a match, so authorization reuses `requireMatchParticipant`
|
||||
* from the message router rather than restating who may see a thread. There is
|
||||
* one rule for "are these two people in this conversation", and it lives there.
|
||||
*/
|
||||
|
||||
/** Lazy expiry, same as requests: a quote past its date is refused on use. */
|
||||
function isLive(quote: { status: string; validUntil: Date }): boolean {
|
||||
return quote.status === 'sent' && quote.validUntil > new Date();
|
||||
}
|
||||
|
||||
export const quoteRouter = router({
|
||||
/**
|
||||
* Every quote on one thread, newest first.
|
||||
*
|
||||
* Both sides see the same list — a pro needs to know what they already sent as
|
||||
* much as the client does, and a quote the two parties remember differently is
|
||||
* the thing this table exists to prevent.
|
||||
*/
|
||||
forMatch: protectedProcedure
|
||||
.input(z.object({ matchId: z.string().uuid() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await requireMatchParticipant(ctx.db, input.matchId, ctx.session.userId);
|
||||
|
||||
const rows = await ctx.db
|
||||
.select()
|
||||
.from(schema.quotes)
|
||||
.where(eq(schema.quotes.matchId, input.matchId))
|
||||
.orderBy(desc(schema.quotes.createdAt));
|
||||
|
||||
return rows.map((q) => ({
|
||||
...q,
|
||||
// Derived, not stored: a quote goes stale by the clock, and a status
|
||||
// column that only becomes 'expired' when something touches it would
|
||||
// show a live "Accept" button on a dead quote.
|
||||
isLive: isLive(q),
|
||||
}));
|
||||
}),
|
||||
|
||||
/**
|
||||
* Send a quote.
|
||||
*
|
||||
* Verified pros only — this is a commercial offer to a real customer, and
|
||||
* `verifiedProProcedure` is the gate for exactly that.
|
||||
*/
|
||||
create: verifiedProProcedure.input(createQuoteSchema).mutation(async ({ ctx, input }) => {
|
||||
const match = await requireMatchParticipant(ctx.db, input.matchId, ctx.session.userId);
|
||||
|
||||
// The client is the other side of this match; a pro quoting their own job
|
||||
// would mean the match rows are wrong, but assert rather than assume.
|
||||
if (match.proId !== ctx.session.userId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the pro can send a quote' });
|
||||
}
|
||||
|
||||
if (match.jobStatus !== 'open' && match.jobStatus !== 'matched') {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message:
|
||||
match.jobStatus === 'booked'
|
||||
? 'This job is already booked.'
|
||||
: 'This job is no longer taking quotes.',
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* One live quote per thread.
|
||||
*
|
||||
* Two open offers is a customer choosing between two prices from the same
|
||||
* person, which is not a negotiation — it is a mistake waiting to be
|
||||
* accepted. Re-quoting withdraws the previous one so there is always exactly
|
||||
* one number on the table.
|
||||
*/
|
||||
const existing = await ctx.db
|
||||
.select()
|
||||
.from(schema.quotes)
|
||||
.where(and(eq(schema.quotes.matchId, input.matchId), eq(schema.quotes.status, 'sent')));
|
||||
|
||||
const [quote] = await ctx.db.transaction(async (tx) => {
|
||||
for (const old of existing.filter(isLive)) {
|
||||
assertTransition('quote', old.status, 'withdrawn');
|
||||
await tx
|
||||
.update(schema.quotes)
|
||||
.set({ status: 'withdrawn', respondedAt: new Date() })
|
||||
.where(eq(schema.quotes.id, old.id));
|
||||
}
|
||||
|
||||
return await tx
|
||||
.insert(schema.quotes)
|
||||
.values({
|
||||
matchId: input.matchId,
|
||||
kind: input.kind,
|
||||
amountCents: input.amountCents,
|
||||
hoursEstimate: input.hoursEstimate ?? null,
|
||||
scope: input.scope,
|
||||
validUntil: new Date(Date.now() + QUOTE_VALIDITY_HOURS * 3_600_000),
|
||||
})
|
||||
.returning();
|
||||
});
|
||||
|
||||
if (!quote) {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Could not send the quote' });
|
||||
}
|
||||
return { ...quote, isLive: true as const };
|
||||
}),
|
||||
|
||||
/** "Actually, ignore that one." Only the pro who sent it. */
|
||||
withdraw: verifiedProProcedure
|
||||
.input(z.object({ quoteId: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const quote = await ctx.db.query.quotes.findFirst({
|
||||
where: eq(schema.quotes.id, input.quoteId),
|
||||
});
|
||||
if (!quote) throw new TRPCError({ code: 'NOT_FOUND', message: 'Quote not found' });
|
||||
|
||||
const match = await requireMatchParticipant(ctx.db, quote.matchId, ctx.session.userId);
|
||||
if (match.proId !== ctx.session.userId) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Quote not found' });
|
||||
}
|
||||
|
||||
assertTransition('quote', quote.status, 'withdrawn');
|
||||
await ctx.db
|
||||
.update(schema.quotes)
|
||||
.set({ status: 'withdrawn', respondedAt: new Date() })
|
||||
.where(eq(schema.quotes.id, quote.id));
|
||||
|
||||
return { withdrawn: true as const };
|
||||
}),
|
||||
|
||||
/** "No thanks." The thread stays open; the pro can send another. */
|
||||
decline: clientProcedure
|
||||
.input(z.object({ quoteId: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const quote = await ctx.db.query.quotes.findFirst({
|
||||
where: eq(schema.quotes.id, input.quoteId),
|
||||
});
|
||||
if (!quote) throw new TRPCError({ code: 'NOT_FOUND', message: 'Quote not found' });
|
||||
|
||||
const match = await requireMatchParticipant(ctx.db, quote.matchId, ctx.session.userId);
|
||||
if (match.clientId !== ctx.session.userId) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Quote not found' });
|
||||
}
|
||||
|
||||
assertTransition('quote', quote.status, 'declined');
|
||||
await ctx.db
|
||||
.update(schema.quotes)
|
||||
.set({ status: 'declined', respondedAt: new Date() })
|
||||
.where(eq(schema.quotes.id, quote.id));
|
||||
|
||||
return { declined: true as const };
|
||||
}),
|
||||
|
||||
/**
|
||||
* "Yes — book it."
|
||||
*
|
||||
* The commitment point, and the only place a booking is created. Everything
|
||||
* happens under a lock on the job row: two taps must not produce two bookings
|
||||
* on one job, and the job's move to `booked` is what closes it to other pros.
|
||||
*
|
||||
* No money changes hands here. Escrow is M3 and lands in a payments router;
|
||||
* `bookings` deliberately carries no amount of its own, so when it arrives the
|
||||
* charge is taken against `quote.amount_cents` and there is nothing to
|
||||
* reconcile between two copies of a price.
|
||||
*/
|
||||
accept: clientProcedure
|
||||
// createBookingSchema already carries matchId, quoteId and the slot, and
|
||||
// refines that the end is after the start and the start is not in the past.
|
||||
.input(createBookingSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return await ctx.db.transaction(async (tx) => {
|
||||
const [quote] = await tx
|
||||
.select()
|
||||
.from(schema.quotes)
|
||||
.where(eq(schema.quotes.id, input.quoteId))
|
||||
.for('update');
|
||||
|
||||
if (!quote) throw new TRPCError({ code: 'NOT_FOUND', message: 'Quote not found' });
|
||||
|
||||
const match = await requireMatchParticipant(tx, quote.matchId, ctx.session.userId);
|
||||
// 404 rather than 403 for the pro's own quote: only the client accepts,
|
||||
// and a pro poking at this should not learn anything from the difference.
|
||||
if (match.clientId !== ctx.session.userId || quote.matchId !== input.matchId) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Quote not found' });
|
||||
}
|
||||
|
||||
if (quote.status !== 'sent') {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message:
|
||||
quote.status === 'accepted'
|
||||
? 'You already accepted this quote.'
|
||||
: 'This quote is no longer open.',
|
||||
});
|
||||
}
|
||||
|
||||
if (quote.validUntil <= new Date()) {
|
||||
// Record the expiry rather than leaving a stale `sent` row behind —
|
||||
// same lazy-expiry treatment as requests.
|
||||
assertTransition('quote', quote.status, 'expired');
|
||||
await tx
|
||||
.update(schema.quotes)
|
||||
.set({ status: 'expired', respondedAt: new Date() })
|
||||
.where(eq(schema.quotes.id, quote.id));
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'This quote expired. Ask them to send a fresh one.',
|
||||
});
|
||||
}
|
||||
|
||||
const [job] = await tx
|
||||
.select()
|
||||
.from(schema.jobs)
|
||||
.where(eq(schema.jobs.id, match.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 bookings.',
|
||||
});
|
||||
}
|
||||
|
||||
assertTransition('quote', quote.status, 'accepted');
|
||||
assertTransition('job', job.status, 'booked');
|
||||
|
||||
await tx
|
||||
.update(schema.quotes)
|
||||
.set({ status: 'accepted', respondedAt: new Date() })
|
||||
.where(eq(schema.quotes.id, quote.id));
|
||||
|
||||
const [booking] = await tx
|
||||
.insert(schema.bookings)
|
||||
.values({
|
||||
matchId: quote.matchId,
|
||||
quoteId: quote.id,
|
||||
scheduledStart: input.scheduledStart,
|
||||
scheduledEnd: input.scheduledEnd,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await tx
|
||||
.update(schema.jobs)
|
||||
.set({ status: 'booked', updatedAt: new Date() })
|
||||
.where(eq(schema.jobs.id, job.id));
|
||||
|
||||
/*
|
||||
* Every other pro still waiting on this job is done.
|
||||
*
|
||||
* Leaving them `pending` would keep a job in their inbox that nobody can
|
||||
* win, and would keep counting against their response rate until it
|
||||
* expired. Same treatment job.cancel already gives them.
|
||||
*/
|
||||
await tx
|
||||
.update(schema.requests)
|
||||
.set({ status: 'expired', respondedAt: new Date() })
|
||||
.where(and(eq(schema.requests.jobId, job.id), eq(schema.requests.status, 'pending')));
|
||||
|
||||
await tx.insert(schema.auditLog).values({
|
||||
actorId: ctx.session.userId,
|
||||
action: 'quote.accepted',
|
||||
entity: 'booking',
|
||||
entityId: booking!.id,
|
||||
metadata: { quoteId: quote.id, jobId: job.id, amountCents: quote.amountCents },
|
||||
ip: ctx.ip,
|
||||
});
|
||||
|
||||
return { bookingId: booking!.id, jobId: job.id };
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { and, eq, gt, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { recomputeProStats, schema } from '@linkder/db';
|
||||
import { notify } from '@linkder/notify';
|
||||
import { assertTransition } from '@linkder/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;
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { and, eq, ne, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { recomputeProStats, schema } from '@linkder/db';
|
||||
import {
|
||||
createReviewSchema,
|
||||
REVIEW_EMBARGO_HOURS,
|
||||
REVIEW_WINDOW_DAYS,
|
||||
} from '@linkder/shared';
|
||||
import { requireMatchParticipant } from './message';
|
||||
import { protectedProcedure, router } from '../trpc';
|
||||
|
||||
/**
|
||||
* Two-way reviews, published double-blind.
|
||||
*
|
||||
* Neither side's review is visible until both are in. Without that, whoever
|
||||
* writes second reads what was said about them and answers in kind, and the
|
||||
* ratings stop describing the work and start describing the argument.
|
||||
*
|
||||
* Publication needs no sweeper. A review is written with `published_at` already
|
||||
* set to its embargo deadline, and every read filters on
|
||||
* `published_at <= now()` — so it publishes itself. When the second side
|
||||
* reviews, both rows are pulled forward to now. `recomputeProStats` reads with
|
||||
* that exact predicate, so the number in a pro's header can never get ahead of
|
||||
* the list underneath it.
|
||||
*/
|
||||
|
||||
export const reviewRouter = router({
|
||||
/**
|
||||
* Completed bookings this person still owes a review on.
|
||||
*
|
||||
* The Past-jobs tab's reason to exist: without this, finished work is an
|
||||
* archive nobody opens.
|
||||
*/
|
||||
pending: protectedProcedure.query(async ({ ctx }) => {
|
||||
const uid = ctx.session.userId;
|
||||
|
||||
const rows = await ctx.db
|
||||
.select({
|
||||
bookingId: schema.bookings.id,
|
||||
matchId: schema.matches.id,
|
||||
jobId: schema.jobs.id,
|
||||
jobTitle: schema.jobs.title,
|
||||
completedAt: schema.bookings.clientConfirmedAt,
|
||||
proId: schema.matches.proId,
|
||||
clientId: schema.matches.clientId,
|
||||
subjectName: sql<string>`(
|
||||
SELECT u.name FROM users u
|
||||
WHERE u.id = CASE WHEN ${schema.matches.clientId} = ${uid}
|
||||
THEN ${schema.matches.proId}
|
||||
ELSE ${schema.matches.clientId} END
|
||||
)`,
|
||||
})
|
||||
.from(schema.bookings)
|
||||
.innerJoin(schema.matches, eq(schema.matches.id, schema.bookings.matchId))
|
||||
.innerJoin(schema.jobs, eq(schema.jobs.id, schema.matches.jobId))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.bookings.status, 'completed'),
|
||||
// Mine, either side of it.
|
||||
sql`(${schema.matches.clientId} = ${uid} OR ${schema.matches.proId} = ${uid})`,
|
||||
// Not already written by me.
|
||||
sql`NOT EXISTS (
|
||||
SELECT 1 FROM reviews r
|
||||
WHERE r.booking_id = ${schema.bookings.id} AND r.author_id = ${uid}
|
||||
)`,
|
||||
// The window closes. A review left three months is not a review of
|
||||
// work anybody remembers.
|
||||
sql`${schema.bookings.updatedAt} > now() - (${REVIEW_WINDOW_DAYS} || ' days')::interval`,
|
||||
),
|
||||
);
|
||||
|
||||
return rows;
|
||||
}),
|
||||
|
||||
/** What this booking already holds, from the caller's side of it. */
|
||||
forBooking: protectedProcedure
|
||||
.input(z.object({ bookingId: z.string().uuid() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const uid = ctx.session.userId;
|
||||
|
||||
const booking = await ctx.db.query.bookings.findFirst({
|
||||
where: eq(schema.bookings.id, input.bookingId),
|
||||
});
|
||||
if (!booking) throw new TRPCError({ code: 'NOT_FOUND', message: 'Booking not found' });
|
||||
await requireMatchParticipant(ctx.db, booking.matchId, uid);
|
||||
|
||||
const rows = await ctx.db
|
||||
.select()
|
||||
.from(schema.reviews)
|
||||
.where(eq(schema.reviews.bookingId, input.bookingId));
|
||||
|
||||
const mine = rows.find((r) => r.authorId === uid) ?? null;
|
||||
const theirs = rows.find((r) => r.authorId !== uid) ?? null;
|
||||
|
||||
return {
|
||||
mine,
|
||||
// Never the other side's WORDS before publication — that is the whole
|
||||
// point of the embargo. Only whether they have written, so the UI can
|
||||
// say "waiting on them" rather than pretending nothing happened.
|
||||
theyHaveReviewed: theirs !== null,
|
||||
theirs:
|
||||
theirs && theirs.publishedAt && theirs.publishedAt <= new Date() ? theirs : null,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Leave a review.
|
||||
*
|
||||
* Only a participant, only on a completed booking, only once — the last is
|
||||
* enforced by `reviews_booking_author_unique` as well as here, because a
|
||||
* unique-violation 500 is not an answer a UI can render.
|
||||
*/
|
||||
create: protectedProcedure.input(createReviewSchema).mutation(async ({ ctx, input }) => {
|
||||
const uid = ctx.session.userId;
|
||||
|
||||
const result = await ctx.db.transaction(async (tx) => {
|
||||
const [booking] = await tx
|
||||
.select()
|
||||
.from(schema.bookings)
|
||||
.where(eq(schema.bookings.id, input.bookingId))
|
||||
.for('update');
|
||||
|
||||
if (!booking) throw new TRPCError({ code: 'NOT_FOUND', message: 'Booking not found' });
|
||||
|
||||
const match = await requireMatchParticipant(tx, booking.matchId, uid);
|
||||
|
||||
if (booking.status !== 'completed') {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'You can review this once the work is finished and confirmed.',
|
||||
});
|
||||
}
|
||||
|
||||
const alreadyMine = await tx
|
||||
.select({ id: schema.reviews.id })
|
||||
.from(schema.reviews)
|
||||
.where(
|
||||
and(eq(schema.reviews.bookingId, booking.id), eq(schema.reviews.authorId, uid)),
|
||||
);
|
||||
if (alreadyMine.length) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'You have already reviewed this job.',
|
||||
});
|
||||
}
|
||||
|
||||
// The subject is simply the other party. Deriving it rather than taking it
|
||||
// from the caller means nobody can review a third party's profile.
|
||||
const subjectId = match.clientId === uid ? match.proId : match.clientId;
|
||||
|
||||
const [theirs] = await tx
|
||||
.select()
|
||||
.from(schema.reviews)
|
||||
.where(
|
||||
and(eq(schema.reviews.bookingId, booking.id), ne(schema.reviews.authorId, uid)),
|
||||
);
|
||||
|
||||
/*
|
||||
* Publication date, decided at write time.
|
||||
*
|
||||
* Second in: both go live now. First in: dated to the embargo deadline, so
|
||||
* it surfaces on its own once the window passes even if the other side
|
||||
* never writes anything. No cron, and a silent counterparty cannot bury a
|
||||
* review by refusing to answer it.
|
||||
*/
|
||||
const now = new Date();
|
||||
const publishedAt = theirs
|
||||
? now
|
||||
: new Date(now.getTime() + REVIEW_EMBARGO_HOURS * 3_600_000);
|
||||
|
||||
const [review] = await tx
|
||||
.insert(schema.reviews)
|
||||
.values({
|
||||
bookingId: booking.id,
|
||||
authorId: uid,
|
||||
subjectId,
|
||||
rating: input.rating,
|
||||
body: input.body,
|
||||
publishedAt,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (theirs) {
|
||||
await tx
|
||||
.update(schema.reviews)
|
||||
.set({ publishedAt: now })
|
||||
.where(eq(schema.reviews.id, theirs.id));
|
||||
}
|
||||
|
||||
await tx.insert(schema.auditLog).values({
|
||||
actorId: uid,
|
||||
action: 'review.created',
|
||||
entity: 'review',
|
||||
entityId: review!.id,
|
||||
metadata: { bookingId: booking.id, subjectId, rating: input.rating },
|
||||
ip: ctx.ip,
|
||||
});
|
||||
|
||||
return { review: review!, subjectId, bothIn: Boolean(theirs), proId: match.proId };
|
||||
});
|
||||
|
||||
/*
|
||||
* Only refresh the pro's counters when something actually became visible.
|
||||
*
|
||||
* An embargoed review changes no published average, so recomputing here
|
||||
* would be a write that cannot change a value — and would run on every
|
||||
* first-in review in the system.
|
||||
*/
|
||||
if (result.bothIn) {
|
||||
await recomputeProStats(ctx.db, result.proId).catch(() => {});
|
||||
}
|
||||
|
||||
return {
|
||||
id: result.review.id,
|
||||
publishedAt: result.review.publishedAt,
|
||||
// What the UI needs to say next: "live now" or "held until they reply".
|
||||
published: result.bothIn,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { and, desc, eq, isNull } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { schema } from '@linkder/db';
|
||||
import { assertTransition, isContactableEmail, updateLocationSchema } from '@linkder/shared';
|
||||
import { resolveLocation } from '../location';
|
||||
import { protectedProcedure, publicProcedure, router } from '../trpc';
|
||||
|
||||
export const userRouter = router({
|
||||
@@ -268,21 +269,30 @@ export const userRouter = router({
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// The label is a display string with no matching role, so it lives on the
|
||||
// user for everyone rather than being duplicated per role.
|
||||
if (input.addressText !== undefined) {
|
||||
// The label is no longer a free-text field stored beside unrelated
|
||||
// coordinates: it is whatever the geocoder called the point we resolved,
|
||||
// so the two cannot drift apart.
|
||||
const resolved = input.place ? await resolveLocation(input.place) : null;
|
||||
|
||||
if (resolved) {
|
||||
await ctx.db
|
||||
.update(schema.users)
|
||||
.set({ locationText: input.addressText || null, updatedAt: new Date() })
|
||||
.set({ locationText: resolved.addressText, updatedAt: new Date() })
|
||||
.where(eq(schema.users.id, ctx.session.userId));
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
if (input.location !== undefined || input.radiusM !== undefined) {
|
||||
if (resolved || input.radiusM !== undefined) {
|
||||
await ctx.db
|
||||
.update(schema.users)
|
||||
.set({
|
||||
...(input.location !== undefined ? { location: input.location } : {}),
|
||||
...(resolved
|
||||
? {
|
||||
location: resolved.location,
|
||||
locationPrecision: resolved.precision,
|
||||
locationPlaceId: resolved.placeId,
|
||||
}
|
||||
: {}),
|
||||
...(input.radiusM !== undefined ? { searchRadiusM: input.radiusM } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
@@ -292,9 +302,9 @@ export const userRouter = router({
|
||||
}
|
||||
|
||||
const moved =
|
||||
input.location !== undefined &&
|
||||
(input.location.lat !== profile.baseLocation.lat ||
|
||||
input.location.lng !== profile.baseLocation.lng);
|
||||
resolved !== null &&
|
||||
(resolved.location.lat !== profile.baseLocation.lat ||
|
||||
resolved.location.lng !== profile.baseLocation.lng);
|
||||
const resized = input.radiusM !== undefined && input.radiusM !== profile.serviceRadiusM;
|
||||
|
||||
// Only a currently-verified pro needs demoting: a draft or pending profile
|
||||
@@ -309,7 +319,13 @@ export const userRouter = router({
|
||||
await tx
|
||||
.update(schema.proProfiles)
|
||||
.set({
|
||||
...(input.location !== undefined ? { baseLocation: input.location } : {}),
|
||||
...(resolved
|
||||
? {
|
||||
baseLocation: resolved.location,
|
||||
baseLocationPrecision: resolved.precision,
|
||||
baseLocationPlaceId: resolved.placeId,
|
||||
}
|
||||
: {}),
|
||||
...(input.radiusM !== undefined ? { serviceRadiusM: input.radiusM } : {}),
|
||||
...(sendBackForReview ? { verificationStatus: 'pending' as const } : {}),
|
||||
updatedAt: new Date(),
|
||||
|
||||
Reference in New Issue
Block a user