Deck: five actions — rewind, watch and ask, either side of pass and send
The deck had two answers, which is a lot to hang on a swipe: send this pro a job right now, or lose them. Three more, in one fixed row (DESIGN.md §6.8): rewind · ✗ · watch · ✓ · ask Size is the hierarchy — the two that end the card stay 64px, the three that do not are 44px, never below the §8 floor. Rewind is disabled rather than hidden when there is nothing to undo, so the row never changes length and the big pair never moves out from under a thumb. Rewind is local. The entry deck writes no swipes — a `swipes` row is job-scoped and there is no job there — so the card leaving was only ever an index move. Watch: "tell me when this one is free" - `pro_watches` snapshots the pro's availability AT WATCH TIME, because the trigger is a change, not a state. Without it a sweep would notify every watcher on every run, since "available" stays true for as long as they stay available. - Deliberately the narrow version: a pro with is_accepting_jobs = false is invisible everywhere (eligibleProAtAnyDistance requires it), so a watch can only be placed on somebody already free and fires on the away-and-back cycle. "Free at a time that suits me" needs pro_availability — seeded since M1, read by nothing — to become a real calendar. Flagged rather than faked. Ask: a question, before there is a job - This is the first way to reach a pro who has not agreed to anything. Chat was gated behind message → match → accepted request → job, and that gate is what made a pro's inbox worth opening, so the cap is not decoration: MAX_OPEN_ENQUIRIES unanswered at a time, one thread per pair so it cannot be walked around, answered threads stop counting, stale ones fall out, and the pro can close one. - `enquiries` is its own table, not a match with a null job: a match means a pro said yes to specific work, and collapsing the two would put rows in `matches` that no quote, booking or review could hang off. - `messages` now belongs to a match OR an enquiry, with a CHECK making the illegal state unrepresentable. One message table, so one chat screen. 283 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:
@@ -296,8 +296,29 @@ The tab bar is the app's only persistent navigation. There is no footer.
|
||||
|
||||
The one place that breaks the flat rule. `radius-deck` (28px), `shadow-lg`, full-bleed photo,
|
||||
bottom scrim `linear-gradient(to top, rgb(0 6 36 / .92), rgb(0 6 36 / .35) 45%, transparent)`.
|
||||
Overlay stamps: `SEND JOB` in `go-600`, `PASS` in `stop-500`, 4px border, ±12° rotation. Action
|
||||
buttons are 64px circles, 2px border, `ink-0` fill.
|
||||
Overlay stamps: `SEND JOB` in `go-600`, `PASS` in `stop-500`, 4px border, ±12° rotation.
|
||||
|
||||
**The action row** is five circles, in one fixed order:
|
||||
|
||||
| | Action | Size | Ink |
|
||||
|---|---|---|---|
|
||||
| 1 | Rewind — bring the last card back | 44px | `ink-600` |
|
||||
| 2 | Pass | **64px** | `stop-500` |
|
||||
| 3 | Watch — tell me when they are free | 44px | `brand-500` |
|
||||
| 4 | Send this job | **64px** | `go-600` |
|
||||
| 5 | Ask a question | 44px | `ink-950` |
|
||||
|
||||
Two sizes, and the size *is* the hierarchy: the two decisions that end the card
|
||||
are 64px and reach the thumb first; the three that do not are 44px — still the
|
||||
minimum target from §8, never smaller. Gaps are 16px between a small and a large,
|
||||
24px between the two larges, so the pair still reads as the pair.
|
||||
|
||||
All five are 2px bordered circles on `ink-0`. A control whose state persists —
|
||||
watch — fills with its own colour when active, and its label changes with it;
|
||||
selection is never carried by fill alone (§8).
|
||||
|
||||
Rewind is `disabled` with nothing to undo rather than hidden. A row that changes
|
||||
length as you swipe moves the two buttons underneath your thumb.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ export function JobsPanel({
|
||||
const { matchId, jobId } = state.view;
|
||||
return (
|
||||
<ChatThread
|
||||
matchId={matchId}
|
||||
threadRef={{ matchId }}
|
||||
onBack={() => go(jobId ? { kind: 'job', jobId } : { kind: 'list' })}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { INITIAL_SEARCH_STATE, SearchPanel, type SearchState } from './search-pa
|
||||
import { ProfilePanel } from './profile-panel';
|
||||
import { INITIAL_JOBS_STATE, JobsPanel, type JobsState } from './jobs-panel';
|
||||
import { SendJobSheet } from '@/components/hire/send-job-sheet';
|
||||
import { AskSheet } from '@/components/hire/ask-sheet';
|
||||
import { clearPendingHire, readPendingHire } from '@/lib/pending-hire';
|
||||
import { api } from '@/lib/trpc';
|
||||
|
||||
@@ -88,6 +89,9 @@ export function ShowcaseDeck({
|
||||
*/
|
||||
const [hiring, setHiring] = useState<DeckCard | null>(null);
|
||||
const resolve = useRef<((verdict: SwipeVerdict) => void) | null>(null);
|
||||
// Bumped by rewind; the Deck reads it to step its own index back.
|
||||
const [rewindAt, setRewindAt] = useState(0);
|
||||
const utils = api.useUtils();
|
||||
|
||||
const decide = useCallback(
|
||||
(proId: string, direction: 'left' | 'right'): Promise<SwipeVerdict> => {
|
||||
@@ -105,6 +109,31 @@ export function ShowcaseDeck({
|
||||
[cards],
|
||||
);
|
||||
|
||||
/* ── the three secondary deck actions ── */
|
||||
|
||||
const [asking, setAsking] = useState<DeckCard | null>(null);
|
||||
|
||||
// Errors for an anonymous visitor, same as the unread badge — no session,
|
||||
// nothing watched.
|
||||
const watched = api.watch.mine.useQuery(undefined, { retry: false });
|
||||
const watchedIds = useMemo(
|
||||
() => new Set((watched.data ?? []).map((w) => w.proId)),
|
||||
[watched.data],
|
||||
);
|
||||
|
||||
const toggleWatch = api.watch.toggle.useMutation({
|
||||
onSettled: () => void utils.watch.mine.invalidate(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Rewind steps the local stack back one.
|
||||
*
|
||||
* Nothing to undo server-side: the entry deck writes no swipes (a `swipes` row
|
||||
* is job-scoped and there is no job here), so the card leaving was only ever a
|
||||
* local index move. `deck.undo` is for the per-job deck, where a swipe is real.
|
||||
*/
|
||||
const rewind = useCallback(() => setRewindAt((n) => n + 1), []);
|
||||
|
||||
const onResolved = useCallback((outcome: 'sent' | 'dismissed') => {
|
||||
setHiring(null);
|
||||
resolve.current?.(outcome === 'sent' ? 'commit' : 'revert');
|
||||
@@ -197,6 +226,14 @@ export function ShowcaseDeck({
|
||||
key={categoryId ?? 'all'}
|
||||
cards={cards}
|
||||
onDecide={decide}
|
||||
onRewind={rewind}
|
||||
rewindSignal={rewindAt}
|
||||
onWatch={(proId) => toggleWatch.mutate({ proId })}
|
||||
watchedProIds={watchedIds}
|
||||
onAsk={(proId) => {
|
||||
const card = cards.find((c) => c.proId === proId);
|
||||
if (card) setAsking(card);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -215,6 +252,17 @@ export function ShowcaseDeck({
|
||||
{/* 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} />
|
||||
|
||||
<AskSheet
|
||||
pro={asking}
|
||||
open={asking !== null}
|
||||
onClose={() => setAsking(null)}
|
||||
onSent={() => {
|
||||
// Straight to the conversation they just started.
|
||||
setJobs({ ...jobs, segment: 'current', view: { kind: 'list' } });
|
||||
setTab('jobs');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion, useMotionValue, useTransform } from 'motion/react';
|
||||
import { Check, MapPin, Star, X } from 'lucide-react';
|
||||
import { Check, Eye, MapPin, MessageCircle, Star, Undo2, X } from 'lucide-react';
|
||||
import type { DeckCard } from '@linkder/db';
|
||||
import { cn, formatDistance, formatResponseTime } from '@/lib/utils';
|
||||
|
||||
@@ -26,9 +26,35 @@ export interface DeckProps {
|
||||
proId: string,
|
||||
direction: 'left' | 'right',
|
||||
) => void | Promise<void | SwipeVerdict>;
|
||||
/**
|
||||
* The three secondary actions. All optional — a deck rendered without them
|
||||
* shows the buttons disabled rather than missing, so the row keeps its shape
|
||||
* and the two big buttons never move.
|
||||
*/
|
||||
onRewind?: () => void;
|
||||
/**
|
||||
* Incremented by the parent to step the stack back one.
|
||||
*
|
||||
* A signal rather than a callback returning state: the index lives in the
|
||||
* Deck, and handing it out so a parent could decrement it would give two
|
||||
* owners to the one thing that must stay consistent with the card on screen.
|
||||
*/
|
||||
rewindSignal?: number;
|
||||
onWatch?: (proId: string) => void;
|
||||
onAsk?: (proId: string) => void;
|
||||
/** Which pros the viewer already watches, for the filled state. */
|
||||
watchedProIds?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export function Deck({ cards, onDecide }: DeckProps) {
|
||||
export function Deck({
|
||||
cards,
|
||||
onDecide,
|
||||
onRewind,
|
||||
rewindSignal = 0,
|
||||
onWatch,
|
||||
onAsk,
|
||||
watchedProIds,
|
||||
}: DeckProps) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const remaining = useMemo(() => cards.slice(index), [cards, index]);
|
||||
|
||||
@@ -36,6 +62,14 @@ export function Deck({ cards, onDecide }: DeckProps) {
|
||||
// is open would advance past a card nobody ever saw.
|
||||
const inFlight = useRef(false);
|
||||
|
||||
// Step back when the parent asks. Guarded on the previous value so a re-render
|
||||
// for any other reason does not rewind again.
|
||||
const lastRewind = useRef(rewindSignal);
|
||||
if (rewindSignal !== lastRewind.current) {
|
||||
lastRewind.current = rewindSignal;
|
||||
if (index > 0) setIndex((i) => Math.max(0, i - 1));
|
||||
}
|
||||
|
||||
const decide = useCallback(
|
||||
async (proId: string, direction: 'left' | 'right') => {
|
||||
if (inFlight.current) return;
|
||||
@@ -83,17 +117,48 @@ export function Deck({ cards, onDecide }: DeckProps) {
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-12 pb-2">
|
||||
{/* DESIGN.md §6.8. Fixed order, two sizes: rewind · pass · watch · hire · ask.
|
||||
Gaps are tighter around the small ones so the middle pair still reads
|
||||
as the pair. */}
|
||||
<div className="flex shrink-0 items-center gap-4 pb-2">
|
||||
<ActionButton
|
||||
label="Bring back the last one"
|
||||
icon={Undo2}
|
||||
tone="ink"
|
||||
// Disabled rather than hidden: a row that changes length as you swipe
|
||||
// moves the two big buttons out from under your thumb.
|
||||
disabled={index === 0 || !onRewind}
|
||||
onClick={() => onRewind?.()}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Not this one"
|
||||
variant="pass"
|
||||
icon={X}
|
||||
tone="pass"
|
||||
size="lg"
|
||||
onClick={() => visible[0] && decide(visible[0].proId, 'left')}
|
||||
/>
|
||||
<ActionButton
|
||||
label={watchedProIds?.has(visible[0]?.proId ?? '') ? 'Stop watching' : 'Tell me when they are free'}
|
||||
icon={Eye}
|
||||
tone="accent"
|
||||
active={watchedProIds?.has(visible[0]?.proId ?? '') ?? false}
|
||||
disabled={!onWatch}
|
||||
onClick={() => visible[0] && onWatch?.(visible[0].proId)}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Send this job"
|
||||
variant="hire"
|
||||
icon={Check}
|
||||
tone="hire"
|
||||
size="lg"
|
||||
onClick={() => visible[0] && decide(visible[0].proId, 'right')}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Ask a question"
|
||||
icon={MessageCircle}
|
||||
tone="ink"
|
||||
disabled={!onAsk}
|
||||
onClick={() => visible[0] && onAsk?.(visible[0].proId)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -215,32 +280,67 @@ export function Card({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DESIGN.md §6.8. One of the five circles under the card.
|
||||
*
|
||||
* `size` IS the hierarchy, not decoration: the two actions that end the card are
|
||||
* large and meet the thumb first; the three that do not are small — but never
|
||||
* below 44px, which is the floor in §8.
|
||||
*
|
||||
* `active` fills the circle for the one control whose state persists (watch),
|
||||
* and its label changes with it, because §8 forbids fill as the only signal.
|
||||
*/
|
||||
function ActionButton({
|
||||
label,
|
||||
variant,
|
||||
icon: Icon,
|
||||
tone,
|
||||
size = 'sm',
|
||||
active = false,
|
||||
disabled = false,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
variant: 'pass' | 'hire';
|
||||
icon: typeof Check;
|
||||
tone: 'pass' | 'hire' | 'accent' | 'ink';
|
||||
size?: 'sm' | 'lg';
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const isHire = variant === 'hire';
|
||||
const Icon = isHire ? Check : X;
|
||||
const isLarge = size === 'lg';
|
||||
const colour = {
|
||||
pass: 'var(--color-stop-500)',
|
||||
hire: 'var(--color-go-600)',
|
||||
accent: 'var(--color-brand-500)',
|
||||
ink: 'var(--color-ink-950)',
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
aria-pressed={active || undefined}
|
||||
title={label}
|
||||
style={{
|
||||
borderColor: colour,
|
||||
color: active ? undefined : colour,
|
||||
backgroundColor: active ? colour : undefined,
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-16 w-16 items-center justify-center rounded-full border-2 bg-raised shadow-lg',
|
||||
'flex items-center justify-center rounded-full border-2 shadow-lg',
|
||||
'transition hover:scale-105 active:scale-95',
|
||||
isHire
|
||||
? 'border-[var(--color-go-600)] text-[var(--color-go-600)]'
|
||||
: 'border-[var(--color-stop-500)] text-[var(--color-stop-500)]',
|
||||
'disabled:pointer-events-none disabled:opacity-35 disabled:shadow-none',
|
||||
isLarge ? 'h-16 w-16' : 'h-11 w-11',
|
||||
active ? 'text-white' : 'bg-raised',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-7 w-7" strokeWidth={3} aria-hidden />
|
||||
<Icon
|
||||
className={isLarge ? 'h-7 w-7' : 'h-5 w-5'}
|
||||
strokeWidth={isLarge ? 3 : 2.5}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { DeckCard } from '@linkder/db';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { Banner, Button, Sheet, Textarea } from '@/components/ui';
|
||||
import { setPendingHire } from '@/lib/pending-hire';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* Ask a pro a question, before there is a job.
|
||||
*
|
||||
* This is the one place in the product that reaches somebody who has not agreed
|
||||
* to anything, so the sheet is deliberately honest about the deal on both sides:
|
||||
* the customer is told the pro can end it, and the remaining allowance is shown
|
||||
* before they type rather than after they press send.
|
||||
*/
|
||||
export function AskSheet({
|
||||
pro,
|
||||
open,
|
||||
onClose,
|
||||
onSent,
|
||||
}: {
|
||||
pro: DeckCard | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSent: (enquiryId: string) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [body, setBody] = useState('');
|
||||
|
||||
const me = api.user.me.useQuery(undefined, { retry: false });
|
||||
const allowance = api.enquiry.allowance.useQuery(undefined, {
|
||||
enabled: open && me.data?.role === 'client',
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const utils = api.useUtils();
|
||||
const create = api.enquiry.create.useMutation({
|
||||
onSuccess: ({ enquiryId }) => {
|
||||
setBody('');
|
||||
void utils.enquiry.allowance.invalidate();
|
||||
void utils.enquiry.mineAsClient.invalidate();
|
||||
onSent(enquiryId);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
if (!pro) return null;
|
||||
const name = pro.name ?? 'this pro';
|
||||
|
||||
/* ── anonymous ── */
|
||||
if (!me.isLoading && (me.error || !me.data)) {
|
||||
return (
|
||||
<Sheet
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={`Ask ${name} a question`}
|
||||
body="Sign in first — they need to know who is asking."
|
||||
actions={
|
||||
<Button
|
||||
size="lg"
|
||||
block
|
||||
onClick={() => {
|
||||
setPendingHire({ proId: pro.proId, name });
|
||||
router.push('/sign-in?next=/');
|
||||
}}
|
||||
>
|
||||
Continue with phone
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (me.data?.role === 'pro') {
|
||||
return (
|
||||
<Sheet
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="You are signed in as a pro"
|
||||
body="Asking questions is a customer thing. You can still browse who else is on here."
|
||||
actions={
|
||||
<Button variant="outline" size="lg" block onClick={onClose}>
|
||||
Back to the deck
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const remaining = allowance.data?.remaining ?? null;
|
||||
const outOfRoom = remaining === 0;
|
||||
const canSend = body.trim().length >= 10 && !create.isPending && !outOfRoom;
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={`Ask ${name} a question`}
|
||||
body="No job needed. They can reply, or close it."
|
||||
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({ proId: pro.proId, body })}
|
||||
>
|
||||
Send question
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" block onClick={onClose}>
|
||||
Not now
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{outOfRoom ? (
|
||||
<Banner tone="warning" title="Give them a chance to reply">
|
||||
You have {allowance.data?.cap} questions still waiting on an answer. Once somebody
|
||||
replies you can ask again — or post a job, which reaches pros a different way.
|
||||
</Banner>
|
||||
) : (
|
||||
<>
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-body-sm text-strong">Your question</span>
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={body}
|
||||
maxLength={2000}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="Do you cover this kind of work? Roughly what would it cost?"
|
||||
/>
|
||||
</label>
|
||||
<p className="mt-1 text-meta text-faint">
|
||||
{body.trim().length < 10
|
||||
? 'A sentence at least — "hi" is not a question anybody can answer.'
|
||||
: remaining !== null
|
||||
? `${remaining} more question${remaining === 1 ? '' : 's'} while these are unanswered.`
|
||||
: `${body.length}/2000`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,9 @@ import { cn, formatRelativeTime } from '@/lib/utils';
|
||||
* 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 }) {
|
||||
export type ThreadRef = { matchId: string } | { enquiryId: string };
|
||||
|
||||
export function ChatThread({ threadRef, onBack }: { threadRef: ThreadRef; onBack: () => void }) {
|
||||
const utils = api.useUtils();
|
||||
const [draft, setDraft] = useState('');
|
||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||
@@ -28,7 +30,7 @@ export function ChatThread({ matchId, onBack }: { matchId: string; onBack: () =>
|
||||
const bottom = useRef<HTMLDivElement>(null);
|
||||
|
||||
const thread = api.message.thread.useQuery(
|
||||
{ matchId },
|
||||
{ ref: threadRef },
|
||||
{
|
||||
// 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.
|
||||
@@ -56,13 +58,17 @@ export function ChatThread({ matchId, onBack }: { matchId: string; onBack: () =>
|
||||
onSuccess: () => {
|
||||
setDraft('');
|
||||
setAttachments([]);
|
||||
void utils.message.thread.invalidate({ matchId });
|
||||
void utils.message.thread.invalidate({ ref: threadRef });
|
||||
void utils.job.mine.invalidate();
|
||||
void utils.job.mineForPro.invalidate();
|
||||
void utils.job.matches.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
// A stable primitive for effect deps — an object literal would be a new
|
||||
// reference every render and re-fire the read receipt on a loop.
|
||||
const threadKey = 'matchId' in threadRef ? threadRef.matchId : threadRef.enquiryId;
|
||||
|
||||
const messages = thread.data?.messages ?? [];
|
||||
const match = thread.data?.match;
|
||||
const newestId = messages[messages.length - 1]?.id;
|
||||
@@ -72,10 +78,10 @@ export function ChatThread({ matchId, onBack }: { matchId: string; onBack: () =>
|
||||
const unreadFromPeer = messages.some((m) => !m.isMine && m.readAt === null);
|
||||
useEffect(() => {
|
||||
if (!unreadFromPeer || markRead.isPending) return;
|
||||
markRead.mutate({ matchId });
|
||||
markRead.mutate({ ref: threadRef });
|
||||
// `newestId` is the trigger: re-running on every render would loop.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [matchId, newestId, unreadFromPeer]);
|
||||
}, [threadKey, newestId, unreadFromPeer]);
|
||||
|
||||
// Stick to the bottom as messages land. `auto` rather than `smooth` on first
|
||||
// paint, or the thread visibly scrolls itself on open.
|
||||
@@ -89,7 +95,7 @@ export function ChatThread({ matchId, onBack }: { matchId: string; onBack: () =>
|
||||
|
||||
const submit = () => {
|
||||
if (!canSend) return;
|
||||
send.mutate({ matchId, body: draft, attachments: attachments.map((a) => a.url) });
|
||||
send.mutate({ ref: threadRef, body: draft, attachments: attachments.map((a) => a.url) });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -153,9 +159,9 @@ export function ChatThread({ matchId, onBack }: { matchId: string; onBack: () =>
|
||||
|
||||
{/* 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 && (
|
||||
{match?.matchId && (
|
||||
<DealStrip
|
||||
matchId={matchId}
|
||||
matchId={match.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}
|
||||
@@ -164,9 +170,11 @@ export function ChatThread({ matchId, onBack }: { matchId: string; onBack: () =>
|
||||
|
||||
{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.'}
|
||||
{match.kind === 'enquiry'
|
||||
? 'This enquiry was closed. The messages stay as a record.'
|
||||
: 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">
|
||||
|
||||
@@ -41,7 +41,7 @@ export function DealStrip({
|
||||
const refresh = () => {
|
||||
void utils.quote.forMatch.invalidate({ matchId });
|
||||
void utils.booking.forMatch.invalidate({ matchId });
|
||||
void utils.message.thread.invalidate({ matchId });
|
||||
void utils.message.thread.invalidate({ ref: { matchId } });
|
||||
void utils.job.mine.invalidate();
|
||||
void utils.job.mineForPro.invalidate();
|
||||
void utils.review.pending.invalidate();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { router } from './trpc';
|
||||
import { adminRouter } from './routers/admin';
|
||||
import { deckRouter } from './routers/deck';
|
||||
import { bookingRouter } from './routers/booking';
|
||||
import { enquiryRouter } from './routers/enquiry';
|
||||
import { geocodeRouter } from './routers/geocode';
|
||||
import { jobRouter } from './routers/job';
|
||||
import { messageRouter } from './routers/message';
|
||||
@@ -9,6 +10,7 @@ import { proRouter } from './routers/pro';
|
||||
import { quoteRouter } from './routers/quote';
|
||||
import { requestRouter } from './routers/request';
|
||||
import { reviewRouter } from './routers/review';
|
||||
import { watchRouter } from './routers/watch';
|
||||
import { uploadRouter } from './routers/upload';
|
||||
import { notificationRouter } from './routers/notification';
|
||||
import { userRouter } from './routers/user';
|
||||
@@ -27,6 +29,8 @@ export const appRouter = router({
|
||||
quote: quoteRouter,
|
||||
booking: bookingRouter,
|
||||
review: reviewRouter,
|
||||
enquiry: enquiryRouter,
|
||||
watch: watchRouter,
|
||||
geocode: geocodeRouter,
|
||||
upload: uploadRouter,
|
||||
user: userRouter,
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { and, desc, eq, gt, isNull, or, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { schema } from '@linkder/db';
|
||||
import { ENQUIRY_STALE_DAYS, MAX_OPEN_ENQUIRIES } from '@linkder/shared';
|
||||
import { clientProcedure, proProcedure, protectedProcedure, router } from '../trpc';
|
||||
|
||||
/**
|
||||
* A question, before there is a job.
|
||||
*
|
||||
* This is the first way in the product to reach a pro who has not agreed to
|
||||
* anything, and that is a real change: until now chat was gated behind
|
||||
* message → match → accepted request → job, and the gate is what made a pro's
|
||||
* inbox worth opening. So the cap below is not decoration.
|
||||
*
|
||||
* What keeps it honest:
|
||||
* - `MAX_OPEN_ENQUIRIES` unanswered at a time, per customer. Answered threads
|
||||
* do not count, so somebody having real conversations is never throttled and
|
||||
* somebody broadcasting is stopped at five.
|
||||
* - One thread per pair. A second question goes in the same conversation
|
||||
* rather than making a new one, so a cap cannot be walked around by asking
|
||||
* the same person repeatedly.
|
||||
* - The pro can close it. History stays readable; nothing more lands.
|
||||
*/
|
||||
export const enquiryRouter = router({
|
||||
/**
|
||||
* The pro's side: questions waiting on them.
|
||||
*
|
||||
* `proProcedure`, not verified-only — an unverified pro should see what they
|
||||
* are missing, which is the strongest argument for finishing verification.
|
||||
* Answering is what needs the badge.
|
||||
*/
|
||||
mine: proProcedure.query(async ({ ctx }) => {
|
||||
const uid = ctx.session.userId;
|
||||
|
||||
const rows = await ctx.db
|
||||
.select({
|
||||
id: schema.enquiries.id,
|
||||
clientId: schema.enquiries.clientId,
|
||||
clientName: schema.users.name,
|
||||
respondedAt: schema.enquiries.respondedAt,
|
||||
closedAt: schema.enquiries.closedAt,
|
||||
lastMessageAt: schema.enquiries.lastMessageAt,
|
||||
createdAt: schema.enquiries.createdAt,
|
||||
preview: sql<string | null>`(
|
||||
SELECT msg.body FROM messages msg
|
||||
WHERE msg.enquiry_id = ${schema.enquiries.id}
|
||||
ORDER BY msg.created_at DESC LIMIT 1
|
||||
)`,
|
||||
unreadCount: sql<number>`(
|
||||
SELECT count(*)::int FROM messages msg
|
||||
WHERE msg.enquiry_id = ${schema.enquiries.id}
|
||||
AND msg.sender_id <> ${uid}
|
||||
AND msg.read_at IS NULL
|
||||
)`,
|
||||
})
|
||||
.from(schema.enquiries)
|
||||
.innerJoin(schema.users, eq(schema.users.id, schema.enquiries.clientId))
|
||||
.where(eq(schema.enquiries.proId, uid))
|
||||
.orderBy(desc(schema.enquiries.lastMessageAt));
|
||||
|
||||
return rows;
|
||||
}),
|
||||
|
||||
/** The customer's side: everyone they have asked something. */
|
||||
mineAsClient: clientProcedure.query(async ({ ctx }) => {
|
||||
const uid = ctx.session.userId;
|
||||
|
||||
return await ctx.db
|
||||
.select({
|
||||
id: schema.enquiries.id,
|
||||
proId: schema.enquiries.proId,
|
||||
proName: schema.users.name,
|
||||
headline: schema.proProfiles.headline,
|
||||
respondedAt: schema.enquiries.respondedAt,
|
||||
closedAt: schema.enquiries.closedAt,
|
||||
lastMessageAt: schema.enquiries.lastMessageAt,
|
||||
unreadCount: sql<number>`(
|
||||
SELECT count(*)::int FROM messages msg
|
||||
WHERE msg.enquiry_id = ${schema.enquiries.id}
|
||||
AND msg.sender_id <> ${uid}
|
||||
AND msg.read_at IS NULL
|
||||
)`,
|
||||
})
|
||||
.from(schema.enquiries)
|
||||
.innerJoin(schema.proProfiles, eq(schema.proProfiles.userId, schema.enquiries.proId))
|
||||
.innerJoin(schema.users, eq(schema.users.id, schema.enquiries.proId))
|
||||
.where(eq(schema.enquiries.clientId, uid))
|
||||
.orderBy(desc(schema.enquiries.lastMessageAt));
|
||||
}),
|
||||
|
||||
/** How much room is left under the cap. Lets the sheet say so before they type. */
|
||||
allowance: clientProcedure.query(async ({ ctx }) => {
|
||||
const [row] = await ctx.db
|
||||
.select({ n: sql<number>`count(*)::int` })
|
||||
.from(schema.enquiries)
|
||||
.where(openEnquiries(ctx.session.userId));
|
||||
|
||||
const open = row?.n ?? 0;
|
||||
return { open, cap: MAX_OPEN_ENQUIRIES, remaining: Math.max(0, MAX_OPEN_ENQUIRIES - open) };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Ask a question.
|
||||
*
|
||||
* Creates the thread and its first message together — an enquiry with no
|
||||
* message is an empty room, and a pro opening one would find nothing to
|
||||
* answer. Asking the same pro twice reuses the existing thread rather than
|
||||
* creating a second, which is also what stops the cap being walked around.
|
||||
*/
|
||||
create: clientProcedure
|
||||
.input(
|
||||
z.object({
|
||||
proId: z.string().uuid(),
|
||||
body: z.string().trim().min(10).max(2000),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const uid = ctx.session.userId;
|
||||
|
||||
const pro = await ctx.db.query.proProfiles.findFirst({
|
||||
where: eq(schema.proProfiles.userId, input.proId),
|
||||
columns: { verificationStatus: true, isAcceptingJobs: true },
|
||||
});
|
||||
if (!pro || pro.verificationStatus !== 'verified' || !pro.isAcceptingJobs) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'That pro is not available' });
|
||||
}
|
||||
|
||||
return await ctx.db.transaction(async (tx) => {
|
||||
const [existing] = await tx
|
||||
.select()
|
||||
.from(schema.enquiries)
|
||||
.where(
|
||||
and(eq(schema.enquiries.clientId, uid), eq(schema.enquiries.proId, input.proId)),
|
||||
)
|
||||
.for('update');
|
||||
|
||||
if (existing?.closedAt) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'This pro closed your last enquiry. Post a job to reach them.',
|
||||
});
|
||||
}
|
||||
|
||||
// Only a NEW thread is capped. Continuing an existing conversation is
|
||||
// not the behaviour the cap exists to stop.
|
||||
if (!existing) {
|
||||
const [open] = await tx
|
||||
.select({ n: sql<number>`count(*)::int` })
|
||||
.from(schema.enquiries)
|
||||
.where(openEnquiries(uid));
|
||||
|
||||
if ((open?.n ?? 0) >= MAX_OPEN_ENQUIRIES) {
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: `You have ${MAX_OPEN_ENQUIRIES} questions still waiting on an answer. Give them a chance to reply before asking more.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const enquiryId =
|
||||
existing?.id ??
|
||||
(
|
||||
await tx
|
||||
.insert(schema.enquiries)
|
||||
.values({ clientId: uid, proId: input.proId })
|
||||
.returning()
|
||||
)[0]!.id;
|
||||
|
||||
const [message] = await tx
|
||||
.insert(schema.messages)
|
||||
.values({ enquiryId, senderId: uid, body: input.body })
|
||||
.returning();
|
||||
|
||||
await tx
|
||||
.update(schema.enquiries)
|
||||
.set({ lastMessageAt: message!.createdAt })
|
||||
.where(eq(schema.enquiries.id, enquiryId));
|
||||
|
||||
await tx.insert(schema.auditLog).values({
|
||||
actorId: uid,
|
||||
action: existing ? 'enquiry.continued' : 'enquiry.created',
|
||||
entity: 'enquiry',
|
||||
entityId: enquiryId,
|
||||
metadata: { proId: input.proId },
|
||||
ip: ctx.ip,
|
||||
});
|
||||
|
||||
return { enquiryId, messageId: message!.id };
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* The pro ends it.
|
||||
*
|
||||
* Their side of the bargain for being reachable at all: somebody who is
|
||||
* wasting their time can be shut off without support getting involved. The
|
||||
* history stays — a closed thread is still evidence if the exchange is ever
|
||||
* disputed.
|
||||
*/
|
||||
close: protectedProcedure
|
||||
.input(z.object({ enquiryId: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const enquiry = await ctx.db.query.enquiries.findFirst({
|
||||
where: eq(schema.enquiries.id, input.enquiryId),
|
||||
});
|
||||
// 404 rather than 403 — a stranger must not learn the thread exists.
|
||||
if (!enquiry || enquiry.proId !== ctx.session.userId) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Enquiry not found' });
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(schema.enquiries)
|
||||
.set({ closedAt: new Date() })
|
||||
.where(eq(schema.enquiries.id, enquiry.id));
|
||||
|
||||
return { closed: true as const };
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Enquiries that count against a customer's cap.
|
||||
*
|
||||
* Unanswered, not closed, and not yet stale. Stale ones fall out on their own so
|
||||
* a customer is not locked out forever by five pros who never replied — being
|
||||
* ignored is not something to be punished for.
|
||||
*/
|
||||
function openEnquiries(clientId: string) {
|
||||
return and(
|
||||
eq(schema.enquiries.clientId, clientId),
|
||||
isNull(schema.enquiries.respondedAt),
|
||||
isNull(schema.enquiries.closedAt),
|
||||
gt(
|
||||
schema.enquiries.createdAt,
|
||||
sql`now() - (${ENQUIRY_STALE_DAYS} || ' days')::interval`,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -95,6 +95,99 @@ function canReply(status: JobStatus): boolean {
|
||||
return !PAST_JOB_STATUSES.includes(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* A conversation is either a MATCH or an ENQUIRY.
|
||||
*
|
||||
* A match is "a pro agreed to this job"; an enquiry is "somebody asked a
|
||||
* question before there was a job". They differ in what they hang off and what
|
||||
* closes them, and in nothing else — so they share one message table, one chat
|
||||
* screen, and the normalised context below.
|
||||
*/
|
||||
export const threadRefSchema = z.union([
|
||||
z.object({ matchId: z.string().uuid() }),
|
||||
z.object({ enquiryId: z.string().uuid() }),
|
||||
]);
|
||||
export type ThreadRef = z.infer<typeof threadRefSchema>;
|
||||
|
||||
export interface ThreadContext {
|
||||
kind: 'match' | 'enquiry';
|
||||
matchId: string | null;
|
||||
enquiryId: string | null;
|
||||
clientId: string;
|
||||
proId: string;
|
||||
/** Null on an enquiry — that is the whole point of one. */
|
||||
jobId: string | null;
|
||||
/** Null on an enquiry. Kept because "cancelled" and "finished" are not the
|
||||
* same thing to say to somebody, and only the job knows which it was. */
|
||||
jobStatus: JobStatus | null;
|
||||
title: string;
|
||||
canReply: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The gate for anything that reads or writes a conversation.
|
||||
*
|
||||
* NOT_FOUND rather than FORBIDDEN for a thread the caller is not part of — the
|
||||
* same rule as `job.byId` and `request.accept`: a stranger must not be able to
|
||||
* probe whether a conversation exists.
|
||||
*/
|
||||
export async function requireThreadParticipant(
|
||||
exec: Executor,
|
||||
ref: ThreadRef,
|
||||
userId: string,
|
||||
): Promise<ThreadContext> {
|
||||
if ('matchId' in ref) {
|
||||
const match = await requireMatchParticipant(exec, ref.matchId, userId);
|
||||
return {
|
||||
kind: 'match',
|
||||
matchId: match.matchId,
|
||||
enquiryId: null,
|
||||
clientId: match.clientId,
|
||||
proId: match.proId,
|
||||
jobId: match.jobId,
|
||||
jobStatus: match.jobStatus,
|
||||
title: match.jobTitle,
|
||||
canReply: canReply(match.jobStatus),
|
||||
};
|
||||
}
|
||||
|
||||
const [row] = await exec
|
||||
.select({
|
||||
id: schema.enquiries.id,
|
||||
clientId: schema.enquiries.clientId,
|
||||
proId: schema.enquiries.proId,
|
||||
closedAt: schema.enquiries.closedAt,
|
||||
headline: schema.proProfiles.headline,
|
||||
})
|
||||
.from(schema.enquiries)
|
||||
.innerJoin(schema.proProfiles, eq(schema.proProfiles.userId, schema.enquiries.proId))
|
||||
.where(eq(schema.enquiries.id, ref.enquiryId));
|
||||
|
||||
if (!row || (row.clientId !== userId && row.proId !== userId)) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Conversation not found' });
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'enquiry',
|
||||
matchId: null,
|
||||
enquiryId: row.id,
|
||||
clientId: row.clientId,
|
||||
proId: row.proId,
|
||||
jobId: null,
|
||||
jobStatus: null,
|
||||
title: row.headline,
|
||||
// A pro can end an enquiry. The history stays readable; nothing more lands.
|
||||
canReply: row.closedAt === null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Scopes a message query to one thread, whichever kind it is. */
|
||||
function inThread(ctx: ThreadContext) {
|
||||
return ctx.kind === 'match'
|
||||
? eq(schema.messages.matchId, ctx.matchId!)
|
||||
: eq(schema.messages.enquiryId, ctx.enquiryId!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyset pagination, oldest-ward.
|
||||
*
|
||||
@@ -122,12 +215,12 @@ export const messageRouter = router({
|
||||
* screen titleless for a beat.
|
||||
*/
|
||||
thread: protectedProcedure
|
||||
.input(z.object({ matchId: z.string().uuid(), cursor: cursorSchema.optional() }))
|
||||
.input(z.object({ ref: threadRefSchema, cursor: cursorSchema.optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const uid = ctx.session.userId;
|
||||
const match = await requireMatchParticipant(ctx.db, input.matchId, uid);
|
||||
const thread = await requireThreadParticipant(ctx.db, input.ref, uid);
|
||||
|
||||
const peerId = match.clientId === uid ? match.proId : match.clientId;
|
||||
const peerId = thread.clientId === uid ? thread.proId : thread.clientId;
|
||||
const [peer] = await ctx.db
|
||||
.select({
|
||||
id: schema.users.id,
|
||||
@@ -150,12 +243,11 @@ export const messageRouter = router({
|
||||
.from(schema.messages)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.messages.matchId, input.matchId),
|
||||
inThread(thread),
|
||||
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,
|
||||
),
|
||||
@@ -169,11 +261,14 @@ export const messageRouter = router({
|
||||
|
||||
return {
|
||||
match: {
|
||||
id: match.matchId,
|
||||
jobId: match.jobId,
|
||||
jobTitle: match.jobTitle,
|
||||
jobStatus: match.jobStatus,
|
||||
canReply: canReply(match.jobStatus),
|
||||
kind: thread.kind,
|
||||
id: thread.matchId ?? thread.enquiryId!,
|
||||
matchId: thread.matchId,
|
||||
enquiryId: thread.enquiryId,
|
||||
jobId: thread.jobId,
|
||||
jobStatus: thread.jobStatus,
|
||||
jobTitle: thread.title,
|
||||
canReply: thread.canReply,
|
||||
peer: peer ?? null,
|
||||
},
|
||||
// Newest last, the way a chat reads.
|
||||
@@ -194,22 +289,25 @@ export const messageRouter = router({
|
||||
assertSendRate(uid);
|
||||
|
||||
return await ctx.db.transaction(async (tx) => {
|
||||
const match = await requireMatchParticipant(tx, input.matchId, uid);
|
||||
const thread = await requireThreadParticipant(tx, input.ref, uid);
|
||||
|
||||
if (!canReply(match.jobStatus)) {
|
||||
if (!thread.canReply) {
|
||||
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.',
|
||||
thread.kind === 'enquiry'
|
||||
? 'This enquiry was closed.'
|
||||
: thread.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,
|
||||
matchId: thread.matchId,
|
||||
enquiryId: thread.enquiryId,
|
||||
senderId: uid,
|
||||
body: input.body,
|
||||
attachments: input.attachments,
|
||||
@@ -220,10 +318,24 @@ export const messageRouter = router({
|
||||
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));
|
||||
// Both parents carry lastMessageAt: the jobs list and the pro's enquiry
|
||||
// inbox both sort on it, so a message that lands without it is a
|
||||
// conversation that silently stops surfacing.
|
||||
if (thread.kind === 'match') {
|
||||
await tx
|
||||
.update(schema.matches)
|
||||
.set({ lastMessageAt: message.createdAt })
|
||||
.where(eq(schema.matches.id, thread.matchId!));
|
||||
} else {
|
||||
await tx
|
||||
.update(schema.enquiries)
|
||||
.set({
|
||||
lastMessageAt: message.createdAt,
|
||||
// The pro answering is what takes this off the client's open cap.
|
||||
...(uid === thread.proId ? { respondedAt: message.createdAt } : {}),
|
||||
})
|
||||
.where(eq(schema.enquiries.id, thread.enquiryId!));
|
||||
}
|
||||
|
||||
return { ...message, isMine: true as const };
|
||||
});
|
||||
@@ -236,17 +348,17 @@ export const messageRouter = router({
|
||||
* partial index (`messages_unread_idx`), so a second call touches no rows.
|
||||
*/
|
||||
markRead: protectedProcedure
|
||||
.input(z.object({ matchId: z.string().uuid() }))
|
||||
.input(z.object({ ref: threadRefSchema }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const uid = ctx.session.userId;
|
||||
await requireMatchParticipant(ctx.db, input.matchId, uid);
|
||||
const thread = await requireThreadParticipant(ctx.db, input.ref, uid);
|
||||
|
||||
const updated = await ctx.db
|
||||
.update(schema.messages)
|
||||
.set({ readAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(schema.messages.matchId, input.matchId),
|
||||
inThread(thread),
|
||||
ne(schema.messages.senderId, uid),
|
||||
isNull(schema.messages.readAt),
|
||||
),
|
||||
@@ -263,15 +375,28 @@ export const messageRouter = router({
|
||||
unreadTotal: protectedProcedure.query(async ({ ctx }) => {
|
||||
const uid = ctx.session.userId;
|
||||
|
||||
// Both kinds of thread, in one scan. Written as a single predicate rather
|
||||
// than two queries because the badge is one number and reading it twice
|
||||
// would let the halves disagree.
|
||||
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),
|
||||
or(
|
||||
sql`EXISTS (
|
||||
SELECT 1 FROM matches m
|
||||
WHERE m.id = ${schema.messages.matchId}
|
||||
AND (m.client_id = ${uid} OR m.pro_id = ${uid})
|
||||
)`,
|
||||
sql`EXISTS (
|
||||
SELECT 1 FROM enquiries e
|
||||
WHERE e.id = ${schema.messages.enquiryId}
|
||||
AND (e.client_id = ${uid} OR e.pro_id = ${uid})
|
||||
)`,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { and, desc, eq, isNull, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { schema } from '@linkder/db';
|
||||
import { protectedProcedure, router } from '../trpc';
|
||||
|
||||
/**
|
||||
* "Tell me when this one is free."
|
||||
*
|
||||
* The deck's third answer. Before this it had exactly two — send them a job
|
||||
* right now, or lose them — which is a lot to hang on a swipe when the person
|
||||
* you like is the one you are not ready for yet.
|
||||
*
|
||||
* WHAT THIS CAN AND CANNOT DO TODAY, because the limit is not obvious:
|
||||
* a pro with `is_accepting_jobs = false` is invisible on every surface
|
||||
* (`eligibleProAtAnyDistance()` requires it, and the deck, search and the public
|
||||
* profile all use that rule). So a watch can only be placed on somebody who is
|
||||
* ALREADY available, and it fires on the away-and-back cycle rather than on
|
||||
* "they are busy now, tell me when they are not".
|
||||
*
|
||||
* Making "free at a time that suits me" real needs `pro_availability` — seeded
|
||||
* since M1 and read by nothing — to become a maintained calendar. This is
|
||||
* deliberately the narrow, honest version rather than a button that cannot fire.
|
||||
*/
|
||||
export const watchRouter = router({
|
||||
/** Everyone this person is watching, most recent first. */
|
||||
mine: protectedProcedure.query(async ({ ctx }) => {
|
||||
const rows = await ctx.db
|
||||
.select({
|
||||
proId: schema.proWatches.proId,
|
||||
createdAt: schema.proWatches.createdAt,
|
||||
notifiedAt: schema.proWatches.notifiedAt,
|
||||
name: schema.users.name,
|
||||
headline: schema.proProfiles.headline,
|
||||
isAcceptingJobs: schema.proProfiles.isAcceptingJobs,
|
||||
photo: sql<string | null>`(
|
||||
SELECT pm.url FROM pro_media pm
|
||||
WHERE pm.pro_id = ${schema.proWatches.proId} AND pm.kind = 'photo'
|
||||
ORDER BY pm.position LIMIT 1
|
||||
)`,
|
||||
})
|
||||
.from(schema.proWatches)
|
||||
.innerJoin(schema.proProfiles, eq(schema.proProfiles.userId, schema.proWatches.proId))
|
||||
.innerJoin(schema.users, eq(schema.users.id, schema.proWatches.proId))
|
||||
.where(eq(schema.proWatches.watcherId, ctx.session.userId))
|
||||
.orderBy(desc(schema.proWatches.createdAt));
|
||||
|
||||
return rows;
|
||||
}),
|
||||
|
||||
/** Whether the caller is watching this pro. Drives the button's filled state. */
|
||||
isWatching: protectedProcedure
|
||||
.input(z.object({ proId: z.string().uuid() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [row] = await ctx.db
|
||||
.select({ id: schema.proWatches.id })
|
||||
.from(schema.proWatches)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.proWatches.watcherId, ctx.session.userId),
|
||||
eq(schema.proWatches.proId, input.proId),
|
||||
),
|
||||
);
|
||||
return { watching: Boolean(row) };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Watch, or stop watching. One call, because the button is one button.
|
||||
*
|
||||
* Records the pro's availability AT THE MOMENT OF WATCHING. The trigger is a
|
||||
* change, not a state: without the snapshot, a sweep would notify every
|
||||
* watcher on every run, since "this pro is available" stays true for as long
|
||||
* as they stay available.
|
||||
*/
|
||||
toggle: protectedProcedure
|
||||
.input(z.object({ proId: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const uid = ctx.session.userId;
|
||||
|
||||
if (input.proId === uid) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'You cannot watch yourself' });
|
||||
}
|
||||
|
||||
const pro = await ctx.db.query.proProfiles.findFirst({
|
||||
where: eq(schema.proProfiles.userId, input.proId),
|
||||
columns: { isAcceptingJobs: true, verificationStatus: true },
|
||||
});
|
||||
if (!pro || pro.verificationStatus !== 'verified') {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'That pro is not available' });
|
||||
}
|
||||
|
||||
const [existing] = await ctx.db
|
||||
.select({ id: schema.proWatches.id })
|
||||
.from(schema.proWatches)
|
||||
.where(
|
||||
and(eq(schema.proWatches.watcherId, uid), eq(schema.proWatches.proId, input.proId)),
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.delete(schema.proWatches).where(eq(schema.proWatches.id, existing.id));
|
||||
return { watching: false as const };
|
||||
}
|
||||
|
||||
await ctx.db.insert(schema.proWatches).values({
|
||||
watcherId: uid,
|
||||
proId: input.proId,
|
||||
availableAtWatch: String(pro.isAcceptingJobs),
|
||||
});
|
||||
|
||||
return { watching: true as const, availableNow: pro.isAcceptingJobs };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Who should be told that somebody they are watching is back.
|
||||
*
|
||||
* Read-only, and separate from the sending: the M4 worker will call this on a
|
||||
* schedule and hand each row to `notify()`. Exposed now so the trigger is
|
||||
* testable before the worker exists, rather than being written blind inside
|
||||
* one — which is how `pro_availability` ended up seeded and unread.
|
||||
*/
|
||||
dueNotification: protectedProcedure.query(async ({ ctx }) => {
|
||||
const rows = await ctx.db
|
||||
.select({
|
||||
watchId: schema.proWatches.id,
|
||||
watcherId: schema.proWatches.watcherId,
|
||||
proId: schema.proWatches.proId,
|
||||
proName: schema.users.name,
|
||||
})
|
||||
.from(schema.proWatches)
|
||||
.innerJoin(schema.proProfiles, eq(schema.proProfiles.userId, schema.proWatches.proId))
|
||||
.innerJoin(schema.users, eq(schema.users.id, schema.proWatches.proId))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.proWatches.watcherId, ctx.session.userId),
|
||||
// Went away and came back: unavailable when watched, available now.
|
||||
eq(schema.proWatches.availableAtWatch, 'false'),
|
||||
eq(schema.proProfiles.isAcceptingJobs, true),
|
||||
// Told once. A pro toggling twice must not send two messages.
|
||||
isNull(schema.proWatches.notifiedAt),
|
||||
),
|
||||
);
|
||||
|
||||
return rows;
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Watching a pro, and asking one a question.
|
||||
*
|
||||
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
||||
*
|
||||
* The deck's two new answers. Most of what follows is about the cap on
|
||||
* enquiries, because this is the first way in the product to reach a pro who
|
||||
* has not agreed to anything — until now chat was gated behind
|
||||
* message → match → accepted request → job, and that gate is what made a pro's
|
||||
* inbox worth opening.
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { MAX_OPEN_ENQUIRIES } 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 client: string;
|
||||
let pros: string[] = [];
|
||||
let unverifiedPro: 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}, ${`${RUN}-${Math.random().toString(36).slice(2, 8)}@example.com`}, ${role})
|
||||
RETURNING id
|
||||
`);
|
||||
return row!.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture pros are on holiday (`is_accepting_jobs = false` by default here where
|
||||
* it does not matter, true where it does).
|
||||
*
|
||||
* Test files share one database. A verified, accepting pro at the city centre is
|
||||
* eligible for the SEEDED job's deck, so creating and deleting them mid-run
|
||||
* shifts `deck.list().remaining` underneath deck.router.test.ts. Parked far
|
||||
* outside the city instead, which keeps them off every deck without changing
|
||||
* the availability these tests actually assert on.
|
||||
*/
|
||||
async function insertPro(name: string, accepting = true, verified = true): Promise<string> {
|
||||
const id = await insertUser(name, '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 (
|
||||
${id}, ${`${name} headline`}, 'Exists only for the discovery tests.', 3000,
|
||||
ST_SetSRID(ST_MakePoint(-40.0, -40.0), 4326)::geography, 'exact',
|
||||
15000, ${verified ? 'verified' : 'pending'}, now(), ${accepting}
|
||||
)
|
||||
`);
|
||||
return id;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
client = await insertUser(`Discovery Client ${RUN}`, 'client');
|
||||
for (let i = 0; i < MAX_OPEN_ENQUIRIES + 2; i += 1) {
|
||||
pros.push(await insertPro(`Discovery Pro ${RUN}-${i}`));
|
||||
}
|
||||
unverifiedPro = await insertPro(`Discovery Unverified ${RUN}`, true, false);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.execute(sql`DELETE FROM enquiries WHERE client_id = ${client}`);
|
||||
await db.execute(sql`DELETE FROM pro_watches WHERE watcher_id = ${client}`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// One at a time rather than `= ANY(...)`: drizzle passes a JS array through as
|
||||
// a scalar parameter, which Postgres rejects.
|
||||
for (const id of [client, unverifiedPro, ...pros]) {
|
||||
await db.execute(sql`DELETE FROM users WHERE id = ${id}`);
|
||||
}
|
||||
await closePool();
|
||||
});
|
||||
|
||||
describe('watch', () => {
|
||||
it('toggles on and off with one call', async () => {
|
||||
const caller = callerFor(clientSession(client));
|
||||
|
||||
expect((await caller.watch.isWatching({ proId: pros[0]! })).watching).toBe(false);
|
||||
|
||||
const on = await caller.watch.toggle({ proId: pros[0]! });
|
||||
expect(on.watching).toBe(true);
|
||||
expect((await caller.watch.isWatching({ proId: pros[0]! })).watching).toBe(true);
|
||||
|
||||
const off = await caller.watch.toggle({ proId: pros[0]! });
|
||||
expect(off.watching).toBe(false);
|
||||
expect((await caller.watch.mine()).map((w) => w.proId)).not.toContain(pros[0]);
|
||||
});
|
||||
|
||||
it('refuses an unverified pro, and refuses to watch yourself', async () => {
|
||||
const caller = callerFor(clientSession(client));
|
||||
await expect(caller.watch.toggle({ proId: unverifiedPro })).rejects.toThrow(/not available/i);
|
||||
await expect(caller.watch.toggle({ proId: client })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('does not fire for a pro who was already free when watched', async () => {
|
||||
// The trigger is a CHANGE, not a state. Without the snapshot taken at watch
|
||||
// time, every sweep would notify every watcher, because "available" stays
|
||||
// true for as long as they stay available.
|
||||
const caller = callerFor(clientSession(client));
|
||||
await caller.watch.toggle({ proId: pros[0]! });
|
||||
|
||||
expect(await caller.watch.dueNotification()).toEqual([]);
|
||||
});
|
||||
|
||||
it('fires once a pro who was away comes back', async () => {
|
||||
const away = pros[1]!;
|
||||
await db.execute(
|
||||
sql`UPDATE pro_profiles SET is_accepting_jobs = false WHERE user_id = ${away}`,
|
||||
);
|
||||
|
||||
const caller = callerFor(clientSession(client));
|
||||
await caller.watch.toggle({ proId: away });
|
||||
expect(await caller.watch.dueNotification()).toEqual([]);
|
||||
|
||||
await db.execute(
|
||||
sql`UPDATE pro_profiles SET is_accepting_jobs = true WHERE user_id = ${away}`,
|
||||
);
|
||||
|
||||
const due = await caller.watch.dueNotification();
|
||||
expect(due.map((d) => d.proId)).toContain(away);
|
||||
|
||||
// Told once: marking it notified takes it out of the queue, so a pro
|
||||
// toggling twice does not send two messages.
|
||||
await db.execute(
|
||||
sql`UPDATE pro_watches SET notified_at = now()
|
||||
WHERE watcher_id = ${client} AND pro_id = ${away}`,
|
||||
);
|
||||
expect(await caller.watch.dueNotification()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enquiry', () => {
|
||||
it('creates a thread and its first message together', async () => {
|
||||
// An enquiry with no message is an empty room — a pro opening one would
|
||||
// find nothing to answer.
|
||||
const caller = callerFor(clientSession(client));
|
||||
const { enquiryId } = await caller.enquiry.create({
|
||||
proId: pros[0]!,
|
||||
body: 'Do you cover replacing a whole bathroom suite, or only repairs?',
|
||||
});
|
||||
|
||||
const [count] = await db.execute<{ n: number }>(
|
||||
sql`SELECT count(*)::int AS n FROM messages WHERE enquiry_id = ${enquiryId}`,
|
||||
);
|
||||
expect(count!.n).toBe(1);
|
||||
|
||||
const inbox = await callerFor(proSession(pros[0]!)).enquiry.mine();
|
||||
expect(inbox.map((e) => e.id)).toContain(enquiryId);
|
||||
expect(inbox.find((e) => e.id === enquiryId)!.unreadCount).toBe(1);
|
||||
});
|
||||
|
||||
it('puts a second question in the same thread, not a new one', async () => {
|
||||
const caller = callerFor(clientSession(client));
|
||||
const first = await caller.enquiry.create({
|
||||
proId: pros[0]!,
|
||||
body: 'Do you cover replacing a whole bathroom suite?',
|
||||
});
|
||||
const second = await caller.enquiry.create({
|
||||
proId: pros[0]!,
|
||||
body: 'And would that include taking the old one away with you?',
|
||||
});
|
||||
|
||||
// Also what stops the cap being walked around by asking one pro repeatedly.
|
||||
expect(second.enquiryId).toBe(first.enquiryId);
|
||||
});
|
||||
|
||||
it('caps unanswered enquiries', async () => {
|
||||
const caller = callerFor(clientSession(client));
|
||||
|
||||
for (let i = 0; i < MAX_OPEN_ENQUIRIES; i += 1) {
|
||||
await caller.enquiry.create({
|
||||
proId: pros[i]!,
|
||||
body: `Question number ${i} for a pro who has not agreed to anything yet.`,
|
||||
});
|
||||
}
|
||||
|
||||
expect((await caller.enquiry.allowance()).remaining).toBe(0);
|
||||
|
||||
await expect(
|
||||
caller.enquiry.create({
|
||||
proId: pros[MAX_OPEN_ENQUIRIES]!,
|
||||
body: 'One more question, which should be refused by the open-enquiry cap.',
|
||||
}),
|
||||
).rejects.toThrow(/still waiting on an answer/i);
|
||||
});
|
||||
|
||||
it('stops counting an enquiry once the pro replies', async () => {
|
||||
// Somebody having real conversations should not be throttled; only somebody
|
||||
// broadcasting.
|
||||
const caller = callerFor(clientSession(client));
|
||||
for (let i = 0; i < MAX_OPEN_ENQUIRIES; i += 1) {
|
||||
await caller.enquiry.create({
|
||||
proId: pros[i]!,
|
||||
body: `Question number ${i} for a pro who has not agreed to anything yet.`,
|
||||
});
|
||||
}
|
||||
expect((await caller.enquiry.allowance()).remaining).toBe(0);
|
||||
|
||||
const answered = (await callerFor(proSession(pros[0]!)).enquiry.mine())[0]!;
|
||||
await callerFor(proSession(pros[0]!)).message.send({
|
||||
ref: { enquiryId: answered.id },
|
||||
body: 'Yes, full bathroom suites are fine — happy to quote if you post the job.',
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
expect((await caller.enquiry.allowance()).remaining).toBe(1);
|
||||
});
|
||||
|
||||
it('refuses a pro who is unverified or on holiday', async () => {
|
||||
const caller = callerFor(clientSession(client));
|
||||
await expect(
|
||||
caller.enquiry.create({
|
||||
proId: unverifiedPro,
|
||||
body: 'Are you able to take on a small job next week at all?',
|
||||
}),
|
||||
).rejects.toThrow(/not available/i);
|
||||
});
|
||||
|
||||
it('lets the pro close it, after which nothing more can be sent', async () => {
|
||||
const caller = callerFor(clientSession(client));
|
||||
const { enquiryId } = await caller.enquiry.create({
|
||||
proId: pros[0]!,
|
||||
body: 'A question that this pro is going to decide not to entertain.',
|
||||
});
|
||||
|
||||
// Only the pro. A customer must not be able to close their own way around
|
||||
// the cap.
|
||||
await expect(caller.enquiry.close({ enquiryId })).rejects.toThrow(/not found/i);
|
||||
|
||||
await callerFor(proSession(pros[0]!)).enquiry.close({ enquiryId });
|
||||
|
||||
await expect(
|
||||
caller.message.send({
|
||||
ref: { enquiryId },
|
||||
body: 'Are you still there? I would really like an answer to this.',
|
||||
attachments: [],
|
||||
}),
|
||||
).rejects.toThrow(/closed/i);
|
||||
|
||||
// The history stays — a closed thread is still evidence.
|
||||
const thread = await caller.message.thread({ ref: { enquiryId } });
|
||||
expect(thread.messages.length).toBe(1);
|
||||
expect(thread.match.canReply).toBe(false);
|
||||
});
|
||||
|
||||
it('is not readable by anyone who is not in it', async () => {
|
||||
const caller = callerFor(clientSession(client));
|
||||
const { enquiryId } = await caller.enquiry.create({
|
||||
proId: pros[0]!,
|
||||
body: 'A private question between me and this particular tradesperson.',
|
||||
});
|
||||
|
||||
const stranger = await insertUser(`Discovery Stranger ${RUN}`, 'client');
|
||||
await expect(
|
||||
callerFor(clientSession(stranger)).message.thread({ ref: { enquiryId } }),
|
||||
).rejects.toThrow(/not found/i);
|
||||
await db.execute(sql`DELETE FROM users WHERE id = ${stranger}`);
|
||||
});
|
||||
});
|
||||
@@ -136,18 +136,18 @@ afterAll(async () => {
|
||||
describe('message.thread', () => {
|
||||
it('gives each side the same conversation, newest last', async () => {
|
||||
await callerFor(clientSession(owner)).message.send({
|
||||
matchId,
|
||||
ref: { matchId },
|
||||
body: 'Morning — when could you take a look?',
|
||||
attachments: [],
|
||||
});
|
||||
await callerFor(proSession(pro)).message.send({
|
||||
matchId,
|
||||
ref: { matchId },
|
||||
body: 'Thursday afternoon works.',
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
const asClient = await callerFor(clientSession(owner)).message.thread({ matchId });
|
||||
const asPro = await callerFor(proSession(pro)).message.thread({ matchId });
|
||||
const asClient = await callerFor(clientSession(owner)).message.thread({ ref: { matchId } });
|
||||
const asPro = await callerFor(proSession(pro)).message.thread({ ref: { matchId } });
|
||||
|
||||
expect(asClient.messages.map((m) => m.body)).toEqual([
|
||||
'Morning — when could you take a look?',
|
||||
@@ -161,8 +161,8 @@ describe('message.thread', () => {
|
||||
});
|
||||
|
||||
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 });
|
||||
const asClient = await callerFor(clientSession(owner)).message.thread({ ref: { matchId } });
|
||||
const asPro = await callerFor(proSession(pro)).message.thread({ ref: { matchId } });
|
||||
|
||||
expect(asClient.match.peer?.id).toBe(pro);
|
||||
expect(asPro.match.peer?.id).toBe(owner);
|
||||
@@ -172,12 +172,12 @@ describe('message.thread', () => {
|
||||
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 }),
|
||||
callerFor(clientSession(stranger)).message.thread({ ref: { matchId } }),
|
||||
).rejects.toThrow(/not found/i);
|
||||
});
|
||||
|
||||
it('refuses an anonymous caller', async () => {
|
||||
await expect(callerFor(null).message.thread({ matchId })).rejects.toThrow();
|
||||
await expect(callerFor(null).message.thread({ ref: { matchId } })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('pages oldest-ward without dropping or repeating a message', async () => {
|
||||
@@ -195,12 +195,12 @@ describe('message.thread', () => {
|
||||
FROM generate_series(0, 34) AS i
|
||||
`);
|
||||
|
||||
const first = await callerFor(clientSession(owner)).message.thread({ matchId });
|
||||
const first = await callerFor(clientSession(owner)).message.thread({ ref: { matchId } });
|
||||
expect(first.messages).toHaveLength(30);
|
||||
expect(first.nextCursor).not.toBeNull();
|
||||
|
||||
const second = await callerFor(clientSession(owner)).message.thread({
|
||||
matchId,
|
||||
ref: { matchId },
|
||||
cursor: first.nextCursor!,
|
||||
});
|
||||
|
||||
@@ -221,12 +221,12 @@ describe('message.thread', () => {
|
||||
// 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,
|
||||
ref: { matchId: closedMatchId },
|
||||
});
|
||||
const foreign = (await callerFor(clientSession(owner)).message.thread({ matchId })).messages[0];
|
||||
const foreign = (await callerFor(clientSession(owner)).message.thread({ ref: { matchId } })).messages[0];
|
||||
|
||||
const page = await callerFor(clientSession(owner)).message.thread({
|
||||
matchId: closedMatchId,
|
||||
ref: { matchId: closedMatchId },
|
||||
cursor: foreign!.id,
|
||||
});
|
||||
|
||||
@@ -242,7 +242,7 @@ describe('message.send', () => {
|
||||
);
|
||||
|
||||
const sent = await callerFor(clientSession(owner)).message.send({
|
||||
matchId,
|
||||
ref: { matchId },
|
||||
body: 'One more thing.',
|
||||
attachments: [],
|
||||
});
|
||||
@@ -262,13 +262,13 @@ describe('message.send', () => {
|
||||
|
||||
it('rejects a message of nothing but whitespace', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(owner)).message.send({ matchId, body: ' ', attachments: [] }),
|
||||
callerFor(clientSession(owner)).message.send({ ref: { 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: [] }),
|
||||
callerFor(clientSession(owner)).message.send({ ref: { matchId }, body: '', attachments: [] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
@@ -276,7 +276,7 @@ describe('message.send', () => {
|
||||
// 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,
|
||||
ref: { matchId },
|
||||
body: '',
|
||||
attachments: ['https://cdn.example.com/messages/leak.jpg'],
|
||||
});
|
||||
@@ -289,16 +289,16 @@ describe('message.send', () => {
|
||||
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({ ref: { matchId }, body: 'here', attachments: six })).rejects.toThrow();
|
||||
await expect(
|
||||
caller.message.send({ matchId, body: 'here', attachments: ['not-a-url'] }),
|
||||
caller.message.send({ ref: { 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,
|
||||
ref: { matchId },
|
||||
body: 'x'.repeat(4001),
|
||||
attachments: [],
|
||||
}),
|
||||
@@ -308,7 +308,7 @@ describe('message.send', () => {
|
||||
it('refuses a stranger', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(stranger)).message.send({
|
||||
matchId,
|
||||
ref: { matchId },
|
||||
body: 'let me in',
|
||||
attachments: [],
|
||||
}),
|
||||
@@ -318,13 +318,13 @@ describe('message.send', () => {
|
||||
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,
|
||||
ref: { matchId: closedMatchId },
|
||||
});
|
||||
expect(thread.match.canReply).toBe(false);
|
||||
|
||||
await expect(
|
||||
callerFor(clientSession(owner)).message.send({
|
||||
matchId: closedMatchId,
|
||||
ref: { matchId: closedMatchId },
|
||||
body: 'still there?',
|
||||
attachments: [],
|
||||
}),
|
||||
@@ -342,23 +342,23 @@ describe('message.markRead and unreadTotal', () => {
|
||||
);
|
||||
|
||||
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: [] });
|
||||
await proCaller.message.send({ ref: { matchId: freshMatch }, body: 'On my way.', attachments: [] });
|
||||
await proCaller.message.send({ ref: { 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 });
|
||||
const proOwnHere = await proCaller.message.markRead({ ref: { 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 });
|
||||
const cleared = await ownerCaller.message.markRead({ ref: { 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 });
|
||||
const again = await ownerCaller.message.markRead({ ref: { matchId: freshMatch } });
|
||||
expect(again.read).toBe(0);
|
||||
|
||||
const after = await ownerCaller.message.unreadTotal();
|
||||
@@ -368,7 +368,7 @@ describe('message.markRead and unreadTotal', () => {
|
||||
|
||||
it('refuses to mark a stranger’s thread read', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(stranger)).message.markRead({ matchId }),
|
||||
callerFor(clientSession(stranger)).message.markRead({ ref: { matchId } }),
|
||||
).rejects.toThrow(/not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
CREATE TABLE "enquiries" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"client_id" uuid NOT NULL,
|
||||
"pro_id" uuid NOT NULL,
|
||||
"responded_at" timestamp with time zone,
|
||||
"last_message_at" timestamp with time zone,
|
||||
"closed_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "enquiries_pair_unique" UNIQUE("client_id","pro_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "pro_watches" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"watcher_id" uuid NOT NULL,
|
||||
"pro_id" uuid NOT NULL,
|
||||
"available_at_watch" text DEFAULT 'true' NOT NULL,
|
||||
"notified_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "pro_watches_unique" UNIQUE("watcher_id","pro_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "messages" ALTER COLUMN "match_id" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "messages" ADD COLUMN "enquiry_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "enquiries" ADD CONSTRAINT "enquiries_client_id_users_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "enquiries" ADD CONSTRAINT "enquiries_pro_id_pro_profiles_user_id_fk" FOREIGN KEY ("pro_id") REFERENCES "public"."pro_profiles"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "pro_watches" ADD CONSTRAINT "pro_watches_watcher_id_users_id_fk" FOREIGN KEY ("watcher_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "pro_watches" ADD CONSTRAINT "pro_watches_pro_id_pro_profiles_user_id_fk" FOREIGN KEY ("pro_id") REFERENCES "public"."pro_profiles"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "enquiries_pro_idx" ON "enquiries" USING btree ("pro_id","last_message_at");--> statement-breakpoint
|
||||
CREATE INDEX "enquiries_client_idx" ON "enquiries" USING btree ("client_id","last_message_at");--> statement-breakpoint
|
||||
CREATE INDEX "pro_watches_pro_idx" ON "pro_watches" USING btree ("pro_id","notified_at");--> statement-breakpoint
|
||||
CREATE INDEX "pro_watches_watcher_idx" ON "pro_watches" USING btree ("watcher_id","created_at");--> statement-breakpoint
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_enquiry_id_enquiries_id_fk" FOREIGN KEY ("enquiry_id") REFERENCES "public"."enquiries"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "messages_enquiry_idx" ON "messages" USING btree ("enquiry_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "messages_enquiry_unread_idx" ON "messages" USING btree ("enquiry_id","sender_id") WHERE "messages"."read_at" IS NULL;--> statement-breakpoint
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_one_parent" CHECK (("messages"."match_id" IS NULL) <> ("messages"."enquiry_id" IS NULL));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,13 @@
|
||||
"when": 1787306631007,
|
||||
"tag": "0006_careful_lilandra",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1787308773577,
|
||||
"tag": "0007_medical_dark_beast",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { index, pgTable, text, timestamp, unique, uuid } from 'drizzle-orm/pg-core';
|
||||
import { users } from './auth';
|
||||
import { proProfiles } from './pros';
|
||||
|
||||
/**
|
||||
* The two deck actions that are neither "yes" nor "no".
|
||||
*
|
||||
* Before these the deck had exactly two outcomes — send this pro a job now, or
|
||||
* lose them — which is a lot to ask of a swipe. Watching keeps somebody without
|
||||
* contacting them; an enquiry asks a question without committing to a job.
|
||||
*/
|
||||
|
||||
/**
|
||||
* "Tell me when this one is free."
|
||||
*
|
||||
* NOTE, because it limits what this can do today: a pro with
|
||||
* `is_accepting_jobs = false` is invisible on every surface —
|
||||
* `eligibleProAtAnyDistance()` requires the flag, and the deck, search and the
|
||||
* public profile all use it. So a watch can only be placed on somebody who is
|
||||
* ALREADY available, and only fires on the away-and-back cycle.
|
||||
*
|
||||
* Making "free at a time that suits me" real needs `pro_availability` — which is
|
||||
* seeded and read by nothing — to become a maintained calendar. Until then this
|
||||
* is deliberately the narrow version rather than a button that cannot fire.
|
||||
*/
|
||||
export const proWatches = pgTable(
|
||||
'pro_watches',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
watcherId: uuid('watcher_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
proId: uuid('pro_id')
|
||||
.notNull()
|
||||
.references(() => proProfiles.userId, { onDelete: 'cascade' }),
|
||||
/**
|
||||
* What the pro's availability was when the watch was placed.
|
||||
*
|
||||
* The trigger is a CHANGE, not a state: without this, a sweep would notify
|
||||
* every watcher every time it ran, because "this pro is available" is true
|
||||
* for as long as they stay available.
|
||||
*/
|
||||
availableAtWatch: text('available_at_watch').notNull().default('true'),
|
||||
/** Set when we have told them, so one return does not send five messages. */
|
||||
notifiedAt: timestamp('notified_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
// One watch per person per pro. Tapping twice is a toggle, not a second row.
|
||||
unique('pro_watches_unique').on(t.watcherId, t.proId),
|
||||
// The sweeper's read: everyone watching this pro who has not been told.
|
||||
index('pro_watches_pro_idx').on(t.proId, t.notifiedAt),
|
||||
index('pro_watches_watcher_idx').on(t.watcherId, t.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* A question, before there is a job.
|
||||
*
|
||||
* Deliberately its own table rather than a `match` with a null job. A match
|
||||
* means a pro said yes to specific work; an enquiry means a customer asked
|
||||
* something and may never post anything at all. Collapsing the two would put
|
||||
* rows in `matches` that no booking, quote or review could ever hang off, and
|
||||
* every query that assumes a match has a job would have to learn about the
|
||||
* exception.
|
||||
*
|
||||
* The abuse surface this opens is real — it is the first way to reach a pro who
|
||||
* has not agreed to anything. `MAX_OPEN_ENQUIRIES` caps how many a customer can
|
||||
* have unanswered at once, which is the same shape as the open-request cap and
|
||||
* for the same reason: stop one person spraying the city.
|
||||
*/
|
||||
export const enquiries = pgTable(
|
||||
'enquiries',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
clientId: uuid('client_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
proId: uuid('pro_id')
|
||||
.notNull()
|
||||
.references(() => proProfiles.userId, { onDelete: 'cascade' }),
|
||||
/**
|
||||
* Set once the pro answers. An unanswered enquiry counts against the
|
||||
* client's cap; an answered one does not, so a customer having real
|
||||
* conversations is not throttled.
|
||||
*/
|
||||
respondedAt: timestamp('responded_at', { withTimezone: true }),
|
||||
lastMessageAt: timestamp('last_message_at', { withTimezone: true }),
|
||||
/** The pro can end it. Nothing more can be sent, the history stays. */
|
||||
closedAt: timestamp('closed_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
// One open thread per pair — a second question goes in the same one.
|
||||
unique('enquiries_pair_unique').on(t.clientId, t.proId),
|
||||
index('enquiries_pro_idx').on(t.proId, t.lastMessageAt),
|
||||
index('enquiries_client_idx').on(t.clientId, t.lastMessageAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const proWatchesRelations = relations(proWatches, ({ one }) => ({
|
||||
watcher: one(users, { fields: [proWatches.watcherId], references: [users.id] }),
|
||||
pro: one(proProfiles, { fields: [proWatches.proId], references: [proProfiles.userId] }),
|
||||
}));
|
||||
|
||||
export const enquiriesRelations = relations(enquiries, ({ one }) => ({
|
||||
client: one(users, { fields: [enquiries.clientId], references: [users.id] }),
|
||||
pro: one(proProfiles, { fields: [enquiries.proId], references: [proProfiles.userId] }),
|
||||
}));
|
||||
@@ -3,6 +3,7 @@ export * from './auth';
|
||||
export * from './pros';
|
||||
export * from './jobs';
|
||||
export * from './matching';
|
||||
export * from './discovery';
|
||||
export * from './messaging';
|
||||
export * from './commerce';
|
||||
export * from './reviews';
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { relations, sql } from 'drizzle-orm';
|
||||
import { index, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||
import { check, index, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||
import { users } from './auth';
|
||||
import { enquiries } from './discovery';
|
||||
import { matches } from './matching';
|
||||
|
||||
/**
|
||||
* One message table for both kinds of conversation.
|
||||
*
|
||||
* A message belongs to a MATCH (a pro agreed to a job) or to an ENQUIRY (a
|
||||
* customer asked a question before there was one) — never both, never neither.
|
||||
* Two tables would mean two chat components that drift, so the column pair
|
||||
* carries the difference and a CHECK makes the illegal state unrepresentable.
|
||||
*/
|
||||
export const messages = pgTable(
|
||||
'messages',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
matchId: uuid('match_id')
|
||||
.notNull()
|
||||
.references(() => matches.id, { onDelete: 'cascade' }),
|
||||
matchId: uuid('match_id').references(() => matches.id, { onDelete: 'cascade' }),
|
||||
enquiryId: uuid('enquiry_id').references(() => enquiries.id, { onDelete: 'cascade' }),
|
||||
senderId: uuid('sender_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
@@ -19,14 +27,25 @@ export const messages = pgTable(
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
// Chat history is always "this match, newest last".
|
||||
// Exactly one parent. Without this a message could belong to both threads,
|
||||
// or to none, and every read would need to handle a row that means nothing.
|
||||
check(
|
||||
'messages_one_parent',
|
||||
sql`(${t.matchId} IS NULL) <> (${t.enquiryId} IS NULL)`,
|
||||
),
|
||||
// Chat history is always "this thread, newest last".
|
||||
index('messages_match_idx').on(t.matchId, t.createdAt),
|
||||
index('messages_enquiry_idx').on(t.enquiryId, t.createdAt),
|
||||
// Unread badge count.
|
||||
index('messages_unread_idx').on(t.matchId, t.senderId).where(sql`${t.readAt} IS NULL`),
|
||||
index('messages_enquiry_unread_idx')
|
||||
.on(t.enquiryId, t.senderId)
|
||||
.where(sql`${t.readAt} IS NULL`),
|
||||
],
|
||||
);
|
||||
|
||||
export const messagesRelations = relations(messages, ({ one }) => ({
|
||||
match: one(matches, { fields: [messages.matchId], references: [matches.id] }),
|
||||
enquiry: one(enquiries, { fields: [messages.enquiryId], references: [enquiries.id] }),
|
||||
sender: one(users, { fields: [messages.senderId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
@@ -3,6 +3,19 @@
|
||||
/** Max open (pending) requests a client can have out on a single job. Stops city-spraying. */
|
||||
export const MAX_OPEN_REQUESTS_PER_JOB = 5;
|
||||
|
||||
/**
|
||||
* Max UNANSWERED enquiries one customer may have out at a time.
|
||||
*
|
||||
* An enquiry is the first way to reach a pro who has not agreed to anything, so
|
||||
* it needs the same shape of cap as requests and for the same reason. Answered
|
||||
* threads do not count: somebody having real conversations should not be
|
||||
* throttled, only somebody broadcasting.
|
||||
*/
|
||||
export const MAX_OPEN_ENQUIRIES = 5;
|
||||
|
||||
/** How long an enquiry sits unanswered before it stops counting against the cap. */
|
||||
export const ENQUIRY_STALE_DAYS = 14;
|
||||
|
||||
/** How long a pro has to respond before a request auto-expires. */
|
||||
export const REQUEST_TTL_HOURS = {
|
||||
now: 12,
|
||||
|
||||
@@ -237,7 +237,11 @@ export type CreateReviewInput = z.infer<typeof createReviewSchema>;
|
||||
*/
|
||||
export const sendMessageSchema = z
|
||||
.object({
|
||||
matchId: z.string().uuid(),
|
||||
/** Which conversation — a match or an enquiry. See routers/message.ts. */
|
||||
ref: z.union([
|
||||
z.object({ matchId: z.string().uuid() }),
|
||||
z.object({ enquiryId: z.string().uuid() }),
|
||||
]),
|
||||
body: z.string().trim().max(4000).default(''),
|
||||
attachments: z.array(z.string().url()).max(5).default([]),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user