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