Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions src/api/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@
import type { Keyring } from '../mail/reply-token.js'
import { sendReply } from '../mail/send.js'
import type { EmailSender } from '../providers/index.js'
import type {
ConversationFolder,
ConversationStatus,
ConversationStore,
StoredThread,
import {
type ConversationFolder,
type ConversationStatus,
type ConversationStore,
derivePreview,
type StoredThread,
} from '../store/conversations.js'
import { decodeCursor, encodeCursor } from './cursor.js'
import { apiError, json } from './responses.js'
Expand Down Expand Up @@ -63,10 +64,12 @@ interface ThreadViewJson {
/** The wire shape of one `ConversationSummary` (specs/api/agent-inbox-v1.md §2) — `Date` fields as ISO strings. */
interface ConversationSummaryJson {
id: string
number: number
subject: string
customerEmail: string
status: ConversationStatus
threadCount: number
preview: string
createdAt: string
updatedAt: string
}
Expand Down Expand Up @@ -191,10 +194,12 @@ export async function handleGetConversation(

const body: ConversationDetailJson = {
id: conversation.id,
number: conversation.number,
subject: conversation.subject,
customerEmail: conversation.customerEmail,
status: conversation.status,
threadCount: conversation.threads.length,
preview: previewFromThreads(conversation.threads),
createdAt: conversation.createdAt.toISOString(),
updatedAt: conversation.updatedAt.toISOString(),
threads: conversation.threads.map(toThreadViewJson),
Expand All @@ -203,6 +208,22 @@ export async function handleGetConversation(
return json(200, body)
}

/**
* Derive a detail response's `preview` from the threads it already carries —
* the SAME rule the store applies for list summaries (`derivePreview`, spec
* §2): the most recent thread with a non-null `bodyText`. Threads arrive
* oldest-first, so this walks from the end.
*/
function previewFromThreads(threads: StoredThread[]): string {
for (let i = threads.length - 1; i >= 0; i--) {
const bodyText = threads[i].bodyText
if (bodyText !== null) {
return derivePreview(bodyText)
}
}
return ''
}

/**
* Handle `POST /api/v1/conversations/{id}/replies` — the Agent replies to a
* conversation (spec §4a). The client supplies only `{ text, html? }`; every
Expand Down Expand Up @@ -505,19 +526,23 @@ function deriveReplyHeaders(conversation: { subject: string; threads: StoredThre

function toConversationSummaryJson(row: {
id: string
number: number
subject: string
customerEmail: string
status: ConversationStatus
threadCount: number
preview: string
createdAt: Date
updatedAt: Date
}): ConversationSummaryJson {
return {
id: row.id,
number: row.number,
subject: row.subject,
customerEmail: row.customerEmail,
status: row.status,
threadCount: row.threadCount,
preview: row.preview,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
}
Expand Down
42 changes: 42 additions & 0 deletions src/api/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1066,6 +1066,48 @@ describe('createInboxApi', () => {
})
})
})

// --- number & preview on the wire (HT-27, spec §2 v1.1) -----------------------

describe('number & preview', () => {
it('list summaries carry number (creation order) and preview (latest text, collapsed)', async () => {
const { store, api } = await freshApi()
const { conversationId: firstId } = await store.createConversation(newConversation())
const { conversationId: secondId } = await store.createConversation(newConversation())
await store.appendThread(firstId, {
direction: 'inbound',
messageId: null,
fromAddress: 'customer@example.test',
bodyText: ' latest\n\nreply ',
})

const res = await api(get('/api/v1/conversations'))
const body = (await res.json()) as {
conversations: Array<{ id: string; number: number; preview: string }>
}
const first = body.conversations.find((c) => c.id === firstId)
const second = body.conversations.find((c) => c.id === secondId)
expect(first).toMatchObject({ number: 1, preview: 'latest reply' })
expect(second).toMatchObject({ number: 2, preview: 'Where is my order?' })
})

it('the detail response carries number and the SAME preview rule as the list', async () => {
const { store, api } = await freshApi()
const { conversationId } = await store.createConversation(newConversation())
// An html-only latest thread — preview must fall back to the inbound text.
await store.appendThread(conversationId, {
direction: 'inbound',
messageId: null,
fromAddress: 'customer@example.test',
bodyHtml: '<p>rich only</p>',
})

const res = await api(get(`/api/v1/conversations/${conversationId}`))
const body = (await res.json()) as { number: number; preview: string }
expect(body.number).toBe(1)
expect(body.preview).toBe('Where is my order?')
})
})
})

describe('createInboxApi — hardening (Codex review)', () => {
Expand Down
69 changes: 68 additions & 1 deletion src/db/migrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ describe('migrate', () => {
{ id: 2, name: 'add_thread_delivery_status' },
{ id: 3, name: 'add_thread_send_idempotency' },
{ id: 4, name: 'four_state_conversation_status' },
{ id: 5, name: 'conversation_number' },
])
})

Expand All @@ -53,7 +54,7 @@ describe('migrate', () => {
await migrate(db) // must not throw (e.g. "relation already exists")

const rows = await db.query<{ id: number }>('SELECT id FROM _migrations ORDER BY id')
expect(rows).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }])
expect(rows).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }])
})

it('migration 002 ties delivery_status to direction: inbound must be NULL, outbound must be pending/sent/failed', async () => {
Expand Down Expand Up @@ -342,4 +343,70 @@ describe('migrate', () => {
]),
).rejects.toThrow()
})

it('migration 005 upgrades a NON-fresh 004 database: existing rows numbered in creation order, the sequence continues after them', async () => {
const database = await createPgliteDb()
db = database

// Apply only through migration 004, then write conversations the way a
// pre-005 deployment would have — no number column yet. Explicit,
// strictly-increasing created_at values so "creation order" is fully
// controlled rather than relying on clock granularity.
await migrate(database, { throughId: 4 })
const insert = async (createdAt: string) => {
const [row] = await database.query<{ id: string }>(
'INSERT INTO conversations (customer_email, created_at) VALUES ($1, $2) RETURNING id',
['customer@example.test', createdAt],
)
return row.id
}
// Inserted out of creation order on purpose — the backfill must number by
// created_at, not by insertion/physical order.
const second = await insert('2026-01-02T00:00:00.000Z')
const first = await insert('2026-01-01T00:00:00.000Z')
const third = await insert('2026-01-03T00:00:00.000Z')

await expect(migrate(database)).resolves.toBeUndefined()

const numberOf = async (id: string) =>
(
await database.query<{ number: number }>('SELECT number FROM conversations WHERE id = $1', [
id,
])
)[0].number
expect(await numberOf(first)).toBe(1)
expect(await numberOf(second)).toBe(2)
expect(await numberOf(third)).toBe(3)

// The sequence picked up AFTER the backfilled rows — a fresh insert is #4.
const [fresh] = await database.query<{ number: number }>(
'INSERT INTO conversations (customer_email) VALUES ($1) RETURNING number',
['customer@example.test'],
)
expect(fresh.number).toBe(4)
})

it('migration 005 on a fresh database: numbering starts at 1, increments per insert, and duplicates are rejected', async () => {
db = await createPgliteDb()
await migrate(db)

const [a] = await db.query<{ number: number }>(
'INSERT INTO conversations (customer_email) VALUES ($1) RETURNING number',
['customer@example.test'],
)
const [b] = await db.query<{ number: number }>(
'INSERT INTO conversations (customer_email) VALUES ($1) RETURNING number',
['customer@example.test'],
)
expect(a.number).toBe(1)
expect(b.number).toBe(2)

// UNIQUE holds — a manual duplicate is rejected at the schema level.
await expect(
db.query('INSERT INTO conversations (customer_email, number) VALUES ($1, $2)', [
'customer@example.test',
1,
]),
).rejects.toThrow()
})
})
45 changes: 45 additions & 0 deletions src/db/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,46 @@ ALTER TABLE conversations ALTER COLUMN status SET DEFAULT 'active';
ALTER TABLE conversations ADD CONSTRAINT conversations_status_check CHECK (status IN ('active','pending','closed','spam','deleted'));
`

/**
* Migration 005 — the human-facing conversation `number` (HT-27;
* specs/api/agent-inbox-v1.md §2, v1.1).
*
* A small sequential per-deployment integer for humans (inbox rows,
* notifications, "#482" in conversation), assigned from a dedicated sequence
* at insert. Display-only by contract: the uuid stays the canonical id and
* `number` is never accepted as an identifier anywhere in the API.
*
* Statement order is load-bearing, in the 002/004 backfill-before-constraint
* tradition:
*
* 1. ADD COLUMN (nullable) — existing rows get NULL, legal at this point.
* 2. BACKFILL existing rows in `(created_at, id)` order via `row_number()` —
* the spec's "existing rows are backfilled in creation order" (§2), `id`
* as the stable tiebreak for same-instant rows.
* 3. CREATE SEQUENCE + `setval(max(number) + 1, false)` so the next insert
* continues where the backfill left off (on an EMPTY table this is
* `setval(1, false)` — the first conversation is #1). The sequence is
* OWNED BY the column so a future drop cascades cleanly.
* 4. Only THEN: SET DEFAULT nextval(...), SET NOT NULL, and the UNIQUE
* constraint — each of which every row now satisfies.
*
* Postgres resolves the `nextval('conversation_number_seq')` DEFAULT to the
* sequence's OID at ALTER time (a `regclass` bind, not a runtime name
* lookup), so the HT-20 Postgres adapter's schema option is honored — the
* default points at the sequence in the configured schema regardless of the
* connection's later search_path.
*/
const MIGRATION_005_CONVERSATION_NUMBER = `
ALTER TABLE conversations ADD COLUMN number integer;
UPDATE conversations SET number = numbered.rn FROM (SELECT id, row_number() OVER (ORDER BY created_at, id) AS rn FROM conversations) AS numbered WHERE conversations.id = numbered.id;
CREATE SEQUENCE conversation_number_seq;
ALTER SEQUENCE conversation_number_seq OWNED BY conversations.number;
SELECT setval('conversation_number_seq', COALESCE((SELECT max(number) FROM conversations), 0) + 1, false);
ALTER TABLE conversations ALTER COLUMN number SET DEFAULT nextval('conversation_number_seq');
ALTER TABLE conversations ALTER COLUMN number SET NOT NULL;
ALTER TABLE conversations ADD CONSTRAINT conversations_number_key UNIQUE (number);
`

/**
* Every migration, in the order they must apply. `id` is the sole ordering
* key (ascending) — array position is not relied upon, so re-sorting this
Expand All @@ -221,6 +261,11 @@ const MIGRATIONS: Migration[] = [
name: 'four_state_conversation_status',
sql: MIGRATION_004_FOUR_STATE_CONVERSATION_STATUS,
},
{
id: 5,
name: 'conversation_number',
sql: MIGRATION_005_CONVERSATION_NUMBER,
},
]

/**
Expand Down
69 changes: 69 additions & 0 deletions src/store/conversations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -885,4 +885,73 @@ describe('createConversationStore', () => {
expect(eligible.map((t) => t.id)).toEqual([ids[0], ids[1]])
})
})

describe('number & preview (HT-27, spec §2 v1.1)', () => {
it('summaries carry the sequential number (creation order) and getConversation carries it too', async () => {
const { store } = await freshStore()
const { conversationId: firstId } = await store.createConversation(newConversation())
const { conversationId: secondId } = await store.createConversation(newConversation())

const all = await store.listConversations({ limit: 50 })
expect(all.find((c) => c.id === firstId)?.number).toBe(1)
expect(all.find((c) => c.id === secondId)?.number).toBe(2)

const detail = await store.getConversation(firstId)
expect(detail?.number).toBe(1)
})

it("preview is the latest thread's text, whitespace-collapsed and capped at 120 chars", async () => {
const { store } = await freshStore()
const { conversationId } = await store.createConversation(newConversation())
const messy = ` padded\t\tand\n\nbroken ${'x'.repeat(200)}`
await store.appendThread(conversationId, newThread({ bodyText: messy }))

const [summary] = await store.listConversations({ limit: 50 })
const expected = `padded and broken ${'x'.repeat(200)}`.slice(0, 120)
expect(summary.preview).toBe(expected)
expect(summary.preview.length).toBe(120)
})

it('an html-only latest thread is skipped — preview falls back to the most recent thread WITH text', async () => {
const { store } = await freshStore()
const { conversationId } = await store.createConversation(newConversation())
await store.appendThread(
conversationId,
newThread({ bodyText: null, bodyHtml: '<p>rich only</p>' }),
)

const [summary] = await store.listConversations({ limit: 50 })
// Falls back past the html-only append to the first (inbound) thread's text.
expect(summary.preview).toBe('Where is my order?')
})

it("preview is '' when no thread has text at all", async () => {
const { store } = await freshStore()
await store.createConversation(
newConversation({
firstMessage: {
direction: 'inbound',
messageId: null,
fromAddress: 'customer@example.test',
bodyHtml: '<p>html only</p>',
},
}),
)

const [summary] = await store.listConversations({ limit: 50 })
expect(summary.preview).toBe('')
})

it('setConversationStatus returns number and preview on the updated summary', async () => {
const { store } = await freshStore()
const { conversationId } = await store.createConversation(newConversation())

const summary = await store.setConversationStatus(conversationId, 'closed')
expect(summary).toMatchObject({
id: conversationId,
number: 1,
preview: 'Where is my order?',
})
})
})
})
Loading
Loading