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:
@@ -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