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:
@@ -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})
|
||||
)`,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user