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:
serfa
2026-08-21 06:49:17 -04:00
co-authored by Claude Opus 5
parent 974e312534
commit 0c49aa9502
21 changed files with 5080 additions and 89 deletions
+4
View File
@@ -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,
+238
View File
@@ -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`,
),
);
}
+150 -25
View File
@@ -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})
)`,
),
),
);
+145
View File
@@ -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;
}),
});