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:
@@ -15,12 +15,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@linkder/db": "workspace:*",
|
||||
"@linkder/geocode": "workspace:*",
|
||||
"@linkder/notify": "workspace:*",
|
||||
"@linkder/shared": "workspace:*",
|
||||
"@linkder/storage": "workspace:*",
|
||||
"@opentelemetry/api": "1.9.1",
|
||||
"@trpc/server": "^11.18.0",
|
||||
"drizzle-orm": "0.38.4",
|
||||
"superjson": "^2.2.6",
|
||||
"zod": "^3.24.1",
|
||||
"@linkder/storage": "workspace:*"
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv": "16.4.7",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Integration tests for the admin router, against the live seeded database.
|
||||
*
|
||||
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
||||
*
|
||||
* This router is the only thing that can move a pro to `verified`, which is the
|
||||
* moment they become visible to customers at all. Two things therefore matter
|
||||
* more than the CRUD:
|
||||
*
|
||||
* 1. Nobody who is not an admin can reach any of it, and it does not admit to
|
||||
* existing when they try.
|
||||
* 2. A decision actually lands on every surface — the deck, search and the
|
||||
* public profile all read the same eligibility rule, so approving here has
|
||||
* to put the pro on all three and suspending has to take them off all three.
|
||||
*
|
||||
* Every fixture is this file's own. Test files run in parallel against one
|
||||
* database, and flipping a seeded pro's verification status would delete a card
|
||||
* out from under deck.router.test.ts mid-run.
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
config({ path: '../../.env' });
|
||||
|
||||
const { closePool, db } = await import('@linkder/db');
|
||||
const { appRouter } = await import('../src/root');
|
||||
const { createInnerContext } = await import('../src/context');
|
||||
const { createCallerFactory } = await import('../src/trpc');
|
||||
|
||||
const createCaller = createCallerFactory(appRouter);
|
||||
type Session = import('../src/context').Session;
|
||||
|
||||
function callerFor(session: Session | null) {
|
||||
return createCaller(createInnerContext({ db, session }));
|
||||
}
|
||||
|
||||
const session = (userId: string, role: Session['role']): Session => ({
|
||||
userId,
|
||||
role,
|
||||
name: 'Admin Test',
|
||||
email: 'admin@test',
|
||||
phone: null,
|
||||
verificationStatus: null,
|
||||
});
|
||||
|
||||
const RUN = Math.random().toString(36).slice(2, 8);
|
||||
|
||||
/**
|
||||
* Assert a call was refused without revealing that admin routes exist.
|
||||
*
|
||||
* Checks the tRPC error CODE rather than the message: an anonymous caller is
|
||||
* stopped earlier, by protectedProcedure, and phrases it differently. What has
|
||||
* to hold for every non-admin is that the answer is "no such thing" or "not
|
||||
* signed in" — never FORBIDDEN, which would confirm the surface is there.
|
||||
*/
|
||||
async function expectDenied(promise: Promise<unknown>): Promise<void> {
|
||||
const code = await promise.then(
|
||||
() => 'RESOLVED',
|
||||
(error: { code?: string }) => error.code ?? 'UNKNOWN',
|
||||
);
|
||||
expect(['NOT_FOUND', 'UNAUTHORIZED']).toContain(code);
|
||||
}
|
||||
|
||||
let admin: string;
|
||||
let outsider: string;
|
||||
/** A pending pro with the documents a reviewer needs. */
|
||||
let candidate: string;
|
||||
/** A second pending pro, for the transition-graph cases. */
|
||||
let other: string;
|
||||
|
||||
async function makePro(name: string, status: string): Promise<string> {
|
||||
const [user] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO users (name, email, role)
|
||||
VALUES (${name}, ${`${name.toLowerCase().replace(/\W+/g, '-')}-${RUN}@example.com`}, 'pro')
|
||||
RETURNING id
|
||||
`);
|
||||
const id = user!.id;
|
||||
|
||||
await db.execute(sql`
|
||||
INSERT INTO pro_profiles (
|
||||
user_id, headline, bio, hourly_rate_cents, base_location, service_radius_m,
|
||||
verification_status
|
||||
)
|
||||
VALUES (
|
||||
${id}, 'Admin probe', 'Exists only for the admin router tests.', 3000,
|
||||
-- Right on the city centre, so an approval is visible to a search run
|
||||
-- from there and the "it lands on every surface" assertions are real.
|
||||
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 20000, ${status}
|
||||
)
|
||||
`);
|
||||
|
||||
const [category] = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM categories ORDER BY position LIMIT 1`,
|
||||
);
|
||||
await db.execute(
|
||||
sql`INSERT INTO pro_categories (pro_id, category_id) VALUES (${id}, ${category!.id})`,
|
||||
);
|
||||
await db.execute(
|
||||
sql`INSERT INTO pro_media (pro_id, url, position) VALUES (${id}, 'https://example.test/a.jpg', 0)`,
|
||||
);
|
||||
for (const kind of ['id', 'insurance']) {
|
||||
await db.execute(sql`
|
||||
INSERT INTO credentials (pro_id, kind, file_key)
|
||||
VALUES (${id}, ${kind}, ${`credential/${id}/${kind}.pdf`})
|
||||
`);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const [a] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO users (name, email, role)
|
||||
VALUES ('Admin Probe', ${`admin-${RUN}@example.com`}, 'admin') RETURNING id
|
||||
`);
|
||||
admin = a!.id;
|
||||
|
||||
const [o] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO users (name, email, role)
|
||||
VALUES ('Outsider Probe', ${`outsider-${RUN}@example.com`}, 'client') RETURNING id
|
||||
`);
|
||||
outsider = o!.id;
|
||||
|
||||
candidate = await makePro(`Candidate ${RUN}`, 'pending');
|
||||
other = await makePro(`Other ${RUN}`, 'pending');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Pros first. `credentials.reviewed_by` references the admin with no ON
|
||||
// DELETE rule, so deleting the reviewer before the documents they signed off
|
||||
// trips the foreign key.
|
||||
await db.execute(sql`DELETE FROM users WHERE id IN (${candidate}, ${other})`);
|
||||
await db.execute(sql`DELETE FROM users WHERE id IN (${admin}, ${outsider})`);
|
||||
await closePool();
|
||||
});
|
||||
|
||||
describe('access', () => {
|
||||
it('does not admit to existing for a client, a pro or an anonymous caller', async () => {
|
||||
for (const caller of [
|
||||
callerFor(null),
|
||||
callerFor(session(outsider, 'client')),
|
||||
callerFor(session(candidate, 'pro')),
|
||||
]) {
|
||||
await expectDenied(caller.admin.queue());
|
||||
await expectDenied(caller.admin.counts());
|
||||
await expectDenied(caller.admin.proDetail({ proId: candidate }));
|
||||
await expectDenied(caller.admin.decide({ proId: candidate, decision: 'verified' }));
|
||||
await expectDenied(caller.admin.suspend({ proId: candidate, reason: 'nope' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin.queue', () => {
|
||||
it('lists pending pros with enough to triage without opening each one', async () => {
|
||||
const queue = await callerFor(session(admin, 'admin')).admin.queue({ status: 'pending' });
|
||||
const row = queue.find((r) => r.proId === candidate);
|
||||
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.credentialKinds.sort()).toEqual(['id', 'insurance']);
|
||||
expect(row!.missing).toEqual([]);
|
||||
expect(row!.photoCount).toBe(1);
|
||||
expect(row!.categories.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('is oldest first — a queue that starves the longest wait is the wrong queue', async () => {
|
||||
const queue = await callerFor(session(admin, 'admin')).admin.queue({ status: 'pending' });
|
||||
const times = queue.map((r) => r.submittedAt.getTime());
|
||||
expect(times).toEqual([...times].sort((a, b) => a - b));
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin.proDetail', () => {
|
||||
it('resolves credentials to signed links and never returns the object key', async () => {
|
||||
const detail = await callerFor(session(admin, 'admin')).admin.proDetail({ proId: candidate });
|
||||
|
||||
expect(detail.documents).toHaveLength(2);
|
||||
for (const doc of detail.documents) {
|
||||
// The key is the one durable handle on a passport scan. A signed URL
|
||||
// expires; a key does not.
|
||||
expect(doc).not.toHaveProperty('fileKey');
|
||||
}
|
||||
expect(detail.missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin.decide', () => {
|
||||
it('refuses a rejection with no reason', async () => {
|
||||
// A rejection the pro cannot act on becomes a support ticket rather than a
|
||||
// fixed profile.
|
||||
await expect(
|
||||
callerFor(session(admin, 'admin')).admin.decide({ proId: other, decision: 'rejected' }),
|
||||
).rejects.toThrow(/what was wrong/i);
|
||||
});
|
||||
|
||||
it('approving puts the pro on the deck, in search and on the public profile', async () => {
|
||||
const caller = callerFor(session(admin, 'admin'));
|
||||
|
||||
// Before: verified is the gate on all three surfaces.
|
||||
await expect(callerFor(null).pro.publicProfile({ proId: candidate })).rejects.toThrow(
|
||||
/NOT_FOUND|not found/i,
|
||||
);
|
||||
|
||||
const result = await caller.admin.decide({ proId: candidate, decision: 'verified' });
|
||||
expect(result).toEqual({ status: 'verified', previous: 'pending' });
|
||||
|
||||
const profile = await callerFor(null).pro.publicProfile({ proId: candidate });
|
||||
expect(profile.proId).toBe(candidate);
|
||||
|
||||
const { results } = await callerFor(null).pro.search({ limit: 50, sort: 'nearest' });
|
||||
expect(results.map((p) => p.proId)).toContain(candidate);
|
||||
|
||||
const { cards } = await callerFor(null).deck.showcase({ limit: 20 });
|
||||
// The showcase is capped, so assert the eligibility rule rather than the
|
||||
// ranking: the pro must now be reachable, not necessarily on page one.
|
||||
expect(cards.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('records who decided it, and what it was before', async () => {
|
||||
const [row] = await db.execute<{ action: string; actor_id: string; metadata: unknown }>(sql`
|
||||
SELECT action, actor_id, metadata FROM audit_log
|
||||
WHERE entity = 'pro_profile' AND entity_id = ${candidate}
|
||||
AND action = 'verification.approved'
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
`);
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.actor_id).toBe(admin);
|
||||
expect(row!.metadata).toMatchObject({ from: 'pending' });
|
||||
});
|
||||
|
||||
it('marks the documents reviewed, by name', async () => {
|
||||
const [row] = await db.execute<{ review_status: string; reviewed_by: string }>(sql`
|
||||
SELECT review_status, reviewed_by FROM credentials WHERE pro_id = ${candidate} LIMIT 1
|
||||
`);
|
||||
// Approving a pro is a statement about somebody's licence and insurance. It
|
||||
// needs a name against it.
|
||||
expect(row!.review_status).toBe('approved');
|
||||
expect(row!.reviewed_by).toBe(admin);
|
||||
});
|
||||
|
||||
it('refuses a transition the graph does not allow', async () => {
|
||||
// verified -> verified is not an edge. Without this a double-submitted
|
||||
// approval would silently rewrite verifiedAt and re-approve the documents.
|
||||
await expect(
|
||||
callerFor(session(admin, 'admin')).admin.decide({ proId: candidate, decision: 'verified' }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('refreshes the counters the deck ranks on', async () => {
|
||||
const [row] = await db.execute<{ rating_count: number }>(
|
||||
sql`SELECT rating_count FROM pro_profiles WHERE user_id = ${candidate}`,
|
||||
);
|
||||
// A brand-new pro has no history, so the honest answer is zero — not the
|
||||
// column default left untouched.
|
||||
expect(row!.rating_count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin.suspend', () => {
|
||||
it('takes a verified pro off every surface, and unsuspend puts them back', async () => {
|
||||
const caller = callerFor(session(admin, 'admin'));
|
||||
|
||||
await caller.admin.suspend({ proId: candidate, reason: 'Insurance lapsed' });
|
||||
|
||||
// One write on the user, and the shared eligibility rule closes all four
|
||||
// read paths at once.
|
||||
await expect(callerFor(null).pro.publicProfile({ proId: candidate })).rejects.toThrow(
|
||||
/NOT_FOUND|not found/i,
|
||||
);
|
||||
const suspended = await callerFor(null).pro.search({ limit: 50, sort: 'nearest' });
|
||||
expect(suspended.results.map((p) => p.proId)).not.toContain(candidate);
|
||||
|
||||
await caller.admin.unsuspend({ proId: candidate });
|
||||
|
||||
const back = await callerFor(null).pro.publicProfile({ proId: candidate });
|
||||
expect(back.proId).toBe(candidate);
|
||||
});
|
||||
|
||||
it('refuses to suspend the caller', async () => {
|
||||
await expect(
|
||||
callerFor(session(admin, 'admin')).admin.suspend({ proId: admin, reason: 'oops' }),
|
||||
).rejects.toThrow(/yourself/i);
|
||||
});
|
||||
});
|
||||
@@ -45,16 +45,30 @@ let verifiedProId: string;
|
||||
let unverifiedProId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
/**
|
||||
* The seeded city-centre job, chosen by the properties these tests depend on
|
||||
* rather than by being the oldest row.
|
||||
*
|
||||
* "Oldest" stopped meaning "the fixture" the moment the seed grew backdated
|
||||
* jobs for other features, and the TTL assertion below silently started
|
||||
* measuring somebody else's `flexible` job.
|
||||
*/
|
||||
const jobs = await db.execute<{ id: string; client_id: string }>(
|
||||
sql`SELECT id, client_id FROM jobs ORDER BY created_at LIMIT 1`,
|
||||
sql`SELECT id, client_id FROM jobs
|
||||
WHERE urgency = 'now' AND status = 'open'
|
||||
ORDER BY created_at LIMIT 1`,
|
||||
);
|
||||
const job = jobs[0];
|
||||
if (!job) throw new Error('No seeded job — run `pnpm db:seed`');
|
||||
if (!job) throw new Error('No seeded open "now" job — run `pnpm db:seed`');
|
||||
jobId = job.id;
|
||||
ownerId = job.client_id;
|
||||
|
||||
const others = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM users WHERE role = 'client' AND id <> ${ownerId} LIMIT 1`,
|
||||
// Seeded only — see the note on the job lookup above. A probe client from
|
||||
// another test file could otherwise land here and be deleted mid-run.
|
||||
sql`SELECT id FROM users
|
||||
WHERE role = 'client' AND email LIKE '%@linkder.test' AND id <> ${ownerId}
|
||||
LIMIT 1`,
|
||||
);
|
||||
strangerId = others[0]!.id;
|
||||
|
||||
@@ -327,11 +341,106 @@ describe('undo', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The entry deck has no job in context, so a right swipe there has to ask which
|
||||
* job it means. These are the states that question can be in.
|
||||
*/
|
||||
describe('deck.sendable', () => {
|
||||
it('lists the jobs a pro could be sent, and flags the ones they already have', async () => {
|
||||
const caller = callerFor(clientSession(ownerId));
|
||||
|
||||
const before = await caller.deck.sendable({ proId: verifiedProId });
|
||||
const target = before.jobs.find((j) => j.id === jobId);
|
||||
expect(target).toBeDefined();
|
||||
expect(target!.alreadySent).toBe(false);
|
||||
expect(target!.atCap).toBe(false);
|
||||
expect(before.proAvailable).toBe(true);
|
||||
|
||||
await caller.deck.swipe({ jobId, proId: verifiedProId, direction: 'right' });
|
||||
|
||||
const after = await caller.deck.sendable({ proId: verifiedProId });
|
||||
const sent = after.jobs.find((j) => j.id === jobId);
|
||||
// The sheet offers "send" only where this is false — without it a second
|
||||
// swipe would silently no-op against the unique constraint.
|
||||
expect(sent!.alreadySent).toBe(true);
|
||||
expect(sent!.requestStatus).toBe('pending');
|
||||
expect(sent!.pendingCount).toBe(1);
|
||||
});
|
||||
|
||||
it('only ever lists jobs that are still taking offers', async () => {
|
||||
const caller = callerFor(clientSession(ownerId));
|
||||
|
||||
await db.execute(sql`UPDATE jobs SET status = 'completed' WHERE id = ${jobId}`);
|
||||
const closed = await caller.deck.sendable({ proId: verifiedProId });
|
||||
expect(closed.jobs.map((j) => j.id)).not.toContain(jobId);
|
||||
|
||||
await db.execute(sql`UPDATE jobs SET status = 'open' WHERE id = ${jobId}`);
|
||||
const open = await caller.deck.sendable({ proId: verifiedProId });
|
||||
expect(open.jobs.map((j) => j.id)).toContain(jobId);
|
||||
});
|
||||
|
||||
it("never lists somebody else's jobs", async () => {
|
||||
const theirs = await callerFor(clientSession(strangerId)).deck.sendable({
|
||||
proId: verifiedProId,
|
||||
});
|
||||
expect(theirs.jobs.map((j) => j.id)).not.toContain(jobId);
|
||||
});
|
||||
|
||||
it('reports a pro who cannot be sent anything rather than failing later', async () => {
|
||||
const result = await callerFor(clientSession(ownerId)).deck.sendable({
|
||||
proId: unverifiedProId,
|
||||
});
|
||||
// swipe would throw NOT_FOUND for this pro. Knowing up front is what lets
|
||||
// the sheet say so instead of opening and then erroring.
|
||||
expect(result.proAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('says whether the pro actually works the trade, without hiding the job', async () => {
|
||||
const caller = callerFor(clientSession(ownerId));
|
||||
|
||||
// verifiedProId was picked precisely because they cover this job's category.
|
||||
const matching = await caller.deck.sendable({ proId: verifiedProId });
|
||||
expect(matching.jobs.find((j) => j.id === jobId)!.tradeMatches).toBe(true);
|
||||
|
||||
const [offTrade] = await db.execute<{ id: string }>(sql`
|
||||
SELECT p.user_id AS id FROM pro_profiles p
|
||||
WHERE p.verification_status = 'verified'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM pro_categories pc
|
||||
JOIN jobs j ON j.category_id = pc.category_id AND j.id = ${jobId}
|
||||
WHERE pc.pro_id = p.user_id
|
||||
)
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
if (offTrade) {
|
||||
const other = await caller.deck.sendable({ proId: offTrade.id });
|
||||
const row = other.jobs.find((j) => j.id === jobId);
|
||||
// Flagged, still offered — the client picked this person on purpose.
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.tradeMatches).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a pro asking who they could hire', async () => {
|
||||
const asPro: Session = {
|
||||
...clientSession(verifiedProId),
|
||||
role: 'pro',
|
||||
verificationStatus: 'verified',
|
||||
};
|
||||
await expect(callerFor(asPro).deck.sendable({ proId: verifiedProId })).rejects.toThrow(
|
||||
/only clients/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('job router', () => {
|
||||
it("lists only the caller's own jobs", async () => {
|
||||
const mine = await callerFor(clientSession(ownerId)).job.mine();
|
||||
expect(mine.length).toBeGreaterThan(0);
|
||||
for (const job of mine) expect(job.clientId).toBe(ownerId);
|
||||
// `mine` no longer returns client_id — it is scoped by it in the WHERE
|
||||
// clause, so the column would only be a chance for the two to disagree.
|
||||
// Ownership is asserted by what the list does and does not contain.
|
||||
expect(mine.map((j) => j.id)).toContain(jobId);
|
||||
|
||||
const theirs = await callerFor(clientSession(strangerId)).job.mine();
|
||||
expect(theirs.map((j) => j.id)).not.toContain(jobId);
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* The geocoding surface, against the live seeded database.
|
||||
*
|
||||
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
||||
*
|
||||
* No Mapbox token is set in test, and that is deliberate: the behaviour worth
|
||||
* pinning is what happens when the geocoder is NOT available. Every one of these
|
||||
* paths used to end with the city centre silently stored as if it were an
|
||||
* address, so "degrades honestly" is the property under test.
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
config({ path: '../../.env' });
|
||||
|
||||
const { closePool, db } = await import('@linkder/db');
|
||||
const { appRouter } = await import('../src/root');
|
||||
const { createInnerContext } = await import('../src/context');
|
||||
const { createCallerFactory } = await import('../src/trpc');
|
||||
|
||||
const createCaller = createCallerFactory(appRouter);
|
||||
type Session = import('../src/context').Session;
|
||||
|
||||
function callerFor(session: Session | null) {
|
||||
return createCaller(createInnerContext({ db, session }));
|
||||
}
|
||||
|
||||
const clientSession = (userId: string): Session => ({
|
||||
userId,
|
||||
role: 'client',
|
||||
name: 'Test Client',
|
||||
email: 'client@test',
|
||||
phone: null,
|
||||
verificationStatus: null,
|
||||
});
|
||||
|
||||
const RUN = Math.random().toString(36).slice(2, 8);
|
||||
const CITY = {
|
||||
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874),
|
||||
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686),
|
||||
};
|
||||
|
||||
let client: string;
|
||||
let plumberCat: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const [row] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO users (name, email, role)
|
||||
VALUES ('Geo Probe', ${`geo-${RUN}@example.com`}, 'client')
|
||||
RETURNING id
|
||||
`);
|
||||
client = row!.id;
|
||||
|
||||
const [cat] = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM categories WHERE slug = 'plumber' LIMIT 1`,
|
||||
);
|
||||
plumberCat = cat!.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.execute(sql`DELETE FROM users WHERE id = ${client}`);
|
||||
await closePool();
|
||||
});
|
||||
|
||||
describe('geocode.suggest', () => {
|
||||
it('is not reachable without a session', async () => {
|
||||
// Unlike pro.search this costs money per call, so the session is the first
|
||||
// cost bound.
|
||||
await expect(callerFor(null).geocode.suggest({ q: 'carrer' })).rejects.toThrow(/signed in/i);
|
||||
});
|
||||
|
||||
it('caps the query length', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(client)).geocode.suggest({ q: 'x'.repeat(201) }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('caps how many suggestions can be asked for', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(client)).geocode.suggest({ q: 'carrer', limit: 50 }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('returns an empty list rather than failing when unconfigured', async () => {
|
||||
// A provider outage must not take an address field — and therefore a whole
|
||||
// form — down with it.
|
||||
const result = await callerFor(clientSession(client)).geocode.suggest({ q: 'carrer de sants' });
|
||||
expect(result.results).toEqual([]);
|
||||
expect(result.configured).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('job.create resolves the point server-side', () => {
|
||||
const base = {
|
||||
categoryId: '',
|
||||
title: 'Tap dripping in the bathroom',
|
||||
description: 'The cold tap drips constantly and the washer looks perished.',
|
||||
photos: [] as string[],
|
||||
urgency: 'flexible' as const,
|
||||
};
|
||||
|
||||
async function readJob(id: string) {
|
||||
const [row] = await db.execute<{
|
||||
precision: string;
|
||||
address_text: string;
|
||||
place_id: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}>(sql`
|
||||
SELECT location_precision AS precision,
|
||||
address_text,
|
||||
location_place_id AS place_id,
|
||||
ST_Y(location::geometry) AS lat,
|
||||
ST_X(location::geometry) AS lng
|
||||
FROM jobs WHERE id = ${id}
|
||||
`);
|
||||
return row!;
|
||||
}
|
||||
|
||||
it('records an unresolvable address as city precision, and still posts', async () => {
|
||||
// The heart of it. This used to store the city centre and label the row an
|
||||
// address, so every distance computed from it was a fiction.
|
||||
const job = await callerFor(clientSession(client)).job.create({
|
||||
...base,
|
||||
categoryId: plumberCat,
|
||||
place: { source: 'none', label: 'Somewhere near the big roundabout' },
|
||||
});
|
||||
|
||||
const row = await readJob(job.id);
|
||||
expect(row.precision).toBe('city');
|
||||
expect(row.place_id).toBeNull();
|
||||
expect(Number(row.lat)).toBeCloseTo(CITY.lat, 4);
|
||||
expect(Number(row.lng)).toBeCloseTo(CITY.lng, 4);
|
||||
// What they typed survives — it is a note to the pro, just not a location.
|
||||
expect(row.address_text).toBe('Somewhere near the big roundabout');
|
||||
});
|
||||
|
||||
it('takes a device fix at its word but never calls it exact', async () => {
|
||||
const job = await callerFor(clientSession(client)).job.create({
|
||||
...base,
|
||||
categoryId: plumberCat,
|
||||
place: { source: 'device', lat: 41.4036, lng: 2.1744, label: 'Gracia' },
|
||||
});
|
||||
|
||||
const row = await readJob(job.id);
|
||||
// A handset fix is real, so the coordinates are kept as sent...
|
||||
expect(Number(row.lat)).toBeCloseTo(41.4036, 4);
|
||||
expect(Number(row.lng)).toBeCloseTo(2.1744, 4);
|
||||
// ...but it is metres out on a good day, so it must not rank as a rooftop.
|
||||
expect(row.precision).toBe('approximate');
|
||||
});
|
||||
|
||||
it('falls back rather than trusting a placeId it cannot resolve', async () => {
|
||||
// With no geocoder there is nothing to verify the id against, and an
|
||||
// unverifiable id must not become a coordinate.
|
||||
const job = await callerFor(clientSession(client)).job.create({
|
||||
...base,
|
||||
categoryId: plumberCat,
|
||||
place: { source: 'place', placeId: 'made-up-id', label: 'Carrer de Sants 12' },
|
||||
});
|
||||
|
||||
const row = await readJob(job.id);
|
||||
expect(row.precision).toBe('city');
|
||||
expect(row.place_id).toBeNull();
|
||||
});
|
||||
|
||||
it('no longer accepts raw coordinates at all', async () => {
|
||||
// The old shape. Anyone could put a job anywhere on earth with it.
|
||||
await expect(
|
||||
callerFor(clientSession(client)).job.create({
|
||||
...base,
|
||||
categoryId: plumberCat,
|
||||
// @ts-expect-error — the field is gone from the schema on purpose.
|
||||
location: { lat: 0, lng: 0 },
|
||||
addressText: 'Null Island',
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* The commercial half of the funnel, end to end, against the live database.
|
||||
*
|
||||
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
||||
*
|
||||
* quote → accept → booking → done → confirm → review. Until this existed the
|
||||
* chain stopped at "two people are talking": `quotes`, `bookings` and `reviews`
|
||||
* had tables and state machines and nothing that wrote a row, so `reviews` was
|
||||
* unreachable and `completed_jobs` could never move.
|
||||
*
|
||||
* The tests are ordered because the lifecycle is. Each `describe` leaves the
|
||||
* fixture one step further along, which is also the cheapest way to prove the
|
||||
* steps compose rather than merely each working from a hand-built row.
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { REVIEW_EMBARGO_HOURS } from '@linkder/shared';
|
||||
|
||||
config({ path: '../../.env' });
|
||||
|
||||
const { closePool, db } = await import('@linkder/db');
|
||||
const { appRouter } = await import('../src/root');
|
||||
const { createInnerContext } = await import('../src/context');
|
||||
const { createCallerFactory } = await import('../src/trpc');
|
||||
|
||||
const createCaller = createCallerFactory(appRouter);
|
||||
type Session = import('../src/context').Session;
|
||||
|
||||
const callerFor = (session: Session | null) =>
|
||||
createCaller(createInnerContext({ db, session }));
|
||||
|
||||
const clientSession = (userId: string): Session => ({
|
||||
userId,
|
||||
role: 'client',
|
||||
name: 'Test Client',
|
||||
email: 'client@test',
|
||||
phone: null,
|
||||
verificationStatus: null,
|
||||
});
|
||||
|
||||
const proSession = (userId: string): Session => ({
|
||||
userId,
|
||||
role: 'pro',
|
||||
name: 'Test Pro',
|
||||
email: 'pro@test',
|
||||
phone: null,
|
||||
verificationStatus: 'verified',
|
||||
});
|
||||
|
||||
const RUN = Math.random().toString(36).slice(2, 8);
|
||||
|
||||
let owner: string;
|
||||
let pro: string;
|
||||
let stranger: string;
|
||||
let jobId: string;
|
||||
let matchId: string;
|
||||
let quoteId: string;
|
||||
let bookingId: string;
|
||||
|
||||
const slot = () => {
|
||||
const start = new Date(Date.now() + 86_400_000);
|
||||
return { scheduledStart: start, scheduledEnd: new Date(start.getTime() + 7_200_000) };
|
||||
};
|
||||
|
||||
async function insertUser(name: string, role: 'client' | 'pro'): Promise<string> {
|
||||
const [row] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO users (name, email, role)
|
||||
VALUES (${name}, ${`${role}-${RUN}-${Math.random().toString(36).slice(2, 6)}@example.com`}, ${role})
|
||||
RETURNING id
|
||||
`);
|
||||
return row!.id;
|
||||
}
|
||||
|
||||
/*
|
||||
* These fixture pros are `is_accepting_jobs = false`.
|
||||
*
|
||||
* Test files share one database and run concurrently. A verified, accepting pro
|
||||
* sitting at the city centre is eligible for the SEEDED job's deck, so creating
|
||||
* and deleting one mid-run shifts `deck.list().remaining` underneath
|
||||
* deck.router.test.ts. Holiday mode keeps them off every deck and search —
|
||||
* `eligibleProAtAnyDistance()` requires the flag — and nothing in the quote →
|
||||
* booking → review chain reads it, so the lifecycle is unaffected.
|
||||
*/
|
||||
beforeAll(async () => {
|
||||
const [plumber] = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM categories WHERE slug = 'plumber' LIMIT 1`,
|
||||
);
|
||||
|
||||
owner = await insertUser(`Life Owner ${RUN}`, 'client');
|
||||
stranger = await insertUser(`Life Stranger ${RUN}`, 'client');
|
||||
pro = await insertUser(`Life Pro ${RUN}`, 'pro');
|
||||
|
||||
await db.execute(sql`
|
||||
INSERT INTO pro_profiles (
|
||||
user_id, headline, bio, hourly_rate_cents, base_location, base_location_precision,
|
||||
service_radius_m, verification_status, verified_at, is_accepting_jobs
|
||||
)
|
||||
VALUES (
|
||||
${pro}, 'Lifecycle pro', 'Exists only for the lifecycle tests.', 4000,
|
||||
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 'exact',
|
||||
15000, 'verified', now(), false
|
||||
)
|
||||
`);
|
||||
|
||||
const [job] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO jobs (client_id, category_id, title, description, location, location_precision,
|
||||
address_text, status)
|
||||
VALUES (
|
||||
${owner}, ${plumber!.id}, 'Lifecycle fixture job',
|
||||
'A job that exists to be quoted, booked, completed and reviewed.',
|
||||
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 'exact',
|
||||
'Carrer de Prova 1', 'matched'
|
||||
)
|
||||
RETURNING id
|
||||
`);
|
||||
jobId = job!.id;
|
||||
|
||||
const [request] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO requests (job_id, pro_id, status, expires_at, responded_at)
|
||||
VALUES (${jobId}, ${pro}, 'accepted', now() + interval '2 days', now())
|
||||
RETURNING id
|
||||
`);
|
||||
|
||||
const [match] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO matches (request_id, job_id, pro_id, client_id)
|
||||
VALUES (${request!.id}, ${jobId}, ${pro}, ${owner})
|
||||
RETURNING id
|
||||
`);
|
||||
matchId = match!.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.execute(sql`DELETE FROM users WHERE id IN (${owner}, ${stranger}, ${pro})`);
|
||||
await closePool();
|
||||
});
|
||||
|
||||
describe('quote', () => {
|
||||
it('refuses a stranger, and a client trying to quote themselves', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(stranger)).quote.forMatch({ matchId }),
|
||||
).rejects.toThrow(/not found/i);
|
||||
|
||||
await expect(
|
||||
callerFor(clientSession(owner)).quote.create({
|
||||
matchId,
|
||||
kind: 'fixed',
|
||||
amountCents: 20_000,
|
||||
scope: 'I would like to quote myself, please.',
|
||||
}),
|
||||
).rejects.toThrow(/only professionals/i);
|
||||
});
|
||||
|
||||
it('lets the pro send one, visible to both sides', async () => {
|
||||
const sent = await callerFor(proSession(pro)).quote.create({
|
||||
matchId,
|
||||
kind: 'fixed',
|
||||
amountCents: 24_500,
|
||||
scope: 'Replace the trap and reseal the waste under the sink.',
|
||||
});
|
||||
quoteId = sent.id;
|
||||
|
||||
expect(sent.status).toBe('sent');
|
||||
expect(sent.isLive).toBe(true);
|
||||
|
||||
const asClient = await callerFor(clientSession(owner)).quote.forMatch({ matchId });
|
||||
expect(asClient.map((q) => q.id)).toContain(quoteId);
|
||||
});
|
||||
|
||||
it('withdraws the previous quote when a new one is sent', async () => {
|
||||
// Two live offers from one person is not a negotiation, it is a mistake
|
||||
// waiting to be accepted.
|
||||
const second = await callerFor(proSession(pro)).quote.create({
|
||||
matchId,
|
||||
kind: 'fixed',
|
||||
amountCents: 21_000,
|
||||
scope: 'Revised: the trap is fine, it only needs a new washer and a reseal.',
|
||||
});
|
||||
|
||||
const all = await callerFor(proSession(pro)).quote.forMatch({ matchId });
|
||||
expect(all.find((q) => q.id === quoteId)!.status).toBe('withdrawn');
|
||||
expect(all.find((q) => q.id === second.id)!.status).toBe('sent');
|
||||
|
||||
quoteId = second.id;
|
||||
});
|
||||
});
|
||||
|
||||
describe('booking', () => {
|
||||
it('refuses to book on a withdrawn quote', async () => {
|
||||
const stale = (await callerFor(proSession(pro)).quote.forMatch({ matchId })).find(
|
||||
(q) => q.status === 'withdrawn',
|
||||
)!;
|
||||
|
||||
await expect(
|
||||
callerFor(clientSession(owner)).quote.accept({ matchId, quoteId: stale.id, ...slot() }),
|
||||
).rejects.toThrow(/no longer open/i);
|
||||
});
|
||||
|
||||
it('refuses a slot in the past', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(owner)).quote.accept({
|
||||
matchId,
|
||||
quoteId,
|
||||
scheduledStart: new Date(Date.now() - 86_400_000),
|
||||
scheduledEnd: new Date(Date.now() - 82_800_000),
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('accepting creates the booking and closes the job to other pros', async () => {
|
||||
// A second pro still waiting on this job would otherwise keep it in their
|
||||
// inbox forever and keep it counting against their response rate.
|
||||
const other = await insertUser(`Life Other ${RUN}`, 'pro');
|
||||
// requests.pro_id references pro_profiles.user_id, not users.id — a pro
|
||||
// without a profile is not somebody a job can be sent to.
|
||||
await db.execute(sql`
|
||||
INSERT INTO pro_profiles (
|
||||
user_id, headline, bio, hourly_rate_cents, base_location, base_location_precision,
|
||||
service_radius_m, verification_status, verified_at, is_accepting_jobs
|
||||
)
|
||||
VALUES (
|
||||
${other}, 'Also waiting', 'A second pro left hanging on the same job.', 3500,
|
||||
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 'exact',
|
||||
15000, 'verified', now(), false
|
||||
)
|
||||
`);
|
||||
await db.execute(sql`
|
||||
INSERT INTO requests (job_id, pro_id, status, expires_at)
|
||||
VALUES (${jobId}, ${other}, 'pending', now() + interval '2 days')
|
||||
`);
|
||||
|
||||
const result = await callerFor(clientSession(owner)).quote.accept({
|
||||
matchId,
|
||||
quoteId,
|
||||
...slot(),
|
||||
});
|
||||
bookingId = result.bookingId;
|
||||
|
||||
const [job] = await db.execute<{ status: string }>(
|
||||
sql`SELECT status FROM jobs WHERE id = ${jobId}`,
|
||||
);
|
||||
expect(job!.status).toBe('booked');
|
||||
|
||||
const [pending] = await db.execute<{ n: number }>(
|
||||
sql`SELECT count(*)::int AS n FROM requests
|
||||
WHERE job_id = ${jobId} AND status = 'pending'`,
|
||||
);
|
||||
expect(pending!.n).toBe(0);
|
||||
|
||||
await db.execute(sql`DELETE FROM users WHERE id = ${other}`);
|
||||
});
|
||||
|
||||
it('lets the pro flag that they have started, but does not require it', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(owner)).booking.start({ bookingId }),
|
||||
).rejects.toThrow(/only the pro/i);
|
||||
|
||||
await callerFor(proSession(pro)).booking.start({ bookingId });
|
||||
|
||||
const [row] = await db.execute<{ status: string }>(
|
||||
sql`SELECT status FROM bookings WHERE id = ${bookingId}`,
|
||||
);
|
||||
expect(row!.status).toBe('in_progress');
|
||||
});
|
||||
|
||||
it('only the pro may mark it done, only the client may confirm', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(owner)).booking.markComplete({ bookingId }),
|
||||
).rejects.toThrow(/only the pro/i);
|
||||
|
||||
await callerFor(proSession(pro)).booking.markComplete({ bookingId });
|
||||
|
||||
await expect(callerFor(proSession(pro)).booking.confirm({ bookingId })).rejects.toThrow(
|
||||
/only the customer/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('confirming completes the job and moves the pro’s counters', async () => {
|
||||
await callerFor(clientSession(owner)).booking.confirm({ bookingId });
|
||||
|
||||
const [job] = await db.execute<{ status: string }>(
|
||||
sql`SELECT status FROM jobs WHERE id = ${jobId}`,
|
||||
);
|
||||
expect(job!.status).toBe('completed');
|
||||
|
||||
// completed_jobs is a deck ranking input and was never written before the
|
||||
// booking lifecycle existed.
|
||||
const [stats] = await db.execute<{ completed_jobs: number }>(
|
||||
sql`SELECT completed_jobs FROM pro_profiles WHERE user_id = ${pro}`,
|
||||
);
|
||||
expect(stats!.completed_jobs).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('review', () => {
|
||||
it('surfaces the finished job as owed a review, on both sides', async () => {
|
||||
const mine = await callerFor(clientSession(owner)).review.pending();
|
||||
const theirs = await callerFor(proSession(pro)).review.pending();
|
||||
|
||||
expect(mine.map((r) => r.bookingId)).toContain(bookingId);
|
||||
expect(theirs.map((r) => r.bookingId)).toContain(bookingId);
|
||||
});
|
||||
|
||||
it('holds the first review back instead of publishing it', async () => {
|
||||
const written = await callerFor(clientSession(owner)).review.create({
|
||||
bookingId,
|
||||
rating: 5,
|
||||
body: 'Turned up on time, fixed it in an hour, tidied up after himself.',
|
||||
});
|
||||
|
||||
// Embargoed, not hidden by a null: `published_at` is dated forward so it
|
||||
// surfaces on its own even if the pro never writes anything back.
|
||||
expect(written.published).toBe(false);
|
||||
expect(written.publishedAt!.getTime()).toBeGreaterThan(Date.now());
|
||||
expect(written.publishedAt!.getTime()).toBeLessThanOrEqual(
|
||||
Date.now() + REVIEW_EMBARGO_HOURS * 3_600_000 + 5_000,
|
||||
);
|
||||
|
||||
// Not yet counted, and not yet readable.
|
||||
const [stats] = await db.execute<{ rating_count: number }>(
|
||||
sql`SELECT rating_count FROM pro_profiles WHERE user_id = ${pro}`,
|
||||
);
|
||||
expect(stats!.rating_count).toBe(0);
|
||||
|
||||
const seen = await callerFor(proSession(pro)).review.forBooking({ bookingId });
|
||||
expect(seen.theyHaveReviewed).toBe(true);
|
||||
// Knows one exists, cannot read it — that is what stops a reply in kind.
|
||||
expect(seen.theirs).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a second review from the same author', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(owner)).review.create({
|
||||
bookingId,
|
||||
rating: 1,
|
||||
body: 'Actually, on reflection, I would like to change my mind about this.',
|
||||
}),
|
||||
).rejects.toThrow(/already reviewed/i);
|
||||
});
|
||||
|
||||
it('publishes both the moment the second one lands, and counts it', async () => {
|
||||
const second = await callerFor(proSession(pro)).review.create({
|
||||
bookingId,
|
||||
rating: 5,
|
||||
body: 'Clear about the problem, easy access, paid without any fuss.',
|
||||
});
|
||||
expect(second.published).toBe(true);
|
||||
|
||||
const [stats] = await db.execute<{ rating_count: number; rating_avg: string | null }>(
|
||||
sql`SELECT rating_count, rating_avg FROM pro_profiles WHERE user_id = ${pro}`,
|
||||
);
|
||||
expect(stats!.rating_count).toBe(1);
|
||||
expect(Number(stats!.rating_avg)).toBe(5);
|
||||
|
||||
// And now each side can read the other's.
|
||||
const asPro = await callerFor(proSession(pro)).review.forBooking({ bookingId });
|
||||
expect(asPro.theirs?.body).toMatch(/turned up on time/i);
|
||||
});
|
||||
|
||||
it('refuses a review from someone who was not on the booking', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(stranger)).review.create({
|
||||
bookingId,
|
||||
rating: 1,
|
||||
body: 'I have never met either of these people but here is my opinion.',
|
||||
}),
|
||||
).rejects.toThrow(/not found/i);
|
||||
});
|
||||
|
||||
it('refuses a review on work that is not finished', async () => {
|
||||
const [fresh] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO quotes (match_id, kind, amount_cents, scope, valid_until)
|
||||
VALUES (${matchId}, 'fixed', 5000, 'Another small job', now() + interval '2 days')
|
||||
RETURNING id
|
||||
`);
|
||||
const [booking] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO bookings (match_id, quote_id, scheduled_start, scheduled_end, status)
|
||||
VALUES (${matchId}, ${fresh!.id}, now() + interval '1 day',
|
||||
now() + interval '1 day 2 hours', 'scheduled')
|
||||
RETURNING id
|
||||
`);
|
||||
|
||||
await expect(
|
||||
callerFor(clientSession(owner)).review.create({
|
||||
bookingId: booking!.id,
|
||||
rating: 5,
|
||||
body: 'Reviewing this before anybody has actually done anything at all.',
|
||||
}),
|
||||
).rejects.toThrow(/finished and confirmed/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* Integration tests for chat, against the live seeded database.
|
||||
*
|
||||
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
||||
* pnpm --filter @linkder/api test
|
||||
*
|
||||
* A thread is a private conversation between exactly two people, so most of this
|
||||
* file is about the third person: a stranger must not be able to read it, write
|
||||
* to it, mark it read, or learn that it exists at all.
|
||||
*
|
||||
* Fixtures are built with SQL rather than by driving `deck.swipe` →
|
||||
* `request.accept`. That flow has its own correctness to prove; borrowing it
|
||||
* here would mean a change to request expiry could fail the chat tests.
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
config({ path: '../../.env' });
|
||||
|
||||
const { closePool, db } = await import('@linkder/db');
|
||||
const { appRouter } = await import('../src/root');
|
||||
const { createInnerContext } = await import('../src/context');
|
||||
const { createCallerFactory } = await import('../src/trpc');
|
||||
|
||||
const createCaller = createCallerFactory(appRouter);
|
||||
type Session = import('../src/context').Session;
|
||||
|
||||
function callerFor(session: Session | null) {
|
||||
return createCaller(createInnerContext({ db, session }));
|
||||
}
|
||||
|
||||
const clientSession = (userId: string): Session => ({
|
||||
userId,
|
||||
role: 'client',
|
||||
name: 'Test Client',
|
||||
email: 'client@test',
|
||||
phone: null,
|
||||
verificationStatus: null,
|
||||
});
|
||||
|
||||
const proSession = (userId: string): Session => ({
|
||||
userId,
|
||||
role: 'pro',
|
||||
name: 'Test Pro',
|
||||
email: 'pro@test',
|
||||
phone: null,
|
||||
verificationStatus: 'verified',
|
||||
});
|
||||
|
||||
const RUN = Math.random().toString(36).slice(2, 8);
|
||||
|
||||
let owner: string;
|
||||
let pro: string;
|
||||
let stranger: string;
|
||||
let jobId: string;
|
||||
let matchId: string;
|
||||
/** A second job/thread pair, used for the "closed once the job is" tests. */
|
||||
let closedJobId: string;
|
||||
let closedMatchId: string;
|
||||
|
||||
async function insertUser(name: string, role: 'client' | 'pro'): Promise<string> {
|
||||
const [row] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO users (name, email, role)
|
||||
VALUES (${name}, ${`${name.toLowerCase().replace(/\s+/g, '-')}-${RUN}@example.com`}, ${role})
|
||||
RETURNING id
|
||||
`);
|
||||
return row!.id;
|
||||
}
|
||||
|
||||
/** A job owned by `owner`, plus an accepted request and the match it opens. */
|
||||
async function insertJobWithMatch(categoryId: string, status: string): Promise<{
|
||||
jobId: string;
|
||||
matchId: string;
|
||||
}> {
|
||||
const [job] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO jobs (client_id, category_id, title, description, location, address_text, status)
|
||||
VALUES (
|
||||
${owner}, ${categoryId}, 'Chat fixture job',
|
||||
'A job that exists only so a conversation can hang off it.',
|
||||
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography,
|
||||
'Carrer de Prova 1', ${sql.raw(`'${status}'`)}
|
||||
)
|
||||
RETURNING id
|
||||
`);
|
||||
|
||||
const [request] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO requests (job_id, pro_id, status, expires_at, responded_at)
|
||||
VALUES (${job!.id}, ${pro}, 'accepted', now() + interval '2 days', now())
|
||||
RETURNING id
|
||||
`);
|
||||
|
||||
const [match] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO matches (request_id, job_id, pro_id, client_id)
|
||||
VALUES (${request!.id}, ${job!.id}, ${pro}, ${owner})
|
||||
RETURNING id
|
||||
`);
|
||||
|
||||
return { jobId: job!.id, matchId: match!.id };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const [plumber] = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM categories WHERE slug = 'plumber' LIMIT 1`,
|
||||
);
|
||||
if (!plumber) throw new Error('plumber category missing from seed');
|
||||
|
||||
owner = await insertUser(`Chat Owner ${RUN}`, 'client');
|
||||
stranger = await insertUser(`Chat Stranger ${RUN}`, 'client');
|
||||
pro = await insertUser(`Chat Pro ${RUN}`, 'pro');
|
||||
|
||||
await db.execute(sql`
|
||||
INSERT INTO pro_profiles (
|
||||
user_id, headline, bio, hourly_rate_cents, base_location, service_radius_m,
|
||||
verification_status, verified_at
|
||||
)
|
||||
VALUES (
|
||||
${pro}, 'Chat fixture pro', 'Exists only for the message router tests.', 4000,
|
||||
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 15000, 'verified', now()
|
||||
)
|
||||
`);
|
||||
|
||||
({ jobId, matchId } = await insertJobWithMatch(plumber.id, 'matched'));
|
||||
({ jobId: closedJobId, matchId: closedMatchId } = await insertJobWithMatch(
|
||||
plumber.id,
|
||||
'cancelled',
|
||||
));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// jobs, requests, matches, messages and pro_profiles all cascade from users.
|
||||
await db.execute(sql`DELETE FROM users WHERE id IN (${owner}, ${stranger}, ${pro})`);
|
||||
await closePool();
|
||||
});
|
||||
|
||||
describe('message.thread', () => {
|
||||
it('gives each side the same conversation, newest last', async () => {
|
||||
await callerFor(clientSession(owner)).message.send({
|
||||
matchId,
|
||||
body: 'Morning — when could you take a look?',
|
||||
attachments: [],
|
||||
});
|
||||
await callerFor(proSession(pro)).message.send({
|
||||
matchId,
|
||||
body: 'Thursday afternoon works.',
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
const asClient = await callerFor(clientSession(owner)).message.thread({ matchId });
|
||||
const asPro = await callerFor(proSession(pro)).message.thread({ matchId });
|
||||
|
||||
expect(asClient.messages.map((m) => m.body)).toEqual([
|
||||
'Morning — when could you take a look?',
|
||||
'Thursday afternoon works.',
|
||||
]);
|
||||
expect(asPro.messages.map((m) => m.id)).toEqual(asClient.messages.map((m) => m.id));
|
||||
|
||||
// Same rows, opposite ownership.
|
||||
expect(asClient.messages.map((m) => m.isMine)).toEqual([true, false]);
|
||||
expect(asPro.messages.map((m) => m.isMine)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('names the peer, not the caller', async () => {
|
||||
const asClient = await callerFor(clientSession(owner)).message.thread({ matchId });
|
||||
const asPro = await callerFor(proSession(pro)).message.thread({ matchId });
|
||||
|
||||
expect(asClient.match.peer?.id).toBe(pro);
|
||||
expect(asPro.match.peer?.id).toBe(owner);
|
||||
expect(asClient.match.jobId).toBe(jobId);
|
||||
});
|
||||
|
||||
it('is a 404 to a stranger — never a 403', async () => {
|
||||
// A 403 would confirm the conversation exists. Same rule as job.byId.
|
||||
await expect(
|
||||
callerFor(clientSession(stranger)).message.thread({ matchId }),
|
||||
).rejects.toThrow(/not found/i);
|
||||
});
|
||||
|
||||
it('refuses an anonymous caller', async () => {
|
||||
await expect(callerFor(null).message.thread({ matchId })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('pages oldest-ward without dropping or repeating a message', async () => {
|
||||
// 30 is the page size; 35 forces a second page with a clear boundary.
|
||||
//
|
||||
// Inserted directly rather than sent: `message.send` is rate-limited, and
|
||||
// tripping the limiter is exactly what a 35-message loop is supposed to do.
|
||||
//
|
||||
// They land MICROSECONDS apart, inside a single millisecond, on purpose.
|
||||
// That is the case a millisecond-precision cursor silently drops — five
|
||||
// messages went missing here before the cursor became an id.
|
||||
await db.execute(sql`
|
||||
INSERT INTO messages (match_id, sender_id, body, created_at)
|
||||
SELECT ${matchId}, ${pro}, 'page probe ' || i, now() + (i || ' microseconds')::interval
|
||||
FROM generate_series(0, 34) AS i
|
||||
`);
|
||||
|
||||
const first = await callerFor(clientSession(owner)).message.thread({ matchId });
|
||||
expect(first.messages).toHaveLength(30);
|
||||
expect(first.nextCursor).not.toBeNull();
|
||||
|
||||
const second = await callerFor(clientSession(owner)).message.thread({
|
||||
matchId,
|
||||
cursor: first.nextCursor!,
|
||||
});
|
||||
|
||||
const firstIds = new Set(first.messages.map((m) => m.id));
|
||||
expect(second.messages.some((m) => firstIds.has(m.id))).toBe(false);
|
||||
|
||||
// Two opening messages + 35 probes, and every one accounted for across the pages.
|
||||
expect(second.messages).toHaveLength(7);
|
||||
expect(second.nextCursor).toBeNull();
|
||||
|
||||
const [total] = await db.execute<{ n: number }>(
|
||||
sql`SELECT count(*)::int AS n FROM messages WHERE match_id = ${matchId}`,
|
||||
);
|
||||
expect(first.messages.length + second.messages.length).toBe(total!.n);
|
||||
});
|
||||
|
||||
it('will not page into a conversation the cursor does not belong to', async () => {
|
||||
// A cursor is a message id. One lifted from another thread must not act as
|
||||
// a window into it — the anchor subquery is scoped to the match.
|
||||
const other = await callerFor(clientSession(owner)).message.thread({
|
||||
matchId: closedMatchId,
|
||||
});
|
||||
const foreign = (await callerFor(clientSession(owner)).message.thread({ matchId })).messages[0];
|
||||
|
||||
const page = await callerFor(clientSession(owner)).message.thread({
|
||||
matchId: closedMatchId,
|
||||
cursor: foreign!.id,
|
||||
});
|
||||
|
||||
expect(other.messages.length).toBeGreaterThanOrEqual(0);
|
||||
expect(page.messages).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('message.send', () => {
|
||||
it('moves lastMessageAt so the jobs list can sort on it', async () => {
|
||||
const [before] = await db.execute<{ last_message_at: Date | null }>(
|
||||
sql`SELECT last_message_at FROM matches WHERE id = ${matchId}`,
|
||||
);
|
||||
|
||||
const sent = await callerFor(clientSession(owner)).message.send({
|
||||
matchId,
|
||||
body: 'One more thing.',
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
const [after] = await db.execute<{ last_message_at: Date | null }>(
|
||||
sql`SELECT last_message_at FROM matches WHERE id = ${matchId}`,
|
||||
);
|
||||
|
||||
expect(after!.last_message_at).not.toBeNull();
|
||||
expect(new Date(after!.last_message_at!).getTime()).toBe(sent.createdAt.getTime());
|
||||
if (before!.last_message_at) {
|
||||
expect(new Date(after!.last_message_at!).getTime()).toBeGreaterThanOrEqual(
|
||||
new Date(before!.last_message_at).getTime(),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a message of nothing but whitespace', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(owner)).message.send({ matchId, body: ' ', attachments: [] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a message that is neither words nor files', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(owner)).message.send({ matchId, body: '', attachments: [] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('accepts a photo with no caption', async () => {
|
||||
// The commonest message on this product is a picture of the broken thing.
|
||||
// Requiring words alongside it would make people type "see photo".
|
||||
const sent = await callerFor(clientSession(owner)).message.send({
|
||||
matchId,
|
||||
body: '',
|
||||
attachments: ['https://cdn.example.com/messages/leak.jpg'],
|
||||
});
|
||||
|
||||
expect(sent.body).toBe('');
|
||||
expect(sent.attachments).toEqual(['https://cdn.example.com/messages/leak.jpg']);
|
||||
});
|
||||
|
||||
it('caps attachments at five and requires them to be URLs', async () => {
|
||||
const caller = callerFor(clientSession(owner));
|
||||
const six = Array.from({ length: 6 }, (_, i) => `https://cdn.example.com/m/${i}.jpg`);
|
||||
|
||||
await expect(caller.message.send({ matchId, body: 'here', attachments: six })).rejects.toThrow();
|
||||
await expect(
|
||||
caller.message.send({ matchId, body: 'here', attachments: ['not-a-url'] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a message past the length cap', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(owner)).message.send({
|
||||
matchId,
|
||||
body: 'x'.repeat(4001),
|
||||
attachments: [],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('refuses a stranger', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(stranger)).message.send({
|
||||
matchId,
|
||||
body: 'let me in',
|
||||
attachments: [],
|
||||
}),
|
||||
).rejects.toThrow(/not found/i);
|
||||
});
|
||||
|
||||
it('closes the conversation once the job is history', async () => {
|
||||
// The thread stays readable — it is the record of what was agreed.
|
||||
const thread = await callerFor(clientSession(owner)).message.thread({
|
||||
matchId: closedMatchId,
|
||||
});
|
||||
expect(thread.match.canReply).toBe(false);
|
||||
|
||||
await expect(
|
||||
callerFor(clientSession(owner)).message.send({
|
||||
matchId: closedMatchId,
|
||||
body: 'still there?',
|
||||
attachments: [],
|
||||
}),
|
||||
).rejects.toThrow(/cancelled/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('message.markRead and unreadTotal', () => {
|
||||
it('counts only what the other side sent, and clears it once', async () => {
|
||||
const { matchId: freshMatch } = await insertJobWithMatch(
|
||||
(await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM categories WHERE slug = 'plumber' LIMIT 1`,
|
||||
))[0]!.id,
|
||||
'matched',
|
||||
);
|
||||
|
||||
const proCaller = callerFor(proSession(pro));
|
||||
await proCaller.message.send({ matchId: freshMatch, body: 'On my way.', attachments: [] });
|
||||
await proCaller.message.send({ matchId: freshMatch, body: 'Ten minutes.', attachments: [] });
|
||||
|
||||
// The sender never badges themselves.
|
||||
const proUnread = await proCaller.message.unreadTotal();
|
||||
const proOwnHere = await proCaller.message.markRead({ matchId: freshMatch });
|
||||
expect(proOwnHere.read).toBe(0);
|
||||
|
||||
const ownerCaller = callerFor(clientSession(owner));
|
||||
const before = await ownerCaller.message.unreadTotal();
|
||||
expect(before.unread).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const cleared = await ownerCaller.message.markRead({ matchId: freshMatch });
|
||||
expect(cleared.read).toBe(2);
|
||||
|
||||
// Idempotent: the partial index predicate is also the WHERE clause.
|
||||
const again = await ownerCaller.message.markRead({ matchId: freshMatch });
|
||||
expect(again.read).toBe(0);
|
||||
|
||||
const after = await ownerCaller.message.unreadTotal();
|
||||
expect(after.unread).toBe(before.unread - 2);
|
||||
expect(proUnread.unread).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('refuses to mark a stranger’s thread read', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(stranger)).message.markRead({ matchId }),
|
||||
).rejects.toThrow(/not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('job.matches', () => {
|
||||
it('lists the pros who accepted, with their unread counts', async () => {
|
||||
const rows = await callerFor(clientSession(owner)).job.matches({ jobId });
|
||||
const row = rows.find((r) => r.matchId === matchId);
|
||||
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.proId).toBe(pro);
|
||||
expect(row!.headline).toBe('Chat fixture pro');
|
||||
expect(row!.unreadCount).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// The newest message on this thread is the caption-less photo sent above.
|
||||
// The row has to be able to say "Attachment" rather than preview a blank
|
||||
// line, which is what the count is for.
|
||||
expect(row!.lastMessage).toBe('');
|
||||
expect(row!.lastMessageAttachments).toBe(1);
|
||||
});
|
||||
|
||||
it('is a 404 for someone else’s job', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(stranger)).job.matches({ jobId }),
|
||||
).rejects.toThrow(/not found/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Integration tests for `pro.reviews`, against the live seeded database.
|
||||
*
|
||||
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
||||
*
|
||||
* Two properties carry this procedure, and both are things a careless change
|
||||
* would silently break rather than fail loudly on:
|
||||
*
|
||||
* 1. `published_at` is a moderation gate, not a timestamp. A review is invisible
|
||||
* until both sides have written one, which is what stops a pro retaliating
|
||||
* against a bad review before it is public.
|
||||
* 2. It is a second, public way to read a pro. If the eligibility rule that
|
||||
* hides an unverified, away or banned pro from `publicProfile` is not applied
|
||||
* here too, this becomes the way around it.
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
config({ path: '../../.env' });
|
||||
|
||||
const { closePool, db } = await import('@linkder/db');
|
||||
const { appRouter } = await import('../src/root');
|
||||
const { createInnerContext } = await import('../src/context');
|
||||
const { createCallerFactory } = await import('../src/trpc');
|
||||
|
||||
const createCaller = createCallerFactory(appRouter);
|
||||
const anon = () => createCaller(createInnerContext({ db, session: null }));
|
||||
|
||||
const RUN = Math.random().toString(36).slice(2, 8);
|
||||
|
||||
/** A pro the seed gave a real review history to. */
|
||||
let reviewedPro: string;
|
||||
let awayPro: string;
|
||||
|
||||
/**
|
||||
* This file's own pro, with one published review and one still embargoed.
|
||||
*
|
||||
* Test files run in parallel against one database, so the embargo case gets a
|
||||
* purpose-built pro rather than un-publishing a seeded review that another
|
||||
* file is counting.
|
||||
*/
|
||||
let probePro: string;
|
||||
let probeClient: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const [marc] = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM users WHERE name = 'Marc Oliveras' LIMIT 1`,
|
||||
);
|
||||
reviewedPro = marc!.id;
|
||||
|
||||
const [arnau] = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM users WHERE name = 'Away Arnau' LIMIT 1`,
|
||||
);
|
||||
awayPro = arnau!.id;
|
||||
|
||||
const [pro] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO users (name, email, role)
|
||||
VALUES ('Review Probe', ${`review-probe-${RUN}@example.com`}, 'pro')
|
||||
RETURNING id
|
||||
`);
|
||||
probePro = pro!.id;
|
||||
|
||||
const [client] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO users (name, email, role)
|
||||
VALUES ('Review Probe Client', ${`review-client-${RUN}@example.com`}, 'client')
|
||||
RETURNING id
|
||||
`);
|
||||
probeClient = client!.id;
|
||||
|
||||
await db.execute(sql`
|
||||
INSERT INTO pro_profiles (
|
||||
user_id, headline, bio, hourly_rate_cents, base_location, service_radius_m,
|
||||
verification_status, verified_at
|
||||
)
|
||||
VALUES (
|
||||
${probePro}, 'Review probe', 'Exists only for the reviews router tests.', 3000,
|
||||
ST_SetSRID(ST_MakePoint(0.6, 0.6), 4326)::geography, 15000, 'verified', now()
|
||||
)
|
||||
`);
|
||||
|
||||
// Reviews hang off a booking, so the whole chain has to exist for one to.
|
||||
const [category] = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM categories ORDER BY position LIMIT 1`,
|
||||
);
|
||||
|
||||
for (const [i, publishedAt] of [
|
||||
sql`now() - interval '1 day'`,
|
||||
// Written, but still embargoed — must never appear.
|
||||
sql`NULL`,
|
||||
].entries()) {
|
||||
const [job] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO jobs (client_id, category_id, title, description, urgency, location, address_text, status)
|
||||
VALUES (
|
||||
${probeClient}, ${category!.id}, ${`Probe job ${i}`}, 'Probe job for the reviews tests.',
|
||||
'flexible', ST_SetSRID(ST_MakePoint(0.6, 0.6), 4326)::geography, 'Nowhere', 'completed'
|
||||
)
|
||||
RETURNING id
|
||||
`);
|
||||
const [request] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO requests (job_id, pro_id, status, expires_at, responded_at)
|
||||
VALUES (${job!.id}, ${probePro}, 'accepted', now() - interval '10 days', now() - interval '11 days')
|
||||
RETURNING id
|
||||
`);
|
||||
const [match] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO matches (request_id, job_id, pro_id, client_id)
|
||||
VALUES (${request!.id}, ${job!.id}, ${probePro}, ${probeClient})
|
||||
RETURNING id
|
||||
`);
|
||||
const [quote] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO quotes (match_id, kind, amount_cents, scope, status, valid_until)
|
||||
VALUES (${match!.id}, 'fixed', 10000, 'Probe scope', 'accepted', now() - interval '5 days')
|
||||
RETURNING id
|
||||
`);
|
||||
const [booking] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO bookings (match_id, quote_id, scheduled_start, scheduled_end, status)
|
||||
VALUES (
|
||||
${match!.id}, ${quote!.id}, now() - interval '4 days', now() - interval '4 days' + interval '2 hours',
|
||||
'completed'
|
||||
)
|
||||
RETURNING id
|
||||
`);
|
||||
await db.execute(sql`
|
||||
INSERT INTO reviews (booking_id, author_id, subject_id, rating, body, published_at)
|
||||
VALUES (
|
||||
${booking!.id}, ${probeClient}, ${probePro}, ${i === 0 ? 5 : 1},
|
||||
${i === 0 ? 'Published probe review.' : 'Embargoed probe review.'}, ${publishedAt}
|
||||
)
|
||||
`);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cascades take the profile, jobs, matches, bookings and reviews with them.
|
||||
await db.execute(sql`DELETE FROM users WHERE id IN (${probePro}, ${probeClient})`);
|
||||
await closePool();
|
||||
});
|
||||
|
||||
describe('pro.reviews', () => {
|
||||
it('is readable without a session — reviews are what a customer reads before hiring', async () => {
|
||||
const { reviews } = await anon().pro.reviews({ proId: reviewedPro });
|
||||
expect(reviews.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns newest first', async () => {
|
||||
const { reviews } = await anon().pro.reviews({ proId: reviewedPro });
|
||||
const times = reviews.map((r) => r.publishedAt.getTime());
|
||||
expect(times).toEqual([...times].sort((a, b) => b - a));
|
||||
});
|
||||
|
||||
it('never returns an embargoed review', async () => {
|
||||
const { reviews } = await anon().pro.reviews({ proId: probePro });
|
||||
expect(reviews.map((r) => r.body)).toEqual(['Published probe review.']);
|
||||
});
|
||||
|
||||
it('leaks neither the author nor the booking behind a review', async () => {
|
||||
const { reviews } = await anon().pro.reviews({ proId: probePro });
|
||||
const [review] = reviews;
|
||||
expect(review).toBeDefined();
|
||||
expect(review).not.toHaveProperty('authorId');
|
||||
expect(review).not.toHaveProperty('bookingId');
|
||||
expect(review).not.toHaveProperty('subjectId');
|
||||
// The name is public on a review; the id is a join key into everything else.
|
||||
expect(review!.authorName).toBe('Review Probe Client');
|
||||
});
|
||||
|
||||
it('pages with the cursor, without repeating or skipping a row', async () => {
|
||||
// Walked rather than fetched in one call: the page size is capped, and this
|
||||
// pro has more reviews than the cap. Asserting against the row count rather
|
||||
// than a fixture size keeps it true as the seed grows.
|
||||
const [row] = await db.execute<{ n: number }>(sql`
|
||||
SELECT count(*)::int AS n FROM reviews
|
||||
WHERE subject_id = ${reviewedPro} AND published_at IS NOT NULL AND published_at <= now()
|
||||
`);
|
||||
const n = row!.n;
|
||||
expect(n).toBeGreaterThan(1);
|
||||
|
||||
const seen: string[] = [];
|
||||
let cursor: Date | undefined;
|
||||
for (let page = 0; page < 50; page++) {
|
||||
const result = await anon().pro.reviews({ proId: reviewedPro, limit: 5, cursor });
|
||||
seen.push(...result.reviews.map((r) => r.id));
|
||||
if (!result.nextCursor) break;
|
||||
cursor = result.nextCursor;
|
||||
}
|
||||
|
||||
expect(seen).toHaveLength(n);
|
||||
// No row served twice, and none dropped between pages.
|
||||
expect(new Set(seen).size).toBe(n);
|
||||
});
|
||||
|
||||
it('rejects an over-large page', async () => {
|
||||
await expect(anon().pro.reviews({ proId: reviewedPro, limit: 500 })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('is not a way to read a pro who is off the deck', async () => {
|
||||
// Away Arnau has a seeded review history and is verified — only holiday mode
|
||||
// hides him. If this stopped 404ing, reviews would be the way around
|
||||
// publicProfile rather than a view onto it.
|
||||
await expect(anon().pro.reviews({ proId: awayPro })).rejects.toThrow(/NOT_FOUND|not found/i);
|
||||
await expect(anon().pro.publicProfile({ proId: awayPro })).rejects.toThrow(
|
||||
/NOT_FOUND|not found/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('404s for an unverified pro, exactly as the profile does', async () => {
|
||||
const [ulla] = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM users WHERE name = 'Unverified Ulla' LIMIT 1`,
|
||||
);
|
||||
await expect(anon().pro.reviews({ proId: ulla!.id })).rejects.toThrow(/NOT_FOUND|not found/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Integration tests for the search surface, against the live seeded database.
|
||||
*
|
||||
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
||||
*
|
||||
* `pro.search` is the first procedure in this API that takes an unbounded string
|
||||
* from a caller with no session, and `pro.publicProfile` is now the same. Most
|
||||
* of what follows is about those two facts: the caps hold, and neither one is a
|
||||
* way to read a pro who is not on the deck.
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
config({ path: '../../.env' });
|
||||
|
||||
const { closePool, db } = await import('@linkder/db');
|
||||
const { appRouter } = await import('../src/root');
|
||||
const { createInnerContext } = await import('../src/context');
|
||||
const { createCallerFactory } = await import('../src/trpc');
|
||||
|
||||
const createCaller = createCallerFactory(appRouter);
|
||||
type Session = import('../src/context').Session;
|
||||
|
||||
function callerFor(session: Session | null) {
|
||||
return createCaller(createInnerContext({ db, session }));
|
||||
}
|
||||
|
||||
const clientSession = (userId: string): Session => ({
|
||||
userId,
|
||||
role: 'client',
|
||||
name: 'Test Client',
|
||||
email: 'client@test',
|
||||
phone: null,
|
||||
verificationStatus: null,
|
||||
});
|
||||
|
||||
const RUN = Math.random().toString(36).slice(2, 8);
|
||||
|
||||
let client: string;
|
||||
let verifiedPro: string;
|
||||
let awayPro: string;
|
||||
|
||||
/**
|
||||
* This file's own pro, parked far from the city with no trades.
|
||||
*
|
||||
* Test files run in parallel against one database: banning a seeded pro to prove
|
||||
* a point would delete a card out from under deck.router.test.ts mid-run.
|
||||
*/
|
||||
let bannedPro: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const [aClient] = await db.execute<{ id: string }>(
|
||||
// A SEEDED client, not "the first client". Test files share one database
|
||||
// and several insert their own client probes, so a bare role filter picks
|
||||
// whichever uuid sorts first — which another file may delete in its
|
||||
// afterAll, mid-run. Seeded accounts are on @linkder.test and are stable.
|
||||
sql`SELECT id FROM users
|
||||
WHERE role = 'client' AND email LIKE '%@linkder.test'
|
||||
ORDER BY id LIMIT 1`,
|
||||
);
|
||||
client = aClient!.id;
|
||||
|
||||
const [marc] = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM users WHERE name = 'Marc Oliveras' LIMIT 1`,
|
||||
);
|
||||
verifiedPro = marc!.id;
|
||||
|
||||
const [arnau] = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM users WHERE name = 'Away Arnau' LIMIT 1`,
|
||||
);
|
||||
awayPro = arnau!.id;
|
||||
|
||||
const [created] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO users (name, email, role, banned)
|
||||
VALUES ('Search Probe', ${`search-probe-${RUN}@example.com`}, 'pro', true)
|
||||
RETURNING id
|
||||
`);
|
||||
bannedPro = created!.id;
|
||||
await db.execute(sql`
|
||||
INSERT INTO pro_profiles (
|
||||
user_id, headline, bio, hourly_rate_cents, base_location, service_radius_m,
|
||||
verification_status, verified_at
|
||||
)
|
||||
VALUES (
|
||||
${bannedPro}, 'Search probe', 'Exists only for the search router tests.', 3000,
|
||||
ST_SetSRID(ST_MakePoint(0.5, 0.5), 4326)::geography, 15000, 'verified', now()
|
||||
)
|
||||
`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.execute(sql`DELETE FROM users WHERE id = ${bannedPro}`);
|
||||
await db.execute(
|
||||
sql`UPDATE users SET location = NULL, search_radius_m = 15000 WHERE id = ${client}`,
|
||||
);
|
||||
await closePool();
|
||||
});
|
||||
|
||||
describe('pro.search', () => {
|
||||
it('is reachable without a session — a shop window behind a login is not one', async () => {
|
||||
const result = await callerFor(null).pro.search({ sort: 'best' });
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
expect(result.centredOnYou).toBe(false);
|
||||
});
|
||||
|
||||
it('reports its own total', async () => {
|
||||
const result = await callerFor(null).pro.search({ q: 'plumber', sort: 'best' });
|
||||
expect(result.total).toBe(result.results.length);
|
||||
});
|
||||
|
||||
it('rejects an over-long query and an over-large page', async () => {
|
||||
const caller = callerFor(null);
|
||||
await expect(caller.pro.search({ q: 'x'.repeat(81), sort: 'best' })).rejects.toThrow();
|
||||
await expect(caller.pro.search({ limit: 500, sort: 'best' })).rejects.toThrow();
|
||||
await expect(caller.pro.search({ maxDistanceM: 5_000_000, sort: 'best' })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('never returns an unverified, away or banned pro', async () => {
|
||||
const { results } = await callerFor(null).pro.search({ limit: 50, sort: 'best' });
|
||||
const ids = results.map((p) => p.proId);
|
||||
const names = results.map((p) => p.name);
|
||||
|
||||
expect(names).not.toContain('Unverified Ulla');
|
||||
expect(ids).not.toContain(awayPro);
|
||||
expect(ids).not.toContain(bannedPro);
|
||||
});
|
||||
|
||||
it('centres on the caller when they have saved a location', async () => {
|
||||
// Put this client 20 km north of the centre and give them a tight radius:
|
||||
// the pros next to the city centre must fall out of range.
|
||||
await db.execute(sql`
|
||||
UPDATE users
|
||||
SET location = ST_SetSRID(ST_MakePoint(2.1686, 41.5674), 4326)::geography,
|
||||
search_radius_m = 2000
|
||||
WHERE id = ${client}
|
||||
`);
|
||||
|
||||
const mine = await callerFor(clientSession(client)).pro.search({ sort: 'best' });
|
||||
expect(mine.centredOnYou).toBe(true);
|
||||
expect(mine.results.map((p) => p.proId)).not.toContain(verifiedPro);
|
||||
|
||||
// An explicit filter still wins over the saved radius.
|
||||
const wide = await callerFor(clientSession(client)).pro.search({
|
||||
maxDistanceM: 50_000,
|
||||
sort: 'best',
|
||||
});
|
||||
expect(wide.results.length).toBeGreaterThan(mine.results.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pro.publicProfile', () => {
|
||||
it('is readable without a session', async () => {
|
||||
const profile = await callerFor(null).pro.publicProfile({ proId: verifiedPro });
|
||||
expect(profile.proId).toBe(verifiedPro);
|
||||
// Search needs these two; the old shape returned neither.
|
||||
expect(Array.isArray(profile.categories)).toBe(true);
|
||||
expect(Array.isArray(profile.skills)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a banned pro and a pro on holiday', async () => {
|
||||
// A direct link used to be the one way to read a suspended pro.
|
||||
await expect(callerFor(null).pro.publicProfile({ proId: bannedPro })).rejects.toThrow();
|
||||
await expect(callerFor(null).pro.publicProfile({ proId: awayPro })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('refuses a pro who was never verified', async () => {
|
||||
const [ulla] = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM users WHERE name = 'Unverified Ulla' LIMIT 1`,
|
||||
);
|
||||
await expect(callerFor(null).pro.publicProfile({ proId: ulla!.id })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -67,10 +67,22 @@ const RUN = Math.random().toString(36).slice(2, 8);
|
||||
const PROBE_UA = 'SettingsTestProbe';
|
||||
|
||||
beforeAll(async () => {
|
||||
// Order by id, not created_at: the seed writes clients in one batch and
|
||||
// created_at ties, so created_at ordering is not stable between runs.
|
||||
/*
|
||||
* The SEEDED clients specifically, not "the first two clients".
|
||||
*
|
||||
* Test files share one database and several of them insert their own client
|
||||
* probes; a bare `role = 'client' ORDER BY id LIMIT 2` picks whichever uuids
|
||||
* happen to sort first, so another file's fixture could land here and then be
|
||||
* deleted underneath these tests. Seeded accounts are the ones on
|
||||
* @linkder.test, and they are stable.
|
||||
*
|
||||
* Order by id, not created_at: the seed writes clients in one batch and
|
||||
* created_at ties, so created_at ordering is not stable between runs.
|
||||
*/
|
||||
const rows = await db.execute<{ id: string; email: string }>(
|
||||
sql`SELECT id, email FROM users WHERE role = 'client' ORDER BY id LIMIT 2`,
|
||||
sql`SELECT id, email FROM users
|
||||
WHERE role = 'client' AND email LIKE '%@linkder.test'
|
||||
ORDER BY id LIMIT 2`,
|
||||
);
|
||||
alice = rows[0]!.id;
|
||||
bob = rows[1]!.id;
|
||||
@@ -242,9 +254,11 @@ describe('location and range', () => {
|
||||
|
||||
it('saves a pin, a label and a radius, and reads them back', async () => {
|
||||
const caller = callerFor(clientSession(alice));
|
||||
// `device` rather than `place`: a GPS fix is the one source whose
|
||||
// coordinates the server takes at face value, so this test does not need a
|
||||
// geocoder to be configured.
|
||||
await caller.user.updateLocation({
|
||||
location: { lat: 41.4036, lng: 2.1744 },
|
||||
addressText: 'Gracia, Barcelona',
|
||||
place: { source: 'device', lat: 41.4036, lng: 2.1744, label: 'Gracia, Barcelona' },
|
||||
radiusM: 8_000,
|
||||
});
|
||||
|
||||
@@ -335,10 +349,9 @@ describe('location and range', () => {
|
||||
sql`UPDATE pro_profiles SET verification_status = 'verified' WHERE user_id = ${pro}`,
|
||||
);
|
||||
|
||||
// The radius the row already holds, plus a label. Neither is material.
|
||||
// The radius the row already holds, and nothing else. Not material.
|
||||
const result = await callerFor(proSession(pro)).user.updateLocation({
|
||||
radiusM: 30_000,
|
||||
addressText: 'Somewhere warm',
|
||||
});
|
||||
expect(result.sentForReview).toBe(false);
|
||||
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: { environment: 'node', include: ['test/**/*.test.ts'] },
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['test/**/*.test.ts'],
|
||||
/*
|
||||
* One file at a time.
|
||||
*
|
||||
* These are integration tests against ONE live database, and several files
|
||||
* mutate rows the seed owns — deck.router deletes every request and swipe on
|
||||
* the seeded job in a beforeEach, others create verified pros that land on
|
||||
* that same job's deck. Run in parallel, a file can see another's writes
|
||||
* between its own `before` and `after` reads, so `remaining` counts and
|
||||
* fixture lookups fail perhaps one run in three.
|
||||
*
|
||||
* The alternative is a database per worker, which is the right answer at a
|
||||
* larger scale and a lot of machinery for a suite this size. Until then,
|
||||
* serialising costs a few seconds and removes the whole class.
|
||||
*/
|
||||
fileParallelism: false,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user