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:
serfa
2026-08-21 06:29:59 -04:00
co-authored by Claude Opus 5
parent 8f3509d1dd
commit 974e312534
115 changed files with 19994 additions and 569 deletions
+53 -2
View File
@@ -12,12 +12,59 @@ REDIS_URL=redis://localhost:6389
# generate with: openssl rand -base64 32 # generate with: openssl rand -base64 32
AUTH_SECRET= AUTH_SECRET=
AUTH_URL=http://localhost:3000 AUTH_URL=http://localhost:3000
# Optional. Leave blank and phone OTP is the only route: the "Continue with # Social sign-in. Each provider is optional and independent — leave a pair
# Google" button still renders, and tells the user it is not set up. # blank and phone OTP still works. The buttons render either way and tell the
# user when a provider is not set up, so the screen never changes shape between
# environments. Set BOTH values of a pair or neither: a half-set pair is treated
# as unset (see lib/auth.ts).
#
# Authorised redirect URI: {NEXT_PUBLIC_APP_URL}/api/auth/callback/google # Authorised redirect URI: {NEXT_PUBLIC_APP_URL}/api/auth/callback/google
AUTH_GOOGLE_ID= AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET= AUTH_GOOGLE_SECRET=
# Microsoft Entra ID (Azure AD). Register an app at
# https://entra.microsoft.com > App registrations, add a Web platform with
# redirect URI {NEXT_PUBLIC_APP_URL}/api/auth/callback/microsoft, then create a
# client secret under Certificates & secrets.
#
# TENANT_ID decides WHO may sign in and defaults to `common`:
# common work, school and personal Microsoft accounts
# organizations work and school only
# consumers personal only
# <tenant guid> one organisation only
# For a consumer marketplace `common` is almost always what you want — set the
# app registration's supported account types to match, or sign-in fails at
# Microsoft's end with AADSTS50194 no matter what is set here.
AUTH_MICROSOFT_ID=
AUTH_MICROSOFT_SECRET=
AUTH_MICROSOFT_TENANT_ID=common
# GitHub. Create an OAuth app at
# https://github.com/settings/developers > New OAuth App, with
# Authorization callback URL {NEXT_PUBLIC_APP_URL}/api/auth/callback/github.
#
# GitHub only returns a primary email if the OAuth app requests `user:email`
# AND the account has a verified one; a user whose email is private signs up
# with no address, so never assume `users.email` is reachable mail — gate
# outbound on isSyntheticEmail() from @linkder/shared, same as phone signups.
AUTH_GITHUB_ID=
AUTH_GITHUB_SECRET=
# ---- Geocoding (Mapbox) ----
# Turns a typed address into the coordinates the deck matches on. Without it,
# every job and every pro base falls back to the city centre and is stored with
# location_precision='city' — honest, but unmatched: ST_Distance measures a
# constant and ST_DWithin passes everyone.
#
# The token MUST be entitled for PERMANENT geocoding. We store the coordinates
# indefinitely because they are the matching primitive, and Mapbox's temporary
# endpoint forbids persistence — every request sets permanent=true, so a token
# without that entitlement returns 401/403 rather than silently working.
MAPBOX_TOKEN=
# ISO 3166-1 alpha-2. Bounds results to one country: "Carrer de Sants" matches
# in several places and the wrong continent is a worse answer than none.
MAPBOX_COUNTRY=es
# ---- Phone OTP (Twilio Verify) ---- # ---- Phone OTP (Twilio Verify) ----
TWILIO_ACCOUNT_SID= TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN= TWILIO_AUTH_TOKEN=
@@ -54,3 +101,7 @@ TWILIO_FROM_NUMBER=
# Dev-only fixed login (+34600000000 / code 000000). MUST stay false/unset in production. # Dev-only fixed login (+34600000000 / code 000000). MUST stay false/unset in production.
ALLOW_DEV_LOGIN=false ALLOW_DEV_LOGIN=false
# Bugsink (Sentry-compatible error tracking). Write-only ingest key, safe in the
# client bundle. Leave blank to disable reporting entirely.
NEXT_PUBLIC_SENTRY_DSN=
+138
View File
@@ -301,6 +301,144 @@ buttons are 64px circles, 2px border, `ink-0` fill.
--- ---
### 6.9 Search field
The §6.2 input, 48px, with a 20px `Search` glyph inset 16px from the left in `ink-500`, and a
44px circular clear button on the right that exists only while the field has content.
The label is **visible above the field**, never the placeholder — §6.2 applies here more than
anywhere, because a placeholder disappears at exactly the moment someone needs reminding what
the box searches. The placeholder carries examples of what to type, not the name of the field.
### 6.10 Result row
`radius-card`, `1px ink-200`, `raised` fill, 16px padding, **12px between rows**. A 56px
`radius-md` thumbnail leads; where there is no image, its initial on an `inset` fill — never an
empty grey square. Title in `h4`, one `body-sm ink-600` line, one `meta` line of figures in
`tabular-nums`, trailing chevron in `accent`.
Hover and press move the border to `brand-500`. Rows do not lift, scale or shadow: a list of
twenty is a scanning surface, and twenty things that react is noise.
A row is **not** a deck card. The deck card is a single-decision object with its own gesture;
reusing it in a list costs the list its scroll.
### 6.11 Skeletons
`inset` fill with `animate-pulse`, at the exact height and radius of whatever it stands in for.
Never more than one screenful — three rows is enough to say "loading"; twenty is a lie about
what is coming.
While REFRESHING existing content, keep the old content on screen instead. A list that blanks on
every keystroke reads as "no results", repeatedly.
### 6.12 Empty and no-result
Two different states, two different messages.
- **Empty** — nothing asked yet. Say what this screen can do and give a way in.
- **No result** — something asked, nothing found. Name what was searched and offer the single
most effective filter to relax. Never a bare "No results."
Both use `EmptyState` (§6.3): dashed hairline, centred, `h4` title, `body-sm ink-600` body.
### 6.13 Segmented control
Two or three mutually exclusive views of **the same list** — "Current / Past", not navigation.
Full width, `radius-pill`, `ink-100` track, 4px inset padding. The selected segment is an
`ink-0` pill on `shadow-sm` with `ink-950` text at weight 600; unselected is `ink-600` on the
bare track. Each segment is a 40px-high target inside a 44px row.
A segment may carry a count after its label in `tabular-nums`; a zero count is rendered, not
hidden, because "Past 0" is information and a missing number reads as a loading state.
Use it only where the segments are the same kind of thing and the user is switching lens. Where
the destinations differ in kind, that is the tab bar's job (§6.7), and where one option is a
filter on a list that has other filters too, use chips (§6.4). Never more than three segments —
at four, the labels truncate at 360px and it becomes a worse tab bar.
Implemented as a `radiogroup`: the selected segment carries `aria-checked`, and selection is
never signalled by fill alone (§8) — the weight change carries it too.
### 6.14 Bottom sheet
The only modal this product has. It rises from the bottom edge, because that is where the thumb
already is (§4) and a centred dialog on a 390px screen is just a card with the page greyed out.
`radius-card` on the **top two corners only**, `page` surface, `shadow-lg`, full width, capped
at 85% of the viewport height with its body scrolling inside. A 36×4px `ink-200` grab handle sits
centred at the top — the affordance that says this can be dismissed downward. Behind it, a
`rgb(0 6 36 / .45)` scrim.
Layout is title (`h3`), optional one-line body (`body-sm ink-600`), content, then actions pinned
at the bottom of the sheet: primary full-width, dismissal as a `ghost` beneath it. Actions never
scroll out of reach.
Dismissal is by scrim tap, Escape, or the grab handle — and **all three mean the same thing**.
A sheet whose scrim tap silently confirms is a trap. Anything destructive or irreversible gets an
explicit button; the sheet closing is always "no".
Enter and exit use `motion-slow` with the sheet translating and the scrim fading; under
`prefers-reduced-motion` both simply appear. `role="dialog"` with `aria-modal`, focus moves to the
sheet on open and returns to the trigger on close.
Use it for a decision that needs context the current screen cannot show — picking which job to
send a pro, confirming a cancellation. Not for navigation, and not for anything with more than one
input: a form belongs on a screen.
### 6.15 Pro profile
What a result row (§6.10) opens onto, and the only screen in the app whose job is *reading*
rather than deciding. It is a pushed screen (§6.6): 44px back chevron top-left labelled with
where it came from, never a bare arrow.
Order is fixed, because it is an argument and the parts only work in sequence: lead photo,
name in `h1`, headline, then one `meta tabular-nums` line of figures — rating, distance, rate,
years, jobs done. The verification line sits directly under it in `go-700` with a shield glyph;
it is the one claim this marketplace is actually selling, so it is never further down the page.
Then titled blocks, each skipped entirely when empty rather than rendered as a heading over
nothing: **Trades** and **Specialises in** as `Tag` pills (§6.4), **About**, **Their work** as a
horizontally scrolling strip, and **Reviews**.
Reviews are a page, not a history. The heading says so — "Showing the most recent of 47" —
because `ratingCount` in the header counts every rating and the list below it never will, and two
numbers that disagree without explanation read as a bug. Each review is a `radius-card` row: 36px
round avatar or initial, author name, five stars filled to the rating, relative date, then the
body. Stars carry their value in the accessible name (§8); an unrated pro gets the `New` pill and
never `0.0 ★`.
The primary action is pinned to the bottom of the screen, not placed after the reviews — a page
of reviews is exactly the length that buries a button (§9). It opens the send sheet (§6.14)
rather than acting directly, because "which job?" is a question this screen cannot answer.
### 6.16 Address field
An address is the only input in this product that must resolve to something real: distance is
what the deck ranks on, so a typed line that never became a coordinate is not an answer.
The §6.9 search field geometry, with a suggestion list below it and a **precision line** beneath
that. Suggestions are §6.10 result rows at 56px, no thumbnail, title plus one `meta` line, and
the list caps at five — a sixth is a scroll inside a form and nobody reads it.
The precision line is the component's whole reason for existing and is never optional:
| State | Line | Tone |
|---|---|---|
| Resolved to a street address | "Matched to <address>" | `go-600` with a check |
| Resolved to a street/area only | "Approximate — we will match from <area>" | `sun-500` with an alert |
| Nothing resolved | "No address yet — matching from <city>" | `ink-600`, no icon |
Never show only a tick. "We found something" and "we found the right thing" are different
claims, and a field that renders them identically is how a placeholder gets stored as a
location.
A "use my current location" control sits below, and on success **must** fill the text with a
reverse-geocoded label — a button that silently sets an invisible pin gives the user nothing to
check.
---
## 7. Motion ## 7. Motion
| Token | Duration | Easing | Use | | Token | Duration | Easing | Use |
+24 -1
View File
@@ -1,4 +1,5 @@
import { config as loadEnv } from 'dotenv'; import { config as loadEnv } from 'dotenv';
import { withSentryConfig } from '@sentry/nextjs';
import type { NextConfig } from 'next'; import type { NextConfig } from 'next';
// The monorepo keeps one .env at the root; Next only looks in the app directory. // The monorepo keeps one .env at the root; Next only looks in the app directory.
@@ -21,4 +22,26 @@ const config: NextConfig = {
serverExternalPackages: ['postgres'], serverExternalPackages: ['postgres'],
}; };
export default config; /**
* Bugsink speaks the Sentry protocol, so the Sentry build plugin applies — but
* only the parts that make sense for a self-hosted error tracker.
*
* Source maps are uploaded so a minified production stack is readable, and then
* deleted from the build output so they are not served publicly. Everything
* tracing-related stays off: Bugsink does not ingest it.
*/
export default withSentryConfig(config, {
// Bugsink has no organisation/project slugs in the Sentry sense; the DSN
// carries the project. These are only used by the upload step, which is
// skipped entirely without an auth token.
silent: true,
disableLogger: true,
sourcemaps: {
deleteSourcemapsAfterUpload: true,
},
// Do NOT route events through a Next rewrite: the tunnel exists to dodge ad
// blockers against sentry.io, and this DSN is our own host already.
tunnelRoute: undefined,
// The SDK's automatic Vercel Cron instrumentation has nothing to talk to here.
automaticVercelMonitors: false,
});
+4 -2
View File
@@ -14,8 +14,10 @@
"dependencies": { "dependencies": {
"@linkder/api": "workspace:*", "@linkder/api": "workspace:*",
"@linkder/db": "workspace:*", "@linkder/db": "workspace:*",
"@linkder/notify": "workspace:*",
"@linkder/shared": "workspace:*", "@linkder/shared": "workspace:*",
"@linkder/storage": "workspace:*", "@linkder/storage": "workspace:*",
"@sentry/nextjs": "^10.70.0",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"@trpc/client": "^11.18.0", "@trpc/client": "^11.18.0",
"@trpc/react-query": "^11.18.0", "@trpc/react-query": "^11.18.0",
@@ -23,14 +25,14 @@
"better-auth": "1.7.1", "better-auth": "1.7.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-orm": "0.38.4",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"motion": "^11.15.0", "motion": "^11.15.0",
"next": "^15.1.4", "next": "^15.1.4",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"superjson": "^2.2.6", "superjson": "^2.2.6",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0"
"drizzle-orm": "0.38.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "3.2.0", "@eslint/eslintrc": "3.2.0",
@@ -0,0 +1,157 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/trpc';
import { Button, Field, FormError, Textarea, useToast } from '@/components/ui';
/**
* Approve, reject, suspend.
*
* The only place in the product where one person's decision makes another
* person visible to customers, so nothing here is a one-tap action: approving
* asks for confirmation, and rejecting refuses to proceed without a reason —
* the server enforces that too, because a rejection the pro cannot act on
* becomes a support ticket instead of a fixed profile.
*/
export function DecisionPanel({
proId,
status,
banned,
}: {
proId: string;
status: string;
banned: boolean;
}) {
const router = useRouter();
const toast = useToast();
const [notes, setNotes] = useState('');
const [reason, setReason] = useState('');
const [error, setError] = useState<string | null>(null);
const onError = (e: { message: string }) => setError(e.message);
const done = (message: string) => {
setError(null);
toast(message);
// The queue, the badge counts and this page all move together.
router.refresh();
};
const decide = api.admin.decide.useMutation({
onSuccess: (r) => done(r.status === 'verified' ? 'Approved — they are live.' : 'Rejected.'),
onError,
});
const suspend = api.admin.suspend.useMutation({
onSuccess: () => done('Suspended. They are off every surface.'),
onError,
});
const unsuspend = api.admin.unsuspend.useMutation({
onSuccess: () => done('Reinstated.'),
onError,
});
const busy = decide.isPending || suspend.isPending || unsuspend.isPending;
const canDecide = status === 'pending';
return (
<div className="rounded-card border border-hairline bg-raised p-5">
<h2 className="text-h4">Decision</h2>
{canDecide ? (
<>
<Field
label="Notes"
hint="Required to reject — the pro reads this and has to be able to act on it."
>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
maxLength={1000}
placeholder="Insurance certificate expired in March."
/>
</Field>
<div className="mt-4 flex flex-col gap-2">
<Button
size="lg"
block
busy={decide.isPending}
disabled={busy}
onClick={() => {
if (!confirm('Approve this pro? They go live to customers immediately.')) return;
decide.mutate({ proId, decision: 'verified', notes: notes || undefined });
}}
>
Approve
</Button>
<Button
variant="ghost"
size="lg"
block
busy={decide.isPending}
disabled={busy || notes.trim().length === 0}
onClick={() => decide.mutate({ proId, decision: 'rejected', notes })}
>
Reject
</Button>
</div>
</>
) : (
<p className="mt-2 text-body-sm text-muted">
{/* The transition graph is the authority, so the UI says the same
thing rather than offering a button the server will refuse. */}
This profile is <strong>{status}</strong>. Only a pending profile can be approved or
rejected; a live pro is taken down with Suspend instead.
</p>
)}
<hr className="my-5 border-hairline" />
{banned ? (
<Button
variant="ghost"
size="md"
block
busy={unsuspend.isPending}
disabled={busy}
onClick={() => unsuspend.mutate({ proId })}
>
Lift suspension
</Button>
) : (
<>
<Field label="Suspension reason">
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={2}
maxLength={1000}
placeholder="Insurance lapsed — off the deck until renewed."
/>
</Field>
<Button
variant="ghost"
size="md"
block
className="mt-3"
busy={suspend.isPending}
disabled={busy || reason.trim().length === 0}
onClick={() => {
if (!confirm('Suspend this pro? They disappear from every surface at once.')) return;
suspend.mutate({ proId, reason });
}}
>
Suspend
</Button>
</>
)}
{error && (
<div className="mt-4">
<FormError>{error}</FormError>
</div>
)}
</div>
);
}
+193
View File
@@ -0,0 +1,193 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { AlertTriangle, ChevronLeft, ExternalLink } from 'lucide-react';
import { getApi } from '@/server/caller';
import { Banner, Tag } from '@/components/ui';
import { formatRelativeTime } from '@/lib/utils';
import { DecisionPanel } from './decision-panel';
export const dynamic = 'force-dynamic';
/**
* One pro, and everything a reviewer needs to decide about them.
*
* The documents are the point. They are private R2 objects with no public URL,
* resolved by `admin.proDetail` into signed links that expire in minutes — so
* this page is readable by the person looking at it and not by anyone they
* forward it to. The object keys never reach the browser.
*/
export default async function AdminProPage({ params }: { params: Promise<{ proId: string }> }) {
const { proId } = await params;
const api = await getApi();
let pro: Awaited<ReturnType<typeof api.admin.proDetail>>;
try {
pro = await api.admin.proDetail({ proId });
} catch {
notFound();
}
const suspended = Boolean(pro.banned);
return (
<>
<Link
href="/admin"
className="-ml-2 mb-4 inline-flex h-11 items-center gap-1 rounded-pill pl-1 pr-3 text-body-sm text-accent"
>
<ChevronLeft className="h-5 w-5" aria-hidden />
Queue
</Link>
<h1 className="text-h1">{pro.name ?? 'Unnamed'}</h1>
<p className="mt-1 text-body-sm text-muted">{pro.profile.headline}</p>
{suspended && (
<Banner tone="error" title="Suspended" className="mt-5">
{pro.profile.suspendedReason ?? 'No reason recorded.'}
{pro.banExpires && ` Expires ${formatRelativeTime(pro.banExpires)}.`}
</Banner>
)}
{pro.missing.length > 0 && (
<Banner tone="warning" title="Documents missing" className="mt-5">
This profile has no {pro.missing.join(' and ')}. Approving it would put an unchecked
tradesperson in front of customers.
</Banner>
)}
<div className="mt-8 grid gap-8 md:grid-cols-[1fr_20rem]">
<div className="min-w-0">
<Section title="Documents">
{pro.documents.length === 0 ? (
<p className="text-body-sm text-muted">Nothing uploaded.</p>
) : (
<ul className="flex flex-col gap-2">
{pro.documents.map((doc) => (
<li
key={doc.id}
className="flex items-center justify-between gap-4 rounded-card border border-hairline bg-raised p-4"
>
<span className="min-w-0">
<span className="block font-semibold capitalize text-strong">{doc.kind}</span>
<span className="mt-0.5 block text-meta text-faint">
{doc.issuer ?? 'No issuer given'}
{doc.expiresAt && ` · expires ${formatRelativeTime(doc.expiresAt)}`}
{` · ${doc.reviewStatus}`}
</span>
</span>
{doc.url ? (
<a
href={doc.url}
target="_blank"
rel="noreferrer"
className="inline-flex shrink-0 items-center gap-1.5 text-body-sm font-semibold text-accent hover:underline"
>
Open
<ExternalLink className="h-4 w-4" aria-hidden />
</a>
) : (
// Storage is not configured, or the object is gone. Say so
// — a missing link must not read as a missing document.
<span className="flex shrink-0 items-center gap-1.5 text-meta text-stop-600">
<AlertTriangle className="h-4 w-4" aria-hidden />
unavailable
</span>
)}
</li>
))}
</ul>
)}
</Section>
<Section title="Profile">
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-body-sm">
<Row label="Status" value={pro.profile.verificationStatus} />
<Row label="Accepting jobs" value={pro.profile.isAcceptingJobs ? 'Yes' : 'No'} />
<Row label="Hourly rate" value={`${(pro.profile.hourlyRateCents / 100).toFixed(0)}`} />
<Row label="Experience" value={`${pro.profile.yearsExperience} years`} />
<Row
label="Service area"
value={`${Math.round(pro.profile.serviceRadiusM / 1000)} km`}
/>
<Row label="Phone" value={pro.phone ?? '—'} />
<Row label="Email" value={pro.email ?? '—'} />
<Row
label="Verified"
value={pro.profile.verifiedAt ? formatRelativeTime(pro.profile.verifiedAt) : 'Never'}
/>
</dl>
<p className="mt-5 whitespace-pre-line text-body-sm text-strong">{pro.profile.bio}</p>
{pro.categories.length > 0 && (
<div className="mt-5 flex flex-wrap gap-2">
{pro.categories.map((c) => (
<Tag key={c}>{c}</Tag>
))}
</div>
)}
</Section>
{pro.photos.length > 0 && (
<Section title="Photos">
<div className="flex flex-wrap gap-3">
{pro.photos.map((url) => (
// eslint-disable-next-line @next/next/no-img-element -- remote pro media, no loader configured
<img
key={url}
src={url}
alt=""
className="h-40 w-32 rounded-md object-cover"
loading="lazy"
/>
))}
</div>
</Section>
)}
{pro.history.length > 0 && (
<Section title="History">
<ul className="flex flex-col gap-2 text-body-sm">
{pro.history.map((entry) => (
<li key={entry.id} className="flex items-baseline justify-between gap-4">
<span className="text-strong">{entry.action}</span>
<span className="shrink-0 text-meta text-faint">
{formatRelativeTime(entry.createdAt)}
</span>
</li>
))}
</ul>
</Section>
)}
</div>
<div className="md:sticky md:top-24 md:self-start">
<DecisionPanel
proId={pro.proId}
status={pro.profile.verificationStatus}
banned={suspended}
/>
</div>
</div>
</>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="mb-8">
<h2 className="mb-3 text-h4">{title}</h2>
{children}
</section>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-meta text-faint">{label}</dt>
<dd className="mt-0.5 text-strong">{value}</dd>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { getApi } from '@/server/caller';
export const metadata = { title: 'Admin' };
export const dynamic = 'force-dynamic';
/**
* The back office.
*
* Deliberately outside the phone. Every other route renders inside the bezel
* that `PhoneFrame` draws in the root layout, because every other route is the
* product — but this is a reviewer reading a passport scan next to an insurance
* certificate, and 390px is the wrong tool for that job.
*
* It escapes with `fixed inset-0` rather than by splitting the app into two
* root layouts. A route group with its own `<html>` would mean moving every
* existing route into a sibling group to match, which is a large change to make
* for one screen, and one that every other page would have to keep working
* around forever.
*
* `notFound()` and not `redirect('/sign-in')` for the same reason
* `adminProcedure` answers NOT_FOUND: a surface that redirects instead of
* 404ing has confirmed it exists.
*/
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const api = await getApi();
let me: Awaited<ReturnType<typeof api.user.me>>;
try {
me = await api.user.me();
} catch {
notFound();
}
if (me.role !== 'admin') notFound();
return (
<div className="fixed inset-0 z-50 overflow-y-auto bg-page">
<header className="sticky top-0 z-10 border-b border-hairline bg-page/95 backdrop-blur-[12px]">
<div className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-6 py-4">
<Link href="/admin" className="font-display text-h4 text-strong">
Linkder admin
</Link>
<span className="text-meta text-faint">
Signed in as {me.name ?? me.email ?? 'admin'}
</span>
</div>
</header>
<main className="mx-auto max-w-5xl px-6 py-8">{children}</main>
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
import Link from 'next/link';
import { AlertTriangle, ChevronRight } from 'lucide-react';
import { getApi } from '@/server/caller';
import { EmptyState } from '@/components/ui';
import { cn, formatRelativeTime } from '@/lib/utils';
export const metadata = { title: 'Review queue' };
export const dynamic = 'force-dynamic';
const STATUSES = ['pending', 'verified', 'rejected', 'suspended', 'draft'] as const;
type Status = (typeof STATUSES)[number];
/**
* The review queue.
*
* The whole reason this exists: `pro.submitForReview` could move a profile to
* `pending` and nothing could move it on, so a tradesperson who finished
* onboarding waited forever unless somebody edited the row by hand.
*
* Oldest first. The pro who has been waiting four days is the one who gives up
* on us, and a newest-first queue is precisely the one that never reaches them.
*/
export default async function AdminQueuePage({
searchParams,
}: {
searchParams: Promise<{ status?: string }>;
}) {
const params = await searchParams;
const status: Status = STATUSES.includes(params.status as Status)
? (params.status as Status)
: 'pending';
const api = await getApi();
const [queue, counts] = await Promise.all([api.admin.queue({ status }), api.admin.counts()]);
return (
<>
<h1 className="text-h1">Verification</h1>
<p className="mt-2 text-body-sm text-muted">
Approving a pro is a statement about their licence and insurance. It is recorded against
your account.
</p>
<nav className="mt-6 flex flex-wrap gap-2" aria-label="Filter by status">
{STATUSES.map((s) => (
<Link
key={s}
href={`/admin?status=${s}`}
aria-current={s === status ? 'page' : undefined}
className={cn(
'inline-flex items-center gap-2 rounded-pill border px-4 py-2 text-body-sm',
'transition-[color,background-color,border-color] duration-[120ms] ease-standard',
s === status
? 'border-brand-500 bg-brand-100 font-semibold text-ink-950'
: 'border-hairline text-strong hover:border-brand-500',
)}
>
<span className="capitalize">{s}</span>
{/* Rendered even at zero: "pending 0" is information, and a missing
number reads as still loading. */}
<span className="tabular-nums text-faint">{counts[s] ?? 0}</span>
</Link>
))}
</nav>
{queue.length === 0 ? (
<EmptyState
className="mt-8"
title={status === 'pending' ? 'Nothing waiting' : `No ${status} pros`}
body={
status === 'pending'
? 'Every pro who has submitted has been dealt with.'
: 'Nobody is in this state right now.'
}
/>
) : (
<ul className="mt-6 flex flex-col gap-3">
{queue.map((pro) => (
<li key={pro.proId}>
<Link
href={`/admin/${pro.proId}`}
className={cn(
'flex items-center justify-between gap-4 rounded-card border border-hairline',
'bg-raised p-4 transition-[border-color] duration-[120ms] ease-standard',
'hover:border-brand-500',
)}
>
<span className="min-w-0">
<span className="block font-display text-h4 text-strong">
{pro.name ?? 'Unnamed'}
</span>
<span className="mt-1 block truncate text-body-sm text-muted">
{pro.categories.join(', ') || 'No trade selected'} · {pro.headline}
</span>
<span className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-faint tabular-nums">
<span>waiting {formatRelativeTime(pro.submittedAt)}</span>
<span aria-hidden>·</span>
<span>{pro.photoCount} photos</span>
{pro.banned && (
<span className="font-semibold text-stop-600">suspended</span>
)}
</span>
{/* The single most useful thing in the list: a profile
missing its insurance certificate can be skipped here
rather than opened, read and closed again. */}
{pro.missing.length > 0 && (
<span className="mt-2 flex items-center gap-1.5 text-meta font-semibold text-sun-600">
<AlertTriangle className="h-3.5 w-3.5" aria-hidden />
missing {pro.missing.join(' and ')}
</span>
)}
</span>
<ChevronRight className="h-5 w-5 shrink-0 text-accent" aria-hidden />
</Link>
</li>
))}
</ul>
)}
</>
);
}
+13 -3
View File
@@ -1,3 +1,4 @@
import * as Sentry from '@sentry/nextjs';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter, createContext } from '@linkder/api'; import { appRouter, createContext } from '@linkder/api';
import { db } from '@linkder/db'; import { db } from '@linkder/db';
@@ -20,11 +21,20 @@ function handler(req: Request) {
resolveSession, resolveSession,
ip: req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? null, ip: req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? null,
}), }),
onError({ error, path }) { onError({ error, path, type }) {
// Client errors are expected; server errors are ours and must be visible. // Client errors are expected; server errors are ours and must be visible.
if (error.code === 'INTERNAL_SERVER_ERROR') { // Reporting BAD_REQUEST or UNAUTHORIZED to Bugsink would bury the real
// failures under a stream of ordinary validation and sign-in noise.
if (error.code !== 'INTERNAL_SERVER_ERROR') return;
console.error(`tRPC ${path ?? '<no path>'} failed:`, error.cause ?? error); console.error(`tRPC ${path ?? '<no path>'} failed:`, error.cause ?? error);
}
Sentry.captureException(error.cause ?? error, {
tags: { trpcPath: path ?? 'unknown', trpcType: type },
// NOT the input: a procedure's input is where phone numbers and OTP
// codes live. The path plus the stack is enough to find the bug.
fingerprint: ['trpc', path ?? 'unknown', error.code],
});
}, },
}); });
} }
+356
View File
@@ -0,0 +1,356 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { api } from '@/lib/trpc';
import { SignedOut } from '@/components/chrome/signed-out';
import { buttonClasses, EmptyState, Segmented } from '@/components/ui';
import { JobRow, JobRowSkeleton, type JobRowData, type Perspective } from '@/components/jobs/job-row';
import { JobDetail } from '@/components/jobs/job-detail';
import { ChatThread } from '@/components/jobs/chat-thread';
import { ReviewSheet } from '@/components/jobs/review-sheet';
import { ReviewPrompt } from '@/components/jobs/review-prompt';
/**
* Where you are inside the tab.
*
* `jobId` on a thread is where Back goes, not where the thread came from: a
* client reaches a conversation through their job and should land back on it,
* while a pro's list row IS the conversation and has no middle screen to return
* to. Storing the destination rather than the history keeps Back from ever
* needing a stack.
*/
export type JobsView =
| { kind: 'list' }
| { kind: 'job'; jobId: string }
| { kind: 'thread'; matchId: string; jobId: string | null };
export type JobsSegment = 'current' | 'past';
export interface JobsState {
view: JobsView;
segment: JobsSegment;
}
export const INITIAL_JOBS_STATE: JobsState = { view: { kind: 'list' }, segment: 'current' };
/**
* The Jobs tab.
*
* Current and past are one list under a segmented control rather than two tabs,
* because they are the same objects at different points in their life — and
* because a job moves between them on its own, without the user doing anything.
* `isActive` comes from the server (ACTIVE_JOB_STATUSES), so the split cannot
* drift from the state machine.
*
* State is owned by the parent for the reason SearchPanel's is: the tab bar
* unmounts panels on switch, and losing your place in a half-read conversation
* because you glanced at Settings is worse than losing a search query.
*/
export function JobsPanel({
state,
onChange,
}: {
state: JobsState;
onChange: (next: JobsState) => void;
}) {
const me = api.user.me.useQuery(undefined, { retry: false });
const go = (view: JobsView) => onChange({ ...state, view });
if (me.isLoading) {
return (
<Shell>
<div className="flex flex-col gap-3">
<JobRowSkeleton />
<JobRowSkeleton />
<JobRowSkeleton />
</div>
</Shell>
);
}
if (me.error || !me.data) {
return (
<SignedOut
title="Sign in to see your jobs"
body="Everything you have posted, and every conversation about it."
/>
);
}
if (state.view.kind === 'thread') {
const { matchId, jobId } = state.view;
return (
<ChatThread
matchId={matchId}
onBack={() => go(jobId ? { kind: 'job', jobId } : { kind: 'list' })}
/>
);
}
if (state.view.kind === 'job') {
const { jobId } = state.view;
return (
<JobDetail
jobId={jobId}
onBack={() => go({ kind: 'list' })}
onOpenThread={(matchId) => go({ kind: 'thread', matchId, jobId })}
/>
);
}
return me.data.role === 'pro' ? (
<ProJobs state={state} onChange={onChange} />
) : (
<ClientJobs state={state} onChange={onChange} />
);
}
/**
* Work that is finished and still owed a review, above the Past list.
*
* The reason the Past segment is worth opening. A rating is the only thing a new
* pro has to trade on, and nobody navigates to a finished job to leave one
* unprompted — so it is asked for where the finished job already is.
*/
function PendingReviews() {
const pending = api.review.pending.useQuery(undefined, { retry: false });
const [open, setOpen] = useState<{
bookingId: string;
subjectName: string;
jobTitle: string;
} | null>(null);
const rows = pending.data ?? [];
if (rows.length === 0) return null;
return (
<>
<ul className="mb-4 flex flex-col gap-2">
{rows.map((r) => (
<li key={r.bookingId}>
<ReviewPrompt
subjectName={r.subjectName}
jobTitle={r.jobTitle}
onOpen={() =>
setOpen({
bookingId: r.bookingId,
subjectName: r.subjectName,
jobTitle: r.jobTitle,
})
}
/>
</li>
))}
</ul>
<ReviewSheet
bookingId={open?.bookingId ?? null}
subjectName={open?.subjectName ?? ''}
jobTitle={open?.jobTitle ?? ''}
open={open !== null}
onClose={() => setOpen(null)}
/>
</>
);
}
function Shell({ children }: { children: React.ReactNode }) {
return <div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4 pt-[3.25rem]">{children}</div>;
}
/* ─────────────────────────────── client ─────────────────────────────── */
function ClientJobs({
state,
onChange,
}: {
state: JobsState;
onChange: (next: JobsState) => void;
}) {
// Polled, not pushed: a job list that goes stale hides the one thing this tab
// exists to surface — that somebody replied. Thirty seconds is cheap here and
// the thread itself refreshes far faster while it is open.
const jobs = api.job.mine.useQuery(undefined, { refetchInterval: 30_000 });
const rows: JobRowData[] = (jobs.data ?? []).map((j) => ({
id: j.id,
title: j.title,
status: j.status,
subtitle: j.categoryName,
createdAt: j.createdAt,
matchCount: j.matchCount,
pendingCount: j.pendingCount,
unreadCount: j.unreadCount,
lastMessageAt: j.lastMessageAt,
nextBookingAt: j.nextBookingAt,
}));
const active = (jobs.data ?? []).filter((j) => j.isActive).map((j) => j.id);
return (
<JobList
perspective="client"
loading={jobs.isLoading}
rows={rows}
activeIds={new Set(active)}
state={state}
onChange={onChange}
onOpen={(jobId) => onChange({ ...state, view: { kind: 'job', jobId } })}
empty={{
current: {
title: 'No jobs on the go',
body: 'Post a job and we will build you a deck of verified pros who cover your area.',
action: (
<Link href="/jobs/new" className={buttonClasses({ variant: 'primary', size: 'md' })}>
Post a job
</Link>
),
},
past: {
title: 'Nothing finished yet',
body: 'Jobs you complete or cancel move here, with the conversation kept as a record.',
},
}}
action={
<Link
href="/jobs/new"
className={buttonClasses({ variant: 'primary', size: 'md', block: true })}
>
Post a job
</Link>
}
/>
);
}
/* ───────────────────────────────── pro ──────────────────────────────── */
function ProJobs({ state, onChange }: { state: JobsState; onChange: (next: JobsState) => void }) {
const jobs = api.job.mineForPro.useQuery(undefined, { refetchInterval: 30_000 });
const byId = new Map((jobs.data ?? []).map((j) => [j.id, j]));
const rows: JobRowData[] = (jobs.data ?? []).map((j) => ({
id: j.id,
title: j.title,
status: j.status,
// The customer, not the trade — a plumber's list of plumbing jobs does not
// need to say "Plumber" eleven times.
subtitle: j.clientName ?? 'Customer',
createdAt: j.createdAt,
unreadCount: j.unreadCount,
lastMessageAt: j.lastMessageAt,
nextBookingAt: j.nextBookingAt,
}));
const active = (jobs.data ?? []).filter((j) => j.isActive).map((j) => j.id);
return (
<JobList
perspective="pro"
loading={jobs.isLoading}
rows={rows}
activeIds={new Set(active)}
state={state}
onChange={onChange}
// A pro's row is one match, so it opens the conversation directly. There
// is no middle screen listing "the pros on this job" — that is them.
onOpen={(jobId) => {
const match = byId.get(jobId);
if (!match) return;
onChange({ ...state, view: { kind: 'thread', matchId: match.matchId, jobId: null } });
}}
empty={{
current: {
title: 'No live jobs',
body: 'Jobs you accept from your inbox appear here, with the customer chat attached.',
},
past: {
title: 'No finished jobs yet',
body: 'Work you complete moves here, and the customer can leave you a review.',
},
}}
/>
);
}
/* ─────────────────────────────── shared ─────────────────────────────── */
interface EmptyCopy {
title: string;
body: string;
action?: React.ReactNode;
}
function JobList({
perspective,
loading,
rows,
activeIds,
state,
onChange,
onOpen,
empty,
action,
}: {
perspective: Perspective;
loading: boolean;
rows: JobRowData[];
activeIds: Set<string>;
state: JobsState;
onChange: (next: JobsState) => void;
onOpen: (jobId: string) => void;
empty: Record<JobsSegment, EmptyCopy>;
action?: React.ReactNode;
}) {
const current = rows.filter((r) => activeIds.has(r.id));
const past = rows.filter((r) => !activeIds.has(r.id));
const shown = state.segment === 'current' ? current : past;
const copy = empty[state.segment];
return (
<Shell>
<h1 className="mb-4 text-h2">Jobs</h1>
{/* Counts are rendered even at zero — a missing number reads as loading. */}
<Segmented
label="Job list view"
value={state.segment}
onChange={(segment) => onChange({ ...state, segment })}
segments={[
{ id: 'current', label: 'Current', count: current.length },
{ id: 'past', label: 'Past', count: past.length },
]}
/>
{/* Past only: a review belongs beside finished work, not beside a job
somebody is still waiting on. */}
{state.segment === 'past' && (
<div className="mt-5">
<PendingReviews />
</div>
)}
{loading ? (
<div className="mt-5 flex flex-col gap-3">
<JobRowSkeleton />
<JobRowSkeleton />
<JobRowSkeleton />
</div>
) : shown.length === 0 ? (
<EmptyState className="mt-5" title={copy.title} body={copy.body} action={copy.action} />
) : (
<>
{action && <div className="mt-5">{action}</div>}
<ul className="mt-3 flex flex-col gap-3">
{shown.map((job) => (
<li key={job.id}>
<JobRow job={job} perspective={perspective} onOpen={onOpen} />
</li>
))}
</ul>
</>
)}
</Shell>
);
}
+61 -35
View File
@@ -1,10 +1,15 @@
'use client'; 'use client';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import type { RouterOutputs } from '@/lib/trpc'; import type { RouterOutputs } from '@/lib/trpc';
import { api } from '@/lib/trpc'; import { api } from '@/lib/trpc';
import { clearPendingHire, readPendingHire } from '@/lib/pending-hire';
import { import {
AddressField,
EMPTY_ADDRESS,
type AddressValue,
Banner,
Button, Button,
Chip, Chip,
Field, Field,
@@ -19,33 +24,67 @@ import {
type Categories = RouterOutputs['job']['categories']; type Categories = RouterOutputs['job']['categories'];
const CITY = {
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874),
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686),
};
const URGENCIES = [ const URGENCIES = [
{ value: 'now', label: 'As soon as possible', hint: 'Pros have 12 hours to respond' }, { value: 'now', label: 'As soon as possible', hint: 'Pros have 12 hours to respond' },
{ value: 'this_week', label: 'This week', hint: '48 hours to respond' }, { value: 'this_week', label: 'This week', hint: '48 hours to respond' },
{ value: 'flexible', label: "I'm flexible", hint: '48 hours to respond' }, { value: 'flexible', label: "I'm flexible", hint: '48 hours to respond' },
] as const; ] as const;
export function NewJobForm({ categories }: { categories: Categories }) { export function NewJobForm({
categories,
sendTo = null,
}: {
categories: Categories;
/** A pro this job is being posted for — see the entry deck's send sheet. */
sendTo?: string | null;
}) {
const router = useRouter(); const router = useRouter();
const [categoryId, setCategoryId] = useState(''); const [categoryId, setCategoryId] = useState('');
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [urgency, setUrgency] = useState<(typeof URGENCIES)[number]['value']>('this_week'); const [urgency, setUrgency] = useState<(typeof URGENCIES)[number]['value']>('this_week');
const [addressText, setAddressText] = useState(''); const [address, setAddress] = useState<AddressValue>(EMPTY_ADDRESS);
const [budgetMin, setBudgetMin] = useState(''); const [budgetMin, setBudgetMin] = useState('');
const [budgetMax, setBudgetMax] = useState(''); const [budgetMax, setBudgetMax] = useState('');
const [location, setLocation] = useState(CITY);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [sendToName, setSendToName] = useState<string | null>(null);
// The name was parked alongside the id when they left the deck. Reading it in
// an effect rather than during render because sessionStorage does not exist
// on the server and this component is prerendered.
useEffect(() => {
if (!sendTo) return;
const pending = readPendingHire();
if (pending?.proId === sendTo) setSendToName(pending.name);
}, [sendTo]);
/**
* Posting for a specific pro sends it to them as well.
*
* Same `deck.swipe` the deck itself calls, so the open-request cap and the
* verification check still apply — this is a shortcut through the deck, not
* around it. A failure here is not fatal: the job exists either way, so it
* lands on the job rather than throwing the whole form away.
*/
const swipe = api.deck.swipe.useMutation();
const create = api.job.create.useMutation({ const create = api.job.create.useMutation({
onSuccess: async (job) => {
if (sendTo) {
try {
await swipe.mutateAsync({ jobId: job.id, proId: sendTo, direction: 'right' });
clearPendingHire();
router.push('/?tab=jobs');
return;
} catch {
// Fall through to the deck: they still have a job, and the deck is
// where they can send it to somebody.
}
}
// Straight into the deck — the whole point is that posting and browsing are // Straight into the deck — the whole point is that posting and browsing are
// one continuous motion, not two separate visits. // one continuous motion, not two separate visits.
onSuccess: (job) => router.push(`/deck/${job.id}`), router.push(`/deck/${job.id}`);
},
onError: (e) => setError(e.message), onError: (e) => setError(e.message),
}); });
@@ -69,13 +108,18 @@ export function NewJobForm({ categories }: { categories: Categories }) {
urgency, urgency,
budgetMinCents: min, budgetMinCents: min,
budgetMaxCents: max, budgetMaxCents: max,
location, place: address.place,
addressText,
}); });
} }
return ( return (
<form onSubmit={submit} className="flex flex-col gap-6"> <form onSubmit={submit} className="flex flex-col gap-6">
{sendTo && (
<Banner tone="info" title={`This one goes to ${sendToName ?? 'the pro you picked'}`}>
As soon as you post it, we send it straight to them. You can send it to more pros
afterwards.
</Banner>
)}
<FieldSet label="Trade"> <FieldSet label="Trade">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{categories.map((c) => ( {categories.map((c) => (
@@ -128,31 +172,13 @@ export function NewJobForm({ categories }: { categories: Categories }) {
</div> </div>
</FieldSet> </FieldSet>
<div className="flex flex-col gap-2"> <AddressField
<Field label="Address" hint="Only shared with a pro once you have booked them."> label="Address"
<Input hint="Only shared with a pro once you have booked them."
value={addressText} value={address}
onChange={(e) => setAddressText(e.target.value)} onChange={setAddress}
required required
minLength={3}
maxLength={255}
/> />
</Field>
<Button
type="button"
variant="ghost"
size="sm"
className="self-start"
onClick={() =>
navigator.geolocation?.getCurrentPosition(
(pos) => setLocation({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
() => setError('We could not get your location, so we will search from the centre.'),
)
}
>
Use my current location
</Button>
</div>
<FieldSet label="Budget (optional)"> <FieldSet label="Budget (optional)">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
+11 -3
View File
@@ -7,7 +7,11 @@ import { NewJobForm } from './form';
export const metadata = { title: 'Post a job' }; export const metadata = { title: 'Post a job' };
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export default async function NewJobPage() { export default async function NewJobPage({
searchParams,
}: {
searchParams: Promise<{ pro?: string }>;
}) {
const api = await getApi(); const api = await getApi();
let me: Awaited<ReturnType<typeof api.user.me>>; let me: Awaited<ReturnType<typeof api.user.me>>;
@@ -18,14 +22,18 @@ export default async function NewJobPage() {
} }
if (me.role === 'pro') redirect('/pro'); if (me.role === 'pro') redirect('/pro');
const categories = await api.job.categories(); const [categories, { pro }] = await Promise.all([api.job.categories(), searchParams]);
// Arrives from the entry deck's "Send a job" sheet: this job is being posted
// FOR someone, and gets sent to them the moment it exists.
const sendTo = /^[0-9a-f-]{36}$/i.test(pro ?? '') ? pro! : null;
return ( return (
<AppShell title="Post a job"> <AppShell title="Post a job">
<ScreenIntro> <ScreenIntro>
Describe it once. We will show you verified pros nearby who can take it on. Describe it once. We will show you verified pros nearby who can take it on.
</ScreenIntro> </ScreenIntro>
<NewJobForm categories={categories} /> <NewJobForm categories={categories} sendTo={sendTo} />
</AppShell> </AppShell>
); );
} }
+14 -92
View File
@@ -1,95 +1,17 @@
import Link from 'next/link';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { ChevronRight, Plus } from 'lucide-react';
import { getApi } from '@/server/caller';
import { AppShell } from '@/components/chrome/app-shell';
import { buttonClasses, EmptyState } from '@/components/ui';
import { cn } from '@/lib/utils';
export const metadata = { title: 'Your jobs' }; /**
export const dynamic = 'force-dynamic'; * Jobs live in the app shell, not on a page of their own.
*
/** Label plus the tint it carries. §8 — status is never colour alone. */ * This route used to render a second jobs list — its own status pills, its own
const STATUS: Record<string, { label: string; className: string }> = { * row markup, its own chrome — beside the one in the Jobs tab. Two lists of the
open: { label: 'Looking for pros', className: 'border-brand-200 bg-brand-100 text-ink-950' }, * same objects drift within a week, and the tab is the better of the two: it has
matched: { label: 'Pros interested', className: 'border-go-100 bg-go-50 text-go-700' }, * the current/past split and the conversations hanging off each job, which a
booked: { label: 'Booked', className: 'border-go-100 bg-go-50 text-go-700' }, * standalone page with no tab bar cannot reach.
completed: { label: 'Done', className: 'border-hairline bg-inset text-muted' }, *
cancelled: { label: 'Cancelled', className: 'border-stop-100 bg-stop-50 text-stop-600' }, * Kept as a redirect rather than deleted because `/jobs` is the default `next`
}; * after sign-in and is linked from elsewhere in the app.
*/
export default async function JobsPage() { export default function JobsPage() {
const api = await getApi(); redirect('/?tab=jobs');
let jobs: Awaited<ReturnType<typeof api.job.mine>>;
try {
jobs = await api.job.mine();
} catch {
redirect('/sign-in?next=/jobs');
}
return (
<AppShell title="Your jobs">
{jobs.length === 0 ? (
<EmptyState
title="No jobs yet"
body="Post a job and we will build you a deck of verified pros who cover your area."
action={
<Link
href="/jobs/new"
className={cn('mt-2', buttonClasses({ variant: 'primary', size: 'md' }))}
>
Post a job
</Link>
}
/>
) : (
<>
<Link
href="/jobs/new"
className={cn('mb-4', buttonClasses({ variant: 'primary', size: 'md', block: true }))}
>
<Plus className="h-5 w-5" aria-hidden />
Post a job
</Link>
<ul className="flex flex-col gap-3">
{jobs.map((job) => {
const status = STATUS[job.status] ?? {
label: job.status,
className: 'border-hairline bg-inset text-muted',
};
return (
<li key={job.id}>
<Link
href={`/deck/${job.id}`}
className={cn(
'flex items-center justify-between gap-3 rounded-card border border-hairline',
'bg-raised p-4 transition-[border-color] duration-[120ms] ease-standard',
'active:border-brand-500',
)}
>
<span className="min-w-0">
<span className="block font-display text-h4">{job.title}</span>
<span className="mt-2 flex flex-wrap items-center gap-2">
<span
className={cn(
'inline-flex items-center rounded-pill border px-3 py-1 text-meta',
status.className,
)}
>
{status.label}
</span>
<span className="truncate text-body-sm text-muted">{job.addressText}</span>
</span>
</span>
<ChevronRight className="h-5 w-5 shrink-0 text-accent" aria-hidden />
</Link>
</li>
);
})}
</ul>
</>
)}
</AppShell>
);
} }
+16 -2
View File
@@ -1,4 +1,5 @@
import { getApi } from '@/server/caller'; import { getApi } from '@/server/caller';
import type { PhoneTab } from '@/components/chrome/phone-tabs';
import { ShowcaseDeck } from './showcase-deck'; import { ShowcaseDeck } from './showcase-deck';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -15,17 +16,30 @@ export const dynamic = 'force-dynamic';
* PhoneFrame) — this is a mobile product, and the illustration exists only so * PhoneFrame) — this is a mobile product, and the illustration exists only so
* the desktop visitor understands that. * the desktop visitor understands that.
*/ */
export default async function Home() { /** The tabs a link is allowed to open on. Anything else falls back to the deck. */
const DEEP_LINKABLE = ['swipe', 'search', 'jobs', 'profile', 'settings'] as const;
function tabFrom(value: string | undefined): PhoneTab {
return DEEP_LINKABLE.includes(value as PhoneTab) ? (value as PhoneTab) : 'swipe';
}
export default async function Home({
searchParams,
}: {
searchParams: Promise<{ tab?: string }>;
}) {
const api = await getApi(); const api = await getApi();
const [{ cards }, categories] = await Promise.all([ const [{ cards }, categories, { tab }] = await Promise.all([
api.deck.showcase(), api.deck.showcase(),
api.job.categories(), api.job.categories(),
searchParams,
]); ]);
return ( return (
<ShowcaseDeck <ShowcaseDeck
categories={categories.map((c) => ({ id: c.id, name: c.name }))} categories={categories.map((c) => ({ id: c.id, name: c.name }))}
initialCards={cards} initialCards={cards}
initialTab={tabFrom(tab)}
/> />
); );
} }
+22 -21
View File
@@ -13,6 +13,9 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { uploadFile } from '@/lib/upload'; import { uploadFile } from '@/lib/upload';
import { import {
AddressField,
EMPTY_ADDRESS,
type AddressValue,
Button, Button,
Chip, Chip,
Field, Field,
@@ -28,11 +31,6 @@ import {
type Categories = RouterOutputs['job']['categories']; type Categories = RouterOutputs['job']['categories'];
type Profile = RouterOutputs['pro']['me']; type Profile = RouterOutputs['pro']['me'];
const CITY = {
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874),
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686),
};
const STEPS = ['Trade', 'About you', 'Photos', 'Documents'] as const; const STEPS = ['Trade', 'About you', 'Photos', 'Documents'] as const;
export function OnboardingWizard({ export function OnboardingWizard({
@@ -60,7 +58,16 @@ export function OnboardingWizard({
const [radiusKm, setRadiusKm] = useState( const [radiusKm, setRadiusKm] = useState(
(initialProfile?.serviceRadiusM ?? DEFAULT_SERVICE_RADIUS_M) / 1000, (initialProfile?.serviceRadiusM ?? DEFAULT_SERVICE_RADIUS_M) / 1000,
); );
const [location, setLocation] = useState(initialProfile?.baseLocation ?? CITY); /*
* Starts empty even for a returning pro.
*
* The profile stores a POINT, not the text that produced it, and seeding the
* box with a label we cannot prove still matches that point is how the two
* drift apart. An empty box says plainly that saving this step re-picks the
* base — and `upsertProfile` only sends it back for review if the resolved
* point actually moved, so re-picking the same address costs nothing.
*/
const [address, setAddress] = useState<AddressValue>(EMPTY_ADDRESS);
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const utils = api.useUtils(); const utils = api.useUtils();
@@ -90,7 +97,7 @@ export function OnboardingWizard({
hourlyRateCents: rateCents, hourlyRateCents: rateCents,
yearsExperience: Number(yearsExperience) || 0, yearsExperience: Number(yearsExperience) || 0,
categoryIds, categoryIds,
location, place: address.place,
serviceRadiusM: Math.round(radiusKm * 1000), serviceRadiusM: Math.round(radiusKm * 1000),
}); });
await utils.pro.me.invalidate(); await utils.pro.me.invalidate();
@@ -195,6 +202,14 @@ export function OnboardingWizard({
</Field> </Field>
</div> </div>
<AddressField
label="Your base address"
hint="Where you set off from. Customers never see it — it only decides which jobs reach you."
value={address}
onChange={setAddress}
required
/>
<Field <Field
label={`How far will you travel? ${radiusKm} km`} label={`How far will you travel? ${radiusKm} km`}
hint="You will only be shown jobs inside this radius." hint="You will only be shown jobs inside this radius."
@@ -209,20 +224,6 @@ export function OnboardingWizard({
/> />
</Field> </Field>
<Button
type="button"
variant="ghost"
size="sm"
className="self-start"
onClick={() =>
navigator.geolocation?.getCurrentPosition(
(pos) => setLocation({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
() => setError('We could not get your location. The city centre will be used.'),
)
}
>
Use my current location as my base
</Button>
<Nav <Nav
onBack={() => setStep(0)} onBack={() => setStep(0)}
+162
View File
@@ -0,0 +1,162 @@
'use client';
import { DEFAULT_SERVICE_RADIUS_M } from '@linkder/shared';
import type { DeckCard } from '@linkder/db';
import { api } from '@/lib/trpc';
import { useDebouncedValue } from '@/lib/use-debounced-value';
import { EmptyState } from '@/components/ui';
import { SearchField } from '@/components/search/search-field';
import { SearchFilters, type SearchFilterState } from '@/components/search/search-filters';
import { ResultRow, ResultRowSkeleton } from '@/components/search/result-row';
import { ProProfilePanel } from '@/components/pro/pro-profile-panel';
import type { Category } from './showcase-deck';
const CITY_NAME = process.env.NEXT_PUBLIC_CITY_NAME ?? 'your city';
export interface SearchState extends SearchFilterState {
q: string;
filtersOpen: boolean;
/**
* The pro whose profile is open on top of the results, if any.
*
* Up here with the query and the filters for the same reason they are: the
* tab bar unmounts this panel on every switch, and a profile that closed
* itself because someone glanced at their jobs would take the search behind
* it with it.
*/
openProId: string | null;
}
export const INITIAL_SEARCH_STATE: SearchState = {
q: '',
categoryId: null,
radiusKm: DEFAULT_SERVICE_RADIUS_M / 1000,
sort: 'best',
filtersOpen: false,
openProId: null,
};
/**
* The Search tab.
*
* The deck answers "who next?"; this answers "who is out there?" — the same
* verified pros, but browsable, filterable and searchable by what they say they
* are good at. Both read the same eligibility rule server-side, so nothing found
* here turns out to be unbookable.
*
* State is owned by the parent, not this component: the tab bar unmounts panels
* on switch, and a query lost every time someone checks their profile is a
* search box nobody trusts.
*/
export function SearchPanel({
categories,
state,
onChange,
onHire,
}: {
categories: Category[];
state: SearchState;
onChange: (next: SearchState) => void;
/** Bubbled to the single SendJobSheet at the phone root. */
onHire: (pro: DeckCard) => void;
}) {
// Per pause, not per keystroke.
const q = useDebouncedValue(state.q, 250);
const search = api.pro.search.useQuery(
{
q: q.trim() || undefined,
categoryId: state.categoryId ?? undefined,
maxDistanceM: state.radiusKm * 1000,
sort: state.sort,
},
{
// Keep the previous list on screen while the next one loads: a list that
// blanks on every keystroke reads as "no results" over and over.
placeholderData: (previous) => previous,
staleTime: 30_000,
},
);
const results = search.data?.results ?? [];
const hasQuery = Boolean(q.trim()) || Boolean(state.categoryId);
const firstLoad = search.isLoading;
/**
* The open profile, taken from the row that was tapped.
*
* Looked up in the current results rather than stored, so the profile always
* describes a pro this search actually returned. If a refetch drops them the
* lookup fails and the list comes back, which is the right answer: they are no
* longer a result.
*/
const openPro = state.openProId
? (results.find((r) => r.proId === state.openProId) ?? null)
: null;
if (openPro) {
return (
<ProProfilePanel
pro={openPro}
onBack={() => onChange({ ...state, openProId: null })}
onHire={onHire}
/>
);
}
return (
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4 pt-[3.25rem]">
<h1 className="mb-4 text-h2">Search</h1>
<div className="flex flex-col gap-3">
<SearchField value={state.q} onChange={(next) => onChange({ ...state, q: next })} />
<SearchFilters
categories={categories}
state={state}
onChange={(next) => onChange({ ...state, ...next })}
expanded={state.filtersOpen}
onToggleExpanded={() => onChange({ ...state, filtersOpen: !state.filtersOpen })}
/>
</div>
<div className="mt-5 flex flex-col gap-3">
{firstLoad ? (
<>
<ResultRowSkeleton />
<ResultRowSkeleton />
<ResultRowSkeleton />
</>
) : results.length === 0 ? (
<EmptyState
title={hasQuery ? 'Nothing matched' : `No pros within ${state.radiusKm} km yet`}
body={
hasQuery
? 'Try a wider distance, a different trade, or fewer words. Searching a trade name works better than a brand name.'
: `We are still signing up pros in ${CITY_NAME}. Widen the distance to see who is out there.`
}
/>
) : (
<>
<p className="text-meta text-muted tabular-nums" aria-live="polite">
{results.length} {results.length === 1 ? 'pro' : 'pros'} within {state.radiusKm} km of{' '}
{search.data?.centredOnYou ? 'you' : CITY_NAME}
{/* Honesty about the ceiling: 50 is a cap, not a count. */}
{results.length === 50 && ' — narrow your search to see the rest'}
</p>
<ul className="flex flex-col gap-3">
{results.map((pro) => (
<li key={pro.proId}>
<ResultRow
pro={pro}
onOpen={(proId) => onChange({ ...state, openProId: proId })}
/>
</li>
))}
</ul>
</>
)}
</div>
</div>
);
}
+111 -21
View File
@@ -1,14 +1,18 @@
'use client'; 'use client';
import { useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import type { DeckCard } from '@linkder/db'; import type { DeckCard } from '@linkder/db';
import { Deck } from '@/components/deck'; import { Deck, type SwipeVerdict } from '@/components/deck';
import { Chip } from '@/components/ui'; import { Chip, ScrollStrip } from '@/components/ui';
import { PhoneTabs, type PhoneTab } from '@/components/chrome/phone-tabs'; import { PhoneTabs, type PhoneTab } from '@/components/chrome/phone-tabs';
import { SettingsPanel } from './settings-panel'; import { SettingsPanel } from './settings-panel';
import { INITIAL_SEARCH_STATE, SearchPanel, type SearchState } from './search-panel';
import { ProfilePanel } from './profile-panel'; import { ProfilePanel } from './profile-panel';
import { INITIAL_JOBS_STATE, JobsPanel, type JobsState } from './jobs-panel';
import { SendJobSheet } from '@/components/hire/send-job-sheet';
import { clearPendingHire, readPendingHire } from '@/lib/pending-hire';
import { api } from '@/lib/trpc'; import { api } from '@/lib/trpc';
export interface Category { export interface Category {
@@ -25,20 +29,39 @@ export interface Category {
* Until a trade is picked the deck shows everyone, so the screen is never empty * Until a trade is picked the deck shows everyone, so the screen is never empty
* and the first swipe costs no taps. * and the first swipe costs no taps.
* *
* `onDecide` deliberately writes NOTHING. A visitor with no session swiping * A right swipe opens SendJobSheet rather than writing anything directly. The
* right must not send a job to a real tradesperson; the card simply leaves. The * card promises "Send this job" and there is no job in context here, so the
* funnel starts at "Post a job", where the authenticated deck (deck.list / * sheet is where that gets decided — sign in, pick one of your open jobs, or
* deck.swipe) takes over. * post one. Nobody is contacted until a job is chosen, which is the property
* the old no-op handler was protecting; it just protected it by doing nothing
* at all, including for the signed-in client who had a job ready to send.
*
* A left swipe still writes nothing. `swipes` rows are job-scoped, so with no
* job there is no tombstone to record — passing here is genuinely local.
*/ */
export function ShowcaseDeck({ export function ShowcaseDeck({
categories, categories,
initialCards, initialCards,
initialTab = 'swipe',
}: { }: {
categories: Category[]; categories: Category[];
initialCards: DeckCard[]; initialCards: DeckCard[];
/**
* Which tab to open on. The app is one screen with no routes inside it, so a
* deep link like `/jobs` — or `?next=/jobs` after sign-in — has nowhere to
* land unless the shell can be told where to start.
*/
initialTab?: PhoneTab;
}) { }) {
const [categoryId, setCategoryId] = useState<string | null>(null); const [categoryId, setCategoryId] = useState<string | null>(null);
const [tab, setTab] = useState<PhoneTab>('swipe'); const [tab, setTab] = useState<PhoneTab>(initialTab);
// Search state lives here, beside the tab state, because the panels unmount on
// every tab switch — owning it inside SearchPanel would throw the query away
// each time someone glanced at another tab.
const [search, setSearch] = useState<SearchState>(INITIAL_SEARCH_STATE);
// Same reason, and it matters more here: losing your place in a half-read
// conversation because you glanced at another tab is worse than losing a query.
const [jobs, setJobs] = useState<JobsState>(INITIAL_JOBS_STATE);
// Filtering happens server-side: a page is 20 cards across 8 trades, so // Filtering happens server-side: a page is 20 cards across 8 trades, so
// filtering an already-fetched page would leave two or three per trade. // filtering an already-fetched page would leave two or three per trade.
@@ -47,7 +70,69 @@ export function ShowcaseDeck({
{ initialData: categoryId ? undefined : { cards: initialCards }, staleTime: 60_000 }, { initialData: categoryId ? undefined : { cards: initialCards }, staleTime: 60_000 },
); );
const cards = data?.cards ?? []; // Errors for an anonymous visitor, which is fine — no session, no badge.
const unread = api.message.unreadTotal.useQuery(undefined, {
retry: false,
refetchInterval: 60_000,
});
// Memoised because `decide` and the resume effect both depend on it: the bare
// `?? []` mints a new array on every render where the query has no data, which
// would rebuild the swipe handler mid-gesture.
const cards = useMemo(() => data?.cards ?? [], [data]);
/**
* The pro a right swipe is asking about, and the promise the Deck is waiting
* on. The Deck holds the card until `resolve` is called, so closing the sheet
* puts the pro back instead of losing them.
*/
const [hiring, setHiring] = useState<DeckCard | null>(null);
const resolve = useRef<((verdict: SwipeVerdict) => void) | null>(null);
const decide = useCallback(
(proId: string, direction: 'left' | 'right'): Promise<SwipeVerdict> => {
// Passing writes nothing without a job — see the note above.
if (direction === 'left') return Promise.resolve('commit');
const card = cards.find((c) => c.proId === proId);
if (!card) return Promise.resolve('commit');
setHiring(card);
return new Promise<SwipeVerdict>((done) => {
resolve.current = done;
});
},
[cards],
);
const onResolved = useCallback((outcome: 'sent' | 'dismissed') => {
setHiring(null);
resolve.current?.(outcome === 'sent' ? 'commit' : 'revert');
resolve.current = null;
}, []);
/**
* Resume after a round trip.
*
* Someone who swiped right while signed out went to sign in; someone with no
* open job went to post one. Both land back here, and the pro they picked is
* waiting in sessionStorage. Reopening the sheet on their behalf is the whole
* reason the intent is stored at all.
*/
useEffect(() => {
const pending = readPendingHire();
if (!pending) return;
clearPendingHire();
const card = cards.find((c) => c.proId === pending.proId);
// Only resumable while they are still on the deck we loaded. If the trade
// filter moved them out of it, silently dropping the intent beats reopening
// a sheet about somebody who is no longer on screen.
if (card) {
setTab('swipe');
setHiring(card);
}
}, [cards]);
const selected = categories.find((c) => c.id === categoryId) ?? null; const selected = categories.find((c) => c.id === categoryId) ?? null;
return ( return (
@@ -56,10 +141,15 @@ export function ShowcaseDeck({
<SettingsPanel /> <SettingsPanel />
) : tab === 'profile' ? ( ) : tab === 'profile' ? (
<ProfilePanel /> <ProfilePanel />
) : tab !== 'swipe' ? ( ) : tab === 'search' ? (
<div className="flex min-h-0 flex-1 items-center justify-center px-8 text-center text-body-sm text-muted"> <SearchPanel
Coming soon. categories={categories}
</div> state={search}
onChange={setSearch}
onHire={setHiring}
/>
) : tab === 'jobs' ? (
<JobsPanel state={jobs} onChange={setJobs} />
) : ( ) : (
<> <>
{/* Trade strip. Above the card, never over the photo — so it cannot steal {/* Trade strip. Above the card, never over the photo — so it cannot steal
@@ -76,11 +166,7 @@ export function ShowcaseDeck({
// is the only affordance saying so — the scrollbar is hidden, and a // is the only affordance saying so — the scrollbar is hidden, and a
// row that simply ends at the bezel reads as the whole list. // row that simply ends at the bezel reads as the whole list.
<div className="relative -mx-4"> <div className="relative -mx-4">
<div <ScrollStrip className="gap-1.5 px-4 pb-1" role="group" aria-label="Filter by trade">
className="flex gap-1.5 overflow-x-auto px-4 pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="group"
aria-label="Filter by trade"
>
{categories.map((c) => ( {categories.map((c) => (
<Chip <Chip
key={c.id} key={c.id}
@@ -91,7 +177,7 @@ export function ShowcaseDeck({
{c.name} {c.name}
</Chip> </Chip>
))} ))}
</div> </ScrollStrip>
<div <div
aria-hidden aria-hidden
className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-page to-transparent" className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-page to-transparent"
@@ -110,7 +196,7 @@ export function ShowcaseDeck({
// instead of resuming at the previous deck's index. // instead of resuming at the previous deck's index.
key={categoryId ?? 'all'} key={categoryId ?? 'all'}
cards={cards} cards={cards}
onDecide={() => {}} onDecide={decide}
/> />
)} )}
</div> </div>
@@ -124,7 +210,11 @@ export function ShowcaseDeck({
</> </>
)} )}
<PhoneTabs active={tab} onChange={setTab} /> <PhoneTabs active={tab} onChange={setTab} badges={{ jobs: unread.data?.unread ?? 0 }} />
{/* Inside the phone frame, not the page — the sheet belongs to this
screen and must not cover the browser chrome around the mock. */}
<SendJobSheet pro={hiring} open={hiring !== null} onResolved={onResolved} />
</div> </div>
); );
} }
+4 -4
View File
@@ -4,7 +4,7 @@ import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { authClient } from '@/lib/auth-client'; import { authClient } from '@/lib/auth-client';
import { Button, Field, FormError, Input } from '@/components/ui'; import { Button, Field, FormError, Input } from '@/components/ui';
import { GoogleButton } from '@/components/auth/google-button'; import { SocialSignIn } from '@/components/auth/social-sign-in';
type Step = 'phone' | 'code'; type Step = 'phone' | 'code';
@@ -122,11 +122,11 @@ export function SignInForm() {
<span className="h-px flex-1 bg-hairline" /> <span className="h-px flex-1 bg-hairline" />
</div> </div>
<GoogleButton callbackURL={next} /> <SocialSignIn callbackURL={next} />
<p className="text-meta text-muted"> <p className="text-meta text-muted">
Signing in with Google creates a separate account from a phone sign-in. If you have used Signing in with Google or Microsoft creates a separate account from a phone sign-in. If
both, contact us and we will link them. you have used more than one, contact us and we will link them.
</p> </p>
</div> </div>
); );
@@ -1,95 +0,0 @@
'use client';
import { useState } from 'react';
import { authClient } from '@/lib/auth-client';
import { Button, useToast } from '@/components/ui';
/**
* "Continue with Google" — the one social route, offered wherever we ask
* someone to sign in.
*
* The button renders whether or not the server has Google credentials. Hiding
* it when the keys are missing would mean the sign-in screen quietly changes
* shape between environments, so a layout that works on a developer's machine
* is one nobody has actually seen in production — and the first person to
* notice would be a user. It is always here; when the server cannot honour it,
* the click says so out loud.
*
* `lib/auth.ts` registers the provider only when both AUTH_GOOGLE_ID and
* AUTH_GOOGLE_SECRET are set, so the unconfigured case comes back as a clean
* 404 PROVIDER_NOT_FOUND rather than a 500 from deep inside the OAuth builder.
* That is what makes "not set up" distinguishable here from "Google is down".
*/
export function GoogleButton({
callbackURL,
size = 'lg',
block = true,
label = 'Continue with Google',
}: {
/** Where to land after Google sends the browser back. */
callbackURL: string;
size?: 'sm' | 'md' | 'lg';
block?: boolean;
label?: string;
}) {
const toast = useToast();
const [busy, setBusy] = useState(false);
async function start() {
setBusy(true);
const { error } = await authClient.signIn.social({ provider: 'google', callbackURL });
// On success better-auth's redirect plugin has already sent the browser to
// Google, so this line is only ever reached on failure — but leave `busy`
// set in the success case rather than flicking the spinner off under a
// navigation that is already in flight.
if (!error) return;
setBusy(false);
if (error.status === 404 || error.code === 'PROVIDER_NOT_FOUND') {
toast('Sign in with your mobile number instead — it takes about the same time.', {
tone: 'warning',
title: 'Google sign-in is not set up yet',
});
return;
}
toast(error.message ?? 'Google did not respond. Try again, or use your mobile number.', {
tone: 'error',
title: 'Could not continue with Google',
});
}
return (
<Button type="button" variant="outline" size={size} block={block} busy={busy} onClick={start}>
{!busy && <GoogleMark />}
{label}
</Button>
);
}
/**
* Google's mark, per their branding terms: the four-colour G, never recoloured
* and never swapped for a monochrome icon-font glyph.
*/
function GoogleMark() {
return (
<svg className="h-5 w-5 shrink-0" viewBox="0 0 18 18" aria-hidden focusable="false">
<path
fill="#4285F4"
d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62Z"
/>
<path
fill="#34A853"
d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.81.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18Z"
/>
<path
fill="#FBBC05"
d="M3.97 10.72a5.4 5.4 0 0 1 0-3.44V4.95H.96a9 9 0 0 0 0 8.1l3.01-2.33Z"
/>
<path
fill="#EA4335"
d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.58C13.46.89 11.43 0 9 0A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58Z"
/>
</svg>
);
}
@@ -0,0 +1,182 @@
'use client';
import { useState } from 'react';
import { authClient } from '@/lib/auth-client';
import { Button, useToast } from '@/components/ui';
export type SocialProvider = 'google' | 'microsoft' | 'github';
/**
* The social routes in, offered wherever we ask someone to sign in.
*
* Buttons render whether or not the server holds credentials for that provider.
* Hiding one when its keys are missing would mean the sign-in screen quietly
* changes shape between environments, so a layout that works on a developer's
* machine is one nobody has actually seen in production — and the first person
* to notice would be a user. They are always here; when the server cannot
* honour a click, the click says so out loud.
*
* `lib/auth.ts` registers each provider only when both of its env vars are set,
* so the unconfigured case comes back as a clean 404 PROVIDER_NOT_FOUND rather
* than a 500 from deep inside the OAuth builder. That is what makes "not set
* up" distinguishable here from "the provider is down".
*/
const PROVIDERS: Record<
SocialProvider,
{ name: string; label: string; mark: () => React.ReactElement }
> = {
google: { name: 'Google', label: 'Continue with Google', mark: GoogleMark },
microsoft: { name: 'Microsoft', label: 'Continue with Microsoft', mark: MicrosoftMark },
github: { name: 'GitHub', label: 'Continue with GitHub', mark: GitHubMark },
};
export function SocialButton({
provider,
callbackURL,
size = 'lg',
block = true,
label,
onBeforeStart,
}: {
provider: SocialProvider;
/** Where to land after the provider sends the browser back. */
callbackURL: string;
size?: 'sm' | 'md' | 'lg';
block?: boolean;
label?: string;
/**
* Runs immediately before the redirect.
*
* The click navigates away, so anything that has to survive the round trip
* has to be written first — a `onClick` alongside this one would be racing a
* navigation already in flight.
*/
onBeforeStart?: () => void;
}) {
const toast = useToast();
const [busy, setBusy] = useState(false);
const config = PROVIDERS[provider];
const Mark = config.mark;
async function start() {
setBusy(true);
onBeforeStart?.();
const { error } = await authClient.signIn.social({ provider, callbackURL });
// On success better-auth's redirect plugin has already sent the browser to
// the provider, so this line is only ever reached on failure — but leave
// `busy` set in the success case rather than flicking the spinner off under
// a navigation that is already in flight.
if (!error) return;
setBusy(false);
const { name } = config;
if (error.status === 404 || error.code === 'PROVIDER_NOT_FOUND') {
toast('Sign in with your mobile number instead — it takes about the same time.', {
tone: 'warning',
title: `${name} sign-in is not set up yet`,
});
return;
}
toast(error.message ?? `${name} did not respond. Try again, or use your mobile number.`, {
tone: 'error',
title: `Could not continue with ${name}`,
});
}
return (
<Button type="button" variant="outline" size={size} block={block} busy={busy} onClick={start}>
{!busy && <Mark />}
{label ?? config.label}
</Button>
);
}
/**
* Every social route, in one place.
*
* Screens compose this rather than the individual buttons, so adding a third
* provider is one edit rather than four — and so the order and spacing cannot
* drift between the sign-in page, the signed-out tabs and the hire sheet.
*/
export function SocialSignIn({
callbackURL,
size = 'lg',
onBeforeStart,
}: {
callbackURL: string;
size?: 'sm' | 'md' | 'lg';
onBeforeStart?: () => void;
}) {
return (
<div className="flex flex-col gap-2">
{(Object.keys(PROVIDERS) as SocialProvider[]).map((provider) => (
<SocialButton
key={provider}
provider={provider}
callbackURL={callbackURL}
size={size}
onBeforeStart={onBeforeStart}
/>
))}
</div>
);
}
/**
* Google's mark, per their branding terms: the four-colour G, never recoloured
* and never swapped for a monochrome icon-font glyph.
*/
function GoogleMark() {
return (
<svg className="h-5 w-5 shrink-0" viewBox="0 0 18 18" aria-hidden focusable="false">
<path
fill="#4285F4"
d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62Z"
/>
<path
fill="#34A853"
d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.81.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18Z"
/>
<path
fill="#FBBC05"
d="M3.97 10.72a5.4 5.4 0 0 1 0-3.44V4.95H.96a9 9 0 0 0 0 8.1l3.01-2.33Z"
/>
<path
fill="#EA4335"
d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.58C13.46.89 11.43 0 9 0A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58Z"
/>
</svg>
);
}
/**
* GitHub's Invertocat, per their logo terms: monochrome only, and it takes the
* button's own ink via `currentColor` so it stays legible in both themes rather
* than being pinned to black on a dark surface.
*/
function GitHubMark() {
return (
<svg className="h-5 w-5 shrink-0" viewBox="0 0 16 16" fill="currentColor" aria-hidden focusable="false">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.42 7.42 0 0 1 2-.27c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
</svg>
);
}
/**
* Microsoft's mark, per their brand guidelines: the four squares at their fixed
* colours, never recoloured and never redrawn as a single-colour glyph.
*/
function MicrosoftMark() {
return (
<svg className="h-5 w-5 shrink-0" viewBox="0 0 21 21" aria-hidden focusable="false">
<path fill="#F25022" d="M1 1h9v9H1z" />
<path fill="#7FBA00" d="M11 1h9v9h-9z" />
<path fill="#00A4EF" d="M1 11h9v9H1z" />
<path fill="#FFB900" d="M11 11h9v9h-9z" />
</svg>
);
}
+21 -3
View File
@@ -8,7 +8,7 @@ export type PhoneTab = 'swipe' | 'search' | 'jobs' | 'profile' | 'settings';
const TABS: { id: PhoneTab; label: string; icon: typeof Flame }[] = [ const TABS: { id: PhoneTab; label: string; icon: typeof Flame }[] = [
{ id: 'swipe', label: 'Swipe', icon: Flame }, { id: 'swipe', label: 'Swipe', icon: Flame },
{ id: 'search', label: 'Search', icon: Search }, { id: 'search', label: 'Search', icon: Search },
{ id: 'jobs', label: 'Past jobs', icon: Layers }, { id: 'jobs', label: 'Jobs', icon: Layers },
{ id: 'profile', label: 'Profile', icon: UserRound }, { id: 'profile', label: 'Profile', icon: UserRound },
{ id: 'settings', label: 'Settings', icon: Settings }, { id: 'settings', label: 'Settings', icon: Settings },
]; ];
@@ -21,9 +21,12 @@ const TABS: { id: PhoneTab; label: string; icon: typeof Flame }[] = [
export function PhoneTabs({ export function PhoneTabs({
active, active,
onChange, onChange,
badges,
}: { }: {
active: PhoneTab; active: PhoneTab;
onChange: (tab: PhoneTab) => void; onChange: (tab: PhoneTab) => void;
/** Unread counts per tab. Zero and undefined both render nothing. */
badges?: Partial<Record<PhoneTab, number>>;
}) { }) {
return ( return (
<nav <nav
@@ -32,15 +35,18 @@ export function PhoneTabs({
> >
{TABS.map(({ id, label, icon: Icon }) => { {TABS.map(({ id, label, icon: Icon }) => {
const isActive = id === active; const isActive = id === active;
const badge = badges?.[id] ?? 0;
return ( return (
<button <button
key={id} key={id}
type="button" type="button"
onClick={() => onChange(id)} onClick={() => onChange(id)}
aria-label={label} // The count goes in the accessible name, not just the pixel badge —
// §8, colour and position are never the only carrier of meaning.
aria-label={badge > 0 ? `${label}, ${badge} unread` : label}
aria-current={isActive ? 'page' : undefined} aria-current={isActive ? 'page' : undefined}
className={cn( className={cn(
'flex h-11 w-11 items-center justify-center rounded-pill', 'relative flex h-11 w-11 items-center justify-center rounded-pill',
'transition-colors duration-[120ms] ease-standard', 'transition-colors duration-[120ms] ease-standard',
isActive ? 'text-accent' : 'text-faint hover:text-muted', isActive ? 'text-accent' : 'text-faint hover:text-muted',
)} )}
@@ -51,6 +57,18 @@ export function PhoneTabs({
fill={isActive && id === 'swipe' ? 'currentColor' : 'none'} fill={isActive && id === 'swipe' ? 'currentColor' : 'none'}
aria-hidden aria-hidden
/> />
{badge > 0 && (
<span
aria-hidden
className={cn(
'absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center',
'rounded-pill bg-brand-500 px-1 text-[0.625rem] font-semibold leading-none',
'text-white tabular-nums ring-2 ring-page',
)}
>
{badge > 9 ? '9+' : badge}
</span>
)}
</button> </button>
); );
})} })}
+15 -1
View File
@@ -1,5 +1,6 @@
import Link from 'next/link'; import Link from 'next/link';
import { LogIn } from 'lucide-react'; import { LogIn } from 'lucide-react';
import { SocialSignIn } from '@/components/auth/social-sign-in';
import { buttonClasses } from '@/components/ui'; import { buttonClasses } from '@/components/ui';
/** /**
@@ -7,6 +8,11 @@ import { buttonClasses } from '@/components/ui';
* *
* The tab stays tappable rather than being greyed out — a bar of dead icons on * The tab stays tappable rather than being greyed out — a bar of dead icons on
* first open reads as a broken app, whereas this explains what is behind it. * first open reads as a broken app, whereas this explains what is behind it.
*
* Every route in, in the same order as /sign-in. This screen used to offer only
* the phone, which made the social options look like something the product had
* dropped: somebody who signed up with Google would land here, see one button
* that was not the one they used, and have no way in short of guessing.
*/ */
export function SignedOut({ title, body }: { title: string; body: string }) { export function SignedOut({ title, body }: { title: string; body: string }) {
return ( return (
@@ -16,9 +22,17 @@ export function SignedOut({ title, body }: { title: string; body: string }) {
<h1 className="text-h3">{title}</h1> <h1 className="text-h3">{title}</h1>
<p className="mt-2 text-body-sm text-muted">{body}</p> <p className="mt-2 text-body-sm text-muted">{body}</p>
</div> </div>
<Link href="/sign-in?next=/" className={buttonClasses({ variant: 'primary', size: 'md' })}>
<div className="flex w-full max-w-72 flex-col gap-2">
<Link
href="/sign-in?next=/"
className={buttonClasses({ variant: 'primary', size: 'md', block: true })}
>
Continue with phone Continue with phone
</Link> </Link>
{/* Lands back on the app, not on /sign-in — they never went there. */}
<SocialSignIn callbackURL="/" size="md" />
</div>
</div> </div>
); );
} }
+38 -5
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import { AnimatePresence, motion, useMotionValue, useTransform } from 'motion/react'; import { AnimatePresence, motion, useMotionValue, useTransform } from 'motion/react';
import { Check, MapPin, Star, X } from 'lucide-react'; import { Check, MapPin, Star, X } from 'lucide-react';
import type { DeckCard } from '@linkder/db'; import type { DeckCard } from '@linkder/db';
@@ -9,21 +9,54 @@ import { cn, formatDistance, formatResponseTime } from '@/lib/utils';
/** Horizontal drag past this many pixels commits the swipe. */ /** Horizontal drag past this many pixels commits the swipe. */
const COMMIT_PX = 110; const COMMIT_PX = 110;
/**
* What a handler can say about a swipe it was given.
*
* `revert` puts the card back. It exists because a right swipe does not always
* complete on its own: on the entry deck it opens a sheet asking which job to
* send, and someone who closes that sheet must not lose the pro they just
* picked. Returning nothing means "committed", which is what the per-job deck
* does — there the swipe IS the send.
*/
export type SwipeVerdict = 'commit' | 'revert';
export interface DeckProps { export interface DeckProps {
cards: DeckCard[]; cards: DeckCard[];
onDecide: (proId: string, direction: 'left' | 'right') => void | Promise<void>; onDecide: (
proId: string,
direction: 'left' | 'right',
) => void | Promise<void | SwipeVerdict>;
} }
export function Deck({ cards, onDecide }: DeckProps) { export function Deck({ cards, onDecide }: DeckProps) {
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const remaining = useMemo(() => cards.slice(index), [cards, index]); const remaining = useMemo(() => cards.slice(index), [cards, index]);
// One decision at a time. Without this, a second swipe landing while a sheet
// is open would advance past a card nobody ever saw.
const inFlight = useRef(false);
const decide = useCallback( const decide = useCallback(
(proId: string, direction: 'left' | 'right') => { async (proId: string, direction: 'left' | 'right') => {
if (inFlight.current) return;
inFlight.current = true;
// The card leaves first and comes back only if refused. Waiting for the
// handler before animating would make every swipe feel like it stuck.
setIndex((i) => i + 1); setIndex((i) => i + 1);
void onDecide(proId, direction); try {
const verdict = await onDecide(proId, direction);
if (verdict !== 'revert') return;
// Restore exactly that card rather than stepping the index back — a
// blind decrement would put back whichever card happened to be behind.
const at = cards.findIndex((c) => c.proId === proId);
if (at >= 0) setIndex((i) => Math.min(i, at));
} finally {
inFlight.current = false;
}
}, },
[onDecide], [cards, onDecide],
); );
if (remaining.length === 0) { if (remaining.length === 0) {
@@ -0,0 +1,298 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { AlertTriangle, Check } from 'lucide-react';
import type { DeckCard } from '@linkder/db';
import { api } from '@/lib/trpc';
import { SocialSignIn } from '@/components/auth/social-sign-in';
import { Banner, Button, Sheet } from '@/components/ui';
import { setPendingHire } from '@/lib/pending-hire';
import { cn } from '@/lib/utils';
/**
* "Send this job to Marc" — the thing a right swipe on the entry deck opens.
*
* A request needs a job and the entry deck has none, so this is where that gets
* decided. It is deliberately the ONLY new place a job gets sent: picking a job
* here calls the same `deck.swipe` the per-job deck calls, so the open-request
* cap, the row lock and the verification check all still apply exactly once.
*/
export function SendJobSheet({
pro,
open,
onResolved,
}: {
pro: DeckCard | null;
open: boolean;
/**
* `sent` when the pro now has the job — the card should stay gone.
* `dismissed` when nothing happened and the card should come back.
*/
onResolved: (outcome: 'sent' | 'dismissed') => void;
}) {
const router = useRouter();
const [chosen, setChosen] = useState<string | null>(null);
const me = api.user.me.useQuery(undefined, { retry: false });
const sendable = api.deck.sendable.useQuery(
{ proId: pro?.proId ?? '' },
// Only ask once there is somebody to ask about, and only for a client — a
// pro browsing the deck gets FORBIDDEN from this procedure by design.
{ enabled: open && Boolean(pro) && me.data?.role === 'client', retry: false },
);
const utils = api.useUtils();
const swipe = api.deck.swipe.useMutation({
onSuccess: () => {
void utils.job.mine.invalidate();
void utils.deck.sendable.invalidate();
onResolved('sent');
},
});
if (!pro) return null;
// DeckCard.name is nullable. Every line of copy below names this person, and
// "Send a job to null" is worse than a generic noun.
const name = pro.name ?? 'this pro';
const close = () => {
setChosen(null);
swipe.reset();
onResolved('dismissed');
};
/* ── anonymous ── */
if (!me.isLoading && (me.error || !me.data)) {
return (
<Sheet
open={open}
onClose={close}
title={`Send a job to ${name}`}
body="Sign in first — we need to know whose job it is before we send it."
actions={
<div className="flex flex-col gap-2">
<Button
size="lg"
block
onClick={() => {
// Parked so the deck can resume here once they are back.
setPendingHire({ proId: pro.proId, name });
router.push('/sign-in?next=/');
}}
>
Continue with phone
</Button>
{/*
Every route in, same as /sign-in. A social click sends the browser
away immediately, so the intent has to be parked BEFORE it can
land — hence onBeforeStart rather than an onClick racing a
redirect that is already in flight.
*/}
<SocialSignIn
callbackURL="/"
onBeforeStart={() => setPendingHire({ proId: pro.proId, name })}
/>
</div>
}
>
<p className="text-body-sm text-muted">
Nobody is contacted until you pick a job and send it.
</p>
</Sheet>
);
}
/* ── a pro is browsing ── */
if (me.data?.role === 'pro') {
return (
<Sheet
open={open}
onClose={close}
title="You are signed in as a pro"
body="Hiring needs a customer account. You can still browse who else is on here."
actions={
<Button variant="outline" size="lg" block onClick={close}>
Back to the deck
</Button>
}
/>
);
}
const loading = me.isLoading || sendable.isLoading;
const data = sendable.data;
const jobs = data?.jobs ?? [];
const sendableJobs = jobs.filter((j) => !j.alreadySent && !j.atCap);
/* ── nothing to send ── */
if (!loading && sendableJobs.length === 0) {
const blockedBySent = jobs.some((j) => j.alreadySent);
const blockedByCap = jobs.some((j) => j.atCap);
return (
<Sheet
open={open}
onClose={close}
title={
blockedBySent && jobs.length === 1
? `${name} already has this job`
: `Post a job for ${name}`
}
body={
blockedBySent && jobs.length === 1
? 'They have not replied yet. You will hear as soon as they do.'
: blockedByCap
? `Every job you have open already has ${data?.cap} pros considering it. Wait for a reply, or post a new job.`
: 'You have no jobs open yet. Tell us what needs doing and we will send it straight to them.'
}
actions={
<div className="flex flex-col gap-2">
<Button
size="lg"
block
onClick={() => {
setPendingHire({ proId: pro.proId, name });
router.push(`/jobs/new?pro=${pro.proId}`);
}}
>
Post a job
</Button>
<Button variant="ghost" size="sm" block onClick={close}>
Not now
</Button>
</div>
}
/>
);
}
const target = jobs.find((j) => j.id === chosen);
// One open job needs no picking — the question answers itself.
const only = sendableJobs.length === 1 ? sendableJobs[0] : null;
const selected = target ?? only ?? null;
return (
<Sheet
open={open}
onClose={close}
title={`Send a job to ${name}`}
body={
only
? 'This goes straight to them. They have a limited time to accept.'
: 'Which job is this for?'
}
actions={
<div className="flex flex-col gap-2">
{swipe.error && (
<p role="alert" className="text-meta text-stop-500">
{swipe.error.message}
</p>
)}
<Button
size="lg"
block
busy={swipe.isPending}
disabled={!selected || loading}
onClick={() =>
selected &&
swipe.mutate({ jobId: selected.id, proId: pro.proId, direction: 'right' })
}
>
{selected ? `Send “${truncate(selected.title)}` : 'Pick a job'}
</Button>
<Button variant="ghost" size="sm" block onClick={close}>
Not now
</Button>
</div>
}
>
{loading ? (
<div className="flex flex-col gap-2">
<div className="h-16 animate-pulse rounded-lg bg-inset" />
<div className="h-16 animate-pulse rounded-lg bg-inset" />
</div>
) : (
<>
{/* Shown, not hidden: the client picked this person on purpose and may
know something the trade list does not. But it should be a choice
made with open eyes. */}
{selected && !selected.tradeMatches && (
<Banner tone="warning" title="Different trade" className="mb-3">
{name} is not listed for {selected.categoryName.toLowerCase()} work. You can
still send it they may just turn it down.
</Banner>
)}
{!only && (
<ul
role="radiogroup"
aria-label="Which job"
className="flex flex-col gap-2 pb-1"
>
{jobs.map((job) => {
const blocked = job.alreadySent || job.atCap;
const isSelected = selected?.id === job.id;
return (
<li key={job.id}>
<button
type="button"
role="radio"
aria-checked={isSelected}
disabled={blocked}
onClick={() => setChosen(job.id)}
className={cn(
'flex w-full items-center gap-3 rounded-lg border-[1.5px] p-4 text-left',
'transition-[border-color,background-color] duration-[120ms] ease-standard',
'disabled:pointer-events-none disabled:opacity-45',
isSelected
? 'border-brand-500 bg-brand-50 dark:bg-accent-soft'
: 'border-hairline hover:border-brand-500',
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-display text-h4 text-strong">
{job.title}
</span>
<span className="mt-0.5 block truncate text-meta text-muted">
{job.alreadySent
? `${name} already has this one`
: job.atCap
? `${data?.cap} pros already considering it`
: `${job.categoryName} · ${job.pendingCount} of ${data?.cap} sent`}
</span>
</span>
{isSelected && (
<Check className="h-5 w-5 shrink-0 text-accent" aria-hidden />
)}
{!job.tradeMatches && !blocked && (
<AlertTriangle className="h-4 w-4 shrink-0 text-sun-500" aria-hidden />
)}
</button>
</li>
);
})}
</ul>
)}
{only && (
<div className="rounded-lg border border-hairline p-4">
<span className="block truncate font-display text-h4 text-strong">
{only.title}
</span>
<span className="mt-0.5 block text-meta text-muted">
{only.categoryName} · {only.pendingCount} of {data?.cap} pros sent
</span>
</div>
)}
</>
)}
</Sheet>
);
}
function truncate(value: string, max = 24): string {
return value.length <= max ? value : `${value.slice(0, max - 1)}`;
}
@@ -0,0 +1,239 @@
'use client';
import { useRef, useState } from 'react';
import { FileText, Paperclip, X } from 'lucide-react';
import { api } from '@/lib/trpc';
import { uploadFile } from '@/lib/upload';
import { cn } from '@/lib/utils';
/** The schema's ceiling. Enforced here too so the picker refuses before uploading. */
const MAX_ATTACHMENTS = 5;
const ACCEPT = 'image/jpeg,image/png,image/webp,application/pdf';
export interface PendingAttachment {
/** The public R2 URL, once the bytes are up. */
url: string;
name: string;
isImage: boolean;
}
/**
* Attaching files to a message.
*
* A hook and two dumb components rather than one, because the two halves belong
* in different places: the previews sit above the composer and the paperclip
* sits inside it, beside the text box. One component cannot be in both.
*
* Uploads happen on PICK, not on send: a 12 MB photo takes seconds on a phone,
* and doing it inside the send handler leaves the send button spinning with
* nothing to show for it. By the time a caption is typed the bytes are usually
* already in R2, and `send` is just a row insert with URLs in it.
*/
export function useAttachments(
attachments: PendingAttachment[],
onChange: (next: PendingAttachment[]) => void,
) {
const [busy, setBusy] = useState(0);
const [error, setError] = useState<string | null>(null);
const presign = api.upload.presign.useMutation();
// The upload loop appends across awaits, so it cannot close over the array it
// was rendered with — two files would each overwrite the other's result.
const latest = useRef(attachments);
latest.current = attachments;
async function pick(files: FileList | null) {
if (!files?.length) return;
setError(null);
const room = MAX_ATTACHMENTS - latest.current.length - busy;
const chosen = Array.from(files).slice(0, Math.max(0, room));
if (files.length > chosen.length) {
setError(`You can attach ${MAX_ATTACHMENTS} files to a message.`);
}
if (!chosen.length) return;
setBusy((n) => n + chosen.length);
// Sequential rather than parallel: these are phone photos on a phone
// connection, and five at once is how you get five timeouts.
for (const file of chosen) {
try {
const url = await uploadFile(file, 'message_attachment', (i) => presign.mutateAsync(i));
const next = [
...latest.current,
{ url, name: file.name, isImage: file.type.startsWith('image/') },
];
latest.current = next;
onChange(next);
} catch (err) {
setError(err instanceof Error ? err.message : 'That file would not upload.');
} finally {
setBusy((n) => n - 1);
}
}
}
const remove = (url: string) => {
const next = latest.current.filter((a) => a.url !== url);
latest.current = next;
onChange(next);
};
return {
pick,
remove,
busy,
error,
full: attachments.length + busy >= MAX_ATTACHMENTS,
};
}
export type Attachments = ReturnType<typeof useAttachments>;
/** Sits above the composer. Renders nothing when there is nothing to show. */
export function AttachmentPreviews({
attachments,
state,
}: {
attachments: PendingAttachment[];
state: Attachments;
}) {
if (attachments.length === 0 && state.busy === 0 && !state.error) return null;
return (
<div className="mb-2 flex flex-col gap-2">
{(attachments.length > 0 || state.busy > 0) && (
<ul className="flex flex-wrap gap-2">
{attachments.map((a) => (
<li key={a.url} className="relative">
{a.isImage ? (
// eslint-disable-next-line @next/next/no-img-element -- remote R2 object, no loader configured
<img
src={a.url}
alt={a.name}
className="h-16 w-16 rounded-md border border-hairline object-cover"
/>
) : (
<span className="flex h-16 w-16 flex-col items-center justify-center gap-1 rounded-md border border-hairline bg-inset px-1">
<FileText className="h-5 w-5 text-muted" aria-hidden />
<span className="w-full truncate text-center text-[0.625rem] text-faint">
{a.name}
</span>
</span>
)}
<button
type="button"
onClick={() => state.remove(a.url)}
aria-label={`Remove ${a.name}`}
className="absolute -right-1.5 -top-1.5 flex h-6 w-6 items-center justify-center rounded-pill bg-ink-950 text-white ring-2 ring-page"
>
<X className="h-3.5 w-3.5" aria-hidden />
</button>
</li>
))}
{Array.from({ length: state.busy }, (_, i) => (
<li
key={`pending-${i}`}
className="h-16 w-16 animate-pulse rounded-md bg-inset"
aria-label="Uploading"
/>
))}
</ul>
)}
{state.error && (
<p role="alert" className="text-meta text-stop-500">
{state.error}
</p>
)}
</div>
);
}
/** Sits in the composer row, left of the text box. */
export function AttachmentButton({
state,
disabled,
}: {
state: Attachments;
disabled?: boolean;
}) {
const input = useRef<HTMLInputElement>(null);
return (
<>
<input
ref={input}
type="file"
multiple
accept={ACCEPT}
className="sr-only"
onChange={(e) => {
void state.pick(e.target.files);
// Reset, or picking the same file twice in a row fires no change event.
e.target.value = '';
}}
/>
<button
type="button"
onClick={() => input.current?.click()}
disabled={disabled || state.full}
aria-label={state.full ? `Attachment limit of ${MAX_ATTACHMENTS} reached` : 'Attach a file'}
className={cn(
'flex h-12 w-12 shrink-0 items-center justify-center rounded-pill text-muted',
'transition-colors duration-[120ms] ease-standard hover:text-accent',
'disabled:pointer-events-none disabled:opacity-45',
)}
>
<Paperclip className="h-5 w-5" aria-hidden />
</button>
</>
);
}
/**
* Attachments on a sent message.
*
* Images render inline — a photo of the leak is the message, and making someone
* tap a filename to see it defeats the point. Anything else is a named link,
* because a PDF has no useful thumbnail.
*/
export function SentAttachments({ urls, isMine }: { urls: readonly string[]; isMine: boolean }) {
if (urls.length === 0) return null;
return (
<ul className={cn('mt-1 flex flex-wrap gap-1.5', isMine ? 'justify-end' : 'justify-start')}>
{urls.map((url) => {
const name = decodeURIComponent(url.split('/').pop() ?? 'file');
const isImage = /\.(jpe?g|png|webp|heic)$/i.test(url);
return (
<li key={url}>
<a
href={url}
target="_blank"
rel="noreferrer"
className="block rounded-md border border-hairline focus:outline-none focus:ring-[3px] focus:ring-brand-200"
>
{isImage ? (
// eslint-disable-next-line @next/next/no-img-element -- remote R2 object, no loader configured
<img
src={url}
alt="Attachment"
className="h-40 w-40 rounded-md object-cover"
loading="lazy"
/>
) : (
<span className="flex h-14 items-center gap-2 rounded-md bg-inset px-3 text-body-sm text-strong">
<FileText className="h-4 w-4 shrink-0 text-muted" aria-hidden />
<span className="max-w-40 truncate">{name}</span>
</span>
)}
</a>
</li>
);
})}
</ul>
);
}
@@ -0,0 +1,257 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { ChevronLeft, SendHorizontal } from 'lucide-react';
import { api } from '@/lib/trpc';
import {
AttachmentButton,
AttachmentPreviews,
SentAttachments,
useAttachments,
type PendingAttachment,
} from './attachment-tray';
import { DealStrip } from './deal-strip';
import { cn, formatRelativeTime } from '@/lib/utils';
/**
* The conversation between the two parties on a job.
*
* Full height inside the phone frame rather than a scrolling page: the composer
* has to stay on the thumb, and a chat whose input scrolls away with the history
* is a chat you have to hunt for.
*/
export function ChatThread({ matchId, onBack }: { matchId: string; onBack: () => void }) {
const utils = api.useUtils();
const [draft, setDraft] = useState('');
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const attach = useAttachments(attachments, setAttachments);
const bottom = useRef<HTMLDivElement>(null);
const thread = api.message.thread.useQuery(
{ matchId },
{
// Polled while open. Four seconds is short enough to feel live and cheap
// enough to run without a socket; the real push lands with M4.
refetchInterval: 4_000,
// Keep the messages on screen through a refetch — a chat that blanks every
// four seconds is unusable.
placeholderData: (previous) => previous,
retry: false,
},
);
const markRead = api.message.markRead.useMutation({
onSuccess: ({ read }) => {
if (read === 0) return;
// Only invalidate when something actually changed, or the 4s poll would
// drag the whole jobs list along behind it.
void utils.job.mine.invalidate();
void utils.job.mineForPro.invalidate();
void utils.job.matches.invalidate();
void utils.message.unreadTotal.invalidate();
},
});
const send = api.message.send.useMutation({
onSuccess: () => {
setDraft('');
setAttachments([]);
void utils.message.thread.invalidate({ matchId });
void utils.job.mine.invalidate();
void utils.job.mineForPro.invalidate();
void utils.job.matches.invalidate();
},
});
const messages = thread.data?.messages ?? [];
const match = thread.data?.match;
const newestId = messages[messages.length - 1]?.id;
// Read receipts follow what is actually on screen: mark on open, and again
// whenever a new message arrives while the thread is in front of the reader.
const unreadFromPeer = messages.some((m) => !m.isMine && m.readAt === null);
useEffect(() => {
if (!unreadFromPeer || markRead.isPending) return;
markRead.mutate({ matchId });
// `newestId` is the trigger: re-running on every render would loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [matchId, newestId, unreadFromPeer]);
// Stick to the bottom as messages land. `auto` rather than `smooth` on first
// paint, or the thread visibly scrolls itself on open.
useEffect(() => {
bottom.current?.scrollIntoView({ block: 'end' });
}, [newestId]);
// A photo with no caption is a message. The schema agrees — see sendMessageSchema.
const hasContent = draft.trim().length > 0 || attachments.length > 0;
const canSend = Boolean(match?.canReply) && hasContent && !send.isPending;
const submit = () => {
if (!canSend) return;
send.mutate({ matchId, body: draft, attachments: attachments.map((a) => a.url) });
};
return (
<div className="flex min-h-0 flex-1 flex-col">
{/* Header. Pinned, because "who am I talking to, about what" is the one
thing you must be able to check mid-scroll. */}
<div className="flex shrink-0 items-center gap-1 border-b border-hairline px-2 pb-2 pt-[3.25rem]">
<button
type="button"
onClick={onBack}
aria-label="Back"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-pill text-accent"
>
<ChevronLeft className="h-6 w-6" aria-hidden />
</button>
<span className="min-w-0 flex-1">
<span className="block truncate font-display text-h4 text-strong">
{match?.peer?.name ?? 'Conversation'}
</span>
<span className="block truncate text-meta text-muted">{match?.jobTitle ?? ''}</span>
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{thread.isLoading ? (
<div className="flex flex-col gap-3" aria-busy>
<div className="h-10 w-2/3 animate-pulse rounded-card bg-inset" />
<div className="h-10 w-1/2 animate-pulse self-end rounded-card bg-inset" />
<div className="h-10 w-3/5 animate-pulse rounded-card bg-inset" />
</div>
) : thread.error ? (
<p className="mt-8 text-center text-body-sm text-muted">
This conversation is not available.
</p>
) : messages.length === 0 ? (
<p className="mt-8 text-center text-body-sm text-muted">
No messages yet. Say hello agree what the job involves and when.
</p>
) : (
<ol className="flex flex-col gap-2">
{messages.map((message, i) => (
<Bubble
key={message.id}
body={message.body}
attachments={message.attachments}
createdAt={message.createdAt}
isMine={message.isMine}
// The read marker belongs on the last thing I said, not on
// every bubble — twenty ticks down a thread is noise.
showRead={
message.isMine &&
message.readAt !== null &&
!messages.slice(i + 1).some((m) => m.isMine)
}
/>
))}
</ol>
)}
<div ref={bottom} />
</div>
{/* The deal, above the composer: quoting and booking happen in the
conversation they are being discussed in, not on a screen of their own. */}
{match && (
<DealStrip
matchId={matchId}
// The peer's role, inverted — if I am talking to a pro, I am the client.
isPro={match.peer?.role !== 'pro'}
canAct={match.canReply}
/>
)}
{match && !match.canReply ? (
<p className="shrink-0 border-t border-hairline px-4 py-4 text-center text-body-sm text-muted">
{match.jobStatus === 'cancelled'
? 'This job was cancelled. The conversation is closed.'
: 'This job is finished. The conversation is closed.'}
</p>
) : (
<div className="shrink-0 border-t border-hairline px-4 pb-2 pt-2">
<AttachmentPreviews attachments={attachments} state={attach} />
<div className="flex items-end gap-1">
<label className="sr-only" htmlFor="chat-composer">
Message
</label>
<AttachmentButton state={attach} disabled={send.isPending} />
<textarea
id="chat-composer"
rows={1}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
// Enter sends, Shift+Enter breaks the line. On a phone the
// on-screen keyboard sends a plain Enter, which is what we want.
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
placeholder="Message"
className={cn(
'max-h-28 min-h-12 flex-1 resize-none rounded-lg border-[1.5px] border-hairline bg-raised',
'px-4 py-3 text-base text-strong placeholder:text-faint',
'focus:border-brand-500 focus:outline-none focus:ring-[3px] focus:ring-brand-200',
)}
/>
<button
type="button"
onClick={submit}
disabled={!canSend}
aria-label="Send"
className={cn(
'flex h-12 w-12 shrink-0 items-center justify-center rounded-pill bg-brand-500 text-white',
'transition-[opacity,background-color] duration-[120ms] ease-standard',
'hover:bg-brand-600 disabled:pointer-events-none disabled:opacity-45',
)}
>
<SendHorizontal className="h-5 w-5" aria-hidden />
</button>
</div>
{send.error && (
<p role="alert" className="mt-2 text-meta text-stop-500">
{send.error.message}
</p>
)}
</div>
)}
</div>
);
}
function Bubble({
body,
attachments,
createdAt,
isMine,
showRead,
}: {
body: string;
attachments: readonly string[];
createdAt: Date;
isMine: boolean;
showRead: boolean;
}) {
return (
<li className={cn('flex flex-col', isMine ? 'items-end' : 'items-start')}>
{/* An attachment with no caption gets no empty bubble above it. */}
{body.length > 0 && (
<div
className={cn(
'max-w-[80%] whitespace-pre-wrap break-words rounded-card px-4 py-2.5 text-body-sm',
isMine ? 'bg-brand-500 text-white' : 'bg-inset text-strong',
)}
>
{body}
</div>
)}
<SentAttachments urls={attachments} isMine={isMine} />
<span className="mt-0.5 px-1 text-meta text-faint tabular-nums">
{formatRelativeTime(createdAt)}
{showRead && ' · Read'}
</span>
</li>
);
}
+381
View File
@@ -0,0 +1,381 @@
'use client';
import { useState } from 'react';
import { CalendarClock, CheckCircle2, FileText } from 'lucide-react';
import { formatCents } from '@linkder/shared';
import { api } from '@/lib/trpc';
import { Banner, Button, Input, Sheet, Textarea } from '@/components/ui';
import { cn, formatWhen } from '@/lib/utils';
/**
* The commercial state of one conversation, above the composer.
*
* A thread is where the deal actually happens, so this is where quoting,
* booking and confirming live rather than on a screen of their own — asking
* someone to leave the conversation to accept the price they are discussing is
* how a funnel loses people.
*
* It renders exactly one thing: the newest live quote, or the current booking,
* or nothing. Two open offers on one thread would be a customer choosing between
* two prices from the same person, and the server refuses to create that
* (quote.create withdraws the previous), so the UI never has to represent it.
*/
export function DealStrip({
matchId,
/** Whose side the viewer is on. The peer's role, inverted. */
isPro,
canAct,
}: {
matchId: string;
isPro: boolean;
/** False once the job is history — the thread stays readable, nothing moves. */
canAct: boolean;
}) {
const utils = api.useUtils();
const [quoting, setQuoting] = useState(false);
const [booking, setBooking] = useState<{ quoteId: string; amountCents: number } | null>(null);
const quotes = api.quote.forMatch.useQuery({ matchId }, { retry: false });
const bookings = api.booking.forMatch.useQuery({ matchId }, { retry: false });
const refresh = () => {
void utils.quote.forMatch.invalidate({ matchId });
void utils.booking.forMatch.invalidate({ matchId });
void utils.message.thread.invalidate({ matchId });
void utils.job.mine.invalidate();
void utils.job.mineForPro.invalidate();
void utils.review.pending.invalidate();
};
const decline = api.quote.decline.useMutation({ onSuccess: refresh });
const start = api.booking.start.useMutation({ onSuccess: refresh });
const markComplete = api.booking.markComplete.useMutation({ onSuccess: refresh });
const confirm = api.booking.confirm.useMutation({ onSuccess: refresh });
// Newest first from the server. The live one is the only one worth showing.
const liveQuote = quotes.data?.find((q) => q.isLive) ?? null;
const activeBooking =
bookings.data?.find((b) => b.status !== 'cancelled' && b.status !== 'completed') ?? null;
if (!canAct) return null;
/* ── a booking exists: the deal is done, this is progress ── */
if (activeBooking) {
return (
<div className="shrink-0 border-t border-hairline px-4 py-3">
<div className="flex items-center gap-2 text-body-sm text-strong">
<CalendarClock className="h-4 w-4 shrink-0 text-accent" aria-hidden />
<span className="min-w-0 flex-1 truncate">
{formatWhen(activeBooking.scheduledStart)}
</span>
<StatusPill status={activeBooking.status} />
</div>
<div className="mt-2 flex gap-2">
{isPro && activeBooking.status === 'scheduled' && (
<Button
variant="outline"
size="sm"
block
busy={start.isPending}
onClick={() => start.mutate({ bookingId: activeBooking.id })}
>
I have started
</Button>
)}
{isPro && activeBooking.status !== 'awaiting_confirmation' && (
<Button
size="sm"
block
busy={markComplete.isPending}
onClick={() => markComplete.mutate({ bookingId: activeBooking.id })}
>
Mark as done
</Button>
)}
{!isPro && activeBooking.status === 'awaiting_confirmation' && (
<Button
size="sm"
block
busy={confirm.isPending}
onClick={() => confirm.mutate({ bookingId: activeBooking.id })}
>
Confirm it is done
</Button>
)}
{!isPro && activeBooking.status !== 'awaiting_confirmation' && (
<p className="py-1 text-meta text-muted">
You will be asked to confirm once they mark it done.
</p>
)}
</div>
</div>
);
}
/* ── a live quote: the client's decision ── */
if (liveQuote) {
return (
<>
<div className="shrink-0 border-t border-hairline bg-inset px-4 py-3">
<div className="flex items-baseline gap-2">
<FileText className="h-4 w-4 shrink-0 self-center text-accent" aria-hidden />
<span className="font-display text-h4 text-strong tabular-nums">
{formatCents(liveQuote.amountCents)}
</span>
<span className="text-meta text-muted">
{liveQuote.kind === 'hourly' ? 'estimate' : 'fixed price'}
</span>
</div>
<p className="mt-1 line-clamp-2 text-body-sm text-muted">{liveQuote.scope}</p>
<div className="mt-2 flex gap-2">
{isPro ? (
<p className="text-meta text-faint">Sent waiting on them.</p>
) : (
<>
<Button
size="sm"
block
onClick={() =>
setBooking({ quoteId: liveQuote.id, amountCents: liveQuote.amountCents })
}
>
Accept and book
</Button>
<Button
variant="ghost"
size="sm"
block
busy={decline.isPending}
onClick={() => decline.mutate({ quoteId: liveQuote.id })}
>
No thanks
</Button>
</>
)}
</div>
</div>
<BookSheet
matchId={matchId}
quote={booking}
onClose={() => setBooking(null)}
onBooked={refresh}
/>
</>
);
}
/* ── nothing yet ── */
if (!isPro) return null;
return (
<>
<div className="shrink-0 border-t border-hairline px-4 py-2">
<Button variant="outline" size="sm" block onClick={() => setQuoting(true)}>
<FileText className="h-4 w-4" aria-hidden />
Send a quote
</Button>
</div>
<QuoteSheet
matchId={matchId}
open={quoting}
onClose={() => setQuoting(false)}
onSent={refresh}
/>
</>
);
}
function StatusPill({ status }: { status: string }) {
const label =
status === 'in_progress'
? 'In progress'
: status === 'awaiting_confirmation'
? 'Waiting on you'
: 'Booked';
return (
<span
className={cn(
'shrink-0 rounded-pill border px-2.5 py-0.5 text-meta',
status === 'awaiting_confirmation'
? 'border-sun-100 bg-sun-50 text-sun-600'
: 'border-go-100 bg-go-50 text-go-700',
)}
>
{label}
</span>
);
}
/** The pro's side: a price and what it covers. */
function QuoteSheet({
matchId,
open,
onClose,
onSent,
}: {
matchId: string;
open: boolean;
onClose: () => void;
onSent: () => void;
}) {
const [amount, setAmount] = useState('');
const [scope, setScope] = useState('');
const create = api.quote.create.useMutation({
onSuccess: () => {
setAmount('');
setScope('');
onSent();
onClose();
},
});
const cents = Math.round(Number(amount) * 100);
const valid = Number.isFinite(cents) && cents >= 500 && scope.trim().length >= 10;
return (
<Sheet
open={open}
onClose={onClose}
title="Send a quote"
body="One price at a time — sending a new one replaces whatever is on the table."
actions={
<div className="flex flex-col gap-2">
{create.error && (
<p role="alert" className="text-meta text-stop-500">
{create.error.message}
</p>
)}
<Button
size="lg"
block
busy={create.isPending}
disabled={!valid}
onClick={() =>
create.mutate({ matchId, kind: 'fixed', amountCents: cents, scope })
}
>
Send it
</Button>
</div>
}
>
<label className="mb-4 flex flex-col gap-2">
<span className="text-body-sm text-strong">Price ()</span>
<Input
value={amount}
inputMode="decimal"
placeholder="120"
onChange={(e) => setAmount(e.target.value.replace(/[^0-9.]/g, ''))}
/>
</label>
<label className="flex flex-col gap-2">
<span className="text-body-sm text-strong">What it covers</span>
<Textarea
rows={4}
value={scope}
maxLength={2000}
onChange={(e) => setScope(e.target.value)}
placeholder="Parts, labour, how long you expect it to take, anything not included."
/>
</label>
<p className="mt-1 text-meta text-faint">
This is what a dispute would be judged against, so be specific.
</p>
</Sheet>
);
}
/** The client's side: pick when. Accepting is what creates the booking. */
function BookSheet({
matchId,
quote,
onClose,
onBooked,
}: {
matchId: string;
quote: { quoteId: string; amountCents: number } | null;
onClose: () => void;
onBooked: () => void;
}) {
const [when, setWhen] = useState('');
const [hours, setHours] = useState('2');
const accept = api.quote.accept.useMutation({
onSuccess: () => {
setWhen('');
onBooked();
onClose();
},
});
if (!quote) return null;
const start = when ? new Date(when) : null;
const valid = start !== null && !Number.isNaN(start.getTime()) && start.getTime() > Date.now();
return (
<Sheet
open={quote !== null}
onClose={onClose}
title="When suits you?"
body={`Booking ${formatCents(quote.amountCents)} of work.`}
actions={
<div className="flex flex-col gap-2">
{accept.error && (
<p role="alert" className="text-meta text-stop-500">
{accept.error.message}
</p>
)}
<Button
size="lg"
block
busy={accept.isPending}
disabled={!valid}
onClick={() =>
start &&
accept.mutate({
matchId,
quoteId: quote.quoteId,
scheduledStart: start,
scheduledEnd: new Date(start.getTime() + Number(hours) * 3_600_000),
})
}
>
Confirm booking
</Button>
<Button variant="ghost" size="sm" block onClick={onClose}>
Not yet
</Button>
</div>
}
>
<label className="mb-4 flex flex-col gap-2">
<span className="text-body-sm text-strong">Date and time</span>
<Input type="datetime-local" value={when} onChange={(e) => setWhen(e.target.value)} />
</label>
<label className="flex flex-col gap-2">
<span className="text-body-sm text-strong">Roughly how long?</span>
<Input
value={hours}
inputMode="numeric"
onChange={(e) => setHours(e.target.value.replace(/\D/g, '') || '1')}
/>
<span className="text-meta text-faint">Hours. A guide for their diary, not a limit.</span>
</label>
<Banner tone="info" title="Booking closes the job" className="mt-4">
<span className="flex items-start gap-1.5">
<CheckCircle2 className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
Any other pros still considering this job will be told it has gone.
</span>
</Banner>
</Sheet>
);
}
+205
View File
@@ -0,0 +1,205 @@
'use client';
import Link from 'next/link';
import { CalendarClock, ChevronLeft, ChevronRight, MessageSquare, Search, Star } from 'lucide-react';
import { PAST_JOB_STATUSES } from '@linkder/shared';
import { api, type RouterOutputs } from '@/lib/trpc';
import { Banner, buttonClasses, EmptyState } from '@/components/ui';
import { cn, formatRelativeTime, formatWhen } from '@/lib/utils';
/**
* One job, and the conversations hanging off it.
*
* The middle screen of the jobs tab, and only the client has one: a job with
* three interested pros is three private threads, and this is where you choose
* which one you are talking to.
*/
export function JobDetail({
jobId,
onBack,
onOpenThread,
}: {
jobId: string;
onBack: () => void;
onOpenThread: (matchId: string) => void;
}) {
const job = api.job.byId.useQuery({ id: jobId }, { retry: false });
const matches = api.job.matches.useQuery({ jobId }, { retry: false, refetchInterval: 20_000 });
const isPast = job.data ? PAST_JOB_STATUSES.includes(job.data.status) : false;
return (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pb-4 pt-[3.25rem]">
<button
type="button"
onClick={onBack}
className="-ml-2 mb-2 flex h-11 items-center gap-1 self-start rounded-pill pl-1 pr-3 text-body-sm text-accent"
>
<ChevronLeft className="h-5 w-5" aria-hidden />
Jobs
</button>
{job.isLoading ? (
<div className="h-24 animate-pulse rounded-card bg-inset" aria-busy />
) : job.error || !job.data ? (
<EmptyState
title="Job not found"
body="It may have been removed, or it was never yours to see."
/>
) : (
<>
<h1 className="text-h2">{job.data.title}</h1>
<p className="mt-1 text-body-sm text-muted">
{job.data.category.name} · posted {formatRelativeTime(job.data.createdAt)}
</p>
{isPast && (
<Banner
tone={job.data.status === 'cancelled' ? 'error' : 'success'}
title={job.data.status === 'cancelled' ? 'Cancelled' : 'Finished'}
className="mt-4"
>
{job.data.status === 'cancelled'
? 'Nobody can reply on this job any more. The conversations stay as a record.'
: 'This job is done. The conversations stay as a record of what was agreed.'}
</Banner>
)}
<p className="mt-4 whitespace-pre-line text-body-sm text-strong">
{job.data.description}
</p>
<h2 className="mb-3 mt-8 text-h4">
{matches.data?.length
? matches.data.length === 1
? '1 pro accepted'
: `${matches.data.length} pros accepted`
: 'Pros'}
</h2>
{matches.isLoading ? (
<div className="flex flex-col gap-3">
<div className="h-[4.5rem] animate-pulse rounded-card bg-inset" aria-hidden />
<div className="h-[4.5rem] animate-pulse rounded-card bg-inset" aria-hidden />
</div>
) : !matches.data?.length ? (
<EmptyState
title={job.data.pendingRequests > 0 ? 'Waiting on replies' : 'Nobody yet'}
body={
job.data.pendingRequests > 0
? `${job.data.pendingRequests} ${
job.data.pendingRequests === 1 ? 'pro has' : 'pros have'
} your job and have not answered yet. We will tell you the moment one does.`
: 'Swipe right on a pro to send them this job. As soon as one accepts, your conversation opens here.'
}
/>
) : (
<ul className="flex flex-col gap-3">
{matches.data.map((m) => (
<li key={m.matchId}>
<MatchRow match={m} onOpen={() => onOpenThread(m.matchId)} />
</li>
))}
</ul>
)}
{/* The way back to the deck for THIS job. It belongs here rather than
on a list: you go looking for more pros from inside the job you are
trying to fill, not from a screen showing all of them. */}
{!isPast && (
<Link
href={`/deck/${jobId}`}
className={cn(
'mt-4',
buttonClasses({ variant: 'outline', size: 'md', block: true }),
)}
>
<Search className="h-4 w-4" aria-hidden />
Find more pros
</Link>
)}
</>
)}
</div>
);
}
type MatchRowData = RouterOutputs['job']['matches'][number];
function MatchRow({ match, onOpen }: { match: MatchRowData; onOpen: () => void }) {
const n = match.lastMessageAttachments;
const preview =
match.lastMessage?.replace(/\s+/g, ' ').trim() ||
(n > 0 ? (n === 1 ? 'Attachment' : `${n} attachments`) : '');
return (
<button
type="button"
onClick={onOpen}
className={cn(
'flex w-full items-center gap-3 rounded-card border border-hairline bg-raised p-4 text-left',
'transition-[border-color] duration-[120ms] ease-standard',
'hover:border-brand-500 active:border-brand-500',
)}
>
{match.photo ? (
// eslint-disable-next-line @next/next/no-img-element -- remote pro media, no loader configured
<img
src={match.photo}
alt=""
className="h-14 w-14 shrink-0 rounded-md object-cover"
loading="lazy"
/>
) : (
<span
aria-hidden
className="flex h-14 w-14 shrink-0 items-center justify-center rounded-md bg-inset font-display text-h4 text-muted"
>
{match.proName?.[0] ?? '?'}
</span>
)}
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-2">
<span className="truncate font-display text-h4 text-strong">{match.proName}</span>
{match.ratingCount > 0 ? (
<span className="flex shrink-0 items-center gap-1 text-meta text-muted tabular-nums">
<Star className="h-3.5 w-3.5 fill-current text-sun-500" aria-hidden />
{match.ratingAvg?.toFixed(1)}
</span>
) : (
<span className="shrink-0 text-meta font-semibold text-accent">New</span>
)}
</span>
{/* The last thing said, or the headline if nothing has been. A row that
says nothing until someone speaks is a row you cannot tell apart. */}
<span className="mt-0.5 block truncate text-body-sm text-muted">
{preview || match.headline}
</span>
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-meta">
{match.unreadCount > 0 ? (
<span className="flex items-center gap-1 font-semibold text-accent">
<MessageSquare className="h-3.5 w-3.5" aria-hidden />
{match.unreadCount} new
</span>
) : match.nextBookingAt ? (
<span className="flex items-center gap-1 text-faint">
<CalendarClock className="h-3.5 w-3.5" aria-hidden />
{formatWhen(match.nextBookingAt)}
</span>
) : (
<span className="text-faint tabular-nums">
{match.lastMessageAt
? formatRelativeTime(match.lastMessageAt)
: `accepted ${formatRelativeTime(match.acceptedAt)}`}
</span>
)}
</span>
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-accent" aria-hidden />
</button>
);
}
+154
View File
@@ -0,0 +1,154 @@
'use client';
import { CalendarClock, ChevronRight, MessageSquare, Users } from 'lucide-react';
import type { JobStatus } from '@linkder/shared';
import { cn, formatRelativeTime, formatWhen } from '@/lib/utils';
export type Perspective = 'client' | 'pro';
/**
* The status of a job, as a word and a tint. §8 — never colour alone.
*
* Two maps, because the same status means different things to the two sides.
* `matched` is "somebody said yes" to a customer and "you said yes" to a pro;
* one shared wording would fit neither, and a pro reading "Pros interested"
* about their own accepted job would reasonably think it was somebody else's.
*/
const STATUS: Record<Perspective, Record<JobStatus, { label: string; className: string }>> = {
client: {
open: { label: 'Looking for pros', className: 'border-brand-200 bg-brand-100 text-ink-950' },
matched: { label: 'Pros interested', className: 'border-go-100 bg-go-50 text-go-700' },
booked: { label: 'Booked', className: 'border-go-100 bg-go-50 text-go-700' },
completed: { label: 'Done', className: 'border-hairline bg-inset text-muted' },
cancelled: { label: 'Cancelled', className: 'border-stop-100 bg-stop-50 text-stop-600' },
},
pro: {
open: { label: 'Still open', className: 'border-brand-200 bg-brand-100 text-ink-950' },
matched: { label: 'You accepted', className: 'border-go-100 bg-go-50 text-go-700' },
booked: { label: 'Booked', className: 'border-go-100 bg-go-50 text-go-700' },
completed: { label: 'Done', className: 'border-hairline bg-inset text-muted' },
cancelled: { label: 'Cancelled', className: 'border-stop-100 bg-stop-50 text-stop-600' },
},
};
export interface JobRowData {
id: string;
title: string;
status: JobStatus;
/** The second line. The trade for a client, the customer's name for a pro. */
subtitle: string;
createdAt: Date;
matchCount?: number;
pendingCount?: number;
unreadCount: number;
lastMessageAt: Date | null;
nextBookingAt: Date | null;
}
/**
* One line of live detail per row, chosen by priority rather than stacked.
*
* A row that shows unread messages AND a booking AND three pending requests is a
* row nobody reads. The order is what the person has to act on soonest: someone
* is waiting for a reply, then something is about to happen, then someone is
* waiting for a decision.
*/
function liveDetail(job: JobRowData): { icon: typeof Users; text: string; urgent: boolean } | null {
if (job.unreadCount > 0) {
return {
icon: MessageSquare,
text: job.unreadCount === 1 ? '1 new message' : `${job.unreadCount} new messages`,
urgent: true,
};
}
if (job.nextBookingAt) {
return { icon: CalendarClock, text: formatWhen(job.nextBookingAt), urgent: false };
}
if (job.matchCount) {
return {
icon: Users,
text: job.matchCount === 1 ? '1 pro accepted' : `${job.matchCount} pros accepted`,
urgent: false,
};
}
if (job.pendingCount) {
return {
icon: Users,
text: job.pendingCount === 1 ? '1 pro deciding' : `${job.pendingCount} pros deciding`,
urgent: false,
};
}
return null;
}
/**
* DESIGN.md §6.10, applied to a job rather than a pro.
*
* No thumbnail: a job's photos are of a broken boiler, and a 56px crop of one
* says nothing at a glance. The trade and the status carry the row instead.
*/
export function JobRow({
job,
perspective,
onOpen,
}: {
job: JobRowData;
perspective: Perspective;
onOpen: (jobId: string) => void;
}) {
const status = STATUS[perspective][job.status];
const detail = liveDetail(job);
const timestamp = job.lastMessageAt ?? job.createdAt;
return (
<button
type="button"
onClick={() => onOpen(job.id)}
className={cn(
'flex w-full items-center gap-3 rounded-card border border-hairline bg-raised p-4 text-left',
'transition-[border-color] duration-[120ms] ease-standard',
'hover:border-brand-500 active:border-brand-500',
)}
>
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-2">
<span className="truncate font-display text-h4 text-strong">{job.title}</span>
<span className="shrink-0 text-meta text-faint tabular-nums">
{formatRelativeTime(timestamp)}
</span>
</span>
<span className="mt-2 flex flex-wrap items-center gap-2">
<span
className={cn(
'inline-flex items-center rounded-pill border px-3 py-1 text-meta',
status.className,
)}
>
{status.label}
</span>
<span className="truncate text-body-sm text-muted">{job.subtitle}</span>
</span>
{detail && (
<span
className={cn(
'mt-1.5 flex items-center gap-1.5 text-meta',
detail.urgent ? 'font-semibold text-accent' : 'text-faint',
)}
>
<detail.icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
{detail.text}
</span>
)}
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-accent" aria-hidden />
</button>
);
}
/** Placeholder at the row's own height, so the list does not jump when it lands. */
export function JobRowSkeleton() {
return <div className="h-[6.5rem] animate-pulse rounded-card bg-inset" aria-hidden />;
}
@@ -0,0 +1,41 @@
'use client';
import { Star } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* "You have not reviewed this yet."
*
* A prompt, not a list row — it asks for something rather than navigating
* somewhere, so it reads as an outstanding task and sits above the list rather
* than inside it. Tinted `sun`, the same warning tone the rest of the product
* uses for "this is waiting on you".
*/
export function ReviewPrompt({
subjectName,
jobTitle,
onOpen,
}: {
subjectName: string;
jobTitle: string;
onOpen: () => void;
}) {
return (
<button
type="button"
onClick={onOpen}
className={cn(
'flex w-full items-center gap-3 rounded-card border border-sun-100 bg-sun-50 p-4 text-left',
'transition-[border-color] duration-[120ms] ease-standard hover:border-sun-500',
)}
>
<Star className="h-5 w-5 shrink-0 text-sun-500" aria-hidden />
<span className="min-w-0 flex-1">
<span className="block font-display text-h4 text-ink-950">
Rate {subjectName}
</span>
<span className="mt-0.5 block truncate text-body-sm text-ink-800">{jobTitle}</span>
</span>
</button>
);
}
@@ -0,0 +1,153 @@
'use client';
import { useState } from 'react';
import { Star } from 'lucide-react';
import { api } from '@/lib/trpc';
import { Banner, Button, Sheet, Textarea } from '@/components/ui';
import { cn } from '@/lib/utils';
/**
* Leave a review on finished work.
*
* Held back until the other side has had their say — so the sheet says so
* plainly rather than letting someone press Send and wonder why nothing
* appeared. Silence about the embargo would read as a bug the first time
* somebody checked the profile they had just reviewed.
*/
export function ReviewSheet({
bookingId,
subjectName,
jobTitle,
open,
onClose,
}: {
bookingId: string | null;
subjectName: string;
jobTitle: string;
open: boolean;
onClose: () => void;
}) {
const utils = api.useUtils();
const [rating, setRating] = useState(0);
const [body, setBody] = useState('');
const [done, setDone] = useState<{ published: boolean } | null>(null);
const create = api.review.create.useMutation({
onSuccess: (result) => {
setDone({ published: result.published });
void utils.review.pending.invalidate();
void utils.job.mine.invalidate();
void utils.job.mineForPro.invalidate();
},
});
function close() {
setRating(0);
setBody('');
setDone(null);
create.reset();
onClose();
}
if (!bookingId) return null;
if (done) {
return (
<Sheet
open={open}
onClose={close}
title="Thanks"
actions={
<Button size="lg" block onClick={close}>
Done
</Button>
}
>
<Banner
tone={done.published ? 'success' : 'info'}
title={done.published ? 'Both reviews are live' : 'Held until they reply'}
>
{done.published
? `${subjectName} reviewed you too, so both are now on your profiles.`
: `Neither review shows until ${subjectName} writes theirs — that way nobody can read yours and answer in kind. If they never do, yours publishes on its own in two weeks.`}
</Banner>
</Sheet>
);
}
// 10 characters is the schema's floor; saying so up front beats a red message
// after they press the button.
const canSend = rating > 0 && body.trim().length >= 10 && !create.isPending;
return (
<Sheet
open={open}
onClose={close}
title={`How did ${subjectName} do?`}
body={jobTitle}
actions={
<div className="flex flex-col gap-2">
{create.error && (
<p role="alert" className="text-meta text-stop-500">
{create.error.message}
</p>
)}
<Button
size="lg"
block
busy={create.isPending}
disabled={!canSend}
onClick={() => create.mutate({ bookingId, rating, body })}
>
Send review
</Button>
<Button variant="ghost" size="sm" block onClick={close}>
Not now
</Button>
</div>
}
>
<div
role="radiogroup"
aria-label="Rating"
className="mb-4 flex items-center justify-center gap-2"
>
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
role="radio"
aria-checked={rating === n}
aria-label={`${n} out of 5`}
onClick={() => setRating(n)}
className="flex h-11 w-11 items-center justify-center rounded-pill"
>
<Star
className={cn(
'h-8 w-8 transition-colors duration-[120ms] ease-standard',
n <= rating ? 'fill-current text-sun-500' : 'text-faint',
)}
aria-hidden
/>
</button>
))}
</div>
<label className="flex flex-col gap-2">
<span className="text-body-sm text-strong">What happened?</span>
<Textarea
rows={4}
value={body}
maxLength={1500}
onChange={(e) => setBody(e.target.value)}
placeholder="What they did, whether they turned up when they said, how it was left."
/>
</label>
<p className="mt-1 text-meta text-faint">
{body.trim().length < 10
? 'A sentence at least — a rating with no words helps nobody.'
: `${body.length}/1500`}
</p>
</Sheet>
);
}
@@ -0,0 +1,216 @@
'use client';
import { ChevronLeft, MapPin, ShieldCheck, Star } from 'lucide-react';
import type { DeckCard } from '@linkder/db';
import { api } from '@/lib/trpc';
import { Button, EmptyState, ScrollStrip, Tag } from '@/components/ui';
import { ReviewList } from './review-list';
import { formatDistance, formatResponseTime } from '@/lib/utils';
/**
* One pro, in full — what a search result opens onto.
*
* A result row is a scanning surface: a name, a rating and two lines of figures,
* which is enough to choose between twenty people and not enough to choose one.
* This is the other half of that decision — their trades, what they say they
* specialise in, their work, and what customers wrote afterwards.
*
* It renders immediately from the row that was tapped rather than showing a
* spinner over data the user is already looking at; `pro.publicProfile` then
* fills in the parts a row does not carry (every photo, the full bio) and is the
* authority once it lands. That query also re-checks eligibility, so a pro who
* went on holiday between the search and the tap resolves to a dead end here
* instead of to a hire button that would fail on send.
*/
export function ProProfilePanel({
pro,
onBack,
onHire,
}: {
/** The row that was tapped. Paints the screen before the query resolves. */
pro: DeckCard;
onBack: () => void;
/** Hands the pro up to the one SendJobSheet that lives at the phone root. */
onHire: (pro: DeckCard) => void;
}) {
const profile = api.pro.publicProfile.useQuery(
{ proId: pro.proId },
{ staleTime: 60_000, retry: false },
);
const p = profile.data;
const name = pro.name ?? p?.name ?? 'This pro';
// Row first, query second: both describe the same pro, and the row is already
// on screen. `media` is the one field a DeckCard flattens, so prefer it once
// it arrives — a profile is where the rest of someone's photos belong.
const photos = p?.media.length ? p.media.map((m) => m.url) : pro.photos;
const categories = p?.categories ?? pro.categories;
const skills = p?.skills ?? pro.skills;
const bio = p?.bio ?? pro.bio;
const ratingCount = p?.ratingCount ?? pro.ratingCount;
const ratingAvg = p?.ratingAvg ?? pro.ratingAvg;
const completedJobs = p?.completedJobs ?? pro.completedJobs;
const responseTime = formatResponseTime(p?.avgResponseMinutes ?? pro.avgResponseMinutes);
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4 pt-[3.25rem]">
<button
type="button"
onClick={onBack}
className="-ml-2 mb-2 flex h-11 items-center gap-1 self-start rounded-pill pl-1 pr-3 text-body-sm text-accent"
>
<ChevronLeft className="h-5 w-5" aria-hidden />
Search
</button>
{/* A 404 here means the pro stopped being bookable since the search ran.
Saying so beats a hire button that throws on the way out. */}
{profile.error ? (
<EmptyState
title={`${name} is not available`}
body="They may have paused new work or left the platform. The rest of your search results are still there."
action={
<Button variant="ghost" size="md" onClick={onBack}>
Back to search
</Button>
}
/>
) : (
<>
{photos[0] && (
// eslint-disable-next-line @next/next/no-img-element -- remote pro media, no loader configured
<img
src={photos[0]}
alt=""
className="mb-4 h-52 w-full rounded-card object-cover"
/>
)}
<h1 className="text-h2">{name}</h1>
<p className="mt-1 text-body-sm text-muted">{p?.headline ?? pro.headline}</p>
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-faint tabular-nums">
{ratingCount > 0 ? (
<span className="flex items-center gap-1 text-muted">
<Star className="h-3.5 w-3.5 fill-current text-sun-500" aria-hidden />
{ratingAvg?.toFixed(1)}
<span className="text-faint">({ratingCount})</span>
</span>
) : (
// §8 — never "0.0 ★" for someone unrated; that reads as a bad score.
<span className="font-semibold text-accent">New</span>
)}
<span className="flex items-center gap-1">
<MapPin className="h-3.5 w-3.5" aria-hidden />
{formatDistance(pro.distanceM)}
</span>
<span>{((p?.hourlyRateCents ?? pro.hourlyRateCents) / 100).toFixed(0)}/hr</span>
<span>{p?.yearsExperience ?? pro.yearsExperience} yrs experience</span>
{completedJobs > 0 && <span>{completedJobs} jobs done</span>}
</div>
{responseTime && <p className="mt-1 text-meta text-faint">{responseTime}</p>}
{/* The one claim this marketplace is actually selling. */}
<p className="mt-3 flex items-center gap-1.5 text-meta font-semibold text-go-700">
<ShieldCheck className="h-4 w-4" aria-hidden />
ID, licence and insurance checked
</p>
{categories.length > 0 && (
<Section title="Trades">
<div className="flex flex-wrap gap-2">
{categories.map((c) => (
<Tag key={c}>{c}</Tag>
))}
</div>
</Section>
)}
{skills.length > 0 && (
<Section title="Specialises in">
<div className="flex flex-wrap gap-2">
{skills.map((s) => (
<Tag key={s}>{s}</Tag>
))}
</div>
</Section>
)}
{bio && (
<Section title="About">
<p className="whitespace-pre-line text-body-sm text-strong">{bio}</p>
</Section>
)}
{photos.length > 1 && (
<Section title="Their work">
{/* Drag-scrollable: this app is a phone mock people use with a
mouse, and a row that will not move reads as broken. */}
<ScrollStrip className="-mx-4 gap-2 px-4">
{photos.slice(1).map((url) => (
// eslint-disable-next-line @next/next/no-img-element -- remote pro media, no loader configured
<img
key={url}
src={url}
alt=""
className="h-40 w-32 shrink-0 rounded-md object-cover"
loading="lazy"
draggable={false}
/>
))}
</ScrollStrip>
</Section>
)}
<Section
title="Reviews"
hint={
ratingCount > 0
? `Showing the most recent of ${ratingCount}.`
: undefined
}
>
<ReviewList proId={pro.proId} ratingCount={ratingCount} />
</Section>
</>
)}
</div>
{/*
The primary action stays in thumb reach rather than below a page of
reviews (§9). It opens the same SendJobSheet a right swipe opens — the
sheet owns signing in, picking a job and posting one, so there is exactly
one path from "I want this person" to a request.
*/}
{!profile.error && (
<div className="shrink-0 border-t border-hairline bg-page/95 px-4 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-3 backdrop-blur-[12px]">
<Button size="lg" block onClick={() => onHire(pro)}>
Send {name} a job
</Button>
</div>
)}
</div>
);
}
/** A titled block. Local to this screen — `ui/page.tsx` Section has no top margin. */
function Section({
title,
hint,
children,
}: {
title: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<section className="mt-7">
<h2 className="text-h4">{title}</h2>
{hint && <p className="mb-3 mt-1 text-meta text-faint">{hint}</p>}
<div className={hint ? undefined : 'mt-3'}>{children}</div>
</section>
);
}
+135
View File
@@ -0,0 +1,135 @@
'use client';
import { Star } from 'lucide-react';
import { api } from '@/lib/trpc';
import { Button, EmptyState } from '@/components/ui';
import { cn, formatRelativeTime } from '@/lib/utils';
/**
* Five stars, filled to the rating.
*
* The number goes in the accessible name rather than being inferred from a row
* of glyphs — §8, meaning is never carried by shape alone. The stars themselves
* are decorative once the label says "4 out of 5".
*/
function Stars({ rating, className }: { rating: number; className?: string }) {
return (
<span
className={cn('flex items-center gap-0.5 text-sun-500', className)}
role="img"
aria-label={`${rating} out of 5`}
>
{[1, 2, 3, 4, 5].map((n) => (
<Star
key={n}
className={cn('h-3.5 w-3.5', n <= rating ? 'fill-current' : 'text-faint')}
aria-hidden
/>
))}
</span>
);
}
/**
* What people wrote about this pro.
*
* A page, never the whole history: `ratingCount` in the header counts every
* rating this pro has ever had, and a profile with sixty of them would push the
* hire button somewhere nobody scrolls. The heading says which it is showing so
* the two numbers cannot be read as a contradiction.
*
* Only published reviews exist as far as this is concerned — `pro.reviews`
* enforces that server-side, because the publication gate is what stops a pro
* retaliating against a bad review before it is visible.
*/
export function ReviewList({ proId, ratingCount }: { proId: string; ratingCount: number }) {
const reviews = api.pro.reviews.useInfiniteQuery(
{ proId },
{ getNextPageParam: (last) => last.nextCursor, staleTime: 60_000, retry: false },
);
const rows = reviews.data?.pages.flatMap((page) => page.reviews) ?? [];
if (reviews.isLoading) {
return (
<div className="flex flex-col gap-3" aria-busy>
{/* §6.11 — three is enough to say "loading"; twenty is a lie about what
is coming. */}
{[0, 1, 2].map((n) => (
<div key={n} className="h-24 animate-pulse rounded-card bg-inset" />
))}
</div>
);
}
if (rows.length === 0) {
return (
<EmptyState
title="No written reviews yet"
body={
ratingCount > 0
? 'This pro has been rated, but nobody has left written feedback that is public yet.'
: 'Nobody has reviewed this pro yet. Reviews appear once a booking is finished.'
}
/>
);
}
return (
<div className="flex flex-col gap-3">
<ul className="flex flex-col gap-3">
{rows.map((review) => (
<li
key={review.id}
className="rounded-card border border-hairline bg-raised p-4"
>
<div className="flex items-center gap-3">
{review.authorImage ? (
// eslint-disable-next-line @next/next/no-img-element -- remote avatar, no loader configured
<img
src={review.authorImage}
alt=""
className="h-9 w-9 shrink-0 rounded-pill object-cover"
loading="lazy"
/>
) : (
<span
aria-hidden
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-pill bg-inset font-display text-body-sm text-muted"
>
{review.authorName?.[0] ?? '?'}
</span>
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-semibold text-strong">
{review.authorName ?? 'A customer'}
</span>
<span className="mt-0.5 flex items-center gap-2">
<Stars rating={review.rating} />
<span className="text-meta text-faint">
{formatRelativeTime(review.publishedAt)}
</span>
</span>
</span>
</div>
<p className="mt-3 text-body-sm text-strong">{review.body}</p>
</li>
))}
</ul>
{reviews.hasNextPage && (
<Button
variant="ghost"
size="md"
block
busy={reviews.isFetchingNextPage}
onClick={() => void reviews.fetchNextPage()}
>
Show more reviews
</Button>
)}
</div>
);
}
@@ -0,0 +1,80 @@
'use client';
import { ChevronRight, Star } from 'lucide-react';
import type { DeckCard } from '@linkder/db';
import { formatDistance } from '@/lib/utils';
/**
* One pro, in a list.
*
* Not the deck <Card>: that one is `absolute inset-0` with `touch-action: none`,
* so a column of them would have no height and would eat the vertical scroll.
* A results list is a different job — scan twenty in a second, tap one.
*
* Follows the row shape already used by the jobs list (app/jobs/page.tsx):
* bordered card, title, two lines of meta, trailing chevron.
*/
export function ResultRow({ pro, onOpen }: { pro: DeckCard; onOpen: (proId: string) => void }) {
return (
<button
type="button"
onClick={() => onOpen(pro.proId)}
className="flex w-full items-center gap-3 rounded-card border border-hairline bg-raised p-4 text-left transition-[border-color] duration-[120ms] ease-standard hover:border-brand-500 active:border-brand-500"
>
{/* Photo, or the initial. An empty grey square reads as a broken image. */}
{pro.photos[0] ? (
// eslint-disable-next-line @next/next/no-img-element -- remote pro media, no loader configured
<img
src={pro.photos[0]}
alt=""
className="h-14 w-14 shrink-0 rounded-md object-cover"
loading="lazy"
/>
) : (
<span
aria-hidden
className="flex h-14 w-14 shrink-0 items-center justify-center rounded-md bg-inset font-display text-h4 text-muted"
>
{pro.name?.[0] ?? '?'}
</span>
)}
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-2">
<span className="truncate font-display text-h4 text-strong">{pro.name}</span>
{pro.ratingCount > 0 ? (
<span className="flex shrink-0 items-center gap-1 text-meta text-muted tabular-nums">
<Star className="h-3.5 w-3.5 fill-current text-sun-500" aria-hidden />
{pro.ratingAvg?.toFixed(1)}
<span className="text-faint">({pro.ratingCount})</span>
</span>
) : (
// §8 — never colour alone, and never "0.0 stars" for someone unrated.
<span className="shrink-0 text-meta font-semibold text-accent">New</span>
)}
</span>
<span className="mt-0.5 block truncate text-body-sm text-muted">{pro.headline}</span>
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-meta text-faint tabular-nums">
<span>{formatDistance(pro.distanceM)}</span>
<span aria-hidden>·</span>
<span>{(pro.hourlyRateCents / 100).toFixed(0)}/hr</span>
{pro.categories[0] && (
<>
<span aria-hidden>·</span>
<span className="truncate">{pro.categories[0]}</span>
</>
)}
</span>
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-accent" aria-hidden />
</button>
);
}
/** Placeholder at the row's own height, so the list does not jump when it lands. */
export function ResultRowSkeleton() {
return <div className="h-[5.75rem] animate-pulse rounded-card bg-inset" aria-hidden />;
}
@@ -0,0 +1,50 @@
'use client';
import { Search, X } from 'lucide-react';
import { MAX_SEARCH_QUERY_LENGTH } from '@linkder/shared';
import { Input } from '@/components/ui';
/**
* The search box. DESIGN.md §6.9.
*
* The label is visible, not a placeholder: §6.2 forbids placeholder-as-label,
* and a placeholder vanishes exactly when someone needs to remember what the
* field searches. The placeholder carries an example instead.
*/
export function SearchField({
value,
onChange,
}: {
value: string;
onChange: (next: string) => void;
}) {
return (
<label className="flex flex-col gap-2">
<span className="text-body-sm font-semibold text-strong">Search pros</span>
<span className="relative block">
<Search
className="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-faint"
aria-hidden
/>
<Input
type="search"
value={value}
onChange={(e) => onChange(e.target.value)}
maxLength={MAX_SEARCH_QUERY_LENGTH}
placeholder="Boiler repair, rewiring, Marta…"
className="pl-12 pr-12 [&::-webkit-search-cancel-button]:hidden"
/>
{value && (
<button
type="button"
onClick={() => onChange('')}
aria-label="Clear search"
className="absolute right-1 top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-pill text-faint transition-colors duration-[120ms] ease-standard hover:text-strong"
>
<X className="h-4 w-4" aria-hidden />
</button>
)}
</span>
</label>
);
}
@@ -0,0 +1,140 @@
'use client';
import { SlidersHorizontal } from 'lucide-react';
import type { SearchSort } from '@linkder/shared';
import { MAX_SERVICE_RADIUS_M, MIN_SERVICE_RADIUS_M } from '@linkder/shared';
import { Chip } from '@/components/ui';
import type { Category } from '@/app/showcase-deck';
const SORTS: { value: SearchSort; label: string }[] = [
{ value: 'best', label: 'Best match' },
{ value: 'nearest', label: 'Nearest' },
{ value: 'rating', label: 'Top rated' },
{ value: 'price', label: 'Lowest price' },
];
export interface SearchFilterState {
categoryId: string | null;
radiusKm: number;
sort: SearchSort;
}
/**
* Trade, distance and sort.
*
* The trade strip is the same horizontal scroller the entry screen uses — 50
* categories will not fit on a 390px phone any other way, and two different
* pickers for the same taxonomy would be two things to keep in step.
*
* Distance and sort live behind a toggle: on a phone, three stacked filters
* above the results push the first result off the screen, and the first result
* is the whole point.
*/
export function SearchFilters({
categories,
state,
onChange,
expanded,
onToggleExpanded,
}: {
categories: Category[];
state: SearchFilterState;
onChange: (next: SearchFilterState) => void;
expanded: boolean;
onToggleExpanded: () => void;
}) {
const selected = categories.find((c) => c.id === state.categoryId) ?? null;
const activeCount = (state.categoryId ? 1 : 0) + (state.sort === 'best' ? 0 : 1);
return (
<div className="flex flex-col gap-3">
<div className="relative -mx-4">
<div
className="flex gap-1.5 overflow-x-auto px-4 pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="group"
aria-label="Filter by trade"
>
<Chip
size="sm"
selected={expanded}
className="shrink-0"
onClick={onToggleExpanded}
aria-expanded={expanded}
>
<SlidersHorizontal className="h-4 w-4" aria-hidden />
Filters
{activeCount > 0 && <span className="tabular-nums">({activeCount})</span>}
</Chip>
{/* The chosen trade stays first so it never scrolls out of view. */}
{selected && (
<Chip
size="sm"
selected
className="shrink-0"
onClick={() => onChange({ ...state, categoryId: null })}
>
{selected.name}
<span className="sr-only">Remove trade filter</span>
</Chip>
)}
{categories
.filter((c) => c.id !== state.categoryId)
.map((c) => (
<Chip
key={c.id}
size="sm"
className="shrink-0"
onClick={() => onChange({ ...state, categoryId: c.id })}
>
{c.name}
</Chip>
))}
</div>
<span
aria-hidden
className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-page to-transparent"
/>
</div>
{expanded && (
<div className="flex flex-col gap-4 rounded-card border border-hairline bg-raised p-4">
<label className="flex flex-col gap-2">
<span className="text-body-sm font-semibold text-strong">
Within <span className="text-muted tabular-nums">{state.radiusKm} km</span>
</span>
<input
type="range"
min={MIN_SERVICE_RADIUS_M / 1000}
max={MAX_SERVICE_RADIUS_M / 1000}
value={state.radiusKm}
onChange={(e) => onChange({ ...state, radiusKm: Number(e.target.value) })}
className="w-full accent-brand-500"
/>
<span className="flex justify-between text-meta text-faint tabular-nums">
<span>{MIN_SERVICE_RADIUS_M / 1000} km</span>
<span>{MAX_SERVICE_RADIUS_M / 1000} km</span>
</span>
</label>
<fieldset className="flex flex-col gap-2">
<legend className="mb-2 text-body-sm font-semibold text-strong">Sort by</legend>
<div className="flex flex-wrap gap-1.5">
{SORTS.map((s) => (
<Chip
key={s.value}
size="sm"
selected={state.sort === s.value}
onClick={() => onChange({ ...state, sort: s.value })}
>
{s.label}
</Chip>
))}
</div>
</fieldset>
</div>
)}
</div>
);
}
@@ -1,16 +1,20 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { LocateFixed } from 'lucide-react';
import { import {
DEFAULT_SERVICE_RADIUS_M, DEFAULT_SERVICE_RADIUS_M,
MAX_SERVICE_RADIUS_M, MAX_SERVICE_RADIUS_M,
MIN_SERVICE_RADIUS_M, MIN_SERVICE_RADIUS_M,
} from '@linkder/shared'; } from '@linkder/shared';
import { api } from '@/lib/trpc'; import { api } from '@/lib/trpc';
import { Button, FormError, Input, SettingsGroup } from '@/components/ui'; import {
AddressField,
const CITY_NAME = process.env.NEXT_PUBLIC_CITY_NAME ?? 'the city centre'; Button,
EMPTY_ADDRESS,
FormError,
SettingsGroup,
type AddressValue,
} from '@/components/ui';
/** /**
* Where you are, and how far you will go. * Where you are, and how far you will go.
@@ -24,9 +28,8 @@ export function LocationGroup({ isPro }: { isPro: boolean }) {
const utils = api.useUtils(); const utils = api.useUtils();
const saved = api.user.location.useQuery(); const saved = api.user.location.useQuery();
const [addressText, setAddressText] = useState(''); const [address, setAddress] = useState<AddressValue>(EMPTY_ADDRESS);
const [radiusKm, setRadiusKm] = useState(DEFAULT_SERVICE_RADIUS_M / 1000); const [radiusKm, setRadiusKm] = useState(DEFAULT_SERVICE_RADIUS_M / 1000);
const [pin, setPin] = useState<{ lat: number; lng: number } | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [savedJustNow, setSavedJustNow] = useState(false); const [savedJustNow, setSavedJustNow] = useState(false);
// Seeding the controls from the query would otherwise overwrite what someone // Seeding the controls from the query would otherwise overwrite what someone
@@ -35,9 +38,14 @@ export function LocationGroup({ isPro }: { isPro: boolean }) {
useEffect(() => { useEffect(() => {
if (!saved.data || dirty) return; if (!saved.data || dirty) return;
setAddressText(saved.data.addressText ?? ''); // The label only. Re-picking is what moves the pin — carrying stale
// coordinates under an editable label is the drift this replaced.
setAddress(
saved.data.addressText
? { text: saved.data.addressText, place: { source: 'none', label: saved.data.addressText } }
: EMPTY_ADDRESS,
);
setRadiusKm(Math.round(saved.data.radiusM / 1000)); setRadiusKm(Math.round(saved.data.radiusM / 1000));
setPin(saved.data.location);
}, [saved.data, dirty]); }, [saved.data, dirty]);
const update = api.user.updateLocation.useMutation({ const update = api.user.updateLocation.useMutation({
@@ -95,42 +103,16 @@ export function LocationGroup({ isPro }: { isPro: boolean }) {
} }
> >
<div className="flex flex-col gap-4 px-4 py-4"> <div className="flex flex-col gap-4 px-4 py-4">
<div className="flex flex-col gap-2"> <AddressField
<label className="flex flex-col gap-2"> label={isPro ? 'Base address' : 'Your address'}
<span className="text-body-sm text-strong"> hint={
{isPro ? 'Base address' : 'Your address'} isPro
</span> ? 'Matching measures from here, never from the text.'
<Input : 'Your deck is centred here. Only shared with a pro once you book.'
value={addressText}
onChange={(e) => edit(setAddressText)(e.target.value)}
maxLength={255}
placeholder={`Neighbourhood, ${CITY_NAME}`}
/>
</label>
<p className="text-meta text-muted">
{pin
? `Pinned to ${pin.lat.toFixed(4)}, ${pin.lng.toFixed(4)}. `
: `No pin yet — we measure from ${CITY_NAME}. `}
{isPro
? 'Matching uses the pin, never the text.'
: 'Matching uses the pin; your address is only shared once you book.'}
</p>
<Button
type="button"
variant="ghost"
size="sm"
className="self-start"
onClick={() =>
navigator.geolocation?.getCurrentPosition(
(pos) => edit(setPin)({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
() => setError('We could not get your location. Type an address instead.'),
)
} }
> value={address}
<LocateFixed className="h-4 w-4" aria-hidden /> onChange={(next) => edit(setAddress)(next)}
Use my current location />
</Button>
</div>
<label className="flex flex-col gap-2"> <label className="flex flex-col gap-2">
<span className="text-body-sm text-strong"> <span className="text-body-sm text-strong">
@@ -161,9 +143,10 @@ export function LocationGroup({ isPro }: { isPro: boolean }) {
busy={update.isPending} busy={update.isPending}
onClick={() => onClick={() =>
update.mutate({ update.mutate({
addressText,
radiusM: radiusKm * 1000, radiusM: radiusKm * 1000,
...(pin ? { location: pin } : {}), // Only send a place when one was actually chosen this session —
// otherwise saving a radius would rewrite the pin to `city`.
...(address.place.source === 'none' ? {} : { place: address.place }),
}) })
} }
> >
@@ -0,0 +1,210 @@
'use client';
import { useState } from 'react';
import { AlertTriangle, Check, LocateFixed, MapPin } from 'lucide-react';
import type { LocationInput } from '@linkder/shared';
import { api } from '@/lib/trpc';
import { useDebouncedValue } from '@/lib/use-debounced-value';
import { cn } from '@/lib/utils';
import { Button } from './button';
import { Field } from './field';
const CITY_NAME = process.env.NEXT_PUBLIC_CITY_NAME ?? 'the city centre';
/** What the parent form holds: the text on screen, plus what it resolved to. */
export interface AddressValue {
text: string;
place: LocationInput;
}
export const EMPTY_ADDRESS: AddressValue = { text: '', place: { source: 'none' } };
/**
* DESIGN.md §6.16. The one address input.
*
* An address is the only field in this product that has to become something
* real: `ST_Distance(p.base_location, j.location)` ranks every deck, so a line
* of text that never resolved is not an answer. Before this component all three
* address surfaces were a bare `<Input>` next to a geolocation button, and a
* user who typed a street and pressed save stored the city centre while the row
* claimed to be their address.
*
* The precision line below the field is the point. "We found something" and "we
* found the right thing" are different claims, and rendering them identically is
* exactly how a placeholder gets stored as a location.
*/
export function AddressField({
label,
hint,
value,
onChange,
required,
}: {
label: string;
hint?: string;
value: AddressValue;
onChange: (next: AddressValue) => void;
required?: boolean;
}) {
// Suggestions are hidden once something is chosen, so picking one does not
// leave the list sitting open over the rest of the form.
const [open, setOpen] = useState(false);
const [locating, setLocating] = useState(false);
// Per pause, not per keystroke — the same 250ms the search tab uses, and here
// it is also a spend control: every call is a billed geocode.
const q = useDebouncedValue(value.text, 250);
const suggest = api.geocode.suggest.useQuery(
{ q: q.trim(), proximity: undefined },
{
enabled: open && q.trim().length >= 3,
// Keep the list on screen while the next one loads, rather than blinking
// empty between keystrokes.
placeholderData: (previous) => previous,
staleTime: 60_000,
retry: false,
},
);
const reverse = api.geocode.reverse.useMutation();
const results = suggest.data?.results ?? [];
const showList = open && results.length > 0;
function useDevicePosition() {
if (!navigator.geolocation) return;
setLocating(true);
navigator.geolocation.getCurrentPosition(
async (pos) => {
const point = { lat: pos.coords.latitude, lng: pos.coords.longitude };
// Give the coordinates a name before storing them. A button that sets an
// invisible pin leaves the user nothing to check.
const named = await reverse.mutateAsync(point).catch(() => null);
const text = named?.result?.label ?? 'Current location';
onChange({ text, place: { source: 'device', ...point, label: text } });
setOpen(false);
setLocating(false);
},
() => {
setLocating(false);
// Not an error state: typing an address is the primary path, and this
// button is the shortcut.
setOpen(true);
},
);
}
return (
<div className="flex flex-col gap-2">
<Field label={label} hint={hint}>
<div className="relative">
<MapPin
className="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-faint"
aria-hidden
/>
<input
type="text"
value={value.text}
required={required}
maxLength={255}
autoComplete="off"
placeholder="Start typing a street and number"
onChange={(e) => {
// Editing the text invalidates whatever was resolved. Keeping the
// old coordinates under new text is the exact drift this
// component exists to stop.
onChange({ text: e.target.value, place: { source: 'none', label: e.target.value } });
setOpen(true);
}}
onFocus={() => setOpen(true)}
className={cn(
'h-12 w-full rounded-lg border-[1.5px] border-hairline bg-raised pl-12 pr-4',
'text-base text-strong placeholder:text-faint',
'focus:border-brand-500 focus:outline-none focus:ring-[3px] focus:ring-brand-200',
)}
/>
</div>
</Field>
{showList && (
<ul className="flex flex-col gap-1.5">
{results.slice(0, 5).map((r) => (
<li key={r.providerId}>
<button
type="button"
onClick={() => {
onChange({
text: r.label,
place: { source: 'place', placeId: r.providerId, label: r.label },
});
setOpen(false);
}}
className={cn(
'flex w-full items-center gap-3 rounded-lg border border-hairline bg-raised p-3 text-left',
'transition-[border-color] duration-[120ms] ease-standard hover:border-brand-500',
)}
>
<MapPin className="h-4 w-4 shrink-0 text-muted" aria-hidden />
<span className="min-w-0 flex-1 truncate text-body-sm text-strong">{r.label}</span>
</button>
</li>
))}
</ul>
)}
<PrecisionLine value={value} configured={suggest.data?.configured ?? true} />
<Button
type="button"
variant="ghost"
size="sm"
className="self-start"
busy={locating}
onClick={useDevicePosition}
>
<LocateFixed className="h-4 w-4" aria-hidden />
Use my current location
</Button>
</div>
);
}
/**
* What we actually know about this point, in words.
*
* Never a bare tick. The three states are three different promises about how
* well this job or profile will match, and the user is the only one who can tell
* us the middle one is not good enough.
*/
function PrecisionLine({ value, configured }: { value: AddressValue; configured: boolean }) {
if (value.place.source === 'place') {
return (
<p className="flex items-start gap-1.5 text-meta text-go-600">
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
<span>Matched to {value.text}</span>
</p>
);
}
if (value.place.source === 'device') {
return (
<p className="flex items-start gap-1.5 text-meta text-sun-500">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
<span>Approximate from your phone, not a confirmed address.</span>
</p>
);
}
return (
<p className="text-meta text-muted">
{value.text.trim().length === 0
? `No address yet — we will match from ${CITY_NAME}.`
: configured
? `Not matched yet — pick a suggestion, or we will match from ${CITY_NAME}.`
: `Address lookup is unavailable — we will match from ${CITY_NAME}.`}
</p>
);
}
+4 -3
View File
@@ -28,9 +28,10 @@ export function Chip({
aria-pressed={selected} aria-pressed={selected}
{...props} {...props}
className={cn( className={cn(
'inline-flex items-center rounded-pill border-[1.5px]', 'inline-flex items-center rounded-pill',
size === 'sm' ? 'border' : 'border-[1.5px]',
'transition-[color,background-color,border-color] duration-[120ms] ease-standard', 'transition-[color,background-color,border-color] duration-[120ms] ease-standard',
size === 'sm' ? 'gap-1 px-3 py-1.5 text-meta' : 'gap-1.5 px-4 py-3 text-body-sm', size === 'sm' ? 'gap-1 px-2.5 py-1 text-[0.6875rem] leading-tight' : 'gap-1.5 px-4 py-3 text-body-sm',
'disabled:pointer-events-none disabled:opacity-45', 'disabled:pointer-events-none disabled:opacity-45',
selected selected
? 'border-brand-500 bg-brand-100 font-semibold text-ink-950' ? 'border-brand-500 bg-brand-100 font-semibold text-ink-950'
@@ -38,7 +39,7 @@ export function Chip({
className, className,
)} )}
> >
{selected && <Check className={size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'} aria-hidden />} {selected && <Check className={size === 'sm' ? 'h-3 w-3' : 'h-4 w-4'} aria-hidden />}
{children} {children}
</button> </button>
); );
+4
View File
@@ -1,4 +1,5 @@
export { Button, IconButton, buttonClasses } from './button'; export { Button, IconButton, buttonClasses } from './button';
export { AddressField, EMPTY_ADDRESS, type AddressValue } from './address-field';
export { Banner, FormError } from './banner'; export { Banner, FormError } from './banner';
export { Card, EmptyState, Stat } from './card'; export { Card, EmptyState, Stat } from './card';
export { Chip, OptionCard, Tag } from './chip'; export { Chip, OptionCard, Tag } from './chip';
@@ -6,3 +7,6 @@ export { Field, FieldNote, FieldSet, Input, Textarea } from './field';
export { ScreenIntro, Section, StickyAction } from './page'; export { ScreenIntro, Section, StickyAction } from './page';
export { SettingsGroup, SettingsRow, SettingsToggle } from './settings-row'; export { SettingsGroup, SettingsRow, SettingsToggle } from './settings-row';
export { ToastProvider, useToast, type ToastTone } from './toast'; export { ToastProvider, useToast, type ToastTone } from './toast';
export { ScrollStrip } from './scroll-strip';
export { Segmented, type Segment } from './segmented';
export { Sheet } from './sheet';
+110
View File
@@ -0,0 +1,110 @@
'use client';
import { useRef } from 'react';
import { cn } from '@/lib/utils';
/**
* A horizontally scrolling row that can also be DRAGGED.
*
* `overflow-x: auto` alone is only half a control. A touch device flicks it
* happily, but with a mouse a browser will not drag-scroll an overflow
* container, and a vertical wheel does not move it sideways — so on a desktop
* the row looks scrollable and refuses to move. Since this app is a phone
* mockup that people use with a mouse, that reads as broken.
*
* Two additions:
* - pointer drag, via setPointerCapture so the gesture survives leaving the
* element;
* - vertical wheel mapped to horizontal scroll.
*
* A drag must not fire the pill underneath it, so past DRAG_THRESHOLD the next
* click is swallowed in the capture phase.
*/
const DRAG_THRESHOLD = 4;
export function ScrollStrip({
className,
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
const ref = useRef<HTMLDivElement>(null);
const start = useRef({ x: 0, scrollLeft: 0, dragging: false, moved: false, captured: false });
return (
<div
{...props}
ref={ref}
className={cn(
'flex overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
// Let the browser own vertical panning so a touch drag can still scroll
// the page, while we take the horizontal axis.
'touch-pan-y',
start.current.dragging ? 'cursor-grabbing' : 'cursor-grab',
className,
)}
onPointerDown={(e) => {
const el = ref.current;
if (!el) return;
// Ignore secondary buttons: a right-click drag is not a scroll.
if (e.button !== 0) return;
// NOTE: do NOT capture the pointer yet. Capturing here retargets the
// whole gesture — including the click that follows — at this element, so
// a plain tap would never reach the pill underneath. Capture only once
// the pointer has actually moved far enough to be a drag.
start.current = {
x: e.clientX,
scrollLeft: el.scrollLeft,
dragging: true,
moved: false,
captured: false,
};
}}
onPointerMove={(e) => {
const el = ref.current;
if (!el || !start.current.dragging) return;
const dx = e.clientX - start.current.x;
if (Math.abs(dx) > DRAG_THRESHOLD) {
start.current.moved = true;
if (!start.current.captured) {
el.setPointerCapture(e.pointerId);
start.current.captured = true;
}
}
if (!start.current.moved) return;
el.scrollLeft = start.current.scrollLeft - dx;
}}
onPointerUp={(e) => {
const el = ref.current;
if (start.current.captured && el?.hasPointerCapture(e.pointerId)) {
el.releasePointerCapture(e.pointerId);
}
start.current.dragging = false;
start.current.captured = false;
}}
onPointerCancel={() => {
start.current.dragging = false;
start.current.captured = false;
}}
onClickCapture={(e) => {
// The pointerup that ends a drag is followed by a click on whichever
// pill is under the cursor. Swallow it, or every drag also picks a trade.
if (start.current.moved) {
e.preventDefault();
e.stopPropagation();
start.current.moved = false;
}
}}
onWheel={(e) => {
const el = ref.current;
if (!el) return;
// A mouse only produces deltaY; map it onto the axis this row actually
// has. Trackpads already send deltaX, so prefer that when present.
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
if (delta === 0) return;
el.scrollLeft += delta;
}}
>
{children}
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
'use client';
import { cn } from '@/lib/utils';
export interface Segment<T extends string> {
id: T;
label: string;
/** Rendered after the label. A zero is shown, not hidden — see DESIGN.md §6.13. */
count?: number;
}
/**
* DESIGN.md §6.13. Two or three lenses onto one list.
*
* A `radiogroup` rather than a row of buttons: these are one choice with several
* options, and a screen reader should hear "Current, 1 of 2" instead of two
* unrelated controls. Selection carries in weight as well as fill, because §8
* forbids colour as the only signal.
*/
export function Segmented<T extends string>({
segments,
value,
onChange,
label,
className,
}: {
segments: readonly Segment<T>[];
value: T;
onChange: (next: T) => void;
/** Names the group for assistive tech — "Job list view", not "Segmented control". */
label: string;
className?: string;
}) {
return (
<div
role="radiogroup"
aria-label={label}
className={cn('flex w-full gap-1 rounded-pill bg-inset p-1', className)}
>
{segments.map((segment) => {
const isSelected = segment.id === value;
return (
<button
key={segment.id}
type="button"
role="radio"
aria-checked={isSelected}
onClick={() => onChange(segment.id)}
className={cn(
'flex h-10 flex-1 items-center justify-center gap-1.5 rounded-pill px-3 text-body-sm',
'transition-[color,background-color,box-shadow] duration-[120ms] ease-standard',
isSelected
? 'bg-raised font-semibold text-strong shadow-sm'
: 'text-muted hover:text-strong',
)}
>
{segment.label}
{segment.count !== undefined && (
<span className={cn('tabular-nums', isSelected ? 'text-muted' : 'text-faint')}>
{segment.count}
</span>
)}
</button>
);
})}
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
'use client';
import { useEffect, useRef } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { cn } from '@/lib/utils';
/**
* DESIGN.md §6.14. The only modal this product has.
*
* Rises from the bottom edge because that is where the thumb already is — a
* centred dialog on a 390px screen is just a card with the page greyed out.
*
* Scrim tap, Escape and the grab handle all mean the same thing: no. A sheet
* whose scrim tap silently confirms is a trap, so `onClose` is never a decision
* — anything irreversible needs a button inside.
*/
export function Sheet({
open,
onClose,
title,
body,
actions,
children,
}: {
open: boolean;
onClose: () => void;
title: string;
body?: string;
/** Pinned at the bottom. Never scrolls out of reach. */
actions?: React.ReactNode;
children?: React.ReactNode;
}) {
const panel = useRef<HTMLDivElement>(null);
const restoreTo = useRef<Element | null>(null);
useEffect(() => {
if (!open) return;
restoreTo.current = document.activeElement;
// Focus the panel itself rather than the first control: the sheet's job is
// to be read before it is answered.
panel.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('keydown', onKey);
if (restoreTo.current instanceof HTMLElement) restoreTo.current.focus();
};
}, [open, onClose]);
return (
<AnimatePresence>
{open && (
<div className="absolute inset-0 z-50 flex flex-col justify-end">
<motion.div
aria-hidden
onClick={onClose}
className="absolute inset-0 bg-[rgb(0_6_36_/_0.45)]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>
<motion.div
ref={panel}
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
className={cn(
'relative flex max-h-[85%] flex-col rounded-t-card bg-page shadow-lg outline-none',
'pb-[calc(0.5rem+env(safe-area-inset-bottom))]',
)}
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 30, stiffness: 320 }}
>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="mx-auto flex h-8 w-full shrink-0 items-center justify-center"
>
<span aria-hidden className="h-1 w-9 rounded-pill bg-ink-200" />
</button>
<div className="shrink-0 px-5 pb-3">
<h2 className="text-h3">{title}</h2>
{body && <p className="mt-1 text-body-sm text-muted">{body}</p>}
</div>
{children && <div className="min-h-0 flex-1 overflow-y-auto px-5">{children}</div>}
{actions && <div className="shrink-0 px-5 pt-4">{actions}</div>}
</motion.div>
</div>
)}
</AnimatePresence>
);
}
+25
View File
@@ -0,0 +1,25 @@
import * as Sentry from '@sentry/nextjs';
import { baseOptions } from '@/lib/observability';
/**
* Browser error reporting for Bugsink.
*
* The DSN must be NEXT_PUBLIC_ to exist in the client bundle. That is fine — a
* Sentry DSN is a write-only ingest key by design, not a secret. Everything
* sensitive is stripped in beforeSend; see @/lib/observability.
*/
Sentry.init({
...baseOptions(process.env.NEXT_PUBLIC_SENTRY_DSN),
// No session replay: Bugsink cannot ingest it, and a replay of this app would
// record someone typing their login OTP.
replaysOnErrorSampleRate: 0,
replaysSessionSampleRate: 0,
// No release-health sessions either. The SDK sends a session envelope on every
// page load by default; Bugsink tracks errors only, so those are requests that
// cost the user bandwidth and produce nothing readable at the other end.
// v10 removed the `autoSessionTracking` flag — it is an integration now.
integrations: (defaults) => defaults.filter((i) => i.name !== 'BrowserSession'),
});
/** Lets Next report client-side navigation failures. */
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
+18
View File
@@ -0,0 +1,18 @@
import * as Sentry from '@sentry/nextjs';
import { baseOptions } from '@/lib/observability';
/**
* Server and edge error reporting for Bugsink.
*
* Next calls register() once per runtime, so the DSN is read here rather than at
* module scope — the edge runtime has a different env surface from Node.
*/
export async function register() {
Sentry.init(baseOptions(process.env.NEXT_PUBLIC_SENTRY_DSN));
}
/**
* Required by Next 15 to report errors thrown inside a React Server Component.
* Without it those surface only as a generic 500 with nothing attached.
*/
export const onRequestError = Sentry.captureRequestError;
+41 -10
View File
@@ -55,15 +55,32 @@ const appUrl =
: 'http://localhost:3000'); : 'http://localhost:3000');
/** /**
* Google OAuth is optional. A developer clone with no Google project, and every * Social sign-in is optional, per provider and independently.
* preview deploy, should still boot and still sign people in by phone — so a *
* missing key is a fact about the environment here, not an error like a missing * A developer clone with no Google project, and every preview deploy, should
* AUTH_SECRET. What it must not do is silently half-register the provider; see * still boot and still sign people in by phone — so a missing key is a fact
* `socialProviders` below. * about the environment here, not an error like a missing AUTH_SECRET. What it
* must not do is silently half-register a provider; see `socialProviders`.
*/ */
const googleId = process.env.AUTH_GOOGLE_ID; const googleId = process.env.AUTH_GOOGLE_ID;
const googleSecret = process.env.AUTH_GOOGLE_SECRET; const googleSecret = process.env.AUTH_GOOGLE_SECRET;
const microsoftId = process.env.AUTH_MICROSOFT_ID;
const microsoftSecret = process.env.AUTH_MICROSOFT_SECRET;
/**
* Which Microsoft accounts may sign in. `common` is both work/school and
* personal accounts, which is what a consumer marketplace wants; a single
* tenant GUID restricts it to one organisation.
*
* better-auth defaults this to `common` itself, but naming it here keeps the
* decision visible — the difference between "anyone with a Microsoft account"
* and "my company only" is not something to discover from a library default.
*/
const microsoftTenant = process.env.AUTH_MICROSOFT_TENANT_ID ?? 'common';
const githubId = process.env.AUTH_GITHUB_ID;
const githubSecret = process.env.AUTH_GITHUB_SECRET;
export const auth = betterAuth({ export const auth = betterAuth({
// Passing `schema` explicitly (rather than letting the adapter read // Passing `schema` explicitly (rather than letting the adapter read
// db._.fullSchema) keeps it from forcing our lazy db Proxy open at module // db._.fullSchema) keeps it from forcing our lazy db Proxy open at module
@@ -140,20 +157,34 @@ export const auth = betterAuth({
emailAndPassword: { enabled: false }, emailAndPassword: { enabled: false },
/** /**
* Google is registered only when it can actually work. * Each provider is registered only when it can actually work.
* *
* With empty strings here better-auth still registers the provider, and * With empty strings here better-auth still registers the provider, and
* /sign-in/social gets as far as the OAuth URL builder before throwing — * /sign-in/social gets as far as the OAuth URL builder before throwing —
* a 500 that says nothing, on an environment that is merely unconfigured * a 500 that says nothing, on an environment that is merely unconfigured
* rather than broken. Omitting the provider instead makes the same click * rather than broken. Omitting the provider instead makes the same click
* return 404 PROVIDER_NOT_FOUND, which <GoogleButton> can tell apart from a * return 404 PROVIDER_NOT_FOUND, which <SocialButton> can tell apart from a
* real failure and turn into "not set up yet" rather than "try again". * real failure and turn into "not set up yet" rather than "try again".
* *
* The button itself is NOT conditional. See components/auth/google-button. * The buttons themselves are NOT conditional. See components/auth/social-sign-in.
*/ */
socialProviders: googleId && googleSecret socialProviders: {
...(googleId && googleSecret
? { google: { clientId: googleId, clientSecret: googleSecret } } ? { google: { clientId: googleId, clientSecret: googleSecret } }
: {}, : {}),
...(microsoftId && microsoftSecret
? {
microsoft: {
clientId: microsoftId,
clientSecret: microsoftSecret,
tenantId: microsoftTenant,
},
}
: {}),
...(githubId && githubSecret
? { github: { clientId: githubId, clientSecret: githubSecret } }
: {}),
},
plugins: [ plugins: [
phoneNumber({ phoneNumber({
+128
View File
@@ -0,0 +1,128 @@
import type { ErrorEvent } from '@sentry/nextjs';
/**
* Shared error-reporting policy for Bugsink.
*
* Bugsink speaks the Sentry wire protocol, so the Sentry SDK is the client. Two
* things follow from what it is and what this app holds:
*
* 1. Bugsink is an ERROR tracker, not an APM. It has no tracing, profiling or
* session-replay ingest, so those are all off — sending them would burn
* bandwidth and the user's battery to produce payloads nothing reads.
*
* 2. On this platform the phone number IS the credential. A login OTP, a
* session token or a phone number reaching a crash report turns the error
* tracker into a place where someone's account can be taken over. Scrubbing
* is therefore not optional hygiene here; it is part of the auth boundary.
*/
/** Fields that must never leave the process, whatever nests them. */
const SECRET_KEYS = [
'phone',
'phonenumber',
'code',
'otp',
'token',
'accesstoken',
'refreshtoken',
'password',
'secret',
'authorization',
'cookie',
'session',
'email',
];
/** E.164 anywhere in free text — a message, a URL, a stack frame. */
const PHONE_PATTERN = /\+\d{8,15}/g;
/** A bare 6-digit run, which is the shape of our OTP. */
const OTP_PATTERN = /\b\d{6}\b/g;
export const REDACTED = '[redacted]';
function scrubString(value: string): string {
return value.replace(PHONE_PATTERN, REDACTED).replace(OTP_PATTERN, REDACTED);
}
/**
* Walk anything and redact by key name and by value shape.
*
* Depth-limited and cycle-safe: an event is arbitrary user data, and a crash
* reporter that itself crashes on a circular reference loses the very report
* that mattered.
*/
export function scrub(value: unknown, seen = new WeakSet<object>(), depth = 0): unknown {
if (depth > 8) return REDACTED;
if (typeof value === 'string') return scrubString(value);
if (value === null || typeof value !== 'object') return value;
if (seen.has(value as object)) return REDACTED;
seen.add(value as object);
if (Array.isArray(value)) return value.map((v) => scrub(v, seen, depth + 1));
const out: Record<string, unknown> = {};
for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
out[key] = SECRET_KEYS.includes(key.toLowerCase()) ? REDACTED : scrub(v, seen, depth + 1);
}
return out;
}
/**
* Last gate before an event leaves the process.
*
* Returning null drops the event entirely — used for noise that is not a bug.
*/
export function beforeSend(event: ErrorEvent): ErrorEvent | null {
// Never ship headers or cookies: the session cookie is a live credential.
if (event.request) {
delete event.request.cookies;
delete event.request.headers;
if (event.request.url) event.request.url = scrubString(event.request.url);
if (event.request.query_string && typeof event.request.query_string === 'string') {
event.request.query_string = scrubString(event.request.query_string);
}
if (event.request.data) event.request.data = scrub(event.request.data);
}
// Identify the user by id only. A phone number or email here would make the
// error tracker a directory of everyone who has ever hit a bug.
if (event.user) {
event.user = { id: event.user.id };
}
if (event.extra) event.extra = scrub(event.extra) as Record<string, unknown>;
if (event.message) event.message = scrubString(event.message);
for (const exception of event.exception?.values ?? []) {
if (exception.value) exception.value = scrubString(exception.value);
}
event.breadcrumbs = event.breadcrumbs?.map((crumb) => ({
...crumb,
message: crumb.message ? scrubString(crumb.message) : crumb.message,
data: crumb.data ? (scrub(crumb.data) as Record<string, unknown>) : crumb.data,
}));
return event;
}
/**
* Options every runtime shares.
*
* `enabled` is gated on the DSN rather than on NODE_ENV: a staging deploy with a
* DSN should report, and a local run without one should stay silent instead of
* throwing at boot.
*/
export function baseOptions(dsn: string | undefined) {
return {
dsn,
enabled: Boolean(dsn),
environment: process.env.NODE_ENV,
// Bugsink ingests errors only — no tracing, no profiling, no replay.
tracesSampleRate: 0,
// Phone numbers, IPs and headers are exactly what must not be collected here.
sendDefaultPii: false,
beforeSend,
};
}
+57
View File
@@ -0,0 +1,57 @@
'use client';
/**
* The pro someone picked, held across a round trip.
*
* Swiping right is where this product's funnel actually starts, and answering
* "which job?" often means leaving the page first — to sign in, or to post the
* job there is nothing to send yet. Without somewhere to park the choice, the
* person comes back to a fresh deck and the pro they wanted is gone.
*
* `sessionStorage`, not a URL param: a pro id in the address bar survives being
* pasted into a chat, and this is nobody else's business. Not `localStorage`
* either — an intent from last Tuesday is not an intent.
*/
const KEY = 'linkder:pending-hire';
export interface PendingHire {
proId: string;
/** For the copy on the way back — "Post a job for Marc". */
name: string;
}
export function setPendingHire(hire: PendingHire): void {
try {
sessionStorage.setItem(KEY, JSON.stringify(hire));
} catch {
// Private mode, or storage disabled. The flow still works, it just cannot
// resume — which is why nothing downstream treats this as load-bearing.
}
}
export function readPendingHire(): PendingHire | null {
try {
const raw = sessionStorage.getItem(KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (
typeof parsed === 'object' &&
parsed !== null &&
typeof (parsed as PendingHire).proId === 'string' &&
typeof (parsed as PendingHire).name === 'string'
) {
return parsed as PendingHire;
}
return null;
} catch {
return null;
}
}
export function clearPendingHire(): void {
try {
sessionStorage.removeItem(KEY);
} catch {
/* nothing to clear if it could never be written */
}
}
+21
View File
@@ -0,0 +1,21 @@
'use client';
import { useEffect, useState } from 'react';
/**
* Trail a fast-changing value by `delayMs`.
*
* Used by search so a query fires per pause, not per keystroke: at ~5 keystrokes
* a second an undebounced box is five round trips for a word nobody finished
* typing.
*/
export function useDebouncedValue<T>(value: T, delayMs = 250): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
+57 -2
View File
@@ -5,12 +5,67 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
/** "1.2 km away" / "800 m away" — pros are local, so precision matters up close. */ /**
* "1.2 km away" / "750 m away" — pros are local, so precision matters up close.
*
* Coarsened to 250m under a kilometre, not 50m.
*
* While every coordinate was the city centre the fine figure was harmless. With
* real geocoding it is not: a job's street address is deliberately withheld from
* a pro until a booking exists (schema/jobs.ts, job.mineForPro), and a distance
* accurate to 50m read from two or three cards triangulates it back. Ranking
* still uses full precision server-side — this blunts only the rendered number.
*/
export function formatDistance(metres: number): string { export function formatDistance(metres: number): string {
if (metres < 1000) return `${Math.round(metres / 50) * 50} m away`; if (metres < 1000) return `${Math.round(metres / 250) * 250} m away`;
return `${(metres / 1000).toFixed(1)} km away`; return `${(metres / 1000).toFixed(1)} km away`;
} }
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/**
* How long ago, for a message or a list row: "now", "12 min", "3 h", "Tue",
* "12 Mar".
*
* Deliberately terse and unitless past a week — this sits at the end of a row
* that has already spent its width on the thing that matters, and "last Tuesday
* at 14:32" is not what someone scanning a list is reading for.
*/
export function formatRelativeTime(value: Date, now: Date = new Date()): string {
const elapsed = now.getTime() - value.getTime();
// Clock skew and optimistic rows can both put a timestamp slightly ahead.
if (elapsed < MINUTE) return 'now';
if (elapsed < HOUR) return `${Math.floor(elapsed / MINUTE)} min`;
if (elapsed < DAY) return `${Math.floor(elapsed / HOUR)} h`;
if (elapsed < 7 * DAY) return value.toLocaleDateString(undefined, { weekday: 'short' });
return value.toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
}
/**
* When something is happening: "Today 14:00", "Tomorrow 09:00", "Thu 14:00",
* "12 Mar 14:00".
*
* Compared on calendar days rather than elapsed hours — 23:00 tonight and 01:00
* tomorrow are two hours apart and must not both read as "Today".
*/
export function formatWhen(value: Date, now: Date = new Date()): string {
const time = value.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const days = Math.round((startOfDay(value) - startOfDay(now)) / DAY);
if (days === 0) return `Today ${time}`;
if (days === 1) return `Tomorrow ${time}`;
if (days === -1) return `Yesterday ${time}`;
if (days > 1 && days < 7) {
return `${value.toLocaleDateString(undefined, { weekday: 'short' })} ${time}`;
}
return `${value.toLocaleDateString(undefined, { day: 'numeric', month: 'short' })} ${time}`;
}
/** "usually replies in 25 min" */ /** "usually replies in 25 min" */
export function formatResponseTime(minutes: number | null): string | null { export function formatResponseTime(minutes: number | null): string | null {
if (minutes === null) return null; if (minutes === null) return null;
+12 -41
View File
@@ -1,48 +1,19 @@
import { sendSms } from '@linkder/notify';
/** /**
* SMS delivery for one-time codes. * SMS delivery for one-time codes.
* *
* In development there is no provider and no spend: the code is logged to the * The transport itself now lives in @linkder/notify, so the API package can
* server console so you can sign in. That path is hard-gated on NODE_ENV so a * reach it too — a tRPC procedure cannot import from `apps/web`, and the sign-in
* production deploy without Twilio credentials FAILS rather than silently * code and a "somebody wants to hire you" text have no business going out
* printing login codes into a log aggregator. * through two different Twilio clients with two different failure policies.
*
* The copy stays here. This is the one message that is part of the auth flow
* rather than part of the product, and it says things the others must not.
*/ */
const isProduction = process.env.NODE_ENV === 'production';
export async function sendVerificationSms(to: string, code: string): Promise<void> { export async function sendVerificationSms(to: string, code: string): Promise<void> {
const sid = process.env.TWILIO_ACCOUNT_SID; await sendSms(
const token = process.env.TWILIO_AUTH_TOKEN; to,
const from = process.env.TWILIO_FROM_NUMBER; `${code} is your Linkder code. It expires in 5 minutes. We will never ask you for it.`,
if (!sid || !token || !from) {
if (isProduction) {
throw new Error(
'SMS is not configured (TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_FROM_NUMBER). ' +
'Refusing to fall back to console logging in production.',
); );
} }
console.info(`\n [dev SMS] verification code for ${to}: ${code}\n`);
return;
}
const response = await fetch(
`https://api.twilio.com/2010-04-01/Accounts/${sid}/Messages.json`,
{
method: 'POST',
headers: {
Authorization: `Basic ${Buffer.from(`${sid}:${token}`).toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
To: to,
From: from,
Body: `${code} is your Linkder code. It expires in 5 minutes. We will never ask you for it.`,
}),
},
);
if (!response.ok) {
// Never log the code itself in production.
const detail = await response.text().catch(() => '<no body>');
throw new Error(`Twilio rejected the message (${response.status}): ${detail}`);
}
}
+112
View File
@@ -0,0 +1,112 @@
/**
* The scrubber is part of the auth boundary, not a nicety.
*
* On this platform a phone number is the login identity and a 6-digit OTP is
* the credential. If either reaches Bugsink, anyone with access to the error
* tracker can sign in as that user — so these tests assert the redaction, not
* the happy path.
*/
import { describe, expect, it } from 'vitest';
import { beforeSend, REDACTED, scrub } from '../src/lib/observability';
type Event = Parameters<typeof beforeSend>[0];
describe('scrub', () => {
it('redacts secret-bearing keys wherever they are nested', () => {
const out = scrub({
safe: 'keep me',
phoneNumber: '+34600111222',
nested: { deeper: { token: 'abc123', otp: '445566' } },
}) as {
safe: string;
phoneNumber: string;
nested: { deeper: { token: string; otp: string } };
};
expect(out.safe).toBe('keep me');
expect(out.phoneNumber).toBe(REDACTED);
expect(out.nested.deeper.token).toBe(REDACTED);
expect(out.nested.deeper.otp).toBe(REDACTED);
});
it('redacts a phone number found in free text, not just in a named field', () => {
const out = scrub('failed to send to +34600111222 after 3 tries');
expect(out).not.toContain('600111222');
expect(out).toContain(REDACTED);
});
it('redacts a bare six-digit code, which is the shape of our OTP', () => {
expect(scrub('code 123456 expired')).toBe(`code ${REDACTED} expired`);
});
it('survives a circular object rather than throwing away the report', () => {
const a: Record<string, unknown> = { name: 'x' };
a.self = a;
expect(() => scrub(a)).not.toThrow();
});
it('walks arrays', () => {
const out = scrub([{ token: 'a' }, { safe: 'b' }]) as Array<Record<string, string>>;
expect(out[0]!.token).toBe(REDACTED);
expect(out[1]!.safe).toBe('b');
});
});
describe('beforeSend', () => {
it('drops cookies and headers entirely', () => {
const event = {
request: {
url: 'https://linkder.app/api',
cookies: { session: 'live-credential' },
headers: { authorization: 'Bearer live-credential' },
},
} as unknown as Event;
const out = beforeSend(event)!;
expect(out.request?.cookies).toBeUndefined();
expect(out.request?.headers).toBeUndefined();
});
it('reduces the user to an id — never a phone or email', () => {
const event = {
user: { id: 'user-1', email: 'someone@example.com', phone: '+34600111222' },
} as unknown as Event;
const out = beforeSend(event)!;
expect(out.user).toEqual({ id: 'user-1' });
});
it('scrubs a phone number out of the exception message', () => {
const event = {
exception: { values: [{ value: 'no user for +34600111222' }] },
} as unknown as Event;
const out = beforeSend(event)!;
expect(out.exception!.values![0]!.value).not.toContain('600111222');
});
it('scrubs request body data and the query string', () => {
const event = {
request: {
url: 'https://linkder.app/verify?code=123456',
query_string: 'code=123456',
data: { phoneNumber: '+34600111222', code: '123456' },
},
} as unknown as Event;
const out = beforeSend(event)!;
expect(out.request!.query_string).not.toContain('123456');
expect(out.request!.url).not.toContain('123456');
expect((out.request!.data as Record<string, string>).phoneNumber).toBe(REDACTED);
});
it('scrubs breadcrumbs, which is where fetch URLs accumulate', () => {
const event = {
breadcrumbs: [{ message: 'POST /phone-number/verify +34600111222', data: { code: '123456' } }],
} as unknown as Event;
const out = beforeSend(event)!;
expect(out.breadcrumbs![0]!.message).not.toContain('600111222');
expect((out.breadcrumbs![0]!.data as Record<string, string>).code).toBe(REDACTED);
});
});
+119
View File
@@ -0,0 +1,119 @@
/**
* Which social providers get registered, and when.
*
* The rule that matters is "both keys or neither". With one key set,
* better-auth still registers the provider and /sign-in/social gets as far as
* the OAuth URL builder before throwing — a 500 on an environment that is merely
* unconfigured. Omitting it makes the same click a clean 404 PROVIDER_NOT_FOUND,
* which the button turns into "not set up yet" rather than "try again".
*
* Env is swapped per case and the module re-imported, because `lib/auth.ts`
* reads `process.env` once at module scope — which is exactly the behaviour
* being pinned here.
*/
import { config } from 'dotenv';
import { afterEach, describe, expect, it, vi } from 'vitest';
config({ path: '../../.env' });
const KEYS = [
'AUTH_GOOGLE_ID',
'AUTH_GOOGLE_SECRET',
'AUTH_MICROSOFT_ID',
'AUTH_MICROSOFT_SECRET',
'AUTH_MICROSOFT_TENANT_ID',
'AUTH_GITHUB_ID',
'AUTH_GITHUB_SECRET',
] as const;
const original = Object.fromEntries(KEYS.map((k) => [k, process.env[k]]));
async function providersWith(env: Partial<Record<(typeof KEYS)[number], string | undefined>>) {
for (const key of KEYS) {
const value = env[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
vi.resetModules();
const { auth } = await import('@/lib/auth');
return (auth.options.socialProviders ?? {}) as Record<string, { tenantId?: string }>;
}
afterEach(() => {
for (const key of KEYS) {
const value = original[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
vi.resetModules();
});
describe('social provider registration', () => {
it('registers each provider when both of its keys are present', async () => {
const providers = await providersWith({
AUTH_GOOGLE_ID: 'g-id',
AUTH_GOOGLE_SECRET: 'g-secret',
AUTH_MICROSOFT_ID: 'm-id',
AUTH_MICROSOFT_SECRET: 'm-secret',
AUTH_GITHUB_ID: 'gh-id',
AUTH_GITHUB_SECRET: 'gh-secret',
});
expect(Object.keys(providers).sort()).toEqual(['github', 'google', 'microsoft']);
});
it('treats a half-set pair as unset rather than half-registering it', async () => {
const providers = await providersWith({
AUTH_GOOGLE_ID: 'g-id',
AUTH_GOOGLE_SECRET: undefined,
AUTH_MICROSOFT_ID: undefined,
AUTH_MICROSOFT_SECRET: 'm-secret',
AUTH_GITHUB_ID: 'gh-id',
AUTH_GITHUB_SECRET: undefined,
});
expect(providers.google).toBeUndefined();
expect(providers.microsoft).toBeUndefined();
expect(providers.github).toBeUndefined();
});
it('leaves phone OTP as the only route when nothing is configured', async () => {
const providers = await providersWith({});
expect(Object.keys(providers)).toEqual([]);
});
it('registers them independently — one missing does not take the others down', async () => {
const providers = await providersWith({
AUTH_GOOGLE_ID: 'g-id',
AUTH_GOOGLE_SECRET: 'g-secret',
});
expect(providers.google).toBeDefined();
expect(providers.microsoft).toBeUndefined();
expect(providers.github).toBeUndefined();
});
it('defaults Microsoft to the multi-tenant endpoint', async () => {
// `common` is work, school AND personal accounts. A consumer marketplace
// that silently defaulted to a single tenant would turn away every customer
// who is not in that organisation.
const providers = await providersWith({
AUTH_MICROSOFT_ID: 'm-id',
AUTH_MICROSOFT_SECRET: 'm-secret',
AUTH_MICROSOFT_TENANT_ID: undefined,
});
expect(providers.microsoft?.tenantId).toBe('common');
});
it('honours an explicit tenant so a single-organisation deploy can lock down', async () => {
const providers = await providersWith({
AUTH_MICROSOFT_ID: 'm-id',
AUTH_MICROSOFT_SECRET: 'm-secret',
AUTH_MICROSOFT_TENANT_ID: '00000000-0000-0000-0000-000000000000',
});
expect(providers.microsoft?.tenantId).toBe('00000000-0000-0000-0000-000000000000');
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* The two formatters the jobs tab and the chat lean on.
*
* Pure functions with an injectable `now`, so none of this needs fake timers —
* which is also why they take one: a formatter that reads the clock itself is a
* formatter you can only test by lying to the runtime.
*/
import { describe, expect, it } from 'vitest';
import { formatRelativeTime, formatWhen } from '../src/lib/utils';
const NOW = new Date('2026-08-21T12:00:00Z');
const ago = (ms: number) => new Date(NOW.getTime() - ms);
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
describe('formatRelativeTime', () => {
it('collapses the first minute to "now"', () => {
expect(formatRelativeTime(ago(0), NOW)).toBe('now');
expect(formatRelativeTime(ago(59 * SECOND), NOW)).toBe('now');
});
it('reads a timestamp slightly in the future as "now" rather than negative', () => {
// Clock skew between the server row and the browser, and optimistic rows.
expect(formatRelativeTime(new Date(NOW.getTime() + 5 * SECOND), NOW)).toBe('now');
});
it('counts minutes, then hours', () => {
expect(formatRelativeTime(ago(MINUTE), NOW)).toBe('1 min');
expect(formatRelativeTime(ago(59 * MINUTE), NOW)).toBe('59 min');
expect(formatRelativeTime(ago(HOUR), NOW)).toBe('1 h');
expect(formatRelativeTime(ago(23 * HOUR), NOW)).toBe('23 h');
});
it('switches to a weekday inside the week and a date beyond it', () => {
// Rounding down matters at the boundary: 24h ago is a day, not "24 h".
expect(formatRelativeTime(ago(DAY), NOW)).not.toMatch(/h$/);
expect(formatRelativeTime(ago(DAY), NOW)).toMatch(/^\w+/);
const old = formatRelativeTime(ago(30 * DAY), NOW);
expect(old).toMatch(/\d/);
});
});
describe('formatWhen', () => {
it('names today, tomorrow and yesterday', () => {
const noon = new Date(2026, 7, 21, 12, 0);
expect(formatWhen(new Date(2026, 7, 21, 14, 0), noon)).toMatch(/^Today /);
expect(formatWhen(new Date(2026, 7, 22, 9, 0), noon)).toMatch(/^Tomorrow /);
expect(formatWhen(new Date(2026, 7, 20, 9, 0), noon)).toMatch(/^Yesterday /);
});
it('compares calendar days, not elapsed hours', () => {
// 23:00 tonight and 01:00 tomorrow are two hours apart. An elapsed-time
// comparison calls both "Today"; only one of them is.
const lateTonight = new Date(2026, 7, 21, 23, 0);
const earlyTomorrow = new Date(2026, 7, 22, 1, 0);
expect(formatWhen(lateTonight, lateTonight)).toMatch(/^Today /);
expect(formatWhen(earlyTomorrow, lateTonight)).toMatch(/^Tomorrow /);
});
it('survives a daylight-saving boundary', () => {
// Europe/Madrid springs forward on 29 March 2026: that day is 23 hours long,
// so a naive divide-by-86400000 would call the next morning "Today".
const beforeChange = new Date(2026, 2, 28, 12, 0);
const afterChange = new Date(2026, 2, 29, 12, 0);
expect(formatWhen(afterChange, beforeChange)).toMatch(/^Tomorrow /);
});
it('uses a weekday inside the week and a date beyond it', () => {
const monday = new Date(2026, 7, 17, 12, 0);
expect(formatWhen(new Date(2026, 7, 20, 14, 0), monday)).not.toMatch(/^(Today|Tomorrow)/);
expect(formatWhen(new Date(2026, 8, 30, 14, 0), monday)).toMatch(/\d/);
});
});
+5 -2
View File
@@ -2,18 +2,21 @@
"name": "linkder", "name": "linkder",
"private": true, "private": true,
"packageManager": "pnpm@9.15.4", "packageManager": "pnpm@9.15.4",
"engines": { "node": ">=20" }, "engines": {
"node": ">=20"
},
"scripts": { "scripts": {
"dev": "turbo run dev", "dev": "turbo run dev",
"build": "turbo run build", "build": "turbo run build",
"lint": "turbo run lint", "lint": "turbo run lint",
"typecheck": "turbo run typecheck", "typecheck": "turbo run typecheck",
"test": "turbo run test", "test": "turbo run test --concurrency=1",
"test:e2e": "turbo run test:e2e", "test:e2e": "turbo run test:e2e",
"format": "prettier --write \"**/*.{ts,tsx,md,json}\"", "format": "prettier --write \"**/*.{ts,tsx,md,json}\"",
"db:generate": "pnpm --filter @linkder/db generate", "db:generate": "pnpm --filter @linkder/db generate",
"db:migrate": "pnpm --filter @linkder/db migrate", "db:migrate": "pnpm --filter @linkder/db migrate",
"db:seed": "pnpm --filter @linkder/db seed", "db:seed": "pnpm --filter @linkder/db seed",
"db:recompute-stats": "pnpm --filter @linkder/db recompute-stats",
"db:studio": "pnpm --filter @linkder/db studio", "db:studio": "pnpm --filter @linkder/db studio",
"services:up": "docker compose up -d postgres redis", "services:up": "docker compose up -d postgres redis",
"services:down": "docker compose down" "services:down": "docker compose down"
+5 -2
View File
@@ -15,12 +15,15 @@
}, },
"dependencies": { "dependencies": {
"@linkder/db": "workspace:*", "@linkder/db": "workspace:*",
"@linkder/geocode": "workspace:*",
"@linkder/notify": "workspace:*",
"@linkder/shared": "workspace:*", "@linkder/shared": "workspace:*",
"@linkder/storage": "workspace:*",
"@opentelemetry/api": "1.9.1",
"@trpc/server": "^11.18.0", "@trpc/server": "^11.18.0",
"drizzle-orm": "0.38.4", "drizzle-orm": "0.38.4",
"superjson": "^2.2.6", "superjson": "^2.2.6",
"zod": "^3.24.1", "zod": "^3.24.1"
"@linkder/storage": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"dotenv": "16.4.7", "dotenv": "16.4.7",
+106
View File
@@ -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;
}
}
+14
View File
@@ -1,7 +1,14 @@
import { router } from './trpc'; import { router } from './trpc';
import { adminRouter } from './routers/admin';
import { deckRouter } from './routers/deck'; import { deckRouter } from './routers/deck';
import { bookingRouter } from './routers/booking';
import { geocodeRouter } from './routers/geocode';
import { jobRouter } from './routers/job'; import { jobRouter } from './routers/job';
import { messageRouter } from './routers/message';
import { proRouter } from './routers/pro'; 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 { uploadRouter } from './routers/upload';
import { notificationRouter } from './routers/notification'; import { notificationRouter } from './routers/notification';
import { userRouter } from './routers/user'; import { userRouter } from './routers/user';
@@ -12,8 +19,15 @@ import { userRouter } from './routers/user';
*/ */
export const appRouter = router({ export const appRouter = router({
job: jobRouter, job: jobRouter,
admin: adminRouter,
deck: deckRouter, deck: deckRouter,
pro: proRouter, pro: proRouter,
request: requestRouter,
message: messageRouter,
quote: quoteRouter,
booking: bookingRouter,
review: reviewRouter,
geocode: geocodeRouter,
upload: uploadRouter, upload: uploadRouter,
user: userRouter, user: userRouter,
notification: notificationRouter, notification: notificationRouter,
+373
View File
@@ -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 };
}),
});
+243
View File
@@ -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 };
}),
});
+122 -4
View File
@@ -1,7 +1,8 @@
import { TRPCError } from '@trpc/server'; 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 { z } from 'zod';
import { getDeck, getDeckCount, getShowcaseDeck, schema } from '@linkder/db'; import { getDeck, getDeckCount, getShowcaseDeck, schema } from '@linkder/db';
import { notify } from '@linkder/notify';
import { import {
DECK_PAGE_SIZE, DECK_PAGE_SIZE,
MAX_OPEN_REQUESTS_PER_JOB, MAX_OPEN_REQUESTS_PER_JOB,
@@ -85,6 +86,86 @@ export const deckRouter = router({
return { cards }; 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. */ /** The next cards for one of the caller's own jobs. */
list: clientProcedure list: clientProcedure
.input(z.object({ jobId: z.string().uuid(), limit: z.number().int().min(1).max(50).optional() })) .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 * (Writing the tombstone first and rolling back would make a dismissed card
* reappear, which contradicts how the deck client is meant to behave.) * 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 await tx
.select({ id: schema.jobs.id }) .select({ id: schema.jobs.id })
.from(schema.jobs) .from(schema.jobs)
@@ -190,14 +271,51 @@ export const deckRouter = router({
.onConflictDoNothing({ target: [schema.requests.jobId, schema.requests.proId] }) .onConflictDoNothing({ target: [schema.requests.jobId, schema.requests.proId] })
.returning(); .returning();
// TODO(M3): enqueue a notification to the pro (web push + email) via BullMQ.
return { return {
requested: true as const, requested: true as const,
requestId: request?.id ?? null, requestId: request?.id ?? null,
expiresInHours: ttlHours, 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. */ /** Undo the last swipe on a job, as long as it has not become a live request. */
+101
View File
@@ -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
View File
@@ -2,8 +2,9 @@ import { TRPCError } from '@trpc/server';
import { and, desc, eq, sql } from 'drizzle-orm'; import { and, desc, eq, sql } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { schema } from '@linkder/db'; import { schema } from '@linkder/db';
import { assertTransition, createJobSchema } from '@linkder/shared'; import { ACTIVE_JOB_STATUSES, assertTransition, createJobSchema } from '@linkder/shared';
import { clientProcedure, publicProcedure, router } from '../trpc'; import { resolveLocation } from '../location';
import { clientProcedure, proProcedure, publicProcedure, router } from '../trpc';
export const jobRouter = router({ export const jobRouter = router({
categories: publicProcedure.query(({ ctx }) => categories: publicProcedure.query(({ ctx }) =>
@@ -22,6 +23,9 @@ export const jobRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Unknown trade' }); 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 const [job] = await ctx.db
.insert(schema.jobs) .insert(schema.jobs)
.values({ .values({
@@ -33,8 +37,10 @@ export const jobRouter = router({
urgency: input.urgency, urgency: input.urgency,
budgetMinCents: input.budgetMinCents ?? null, budgetMinCents: input.budgetMinCents ?? null,
budgetMaxCents: input.budgetMaxCents ?? null, budgetMaxCents: input.budgetMaxCents ?? null,
location: input.location, location: resolved.location,
addressText: input.addressText, locationPrecision: resolved.precision,
locationPlaceId: resolved.placeId,
addressText: resolved.addressText,
}) })
.returning(); .returning();
@@ -42,14 +48,125 @@ export const jobRouter = router({
return job; return job;
}), }),
/** The caller's own jobs, newest first. */ /**
mine: clientProcedure.query(({ ctx }) => * The caller's own jobs, newest first, with everything a list row shows.
ctx.db *
.select() * 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) .from(schema.jobs)
.where(eq(schema.jobs.clientId, ctx.session.userId)) .innerJoin(schema.categories, eq(schema.categories.id, schema.jobs.categoryId))
.orderBy(desc(schema.jobs.createdAt)), .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 }) => { byId: clientProcedure.input(z.object({ id: z.string().uuid() })).query(async ({ ctx, input }) => {
const job = await ctx.db.query.jobs.findFirst({ const job = await ctx.db.query.jobs.findFirst({
@@ -69,6 +186,81 @@ export const jobRouter = router({
return { ...job, pendingRequests: pending?.n ?? 0 }; 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 cancel: clientProcedure
.input(z.object({ id: z.string().uuid(), reason: z.string().max(500).optional() })) .input(z.object({ id: z.string().uuid(), reason: z.string().max(500).optional() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
+280
View File
@@ -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 };
}),
});
+206 -23
View File
@@ -1,14 +1,28 @@
import { TRPCError } from '@trpc/server'; 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 { z } from 'zod';
import { schema } from '@linkder/db'; import { eligibleProAtAnyDistance, schema, searchPros } from '@linkder/db';
import { notify } from '@linkder/notify';
import { import {
assertTransition, assertTransition,
credentialSchema, credentialSchema,
proProfileSchema, proProfileSchema,
proReviewsSchema,
REVIEWS_PAGE_SIZE,
searchProsSchema,
updateSkillsSchema, updateSkillsSchema,
} from '@linkder/shared'; } 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 * A pro is only shown to clients once verification passes. These procedures
@@ -17,6 +31,60 @@ import { protectedProcedure, proProcedure, router } from '../trpc';
* onboarding impossible. * onboarding impossible.
*/ */
export const proRouter = router({ 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. */ /** The caller's own pro profile, with everything the onboarding wizard needs. */
me: proProcedure.query(async ({ ctx }) => { me: proProcedure.query(async ({ ctx }) => {
const profile = await ctx.db.query.proProfiles.findFirst({ const profile = await ctx.db.query.proProfiles.findFirst({
@@ -88,11 +156,16 @@ export const proRouter = router({
: []; : [];
const nextCategoryIds = [...input.categoryIds].sort(); 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( const materiallyChanged = Boolean(
existing && existing &&
(existing.serviceRadiusM !== input.serviceRadiusM || (existing.serviceRadiusM !== input.serviceRadiusM ||
existing.baseLocation.lat !== input.location.lat || existing.baseLocation.lat !== resolved.location.lat ||
existing.baseLocation.lng !== input.location.lng || existing.baseLocation.lng !== resolved.location.lng ||
previousCategoryIds.length !== nextCategoryIds.length || previousCategoryIds.length !== nextCategoryIds.length ||
previousCategoryIds.some((id, i) => id !== nextCategoryIds[i])), previousCategoryIds.some((id, i) => id !== nextCategoryIds[i])),
); );
@@ -118,7 +191,9 @@ export const proRouter = router({
bio: input.bio, bio: input.bio,
hourlyRateCents: input.hourlyRateCents, hourlyRateCents: input.hourlyRateCents,
yearsExperience: input.yearsExperience, yearsExperience: input.yearsExperience,
baseLocation: input.location, baseLocation: resolved.location,
baseLocationPrecision: resolved.precision,
baseLocationPlaceId: resolved.placeId,
serviceRadiusM: input.serviceRadiusM, serviceRadiusM: input.serviceRadiusM,
updatedAt: new Date(), updatedAt: new Date(),
...(sendBackForReview ? { verificationStatus: 'pending' as const } : {}), ...(sendBackForReview ? { verificationStatus: 'pending' as const } : {}),
@@ -234,6 +309,7 @@ export const proRouter = router({
distanceM: 2_400, distanceM: 2_400,
photos: media.map((m) => m.url), photos: media.map((m) => m.url),
categories: categories.map((c) => c.name), categories: categories.map((c) => c.name),
skills: profile.skills,
score: 0, score: 0,
verificationStatus: profile.verificationStatus, verificationStatus: profile.verificationStatus,
isAcceptingJobs: profile.isAcceptingJobs, isAcceptingJobs: profile.isAcceptingJobs,
@@ -371,6 +447,17 @@ export const proRouter = router({
const missing: string[] = []; const missing: string[] = [];
if (categories.length === 0) missing.push('at least one trade'); if (categories.length === 0) missing.push('at least one trade');
if (media.length === 0) missing.push('at least one photo'); 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 === 'id')) missing.push('a photo ID');
if (!credentials.some((c) => c.kind === 'insurance')) missing.push('proof of insurance'); if (!credentials.some((c) => c.kind === 'insurance')) missing.push('proof of insurance');
@@ -396,7 +483,28 @@ export const proRouter = router({
ip: ctx.ip, 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 }; 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, * Public profile, for the card detail view. Only ever returns a verified pro,
* and deliberately omits anything private — no phone, no documents, no address. * and deliberately omits anything private — no phone, no documents, no address.
*/ */
publicProfile: protectedProcedure publicProfile: publicProcedure
.input(z.object({ proId: z.string().uuid() })) .input(z.object({ proId: z.string().uuid() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const profile = await ctx.db.query.proProfiles.findFirst({ const [row] = await ctx.db
where: and( .select({ profile: p, name: u.name, image: u.image })
eq(schema.proProfiles.userId, input.proId), .from(p)
eq(schema.proProfiles.verificationStatus, 'verified'), .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
if (!profile) throw new TRPCError({ code: 'NOT_FOUND' }); // shares the rule rather than restating it.
.where(and(eq(p.userId, input.proId), eligibleProAtAnyDistance()));
const [user] = await ctx.db if (!row) throw new TRPCError({ code: 'NOT_FOUND' });
.select({ name: schema.users.name, image: schema.users.image }) const { profile } = row;
.from(schema.users)
.where(eq(schema.users.id, input.proId));
const media = await ctx.db const [media, categories] = await Promise.all([
ctx.db
.select() .select()
.from(schema.proMedia) .from(schema.proMedia)
.where(eq(schema.proMedia.proId, input.proId)) .where(eq(schema.proMedia.proId, input.proId))
.orderBy(schema.proMedia.position); .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 { return {
proId: profile.userId, proId: profile.userId,
name: user?.name ?? null, name: row.name,
image: user?.image ?? null, image: row.image,
headline: profile.headline, headline: profile.headline,
bio: profile.bio, bio: profile.bio,
hourlyRateCents: profile.hourlyRateCents, hourlyRateCents: profile.hourlyRateCents,
@@ -449,7 +563,76 @@ export const proRouter = router({
ratingAvg: profile.ratingAvg === null ? null : Number(profile.ratingAvg), ratingAvg: profile.ratingAvg === null ? null : Number(profile.ratingAvg),
ratingCount: profile.ratingCount, ratingCount: profile.ratingCount,
completedJobs: profile.completedJobs, completedJobs: profile.completedJobs,
responseRate: profile.responseRate === null ? null : Number(profile.responseRate),
avgResponseMinutes: profile.avgResponseMinutes,
categories: categories.map((c) => c.name),
skills: profile.skills,
media, 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,
};
}),
}); });
+289
View File
@@ -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 };
});
}),
});
+259
View File
@@ -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;
}),
});
+221
View File
@@ -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,
};
}),
});
+26 -10
View File
@@ -4,6 +4,7 @@ import { and, desc, eq, isNull } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { schema } from '@linkder/db'; import { schema } from '@linkder/db';
import { assertTransition, isContactableEmail, updateLocationSchema } from '@linkder/shared'; import { assertTransition, isContactableEmail, updateLocationSchema } from '@linkder/shared';
import { resolveLocation } from '../location';
import { protectedProcedure, publicProcedure, router } from '../trpc'; import { protectedProcedure, publicProcedure, router } from '../trpc';
export const userRouter = router({ export const userRouter = router({
@@ -268,21 +269,30 @@ export const userRouter = router({
}) })
: undefined; : undefined;
// The label is a display string with no matching role, so it lives on the // The label is no longer a free-text field stored beside unrelated
// user for everyone rather than being duplicated per role. // coordinates: it is whatever the geocoder called the point we resolved,
if (input.addressText !== undefined) { // so the two cannot drift apart.
const resolved = input.place ? await resolveLocation(input.place) : null;
if (resolved) {
await ctx.db await ctx.db
.update(schema.users) .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)); .where(eq(schema.users.id, ctx.session.userId));
} }
if (!profile) { if (!profile) {
if (input.location !== undefined || input.radiusM !== undefined) { if (resolved || input.radiusM !== undefined) {
await ctx.db await ctx.db
.update(schema.users) .update(schema.users)
.set({ .set({
...(input.location !== undefined ? { location: input.location } : {}), ...(resolved
? {
location: resolved.location,
locationPrecision: resolved.precision,
locationPlaceId: resolved.placeId,
}
: {}),
...(input.radiusM !== undefined ? { searchRadiusM: input.radiusM } : {}), ...(input.radiusM !== undefined ? { searchRadiusM: input.radiusM } : {}),
updatedAt: new Date(), updatedAt: new Date(),
}) })
@@ -292,9 +302,9 @@ export const userRouter = router({
} }
const moved = const moved =
input.location !== undefined && resolved !== null &&
(input.location.lat !== profile.baseLocation.lat || (resolved.location.lat !== profile.baseLocation.lat ||
input.location.lng !== profile.baseLocation.lng); resolved.location.lng !== profile.baseLocation.lng);
const resized = input.radiusM !== undefined && input.radiusM !== profile.serviceRadiusM; const resized = input.radiusM !== undefined && input.radiusM !== profile.serviceRadiusM;
// Only a currently-verified pro needs demoting: a draft or pending profile // Only a currently-verified pro needs demoting: a draft or pending profile
@@ -309,7 +319,13 @@ export const userRouter = router({
await tx await tx
.update(schema.proProfiles) .update(schema.proProfiles)
.set({ .set({
...(input.location !== undefined ? { baseLocation: input.location } : {}), ...(resolved
? {
baseLocation: resolved.location,
baseLocationPrecision: resolved.precision,
baseLocationPlaceId: resolved.placeId,
}
: {}),
...(input.radiusM !== undefined ? { serviceRadiusM: input.radiusM } : {}), ...(input.radiusM !== undefined ? { serviceRadiusM: input.radiusM } : {}),
...(sendBackForReview ? { verificationStatus: 'pending' as const } : {}), ...(sendBackForReview ? { verificationStatus: 'pending' as const } : {}),
updatedAt: new Date(), updatedAt: new Date(),
+283
View File
@@ -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);
});
});
+114 -5
View File
@@ -45,16 +45,30 @@ let verifiedProId: string;
let unverifiedProId: string; let unverifiedProId: string;
beforeAll(async () => { 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 }>( 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]; 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; jobId = job.id;
ownerId = job.client_id; ownerId = job.client_id;
const others = await db.execute<{ id: string }>( 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; 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', () => { describe('job router', () => {
it("lists only the caller's own jobs", async () => { it("lists only the caller's own jobs", async () => {
const mine = await callerFor(clientSession(ownerId)).job.mine(); const mine = await callerFor(clientSession(ownerId)).job.mine();
expect(mine.length).toBeGreaterThan(0); // `mine` no longer returns client_id — it is scoped by it in the WHERE
for (const job of mine) expect(job.clientId).toBe(ownerId); // 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(); const theirs = await callerFor(clientSession(strangerId)).job.mine();
expect(theirs.map((j) => j.id)).not.toContain(jobId); expect(theirs.map((j) => j.id)).not.toContain(jobId);
+180
View File
@@ -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();
});
});
+391
View File
@@ -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 pros 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);
});
});
+398
View File
@@ -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 strangers 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 elses job', async () => {
await expect(
callerFor(clientSession(stranger)).job.matches({ jobId }),
).rejects.toThrow(/not found/i);
});
});
+212
View File
@@ -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);
});
});
+173
View File
@@ -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();
});
});
+20 -7
View File
@@ -67,10 +67,22 @@ const RUN = Math.random().toString(36).slice(2, 8);
const PROBE_UA = 'SettingsTestProbe'; const PROBE_UA = 'SettingsTestProbe';
beforeAll(async () => { 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 }>( 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; alice = rows[0]!.id;
bob = rows[1]!.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 () => { it('saves a pin, a label and a radius, and reads them back', async () => {
const caller = callerFor(clientSession(alice)); 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({ await caller.user.updateLocation({
location: { lat: 41.4036, lng: 2.1744 }, place: { source: 'device', lat: 41.4036, lng: 2.1744, label: 'Gracia, Barcelona' },
addressText: 'Gracia, Barcelona',
radiusM: 8_000, radiusM: 8_000,
}); });
@@ -335,10 +349,9 @@ describe('location and range', () => {
sql`UPDATE pro_profiles SET verification_status = 'verified' WHERE user_id = ${pro}`, 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({ const result = await callerFor(proSession(pro)).user.updateLocation({
radiusM: 30_000, radiusM: 30_000,
addressText: 'Somewhere warm',
}); });
expect(result.sentForReview).toBe(false); expect(result.sentForReview).toBe(false);
+19 -1
View File
@@ -1,5 +1,23 @@
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
export default defineConfig({ 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,
},
}); });
@@ -0,0 +1,7 @@
CREATE TYPE "public"."location_precision" AS ENUM('exact', 'approximate', 'city');--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "location_precision" "location_precision" DEFAULT 'city' NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "location_place_id" text;--> statement-breakpoint
ALTER TABLE "pro_profiles" ADD COLUMN "base_location_precision" "location_precision" DEFAULT 'city' NOT NULL;--> statement-breakpoint
ALTER TABLE "pro_profiles" ADD COLUMN "base_location_place_id" text;--> statement-breakpoint
ALTER TABLE "jobs" ADD COLUMN "location_precision" "location_precision" DEFAULT 'city' NOT NULL;--> statement-breakpoint
ALTER TABLE "jobs" ADD COLUMN "location_place_id" text;
@@ -0,0 +1,13 @@
CREATE TABLE "notification_deliveries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"kind" text NOT NULL,
"channel" text NOT NULL,
"status" text NOT NULL,
"detail" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "notification_deliveries" ADD CONSTRAINT "notification_deliveries_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "notification_deliveries_user_idx" ON "notification_deliveries" USING btree ("user_id","created_at");--> statement-breakpoint
CREATE INDEX "notification_deliveries_status_idx" ON "notification_deliveries" USING btree ("status","created_at");
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -36,6 +36,20 @@
"when": 1787296176203, "when": 1787296176203,
"tag": "0004_closed_nextwave", "tag": "0004_closed_nextwave",
"breakpoints": true "breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1787305583953,
"tag": "0005_stale_human_fly",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1787306631007,
"tag": "0006_careful_lilandra",
"breakpoints": true
} }
] ]
} }
+4 -2
View File
@@ -15,11 +15,13 @@
"push": "drizzle-kit push", "push": "drizzle-kit push",
"studio": "drizzle-kit studio", "studio": "drizzle-kit studio",
"seed": "tsx src/seed.ts", "seed": "tsx src/seed.ts",
"recompute-stats": "tsx src/recompute-stats.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@linkder/shared": "workspace:*", "@linkder/shared": "workspace:*",
"@opentelemetry/api": "1.9.1",
"drizzle-orm": "0.38.4", "drizzle-orm": "0.38.4",
"postgres": "^3.4.5" "postgres": "^3.4.5"
}, },
@@ -27,7 +29,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"drizzle-kit": "^0.30.1", "drizzle-kit": "^0.30.1",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"vitest": "^2.1.8", "typescript": "^5.7.3",
"typescript": "^5.7.3" "vitest": "^2.1.8"
} }
} }
+3
View File
@@ -2,3 +2,6 @@ export * from './client';
export * from './postgis'; export * from './postgis';
export * as schema from './schema/index'; export * as schema from './schema/index';
export * from './queries/deck'; export * from './queries/deck';
export * from './queries/eligibility';
export * from './queries/search';
export * from './queries/stats';
+16 -16
View File
@@ -1,6 +1,7 @@
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { DECK_PAGE_SIZE, score, type RankingInput } from '@linkder/shared'; import { DECK_PAGE_SIZE, score, type RankingInput } from '@linkder/shared';
import type { Db } from '../client'; import type { Db } from '../client';
import { eligiblePro } from './eligibility';
export interface DeckCard { export interface DeckCard {
proId: string; proId: string;
@@ -18,6 +19,8 @@ export interface DeckCard {
distanceM: number; distanceM: number;
photos: string[]; photos: string[];
categories: string[]; categories: string[];
/** The pro's own words for what they specialise in. Never matched on by the deck. */
skills: string[];
/** Debug/tuning aid — surfaced in admin, never in the client UI. */ /** Debug/tuning aid — surfaced in admin, never in the client UI. */
score: number; score: number;
} }
@@ -68,6 +71,7 @@ export async function getDeck(
created_at: string; created_at: string;
photos: string[] | null; photos: string[] | null;
categories: string[] | null; categories: string[] | null;
skills: string[] | null;
}>(sql` }>(sql`
SELECT SELECT
p.user_id AS pro_id, p.user_id AS pro_id,
@@ -97,17 +101,14 @@ export async function getDeck(
JOIN categories c ON c.id = pc.category_id JOIN categories c ON c.id = pc.category_id
WHERE pc.pro_id = p.user_id), WHERE pc.pro_id = p.user_id),
'{}' '{}'
) AS categories ) AS categories,
p.skills
FROM jobs j FROM jobs j
JOIN pro_categories pcat ON pcat.category_id = j.category_id JOIN pro_categories pcat ON pcat.category_id = j.category_id
JOIN pro_profiles p ON p.user_id = pcat.pro_id JOIN pro_profiles p ON p.user_id = pcat.pro_id
JOIN users u ON u.id = p.user_id JOIN users u ON u.id = p.user_id
WHERE j.id = ${args.jobId} WHERE j.id = ${args.jobId}
AND p.verification_status = 'verified' AND ${eligiblePro(sql`j.location`)}
AND p.is_accepting_jobs = true
AND (u.banned IS NOT TRUE OR (u.ban_expires IS NOT NULL AND u.ban_expires < now()))
-- the pro must be willing to travel to this job, index-accelerated
AND ST_DWithin(p.base_location, j.location, p.service_radius_m)
-- never show a card the client has already decided on -- never show a card the client has already decided on
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM swipes s SELECT 1 FROM swipes s
@@ -154,6 +155,7 @@ export async function getDeck(
distanceM: Math.round(Number(r.distance_m)), distanceM: Math.round(Number(r.distance_m)),
photos: r.photos ?? [], photos: r.photos ?? [],
categories: r.categories ?? [], categories: r.categories ?? [],
skills: r.skills ?? [],
score: score(input), score: score(input),
} satisfies DeckCard; } satisfies DeckCard;
}); });
@@ -172,7 +174,8 @@ export async function getDeck(
* The eligibility rules are deliberately IDENTICAL to getDeck's — verified, * The eligibility rules are deliberately IDENTICAL to getDeck's — verified,
* accepting work, not banned, and willing to travel to the point in question. * accepting work, not banned, and willing to travel to the point in question.
* Nobody should ever appear in the shop window who could not appear on a real * Nobody should ever appear in the shop window who could not appear on a real
* deck. If you change one, change both. * deck, so both share `eligiblePro()` rather than a comment asking you to
* remember.
*/ */
export async function getShowcaseDeck( export async function getShowcaseDeck(
db: Db, db: Db,
@@ -229,6 +232,7 @@ export async function getShowcaseDeck(
created_at: string; created_at: string;
photos: string[] | null; photos: string[] | null;
categories: string[] | null; categories: string[] | null;
skills: string[] | null;
}>(sql` }>(sql`
WITH centre AS ( WITH centre AS (
SELECT ST_SetSRID(ST_MakePoint(${args.lng}, ${args.lat}), 4326)::geography AS g SELECT ST_SetSRID(ST_MakePoint(${args.lng}, ${args.lat}), 4326)::geography AS g
@@ -261,14 +265,12 @@ export async function getShowcaseDeck(
JOIN categories c ON c.id = pc.category_id JOIN categories c ON c.id = pc.category_id
WHERE pc.pro_id = p.user_id), WHERE pc.pro_id = p.user_id),
'{}' '{}'
) AS categories ) AS categories,
p.skills
FROM pro_profiles p FROM pro_profiles p
JOIN users u ON u.id = p.user_id JOIN users u ON u.id = p.user_id
CROSS JOIN centre CROSS JOIN centre
WHERE p.verification_status = 'verified' WHERE ${eligiblePro(sql`centre.g`)}
AND p.is_accepting_jobs = true
AND (u.banned IS NOT TRUE OR (u.ban_expires IS NOT NULL AND u.ban_expires < now()))
AND ST_DWithin(p.base_location, centre.g, p.service_radius_m)
${distanceFilter} ${distanceFilter}
${categoryFilter} ${categoryFilter}
ORDER BY ST_Distance(p.base_location, centre.g) ASC ORDER BY ST_Distance(p.base_location, centre.g) ASC
@@ -305,6 +307,7 @@ export async function getShowcaseDeck(
distanceM: Math.round(Number(r.distance_m)), distanceM: Math.round(Number(r.distance_m)),
photos: r.photos ?? [], photos: r.photos ?? [],
categories: r.categories ?? [], categories: r.categories ?? [],
skills: r.skills ?? [],
score: score(input), score: score(input),
} satisfies DeckCard; } satisfies DeckCard;
}); });
@@ -322,10 +325,7 @@ export async function getDeckCount(db: Db, jobId: string): Promise<number> {
JOIN pro_profiles p ON p.user_id = pcat.pro_id JOIN pro_profiles p ON p.user_id = pcat.pro_id
JOIN users u ON u.id = p.user_id JOIN users u ON u.id = p.user_id
WHERE j.id = ${jobId} WHERE j.id = ${jobId}
AND p.verification_status = 'verified' AND ${eligiblePro(sql`j.location`)}
AND p.is_accepting_jobs = true
AND (u.banned IS NOT TRUE OR (u.ban_expires IS NOT NULL AND u.ban_expires < now()))
AND ST_DWithin(p.base_location, j.location, p.service_radius_m)
AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.job_id = j.id AND s.pro_id = p.user_id) AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.job_id = j.id AND s.pro_id = p.user_id)
AND NOT EXISTS (SELECT 1 FROM requests r WHERE r.job_id = j.id AND r.pro_id = p.user_id) AND NOT EXISTS (SELECT 1 FROM requests r WHERE r.job_id = j.id AND r.pro_id = p.user_id)
AND p.user_id <> j.client_id AND p.user_id <> j.client_id
+38
View File
@@ -0,0 +1,38 @@
import { sql, type SQL } from 'drizzle-orm';
/**
* Who may be shown to a customer, anywhere.
*
* This predicate was copy-pasted into three queries with a comment on each
* saying "if you change one, change both" — which is a comment doing a
* function's job. The rule is the product's core promise: the word "verified"
* has to mean the same thing on the deck, in the shop window and in search, or
* one surface becomes the way around the other two.
*
* Assumes the query aliases pro_profiles as `p` and users as `u`, which all four
* callers do.
*
* @param target a geography point the pro must be willing to travel to
*/
export function eligiblePro(target: SQL): SQL {
return sql`
${eligibleProAtAnyDistance()}
-- the pro must be willing to travel this far, index-accelerated via GiST
AND ST_DWithin(p.base_location, ${target}, p.service_radius_m)
`;
}
/**
* The non-geographic half — verified, working, not banned.
*
* Split out for the one caller that has no point to measure from: a profile
* looked up by id. Distance is irrelevant there, but "suspended" is not.
*/
export function eligibleProAtAnyDistance(): SQL {
return sql`
p.verification_status = 'verified'
AND p.is_accepting_jobs = true
-- a ban with an expiry that has passed is spent, not active
AND (u.banned IS NOT TRUE OR (u.ban_expires IS NOT NULL AND u.ban_expires < now()))
`;
}
+228
View File
@@ -0,0 +1,228 @@
import { sql, type SQL } from 'drizzle-orm';
import { score, type RankingInput } from '@linkder/shared';
import type { Db } from '../client';
import { eligiblePro } from './eligibility';
import type { DeckCard } from './deck';
/**
* Search, as opposed to the deck.
*
* The deck answers "who should I show this customer next?" — one job, ranked,
* one card at a time. This answers "show me what is out there", which is a
* different question with the same eligibility rules: `eligiblePro()` is shared
* with getDeck and getShowcaseDeck precisely so a pro can never be findable here
* but unbookable there.
*
* TEXT MATCHING IS `ILIKE`, DELIBERATELY. There is no pg_trgm, no tsvector and
* no GIN index in this database, and at launch there are tens of eligible pros
* in one city — the radius filter has already cut the set to a couple of hundred
* rows on the GiST index before a single string is compared. A sequential scan
* over that is free.
*
* Replace this with a generated tsvector column + GIN when either becomes true:
* - the eligible pool in one city passes ~2,000 pros, or
* - someone reports "no results" for an obvious typo (ILIKE cannot fuzzy match).
* Not before. A search index over 40 rows is a liability, not an optimisation.
*/
/** Hard ceiling on rows pulled before ranking. Mirrors the deck's CANDIDATE_POOL. */
const CANDIDATE_POOL = 200;
export type SearchSort = 'best' | 'nearest' | 'rating' | 'price';
export interface SearchArgs {
/** Where the searcher is. Their saved pin, or the city centre. */
lat: number;
lng: number;
/** Free text over name, headline, bio, skills and trade names. */
q?: string;
categoryId?: string;
/** How far the searcher will go. Applied on top of each pro's own radius. */
maxDistanceM?: number;
minRating?: number;
maxHourlyRateCents?: number;
sort?: SearchSort;
limit?: number;
now?: Date;
}
export async function searchPros(db: Db, args: SearchArgs): Promise<DeckCard[]> {
const limit = args.limit ?? 50;
const now = args.now ?? new Date();
const sort = args.sort ?? 'best';
const q = args.q?.trim();
/**
* One pattern, matched against every text field a pro controls plus their
* trade names. `%` and `_` are escaped: without it, a search for "50%" would
* match every pro in the city, which reads as a broken filter rather than a
* clever query.
*/
const textFilter: SQL = q
? sql`AND (
u.name ILIKE ${'%' + escapeLike(q) + '%'} ESCAPE '\\'
OR p.headline ILIKE ${'%' + escapeLike(q) + '%'} ESCAPE '\\'
OR p.bio ILIKE ${'%' + escapeLike(q) + '%'} ESCAPE '\\'
OR array_to_string(p.skills, ' ') ILIKE ${'%' + escapeLike(q) + '%'} ESCAPE '\\'
OR EXISTS (
SELECT 1 FROM pro_categories pc2
JOIN categories c2 ON c2.id = pc2.category_id
WHERE pc2.pro_id = p.user_id
AND c2.name ILIKE ${'%' + escapeLike(q) + '%'} ESCAPE '\\'
)
)`
: sql``;
const categoryFilter: SQL = args.categoryId
? sql`AND EXISTS (
SELECT 1 FROM pro_categories pc
WHERE pc.pro_id = p.user_id AND pc.category_id = ${args.categoryId}
)`
: sql``;
const distanceFilter: SQL =
args.maxDistanceM === undefined
? sql``
: sql`AND ST_DWithin(p.base_location, centre.g, ${args.maxDistanceM})`;
// An unrated pro has no average, so a rating floor must exclude them rather
// than let NULL slip through — "4 stars and up" cannot include "no stars yet".
const ratingFilter: SQL =
args.minRating === undefined || args.minRating <= 0
? sql``
: sql`AND p.rating_avg IS NOT NULL AND p.rating_avg >= ${String(args.minRating)}`;
const priceFilter: SQL =
args.maxHourlyRateCents === undefined
? sql``
: sql`AND p.hourly_rate_cents <= ${args.maxHourlyRateCents}`;
/**
* Ordering happens in SQL for every sort except `best`, so the cut to
* CANDIDATE_POOL keeps the rows that sort asked for. Ordering by distance and
* then re-sorting by price in JS would silently drop the cheapest pro in the
* city the moment there were more than 200 candidates.
*/
const orderBy: SQL =
sort === 'price'
? sql`p.hourly_rate_cents ASC, ST_Distance(p.base_location, centre.g) ASC`
: sort === 'rating'
? sql`p.rating_avg DESC NULLS LAST, p.rating_count DESC`
: sql`ST_Distance(p.base_location, centre.g) ASC`;
const rows = await db.execute<{
pro_id: string;
name: string | null;
image: string | null;
headline: string;
bio: string;
hourly_rate_cents: number;
years_experience: number;
rating_avg: string | null;
rating_count: number;
completed_jobs: number;
response_rate: string | null;
avg_response_minutes: number | null;
distance_m: number;
service_radius_m: number;
last_active_at: string | null;
created_at: string;
photos: string[] | null;
categories: string[] | null;
skills: string[] | null;
}>(sql`
WITH centre AS (
SELECT ST_SetSRID(ST_MakePoint(${args.lng}, ${args.lat}), 4326)::geography AS g
)
SELECT
p.user_id AS pro_id,
u.name,
u.image,
p.headline,
p.bio,
p.hourly_rate_cents,
p.years_experience,
p.rating_avg,
p.rating_count,
p.completed_jobs,
p.response_rate,
p.avg_response_minutes,
ST_Distance(p.base_location, centre.g) AS distance_m,
p.service_radius_m,
u.last_active_at,
p.created_at,
COALESCE(
(SELECT array_agg(m.url ORDER BY m.position)
FROM pro_media m WHERE m.pro_id = p.user_id),
'{}'
) AS photos,
COALESCE(
(SELECT array_agg(c.name)
FROM pro_categories pc
JOIN categories c ON c.id = pc.category_id
WHERE pc.pro_id = p.user_id),
'{}'
) AS categories,
p.skills
FROM pro_profiles p
JOIN users u ON u.id = p.user_id
CROSS JOIN centre
WHERE ${eligiblePro(sql`centre.g`)}
${distanceFilter}
${categoryFilter}
${textFilter}
${ratingFilter}
${priceFilter}
ORDER BY ${orderBy}
LIMIT ${CANDIDATE_POOL}
`);
const results = rows.map((r) => {
const ratingAvg = r.rating_avg === null ? null : Number(r.rating_avg);
const responseRate = r.response_rate === null ? null : Number(r.response_rate);
const input: RankingInput = {
ratingAvg,
ratingCount: Number(r.rating_count),
responseRate,
distanceM: Number(r.distance_m),
serviceRadiusM: Number(r.service_radius_m),
lastActiveAt: r.last_active_at ? new Date(r.last_active_at) : null,
createdAt: new Date(r.created_at),
now,
};
return {
proId: r.pro_id,
name: r.name,
image: r.image,
headline: r.headline,
bio: r.bio,
hourlyRateCents: Number(r.hourly_rate_cents),
yearsExperience: Number(r.years_experience),
ratingAvg,
ratingCount: Number(r.rating_count),
completedJobs: Number(r.completed_jobs),
responseRate,
avgResponseMinutes: r.avg_response_minutes === null ? null : Number(r.avg_response_minutes),
distanceM: Math.round(Number(r.distance_m)),
photos: r.photos ?? [],
categories: r.categories ?? [],
skills: r.skills ?? [],
score: score(input),
} satisfies DeckCard;
});
// `best` is the only sort SQL cannot express: score() lives in JS so the
// weights stay tunable in one file. Every other sort is already ordered.
if (sort === 'best') {
results.sort((a, b) => b.score - a.score || a.distanceM - b.distanceM);
}
return results.slice(0, limit);
}
/** Neutralise LIKE wildcards in user input so they match literally. */
function escapeLike(value: string): string {
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
}
+89
View File
@@ -0,0 +1,89 @@
import { sql, type SQL } from 'drizzle-orm';
import type { Db } from '../client';
/**
* Recompute the denormalised ranking counters on `pro_profiles`.
*
* `rating_avg`, `rating_count`, `completed_jobs`, `response_rate` and
* `avg_response_minutes` are inputs to `score()` in @linkder/shared, and until
* this existed nothing ever wrote them after the seed. The deck ranked on
* numbers that were invented once and never moved, and the card told customers
* "usually replies in 25 min" on the strength of it.
*
* They stay denormalised rather than being computed per query: the deck scores
* every candidate pro on every load, and four correlated subqueries per card is
* the kind of cost that only shows up once a city is full. The trade is that
* they must be refreshed when their inputs change — see the callers.
*
* Written as one statement over a filtered set so a single pro and a full
* backfill cannot drift apart. It is idempotent by construction: it derives
* every value from source rows rather than incrementing anything, so running it
* twice is the same as running it once, and running it after a missed event
* repairs the counter rather than compounding the mistake.
*/
function recomputeWhere(where: SQL): SQL {
return sql`
UPDATE pro_profiles p SET
-- Multi-column assignment so each source table is scanned once rather
-- than once per column. An aggregate with no GROUP BY always returns a
-- row, so a pro with no history gets (NULL, 0) and not a failed update.
(rating_avg, rating_count) = (
SELECT round(avg(rating)::numeric, 2), count(*)::int
FROM reviews
-- Published only, and the same predicate pro.reviews reads with. A
-- count that included embargoed reviews would put a number in the
-- header that the list underneath it can never reach.
WHERE subject_id = p.user_id
AND published_at IS NOT NULL
AND published_at <= now()
),
completed_jobs = (
SELECT count(*)::int
FROM bookings bk
JOIN matches m ON m.id = bk.match_id
WHERE m.pro_id = p.user_id
AND bk.status = 'completed'
),
(response_rate, avg_response_minutes) = (
SELECT
/*
* Answered over decided — NOT over sent.
*
* A request still inside its window has not been ignored yet, so
* counting it as a miss would punish a pro for work that just
* arrived and let them recover only once it expired. The ones that
* count against them are those past their expiry with no response,
* whether or not the lazy sweeper has relabelled the row yet.
*/
CASE WHEN count(*) FILTER (
WHERE responded_at IS NOT NULL OR expires_at < now()
) = 0
THEN NULL
ELSE round(
count(*) FILTER (WHERE responded_at IS NOT NULL)::numeric
/ count(*) FILTER (WHERE responded_at IS NOT NULL OR expires_at < now()),
3)
END,
round(avg(
EXTRACT(EPOCH FROM (responded_at - created_at)) / 60
) FILTER (WHERE responded_at IS NOT NULL))::int
FROM requests
WHERE pro_id = p.user_id
),
updated_at = now()
WHERE ${where}
`;
}
/** Refresh one pro. Call after anything that changes their history. */
export async function recomputeProStats(db: Db, proId: string): Promise<void> {
await db.execute(recomputeWhere(sql`p.user_id = ${proId}`));
}
/**
* Refresh every pro. For the seed, for a backfill, and for a nightly sweep that
* repairs anything a missed event left behind.
*/
export async function recomputeAllProStats(db: Db): Promise<void> {
await db.execute(recomputeWhere(sql`true`));
}
+20
View File
@@ -0,0 +1,20 @@
import { config } from 'dotenv';
import { closePool, db } from './client';
import { recomputeAllProStats } from './queries/stats';
config({ path: '../../.env' });
/**
* Backfill every pro's ranking counters from their real history.
*
* Run after a deploy that changes how a counter is derived, or to repair drift
* left by an event whose refresh failed. Safe to run at any time and as often as
* you like — recomputeAllProStats derives rather than increments.
*/
const before = await db.execute<{ n: number }>(
`SELECT count(*)::int AS n FROM pro_profiles`,
);
await recomputeAllProStats(db);
console.log(`Recomputed ranking counters for ${before[0]?.n ?? 0} pros.`);
await closePool();
+8 -1
View File
@@ -11,7 +11,7 @@ import {
} from 'drizzle-orm/pg-core'; } from 'drizzle-orm/pg-core';
import { DEFAULT_SERVICE_RADIUS_M } from '@linkder/shared'; import { DEFAULT_SERVICE_RADIUS_M } from '@linkder/shared';
import { point } from '../postgis'; import { point } from '../postgis';
import { userRole } from './enums'; import { locationPrecision, userRole } from './enums';
/** /**
* Tables owned by better-auth, plus the marketplace columns we add on top. * Tables owned by better-auth, plus the marketplace columns we add on top.
@@ -73,6 +73,13 @@ export const users = pgTable(
location: point('location'), location: point('location'),
/** Human label for `location` — "Gràcia, Barcelona". Display only; never matched on. */ /** Human label for `location` — "Gràcia, Barcelona". Display only; never matched on. */
locationText: text('location_text'), locationText: text('location_text'),
/**
* See jobs.location_precision. Lowest stakes of the three: this only centres
* a client's own browsing, so a `city` value here is a fine default rather
* than something to gate on.
*/
locationPrecision: locationPrecision('location_precision').notNull().default('city'),
locationPlaceId: text('location_place_id'),
searchRadiusM: integer('search_radius_m').notNull().default(DEFAULT_SERVICE_RADIUS_M), searchRadiusM: integer('search_radius_m').notNull().default(DEFAULT_SERVICE_RADIUS_M),
/** Deck ranking penalises dormant pros, so this has to be maintained. */ /** Deck ranking penalises dormant pros, so this has to be maintained. */
+3
View File
@@ -1,6 +1,7 @@
import { pgEnum } from 'drizzle-orm/pg-core'; import { pgEnum } from 'drizzle-orm/pg-core';
import { import {
BOOKING_STATUSES, BOOKING_STATUSES,
LOCATION_PRECISIONS,
JOB_STATUSES, JOB_STATUSES,
PAYMENT_STATUSES, PAYMENT_STATUSES,
QUOTE_STATUSES, QUOTE_STATUSES,
@@ -21,6 +22,8 @@ export const bookingStatus = pgEnum('booking_status', BOOKING_STATUSES);
export const paymentStatus = pgEnum('payment_status', PAYMENT_STATUSES); export const paymentStatus = pgEnum('payment_status', PAYMENT_STATUSES);
export const verificationStatus = pgEnum('verification_status', VERIFICATION_STATUSES); export const verificationStatus = pgEnum('verification_status', VERIFICATION_STATUSES);
export const urgency = pgEnum('urgency', ['now', 'this_week', 'flexible']); export const urgency = pgEnum('urgency', ['now', 'this_week', 'flexible']);
/** How a stored point was obtained — see LOCATION_PRECISIONS in @linkder/shared. */
export const locationPrecision = pgEnum('location_precision', LOCATION_PRECISIONS);
export const swipeDirection = pgEnum('swipe_direction', ['left', 'right']); export const swipeDirection = pgEnum('swipe_direction', ['left', 'right']);
export const credentialKind = pgEnum('credential_kind', ['id', 'licence', 'insurance']); export const credentialKind = pgEnum('credential_kind', ['id', 'licence', 'insurance']);
export const reviewStatus = pgEnum('review_status', ['pending', 'approved', 'rejected']); export const reviewStatus = pgEnum('review_status', ['pending', 'approved', 'rejected']);
+10 -1
View File
@@ -3,7 +3,7 @@ import { index, integer, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-c
import { point } from '../postgis'; import { point } from '../postgis';
import { users } from './auth'; import { users } from './auth';
import { categories } from './pros'; import { categories } from './pros';
import { jobStatus, urgency } from './enums'; import { jobStatus, locationPrecision, urgency } from './enums';
export const jobs = pgTable( export const jobs = pgTable(
'jobs', 'jobs',
@@ -22,6 +22,15 @@ export const jobs = pgTable(
budgetMinCents: integer('budget_min_cents'), budgetMinCents: integer('budget_min_cents'),
budgetMaxCents: integer('budget_max_cents'), budgetMaxCents: integer('budget_max_cents'),
location: point('location').notNull(), location: point('location').notNull(),
/**
* How `location` was obtained. Defaults to `city` so that every row written
* before geocoding existed describes itself honestly: those points ARE the
* city centre, and the deck must be able to tell them from a real address
* rather than ranking a placeholder as if it were one.
*/
locationPrecision: locationPrecision('location_precision').notNull().default('city'),
/** Geocoder feature id, so the point can be re-resolved without the free text. */
locationPlaceId: text('location_place_id'),
/** Street-level address, only revealed to the pro once a booking exists. */ /** Street-level address, only revealed to the pro once a booking exists. */
addressText: text('address_text').notNull(), addressText: text('address_text').notNull(),
status: jobStatus('status').notNull().default('open'), status: jobStatus('status').notNull().default('open'),
+17 -1
View File
@@ -12,7 +12,13 @@ import {
} from 'drizzle-orm/pg-core'; } from 'drizzle-orm/pg-core';
import { point } from '../postgis'; import { point } from '../postgis';
import { users } from './auth'; import { users } from './auth';
import { credentialKind, mediaKind, reviewStatus, verificationStatus } from './enums'; import {
credentialKind,
locationPrecision,
mediaKind,
reviewStatus,
verificationStatus,
} from './enums';
export const categories = pgTable('categories', { export const categories = pgTable('categories', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
@@ -35,6 +41,16 @@ export const proProfiles = pgTable(
hourlyRateCents: integer('hourly_rate_cents').notNull(), hourlyRateCents: integer('hourly_rate_cents').notNull(),
yearsExperience: integer('years_experience').notNull().default(0), yearsExperience: integer('years_experience').notNull().default(0),
baseLocation: point('base_location').notNull(), baseLocation: point('base_location').notNull(),
/**
* See jobs.location_precision. This one carries more weight: base_location is
* the LEFT operand of every ST_Distance and ST_DWithin in the deck, so a pro
* parked at the city centre silently passes every radius check in the city.
* submitForReview refuses a `city` base for that reason.
*/
baseLocationPrecision: locationPrecision('base_location_precision')
.notNull()
.default('city'),
baseLocationPlaceId: text('base_location_place_id'),
serviceRadiusM: integer('service_radius_m').notNull().default(15000), serviceRadiusM: integer('service_radius_m').notNull().default(15000),
/** /**
+44 -4
View File
@@ -1,5 +1,5 @@
import { relations } from 'drizzle-orm'; import { relations } from 'drizzle-orm';
import { boolean, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; import { boolean, index, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { users } from './auth'; import { users } from './auth';
/** /**
@@ -9,9 +9,9 @@ import { users } from './auth';
* where an existing user has no preferences. Every column therefore defaults to * where an existing user has no preferences. Every column therefore defaults to
* the value we would use in the absence of a row. * the value we would use in the absence of a row.
* *
* NOTE: nothing consumes these yet — the worker that sends the messages arrives * These are read on every send — see `notify()` in @linkder/notify, which maps
* in M4. Until then this stores intent only, and the UI must say so rather than * each notification kind to the column that governs it and drops the message
* implying a toggle stops an SMS today. * when the answer is false.
*/ */
export const notificationPreferences = pgTable('notification_preferences', { export const notificationPreferences = pgTable('notification_preferences', {
userId: uuid('user_id') userId: uuid('user_id')
@@ -55,6 +55,46 @@ export const deletionRequests = pgTable('deletion_requests', {
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}); });
/**
* Every notification we tried to send, and what happened.
*
* Sends are inline and best-effort — a failed SMS must never roll back the
* request it was about — which means a failure has nowhere to surface unless it
* is written down. Without this row an outage is invisible: the product looks
* like it is notifying people and simply is not, which is the state this table
* exists to make impossible to reach unnoticed.
*
* It is also what a retry would read. When these move into a worker, the queue
* consumes `status = 'failed'` from here rather than needing its own store.
*/
export const notificationDeliveries = pgTable(
'notification_deliveries',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** The event, not the copy: 'request.received', 'request.accepted', … */
kind: text('kind').notNull(),
channel: text('channel').notNull(),
/** 'sent' | 'skipped' | 'failed'. Text, not an enum: this list will churn. */
status: text('status').notNull(),
/** Why it was skipped, or how it failed. Never the message body — that can
* carry an address or a phone number, and this table is read casually. */
detail: text('detail'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index('notification_deliveries_user_idx').on(t.userId, t.createdAt),
// What an operator actually queries: what is broken, most recent first.
index('notification_deliveries_status_idx').on(t.status, t.createdAt),
],
);
export const notificationDeliveriesRelations = relations(notificationDeliveries, ({ one }) => ({
user: one(users, { fields: [notificationDeliveries.userId], references: [users.id] }),
}));
export const notificationPreferencesRelations = relations(notificationPreferences, ({ one }) => ({ export const notificationPreferencesRelations = relations(notificationPreferences, ({ one }) => ({
user: one(users, { fields: [notificationPreferences.userId], references: [users.id] }), user: one(users, { fields: [notificationPreferences.userId], references: [users.id] }),
})); }));
+481 -7
View File
@@ -12,6 +12,7 @@ import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres'; import postgres from 'postgres';
import { DEFAULT_SERVICE_RADIUS_M } from '@linkder/shared'; import { DEFAULT_SERVICE_RADIUS_M } from '@linkder/shared';
import * as schema from './schema/index'; import * as schema from './schema/index';
import { recomputeAllProStats } from './queries/stats';
config({ path: '../../.env' }); config({ path: '../../.env' });
@@ -115,6 +116,8 @@ const CATEGORIES = [
interface SeedPro { interface SeedPro {
name: string; name: string;
cat: string; cat: string;
/** Free-text specialisms. Search matches these, so a few pros must have some. */
skills?: string[];
distanceM: number; distanceM: number;
rating: number | null; rating: number | null;
reviews: number; reviews: number;
@@ -126,13 +129,16 @@ interface SeedPro {
/** distanceM is measured from the city centre — deck tests assert against these. */ /** distanceM is measured from the city centre — deck tests assert against these. */
const PROS: SeedPro[] = [ const PROS: SeedPro[] = [
{ name: 'Marc Oliveras', cat: 'plumber', distanceM: 800, rating: 4.9, reviews: 47, radius: 15_000 }, { name: 'Marc Oliveras', cat: 'plumber', distanceM: 800, rating: 4.9, reviews: 47, radius: 15_000,
{ name: 'Ana Ferrer', cat: 'plumber', distanceM: 2_400, rating: 4.7, reviews: 23, radius: 15_000 }, skills: ['Underfloor heating', 'Emergency callouts', 'Boiler swaps'] },
{ name: 'Ana Ferrer', cat: 'plumber', distanceM: 2_400, rating: 4.7, reviews: 23, radius: 15_000,
skills: ['Bathroom fitting', 'Leak detection'] },
{ name: 'Jordi Puig', cat: 'plumber', distanceM: 6_500, rating: 4.5, reviews: 12, radius: 10_000 }, { name: 'Jordi Puig', cat: 'plumber', distanceM: 6_500, rating: 4.5, reviews: 12, radius: 10_000 },
{ name: 'Nuria Sala', cat: 'plumber', distanceM: 18_000, rating: 5.0, reviews: 3, radius: 25_000 }, { name: 'Nuria Sala', cat: 'plumber', distanceM: 18_000, rating: 5.0, reviews: 3, radius: 25_000 },
// Further away than they are willing to travel — must NOT appear for a central job. // Further away than they are willing to travel — must NOT appear for a central job.
{ name: 'Pau Ribas', cat: 'plumber', distanceM: 22_000, rating: 4.8, reviews: 31, radius: 5_000 }, { name: 'Pau Ribas', cat: 'plumber', distanceM: 22_000, rating: 4.8, reviews: 31, radius: 5_000 },
{ name: 'Laia Mestre', cat: 'electrician', distanceM: 1_200, rating: 4.8, reviews: 56, radius: 20_000 }, { name: 'Laia Mestre', cat: 'electrician', distanceM: 1_200, rating: 4.8, reviews: 56, radius: 20_000,
skills: ['EV chargers', 'Rewiring', 'Fuse boards'] },
{ name: 'Oriol Camps', cat: 'electrician', distanceM: 3_900, rating: 4.6, reviews: 18, radius: 15_000 }, { name: 'Oriol Camps', cat: 'electrician', distanceM: 3_900, rating: 4.6, reviews: 18, radius: 15_000 },
{ name: 'Marta Vidal', cat: 'electrician', distanceM: 9_100, rating: 4.9, reviews: 71, radius: 30_000 }, { name: 'Marta Vidal', cat: 'electrician', distanceM: 9_100, rating: 4.9, reviews: 71, radius: 30_000 },
{ name: 'Sergi Bonet', cat: 'handyman', distanceM: 1_800, rating: 4.4, reviews: 9, radius: 12_000 }, { name: 'Sergi Bonet', cat: 'handyman', distanceM: 1_800, rating: 4.4, reviews: 9, radius: 12_000 },
@@ -140,13 +146,15 @@ const PROS: SeedPro[] = [
{ name: 'Ivan Serra', cat: 'handyman', distanceM: 11_500, rating: 4.2, reviews: 6, radius: 15_000 }, { name: 'Ivan Serra', cat: 'handyman', distanceM: 11_500, rating: 4.2, reviews: 6, radius: 15_000 },
{ name: 'Elena Prat', cat: 'painter', distanceM: 2_100, rating: 4.9, reviews: 28, radius: 20_000 }, { name: 'Elena Prat', cat: 'painter', distanceM: 2_100, rating: 4.9, reviews: 28, radius: 20_000 },
{ name: 'Toni Blanch', cat: 'painter', distanceM: 7_800, rating: 4.3, reviews: 15, radius: 15_000 }, { name: 'Toni Blanch', cat: 'painter', distanceM: 7_800, rating: 4.3, reviews: 15, radius: 15_000 },
{ name: 'Rosa Ventura', cat: 'carpenter', distanceM: 3_300, rating: 4.8, reviews: 41, radius: 25_000 }, { name: 'Rosa Ventura', cat: 'carpenter', distanceM: 3_300, rating: 4.8, reviews: 41, radius: 25_000,
skills: ['Fitted wardrobes', 'Listed buildings'] },
{ name: 'Guillem Costa', cat: 'carpenter', distanceM: 8_600, rating: 4.6, reviews: 19, radius: 15_000 }, { name: 'Guillem Costa', cat: 'carpenter', distanceM: 8_600, rating: 4.6, reviews: 19, radius: 15_000 },
{ name: 'Silvia Marti', cat: 'locksmith', distanceM: 950, rating: 4.7, reviews: 62, radius: 25_000 }, { name: 'Silvia Marti', cat: 'locksmith', distanceM: 950, rating: 4.7, reviews: 62, radius: 25_000 },
{ name: 'Xavi Duran', cat: 'locksmith', distanceM: 4_400, rating: 4.5, reviews: 22, radius: 20_000 }, { name: 'Xavi Duran', cat: 'locksmith', distanceM: 4_400, rating: 4.5, reviews: 22, radius: 20_000 },
{ name: 'Berta Lloret', cat: 'appliance-repair', distanceM: 2_700, rating: 4.8, reviews: 37, radius: 18_000 }, { name: 'Berta Lloret', cat: 'appliance-repair', distanceM: 2_700, rating: 4.8, reviews: 37, radius: 18_000 },
{ name: 'Adria Font', cat: 'appliance-repair', distanceM: 10_200, rating: 4.4, reviews: 11, radius: 20_000 }, { name: 'Adria Font', cat: 'appliance-repair', distanceM: 10_200, rating: 4.4, reviews: 11, radius: 20_000 },
{ name: 'Carla Ripoll', cat: 'hvac', distanceM: 3_100, rating: 4.9, reviews: 44, radius: 22_000 }, { name: 'Carla Ripoll', cat: 'hvac', distanceM: 3_100, rating: 4.9, reviews: 44, radius: 22_000,
skills: ['Split units', 'Heat pumps'] },
{ name: 'Marc Segura', cat: 'hvac', distanceM: 12_800, rating: 4.6, reviews: 27, radius: 25_000 }, { name: 'Marc Segura', cat: 'hvac', distanceM: 12_800, rating: 4.6, reviews: 27, radius: 25_000 },
// Brand new and unrated — proves the new-pro boost keeps fresh supply visible. // Brand new and unrated — proves the new-pro boost keeps fresh supply visible.
{ name: 'Nil Bosch', cat: 'plumber', distanceM: 1_500, rating: null, reviews: 0, radius: 15_000, isNew: true }, { name: 'Nil Bosch', cat: 'plumber', distanceM: 1_500, rating: null, reviews: 0, radius: 15_000, isNew: true },
@@ -159,13 +167,144 @@ const PROS: SeedPro[] = [
const CLIENTS = ['Sofia Grau', 'Daniel Miro', 'Emma Rovira', 'Lucas Pons', 'Alba Torres']; const CLIENTS = ['Sofia Grau', 'Daniel Miro', 'Emma Rovira', 'Lucas Pons', 'Alba Torres'];
/**
* Finished work, per trade, for the review histories below.
*
* Written out rather than generated because the profile screen is a reading
* surface: "Job 3 completed. Good service." twenty times over tells you nothing
* about whether the reviews list works, and nothing about whether a real one
* would be worth reading.
*/
interface SeedWork {
title: string;
scope: string;
amountCents: number;
review: string;
}
const WORK: Record<string, SeedWork[]> = {
plumber: [
{ title: 'Replace a leaking kitchen trap', scope: 'Remove and replace the sink trap, test for leaks.', amountCents: 9_000,
review: 'Came the same evening, found the leak in about a minute and had it swapped out before I had finished making tea. Left the cupboard drier than he found it.' },
{ title: 'New thermostatic shower valve', scope: 'Supply and fit a thermostatic mixer, make good the tiling.', amountCents: 28_500,
review: 'Explained the options without pushing me at the expensive one. Tidy work around the tiles and the temperature is finally steady.' },
{ title: 'Boiler losing pressure', scope: 'Trace and repair pressure loss, refill and rebalance the system.', amountCents: 14_000,
review: 'Took a while to track down but stuck with it and did not charge me for the extra hour. Pressure has held for two months now.' },
{ title: 'Fit an outside tap', scope: 'Tee off the rising main, fit an outside tap with an isolator.', amountCents: 12_000,
review: 'Quick, clean job and tidied up afterwards. Would have them back.' },
{ title: 'Bathroom refit second fix', scope: 'Connect basin, WC and bath after tiling.', amountCents: 46_000,
review: 'Turned up when they said they would every single day, which after our last builder felt like a luxury.' },
],
electrician: [
{ title: 'Install an EV charger', scope: 'Fit a 7kW charger on its own RCBO, with a certificate.', amountCents: 68_000,
review: 'Neat cable run, tested everything in front of me and sent the certificate through the same day. No mess left behind.' },
{ title: 'Consumer unit replacement', scope: 'Replace the fuse board, full test and certification.', amountCents: 52_000,
review: 'Talked me through what was actually unsafe and what was just old, which I appreciated. Power was only off for the afternoon.' },
{ title: 'Kitchen sockets and lighting', scope: 'Add four sockets and two lighting circuits.', amountCents: 39_000,
review: 'Good work and a fair price. Chased the walls neatly so the plasterer had an easy job.' },
{ title: 'Tripping circuit', scope: 'Fault-find a nuisance trip and repair.', amountCents: 11_000,
review: 'Found a nail through a cable in the loft within half an hour. Straightforward and honest about the cost.' },
],
handyman: [
{ title: 'Hang six internal doors', scope: 'Hang and adjust six doors with new furniture.', amountCents: 32_000,
review: 'All six shut properly for the first time since we moved in. Cleaned up all the shavings too.' },
{ title: 'Flat-pack wardrobes', scope: 'Assemble and wall-fix two double wardrobes.', amountCents: 15_000,
review: 'Saved my weekend. Fixed them to the wall without being asked, because of the kids.' },
{ title: 'Repair a sagging side gate', scope: 'Rehang the gate and fit a new latch.', amountCents: 8_500,
review: 'Turned up on time, sorted it in an hour, charged what was quoted.' },
{ title: 'Patch and paint a ceiling', scope: 'Fill, sand and repaint a water-damaged ceiling.', amountCents: 18_000,
review: 'Cannot tell where the damage was. Very careful with the carpet.' },
],
painter: [
{ title: 'Repaint a stairwell', scope: 'Prepare and paint stairwell walls and woodwork.', amountCents: 42_000,
review: 'The cutting-in is genuinely straight, which is the whole job really. Dust sheets everywhere and not a mark on the floor.' },
{ title: 'Two bedrooms in emulsion', scope: 'Fill, sand and two coats to two bedrooms.', amountCents: 34_000,
review: 'Quick and neat, and matched the old colour on the landing so it blends.' },
{ title: 'Exterior window frames', scope: 'Sand back, prime and paint six frames.', amountCents: 26_000,
review: 'Good preparation, which is where most people cut corners. Looks like new.' },
{ title: 'Hallway feature wall', scope: 'Hang wallpaper to one wall and paint the rest.', amountCents: 21_000,
review: 'Pattern lines up perfectly at the joins. Very pleased.' },
],
carpenter: [
{ title: 'Fitted alcove wardrobes', scope: 'Design, build and fit two alcove wardrobes.', amountCents: 145_000,
review: 'Beautiful work. Scribed into a wall that is nowhere near straight and you would never know.' },
{ title: 'Replace a rotten sash sill', scope: 'Splice in a new sill section and repaint.', amountCents: 38_000,
review: 'Repaired rather than replaced, which on a listed building saved us a small fortune in paperwork.' },
{ title: 'Build understairs storage', scope: 'Build and fit understairs drawers.', amountCents: 62_000,
review: 'Measured twice, delivered exactly what was drawn. Runs smoothly.' },
{ title: 'Loft hatch and ladder', scope: 'Enlarge the hatch and fit a folding ladder.', amountCents: 24_000,
review: 'Straightforward and tidy. Explained why the old hatch was too small for the ladder I had bought.' },
],
locksmith: [
{ title: 'Locked out at 11pm', scope: 'Non-destructive entry and a new cylinder.', amountCents: 13_500,
review: 'Answered the phone at eleven at night and was here in twenty minutes. Opened it without damaging the door.' },
{ title: 'Upgrade to anti-snap cylinders', scope: 'Replace three cylinders with anti-snap.', amountCents: 19_000,
review: 'Insurance wanted a specific standard and they knew exactly which one without me having to explain.' },
{ title: 'New front door lock', scope: 'Supply and fit a mortice lock to BS3621.', amountCents: 16_000,
review: 'Clean fit, no splintering, and all the keys work in both locks now.' },
{ title: 'Repair a failed uPVC mechanism', scope: 'Replace a multipoint locking mechanism.', amountCents: 17_500,
review: 'Had the part on the van. The door finally closes without a shoulder barge.' },
],
'appliance-repair': [
{ title: 'Washing machine not draining', scope: 'Clear the pump and replace the drain hose.', amountCents: 8_000,
review: 'Fixed for the price of a takeaway when I had already been told to buy a new machine.' },
{ title: 'Oven element replacement', scope: 'Diagnose and replace a failed fan oven element.', amountCents: 11_000,
review: 'Diagnosed it over the phone and brought the right part first time. Very efficient.' },
{ title: 'Fridge freezer icing up', scope: 'Clear a blocked defrost drain and reseal the door.', amountCents: 9_500,
review: 'Honest about whether it was worth repairing at all, which I did not expect.' },
{ title: 'Dishwasher leak', scope: 'Replace the door seal and test.', amountCents: 7_500,
review: 'In and out in under an hour and no more puddle.' },
],
hvac: [
{ title: 'Install two split units', scope: 'Supply and install two wall-mounted split units.', amountCents: 190_000,
review: 'Careful with the core drilling and the pipe run outside is genuinely tidy. The whole house is bearable in August now.' },
{ title: 'Annual aircon service', scope: 'Clean, regas and service two indoor units.', amountCents: 14_000,
review: 'Thorough, and pointed out a filter I could clean myself rather than charging me for a return visit.' },
{ title: 'Heat pump commissioning', scope: 'Commission an air-source heat pump and balance the system.', amountCents: 78_000,
review: 'Knew the system better than the people who supplied it. Running costs came in where they said they would.' },
{ title: 'Noisy outdoor unit', scope: 'Replace worn fan bearings and rebalance.', amountCents: 22_000,
review: 'The neighbours have stopped complaining. Fair price for a Saturday.' },
],
};
/** Any trade without its own list still gets a plausible history. */
const GENERIC_WORK: SeedWork[] = [
{ title: 'Small job, quoted and completed', scope: 'Agreed scope completed in a single visit.', amountCents: 12_000,
review: 'Turned up on time, did what was quoted and cleaned up afterwards. No complaints at all.' },
{ title: 'Follow-up visit', scope: 'Second visit to finish the agreed work.', amountCents: 9_000,
review: 'Good communication throughout and the price did not move from the quote.' },
{ title: 'Half a day on site', scope: 'Half a day of work, materials included.', amountCents: 18_000,
review: 'Straightforward, professional and easy to deal with. Would use again.' },
];
/**
* Star ratings for one pro's seeded reviews.
*
* Built to AVERAGE to the pro's headline figure rather than scattered around
* it, because the counters are derived from these rows: deck.test.ts asserts
* Marc Oliveras rates 4.9, and that now has to come out of 47 individual
* scores rather than being asserted directly on the profile.
*
* So a 4.9 becomes forty-two 5s and five 4s. Whole stars only — nobody awards
* 4.9 — and the mix is what carries the average, which is also what makes the
* list look like a real one instead of a wall of fives.
*/
function seedRatings(avg: number, n: number): number[] {
const low = Math.max(1, Math.min(5, Math.floor(avg)));
const high = Math.min(5, low + 1);
// How many have to be the higher score for the mean to land on `avg`.
const highCount = high === low ? n : Math.round((avg - low) * n);
return Array.from({ length: n }, (_, k) => (k < highCount ? high : low));
}
async function main() { async function main() {
console.log(`Seeding ${CITY.name} (${CITY.lat}, ${CITY.lng})`); console.log(`Seeding ${CITY.name} (${CITY.lat}, ${CITY.lng})`);
// Truncate in FK-safe order — reseeding must be idempotent. // Truncate in FK-safe order — reseeding must be idempotent.
await db.execute(sql` await db.execute(sql`
TRUNCATE TABLE TRUNCATE TABLE
audit_log, reviews, payments, bookings, quotes, messages, audit_log, notification_deliveries, reviews, payments, bookings, quotes, messages,
matches, requests, swipes, jobs, matches, requests, swipes, jobs,
pro_availability, verification_sessions, credentials, pro_availability, verification_sessions, credentials,
pro_media, pro_categories, pro_profiles, pro_media, pro_categories, pro_profiles,
@@ -186,7 +325,15 @@ async function main() {
CLIENTS.map((name, i) => ({ CLIENTS.map((name, i) => ({
name, name,
email: `client${i + 1}@linkder.test`, email: `client${i + 1}@linkder.test`,
phoneNumber: `+3460000${String(i + 1).padStart(4, '0')}`, /**
* The first client gets the dev-login number (see web/src/server/dev-login.ts).
*
* Without this, signing in locally creates a brand-new empty user and
* every seeded job, match and conversation belongs to somebody you
* cannot log in as — which makes the seed data invisible in the app it
* exists to fill.
*/
phoneNumber: i === 0 ? '+34600000000' : `+3460000${String(i + 1).padStart(4, '0')}`,
role: 'client' as const, role: 'client' as const,
emailVerified: true, emailVerified: true,
phoneNumberVerified: true, phoneNumberVerified: true,
@@ -205,6 +352,9 @@ async function main() {
}); });
const now = Date.now(); const now = Date.now();
/** Kept so the review pass below can build a history for each pro. */
const proRows: { id: string; seed: SeedPro; categoryId: string }[] = [];
for (const [i, p] of PROS.entries()) { for (const [i, p] of PROS.entries()) {
const bearing = (i * 360) / PROS.length; const bearing = (i * 360) / PROS.length;
const pos = offset(CITY.lat, CITY.lng, p.distanceM, bearing); const pos = offset(CITY.lat, CITY.lng, p.distanceM, bearing);
@@ -234,7 +384,12 @@ async function main() {
hourlyRateCents: 3_500 + (i % 6) * 500, hourlyRateCents: 3_500 + (i % 6) * 500,
yearsExperience: 2 + (i % 18), yearsExperience: 2 + (i % 18),
baseLocation: pos, baseLocation: pos,
// The seed places pros at known bearings and distances, so these ARE real
// points — labelling them `city` would make every seeded pro fail the
// submitForReview gate and read as unlocatable in tests.
baseLocationPrecision: 'exact' as const,
serviceRadiusM: p.radius ?? DEFAULT_SERVICE_RADIUS_M, serviceRadiusM: p.radius ?? DEFAULT_SERVICE_RADIUS_M,
skills: p.skills ?? [],
verificationStatus: status, verificationStatus: status,
verifiedAt: status === 'verified' ? new Date() : null, verifiedAt: status === 'verified' ? new Date() : null,
isAcceptingJobs: !p.away, isAcceptingJobs: !p.away,
@@ -249,6 +404,7 @@ async function main() {
const catId = catBySlug.get(p.cat); const catId = catBySlug.get(p.cat);
if (!catId) throw new Error(`unknown category ${p.cat}`); if (!catId) throw new Error(`unknown category ${p.cat}`);
await db.insert(schema.proCategories).values({ proId: user.id, categoryId: catId }); await db.insert(schema.proCategories).values({ proId: user.id, categoryId: catId });
proRows.push({ id: user.id, seed: p, categoryId: catId });
const slug = encodeURIComponent(p.name.toLowerCase().replace(/\s+/g, '-')); const slug = encodeURIComponent(p.name.toLowerCase().replace(/\s+/g, '-'));
// The first photo is the deck card, so it has to be a FACE. picsum returns // The first photo is the deck card, so it has to be a FACE. picsum returns
@@ -281,6 +437,174 @@ async function main() {
const eligible = PROS.filter((p) => !p.unverified && !p.away).length; const eligible = PROS.filter((p) => !p.unverified && !p.away).length;
console.log(` ${PROS.length} pros (${eligible} deck-eligible)`); console.log(` ${PROS.length} pros (${eligible} deck-eligible)`);
/**
/**
* The work behind every counter on a pro's card.
*
* A review row cannot exist on its own — it hangs off a booking, which hangs
* off a quote, a match, a request and a job. Seeding the whole chain rather
* than faking the leaf is the point: `ratingAvg`, `ratingCount`,
* `completedJobs`, `responseRate` and `avgResponseMinutes` are DERIVED from
* these rows at the end of this file, so a pro credited with 47 reviews has
* 47 of them and the deck ranks on a history that exists.
*
* Inserted a table at a time rather than a row at a time: this is ~600
* histories across six tables, and one round trip per row makes the seed take
* minutes. Postgres returns a single multi-row INSERT ... RETURNING in the
* order the values were given, which is what lets the next table's foreign
* keys line up by index.
*/
let reviewCount = 0;
let ignoredCount = 0;
for (const [i, pro] of proRows.entries()) {
if (pro.seed.reviews === 0 || pro.seed.rating === null) continue;
const work = WORK[pro.seed.cat] ?? GENERIC_WORK;
const n = pro.seed.reviews;
const ratings = seedRatings(pro.seed.rating, n);
// Spread across pros so the deck has something to rank on. Bodies cycle
// past the end of the trade's list; the newest are laid down first, so the
// page a profile actually shows stays varied.
const replyMinutes = 15 + (i % 8) * 20;
/*
* Requests this pro let expire without answering.
*
* `responseRate` is answered ÷ decided, so with nothing but accepted
* requests every pro scores a flat 1.000 and the ranking weight does
* nothing. Capped rather than solved exactly: the ratio only has to vary
* and be real, and each one costs a job row.
*/
const targetRate = Math.min(0.98, 0.6 + (i % 40) / 100);
const ignored = Math.min(8, Math.round((n * (1 - targetRate)) / targetRate));
const at = (k: number) => new Date(now - (k + 1) * 9 * 86_400_000 - i * 3_600_000);
const jobRows = await db
.insert(schema.jobs)
.values([
...Array.from({ length: n }, (_, k) => {
const w = work[k % work.length]!;
return {
clientId: clientRows[(i + k) % clientRows.length]!.id,
categoryId: pro.categoryId,
title: w.title,
description: w.scope,
photos: [],
urgency: 'flexible' as const,
location: { lat: CITY.lat, lng: CITY.lng },
locationPrecision: 'exact' as const,
addressText: `Carrer Example ${10 + (k % 40)}, ${CITY.name}`,
status: 'completed' as const,
createdAt: new Date(at(k).getTime() - 6 * 86_400_000),
};
}),
// The ones nobody answered. Cancelled, because that is what a client
// does when a pro never replies.
...Array.from({ length: ignored }, (_, k) => ({
clientId: clientRows[(i + k + 1) % clientRows.length]!.id,
categoryId: pro.categoryId,
title: work[(k + 1) % work.length]!.title,
description: work[(k + 1) % work.length]!.scope,
photos: [],
urgency: 'this_week' as const,
location: { lat: CITY.lat, lng: CITY.lng },
locationPrecision: 'exact' as const,
addressText: `Carrer Example ${60 + (k % 20)}, ${CITY.name}`,
status: 'cancelled' as const,
createdAt: new Date(at(n + k).getTime() - 6 * 86_400_000),
})),
])
.returning({ id: schema.jobs.id });
const requestRows = await db
.insert(schema.requests)
.values(
jobRows.map((job, k) => {
const answered = k < n;
// Sent, then answered `replyMinutes` later. Left to the column
// default `created_at` would be now() while `responded_at` sat months
// in the past, and every derived response time came out negative.
const sentAt = new Date(at(k).getTime() - 5 * 86_400_000);
return {
jobId: job.id,
proId: pro.id,
status: answered ? ('accepted' as const) : ('expired' as const),
createdAt: sentAt,
respondedAt: answered
? new Date(sentAt.getTime() + replyMinutes * 60_000)
: null,
expiresAt: new Date(sentAt.getTime() + 48 * 3_600_000),
};
}),
)
.returning({ id: schema.requests.id });
const matchRows = await db
.insert(schema.matches)
.values(
requestRows.slice(0, n).map((req, k) => ({
requestId: req.id,
jobId: jobRows[k]!.id,
proId: pro.id,
clientId: clientRows[(i + k) % clientRows.length]!.id,
})),
)
.returning({ id: schema.matches.id });
const quoteRows = await db
.insert(schema.quotes)
.values(
matchRows.map((match, k) => ({
matchId: match.id,
kind: 'fixed' as const,
amountCents: work[k % work.length]!.amountCents,
scope: work[k % work.length]!.scope,
status: 'accepted' as const,
validUntil: new Date(at(k).getTime() - 2 * 86_400_000),
respondedAt: new Date(at(k).getTime() - 3 * 86_400_000),
})),
)
.returning({ id: schema.quotes.id });
const bookingRows = await db
.insert(schema.bookings)
.values(
quoteRows.map((quote, k) => ({
matchId: matchRows[k]!.id,
quoteId: quote.id,
scheduledStart: new Date(at(k).getTime() - 4 * 3_600_000),
scheduledEnd: at(k),
status: 'completed' as const,
proCompletedAt: at(k),
clientConfirmedAt: new Date(at(k).getTime() + 2 * 3_600_000),
})),
)
.returning({ id: schema.bookings.id });
await db.insert(schema.reviews).values(
bookingRows.map((booking, k) => ({
bookingId: booking.id,
authorId: clientRows[(i + k) % clientRows.length]!.id,
subjectId: pro.id,
rating: ratings[k]!,
body: work[k % work.length]!.review,
// Set, and in the past: `published_at` is the moderation gate that
// keeps a review hidden until both sides have written one, and both
// `pro.reviews` and the rating counters read nothing without it.
publishedAt: new Date(at(k).getTime() + 3 * 86_400_000),
createdAt: new Date(at(k).getTime() + 2 * 86_400_000),
})),
);
reviewCount += n;
ignoredCount += ignored;
}
console.log(
` ${reviewCount} published reviews across completed bookings, ` +
`${ignoredCount} requests left to expire`,
);
// One open job at the exact city centre — the fixture every deck test uses. // One open job at the exact city centre — the fixture every deck test uses.
const firstClient = clientRows[0]; const firstClient = clientRows[0];
const plumberCat = catBySlug.get('plumber'); const plumberCat = catBySlug.get('plumber');
@@ -298,11 +622,161 @@ async function main() {
budgetMinCents: 8_000, budgetMinCents: 8_000,
budgetMaxCents: 20_000, budgetMaxCents: 20_000,
location: { lat: CITY.lat, lng: CITY.lng }, location: { lat: CITY.lat, lng: CITY.lng },
locationPrecision: 'exact' as const,
addressText: `Carrer Example 12, ${CITY.name}`, addressText: `Carrer Example 12, ${CITY.name}`,
}) })
.returning(); .returning();
console.log(` 1 open job at the city centre (${job?.id})`); console.log(` 1 open job at the city centre (${job?.id})`);
/**
* A job with a conversation on it, and one that is already history.
*
* The jobs tab has three screens — the list, the pros on a job, and the chat
* — and none of them can be looked at against a database whose only job is
* open with nobody on it. This is the smallest fixture that lights all three
* and gives the Current/Past segments something on each side.
*/
const [chattyPro] = await db
.select({ id: schema.proProfiles.userId })
.from(schema.proProfiles)
.where(sql`${schema.proProfiles.verificationStatus} = 'verified'`)
.limit(1);
if (chattyPro) {
const conversations = [
{
status: 'matched' as const,
title: 'Radiator not heating up in the back bedroom',
description:
'One radiator stays cold while the rest of the house is fine. Bled it twice, no change. Boiler was serviced in the spring.',
messages: [
{ fromPro: false, body: 'Hi — are you free to take a look this week?' },
{ fromPro: true, body: 'I can do Thursday afternoon. Is the boiler a combi?' },
{ fromPro: false, body: 'It is, a Vaillant. Thursday works, any time after 14:00.' },
],
},
{
status: 'completed' as const,
title: 'Replace the outside tap',
description:
'Old garden tap is seized and weeping at the thread. Needs replacing, easy access from the patio.',
messages: [
{ fromPro: false, body: 'Could you replace an outside tap?' },
{ fromPro: true, body: 'Yes — done. New tap fitted and tested, no drips.' },
],
},
];
for (const c of conversations) {
const [j] = await db
.insert(schema.jobs)
.values({
clientId: firstClient.id,
categoryId: plumberCat,
title: c.title,
description: c.description,
photos: [],
urgency: 'this_week' as const,
location: { lat: CITY.lat, lng: CITY.lng },
locationPrecision: 'exact' as const,
addressText: `Carrer Example 12, ${CITY.name}`,
status: c.status,
})
.returning();
const [req] = await db
.insert(schema.requests)
.values({
jobId: j!.id,
proId: chattyPro.id,
status: 'accepted',
// Sent two hours ago and answered an hour later. `created_at` has
// to be set: left to the column default it is now(), which puts the
// response BEFORE the request and drags the pro's derived
// avgResponseMinutes negative.
createdAt: new Date(now - 2 * 3_600_000),
respondedAt: new Date(now - 3_600_000),
expiresAt: new Date(now + 86_400_000),
})
.returning();
const [match] = await db
.insert(schema.matches)
.values({
requestId: req!.id,
jobId: j!.id,
proId: chattyPro.id,
clientId: firstClient.id,
})
.returning();
// Spaced a minute apart so the thread has a readable order, and the
// pro's last message is left UNREAD — that is what puts a badge on the
// tab bar, which is the part worth being able to see.
const sentAt = (n: number) => new Date(now - (c.messages.length - n) * 60_000);
await db.insert(schema.messages).values(
c.messages.map((m, n) => ({
matchId: match!.id,
senderId: m.fromPro ? chattyPro.id : firstClient.id,
body: m.body,
createdAt: sentAt(n),
readAt: m.fromPro && n === c.messages.length - 1 ? null : sentAt(n),
})),
);
await db
.update(schema.matches)
.set({ lastMessageAt: sentAt(c.messages.length - 1) })
.where(sql`${schema.matches.id} = ${match!.id}`);
/*
* The finished one gets the full commercial trail: quote, booking,
* completed. Without it the Past tab has a job in it and nothing to do,
* and the review flow — which only opens on a completed booking — is
* invisible in the running app.
*/
if (c.status === 'completed') {
const [q] = await db
.insert(schema.quotes)
.values({
matchId: match!.id,
kind: 'fixed',
amountCents: 8_500,
scope: 'Supply and fit a new outside tap, including the wall plate and sealing.',
status: 'accepted',
validUntil: new Date(now - 5 * 86_400_000),
respondedAt: new Date(now - 6 * 86_400_000),
})
.returning();
await db.insert(schema.bookings).values({
matchId: match!.id,
quoteId: q!.id,
scheduledStart: new Date(now - 4 * 86_400_000),
scheduledEnd: new Date(now - 4 * 86_400_000 + 2 * 3_600_000),
status: 'completed',
proCompletedAt: new Date(now - 4 * 86_400_000 + 2 * 3_600_000),
clientConfirmedAt: new Date(now - 4 * 86_400_000 + 3 * 3_600_000),
});
} }
}
console.log(` 2 jobs with conversations (1 current, 1 past)`);
}
}
/*
* Derive the ranking counters from everything above.
*
* ratingAvg, ratingCount, completedJobs, responseRate and
* avgResponseMinutes used to be written straight onto the profile beside a
* history that did not contain them, so the seed asserted a record no query
* could reproduce. Deriving them here makes this a fixture the deck ranking
* can be tested against, and exercises the same function the app calls on
* every accept, decline and review.
*/
await recomputeAllProStats(db);
console.log(' ranking counters derived from the seeded history');
console.log('\nSeed complete. Sign in as client1@linkder.test or pro1@linkder.test'); console.log('\nSeed complete. Sign in as client1@linkder.test or pro1@linkder.test');
} }
+13 -1
View File
@@ -22,8 +22,20 @@ let jobId: string;
let clientId: string; let clientId: string;
beforeAll(async () => { beforeAll(async () => {
/*
* The fixture job, selected by what makes it the fixture rather than by
* position. It used to be "the oldest job", which held only while it was the
* only job: the seed now backdates hundreds of completed ones to give pros a
* real record, and the oldest row became somebody else's finished electrical
* job — so a deck of plumbers was asserted against a deck of electricians.
*
* It is also the only OPEN job at the centre, and open is the only state a
* deck is ever built for.
*/
const rows = await db.execute<{ id: string; client_id: string }>( const rows = 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 row = rows[0]; const row = rows[0];
if (!row) throw new Error('No seeded job found — run `pnpm db:seed` first'); if (!row) throw new Error('No seeded job found — run `pnpm db:seed` first');
+146
View File
@@ -0,0 +1,146 @@
/**
* Integration test — runs against a live seeded database.
*
* pnpm services:up && pnpm db:migrate && pnpm db:seed
* pnpm --filter @linkder/db test
*
* Search is a second door onto the same supply as the deck, so the test that
* matters most is the parity one: a pro who can be found here must be a pro who
* could be swiped there. That invariant is why `eligiblePro()` exists.
*/
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('../src/client');
const { searchPros } = await import('../src/queries/search');
const { getShowcaseDeck } = await import('../src/queries/deck');
/** The seed places every pro relative to this point. */
const CENTRE = { lat: 41.3874, lng: 2.1686 };
let plumberId: string;
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');
plumberId = plumber.id;
// Seeded skills are empty, so text search has nothing to match until we give
// one pro something to find. Marc Oliveras is 800 m from the centre.
await db.execute(sql`
UPDATE pro_profiles SET skills = ARRAY['Underfloor heating', 'Emergency callouts']
WHERE user_id = (SELECT id FROM users WHERE name = 'Marc Oliveras')
`);
});
afterAll(async () => {
await db.execute(sql`
UPDATE pro_profiles SET skills = '{}'::text[]
WHERE user_id = (SELECT id FROM users WHERE name = 'Marc Oliveras')
`);
await closePool();
});
describe('searchPros', () => {
it('returns eligible pros with no query at all', async () => {
const results = await searchPros(db, { ...CENTRE, limit: 50 });
expect(results.length).toBeGreaterThan(0);
});
it('never returns anyone the shop window would not show', async () => {
// The parity invariant. If these two ever disagree, one surface has become
// the way around the other.
const found = await searchPros(db, { ...CENTRE, limit: 50 });
const showcase = await getShowcaseDeck(db, { ...CENTRE, limit: 100 });
const shownIds = new Set(showcase.map((c) => c.proId));
for (const pro of found) {
expect(shownIds.has(pro.proId)).toBe(true);
}
});
it('excludes the unverified, the away and the too-far', async () => {
const names = (await searchPros(db, { ...CENTRE, limit: 50 })).map((p) => p.name);
expect(names).not.toContain('Unverified Ulla');
expect(names).not.toContain('Away Arnau');
expect(names).not.toContain('Pau Ribas'); // 22 km out, 5 km radius
});
it('matches a skill', async () => {
const names = (await searchPros(db, { ...CENTRE, q: 'underfloor', limit: 50 })).map(
(p) => p.name,
);
expect(names).toContain('Marc Oliveras');
expect(names).not.toContain('Laia Mestre'); // an electrician with no such skill
});
it('matches a trade name', async () => {
const results = await searchPros(db, { ...CENTRE, q: 'electrician', limit: 50 });
expect(results.length).toBeGreaterThan(0);
expect(results.every((p) => p.categories.includes('Electrician'))).toBe(true);
});
it('matches a pro by name', async () => {
const names = (await searchPros(db, { ...CENTRE, q: 'oliveras', limit: 50 })).map((p) => p.name);
expect(names).toEqual(['Marc Oliveras']);
});
it('treats wildcards as literal characters', async () => {
// Without escaping, '%' matches everyone and the filter looks broken.
const results = await searchPros(db, { ...CENTRE, q: '%', limit: 50 });
expect(results).toHaveLength(0);
});
it('narrows to one trade', async () => {
const results = await searchPros(db, { ...CENTRE, categoryId: plumberId, limit: 50 });
expect(results.length).toBeGreaterThan(0);
expect(results.every((p) => p.categories.includes('Plumber'))).toBe(true);
});
it("honours the searcher's own distance limit", async () => {
const near = await searchPros(db, { ...CENTRE, maxDistanceM: 3_000, limit: 50 });
expect(near.every((p) => p.distanceM <= 3_000)).toBe(true);
expect(near.map((p) => p.name)).not.toContain('Marta Vidal'); // 9.1 km out
});
it('sorts by distance, price and rating', async () => {
const nearest = await searchPros(db, { ...CENTRE, sort: 'nearest', limit: 50 });
for (let i = 1; i < nearest.length; i++) {
expect(nearest[i]!.distanceM).toBeGreaterThanOrEqual(nearest[i - 1]!.distanceM);
}
const cheapest = await searchPros(db, { ...CENTRE, sort: 'price', limit: 50 });
for (let i = 1; i < cheapest.length; i++) {
expect(cheapest[i]!.hourlyRateCents).toBeGreaterThanOrEqual(cheapest[i - 1]!.hourlyRateCents);
}
const rated = await searchPros(db, { ...CENTRE, sort: 'rating', limit: 50 });
const scored = rated.filter((p) => p.ratingAvg !== null);
for (let i = 1; i < scored.length; i++) {
expect(scored[i]!.ratingAvg!).toBeLessThanOrEqual(scored[i - 1]!.ratingAvg!);
}
// Unrated pros sort last rather than being read as zero.
const firstUnrated = rated.findIndex((p) => p.ratingAvg === null);
if (firstUnrated !== -1) {
expect(rated.slice(firstUnrated).every((p) => p.ratingAvg === null)).toBe(true);
}
});
it('excludes the unrated from a rating floor', async () => {
const results = await searchPros(db, { ...CENTRE, minRating: 4.5, limit: 50 });
expect(results.every((p) => p.ratingAvg !== null && p.ratingAvg >= 4.5)).toBe(true);
});
it('honours a price ceiling and the limit', async () => {
const cheap = await searchPros(db, { ...CENTRE, maxHourlyRateCents: 3_000, limit: 50 });
expect(cheap.every((p) => p.hourlyRateCents <= 3_000)).toBe(true);
const two = await searchPros(db, { ...CENTRE, limit: 2 });
expect(two).toHaveLength(2);
});
});
+218
View File
@@ -0,0 +1,218 @@
/**
* Integration test — runs against a live seeded database.
*
* pnpm services:up && pnpm db:migrate && pnpm db:seed
*
* `recomputeProStats` is the only thing that ever writes the five denormalised
* ranking counters, and `score()` reads all five. If it drifts from the rows it
* claims to summarise, the deck ranks on fiction and nothing fails — which is
* the situation this function was written to end, so it needs a test that
* checks the arithmetic rather than that it ran.
*/
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('../src/client');
const { recomputeProStats } = await import('../src/queries/stats');
const RUN = Math.random().toString(36).slice(2, 8);
interface Counters {
rating_avg: string | null;
rating_count: number;
completed_jobs: number;
response_rate: string | null;
avg_response_minutes: number | null;
}
/** This file's own pro and client, so a parallel test file never sees them. */
let pro: string;
let client: string;
let categoryId: string;
async function counters(): Promise<Counters> {
const [row] = await db.execute<Counters>(sql`
SELECT rating_avg, rating_count, completed_jobs, response_rate, avg_response_minutes
FROM pro_profiles WHERE user_id = ${pro}
`);
return row!;
}
/**
* One finished, reviewed job.
*
* The whole chain, because that is what the counters read: a review hangs off a
* booking, which hangs off a quote, a match and a request.
*/
async function completedJob(opts: {
rating: number;
published: boolean;
replyMinutes: number;
}): Promise<void> {
const [job] = await db.execute<{ id: string }>(sql`
INSERT INTO jobs (client_id, category_id, title, description, urgency, location, address_text, status)
VALUES (${client}, ${categoryId}, 'Stats probe', 'Probe job for the stats tests.', 'flexible',
ST_SetSRID(ST_MakePoint(0.7, 0.7), 4326)::geography, 'Nowhere', 'completed')
RETURNING id
`);
const [request] = await db.execute<{ id: string }>(sql`
INSERT INTO requests (job_id, pro_id, status, created_at, responded_at, expires_at)
VALUES (${job!.id}, ${pro}, 'accepted',
now() - interval '10 days',
now() - interval '10 days' + ${`${opts.replyMinutes} minutes`}::interval,
now() - interval '8 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}, ${pro}, ${client}) 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', 'accepted', now() - interval '7 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 '6 days',
now() - interval '6 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}, ${client}, ${pro}, ${opts.rating}, 'Probe review.',
${opts.published ? sql`now() - interval '5 days'` : sql`NULL`})
`);
}
/** A request the pro let run out. Counts against responseRate, nothing else. */
async function ignoredRequest(): Promise<void> {
const [job] = await db.execute<{ id: string }>(sql`
INSERT INTO jobs (client_id, category_id, title, description, urgency, location, address_text, status)
VALUES (${client}, ${categoryId}, 'Stats probe (ignored)', 'Probe job.', 'flexible',
ST_SetSRID(ST_MakePoint(0.7, 0.7), 4326)::geography, 'Nowhere', 'cancelled')
RETURNING id
`);
await db.execute(sql`
INSERT INTO requests (job_id, pro_id, status, created_at, expires_at)
VALUES (${job!.id}, ${pro}, 'expired', now() - interval '10 days', now() - interval '8 days')
`);
}
beforeAll(async () => {
const [cat] = await db.execute<{ id: string }>(
sql`SELECT id FROM categories ORDER BY position LIMIT 1`,
);
categoryId = cat!.id;
const [p] = await db.execute<{ id: string }>(sql`
INSERT INTO users (name, email, role) VALUES ('Stats Probe', ${`stats-pro-${RUN}@example.com`}, 'pro')
RETURNING id
`);
pro = p!.id;
const [c] = await db.execute<{ id: string }>(sql`
INSERT INTO users (name, email, role) VALUES ('Stats Client', ${`stats-client-${RUN}@example.com`}, 'client')
RETURNING id
`);
client = c!.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, rating_avg, rating_count, completed_jobs,
response_rate, avg_response_minutes
)
VALUES (
${pro}, 'Stats probe', 'Exists only for the stats tests.', 3000,
ST_SetSRID(ST_MakePoint(0.7, 0.7), 4326)::geography, 15000, 'verified', now(),
-- Deliberate nonsense, so a test that passes proves the recompute WROTE
-- rather than that the seeded value happened to be right.
'1.00', 999, 999, '0.001', 999
)
`);
});
afterAll(async () => {
await db.execute(sql`DELETE FROM users WHERE id IN (${pro}, ${client})`);
await closePool();
});
describe('recomputeProStats', () => {
it('zeroes a pro with no history instead of leaving stale numbers', async () => {
await recomputeProStats(db, pro);
const c = await counters();
expect(c.rating_count).toBe(0);
expect(c.completed_jobs).toBe(0);
// Not 0.0 — an unrated pro has no rating, and "0.0 ★" reads as a bad score
// everywhere it is rendered.
expect(c.rating_avg).toBeNull();
// Likewise: nobody has asked them anything, so there is no rate to report.
expect(c.response_rate).toBeNull();
expect(c.avg_response_minutes).toBeNull();
});
it('averages the published reviews and counts them', async () => {
await completedJob({ rating: 5, published: true, replyMinutes: 10 });
await completedJob({ rating: 4, published: true, replyMinutes: 30 });
await recomputeProStats(db, pro);
const c = await counters();
expect(c.rating_count).toBe(2);
expect(Number(c.rating_avg)).toBeCloseTo(4.5, 2);
expect(c.completed_jobs).toBe(2);
expect(c.avg_response_minutes).toBe(20);
});
it('ignores an embargoed review, so the count matches what pro.reviews lists', async () => {
// Published_at is the moderation gate. A rating counted in the header but
// absent from the list underneath is the exact mismatch the seed used to
// have, and the reason both read the same predicate.
await completedJob({ rating: 1, published: false, replyMinutes: 10 });
await recomputeProStats(db, pro);
const c = await counters();
expect(c.rating_count).toBe(2);
expect(Number(c.rating_avg)).toBeCloseTo(4.5, 2);
// The booking behind it still happened, though — that is not moderated.
expect(c.completed_jobs).toBe(3);
});
it('scores response rate over decided requests, not over sent ones', async () => {
await ignoredRequest();
await recomputeProStats(db, pro);
// Three answered, one expired unanswered.
expect(Number((await counters()).response_rate)).toBeCloseTo(0.75, 3);
});
it('does not count a request that is still inside its window', async () => {
const [job] = await db.execute<{ id: string }>(sql`
INSERT INTO jobs (client_id, category_id, title, description, urgency, location, address_text)
VALUES (${client}, ${categoryId}, 'Stats probe (live)', 'Probe job.', 'flexible',
ST_SetSRID(ST_MakePoint(0.7, 0.7), 4326)::geography, 'Nowhere')
RETURNING id
`);
await db.execute(sql`
INSERT INTO requests (job_id, pro_id, status, created_at, expires_at)
VALUES (${job!.id}, ${pro}, 'pending', now(), now() + interval '12 hours')
`);
await recomputeProStats(db, pro);
// Still 0.75: a request that arrived an hour ago has not been ignored, and
// counting it as a miss would punish a pro for work that just came in.
expect(Number((await counters()).response_rate)).toBeCloseTo(0.75, 3);
});
it('is idempotent — it derives rather than increments', async () => {
await recomputeProStats(db, pro);
const once = await counters();
await recomputeProStats(db, pro);
await recomputeProStats(db, pro);
expect(await counters()).toEqual(once);
});
});
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@linkder/geocode",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@linkder/shared": "workspace:*",
"zod": "^3.24.1"
},
"devDependencies": {
"typescript": "^5.7.3",
"vitest": "^2.1.8"
}
}

Some files were not shown because too many files have changed in this diff Show More