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
@@ -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
+7
View File
@@ -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
}
]
}
+110
View File
@@ -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] }),
}));
+1
View File
@@ -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';
+24 -5
View File
@@ -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] }),
}));