From 511022d248f6184cd0d74ef2ce4b1c8008fbd2ca Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Mon, 31 Aug 2026 19:46:01 -0500 Subject: [PATCH] feat(intake): record a draft without publishing it (#223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The copy goes on item_drafts, never on the item. The item keeps its placeholder name and description until a person approves them in the review queue (#225) — nothing a model wrote reaches the catalogue unreviewed. The price is the deliberate exception, because #220 chose to price an item on arrival rather than leave it unpriced. price_source records that the number came from a model rather than a person, so the review queue can show it as unconfirmed. With no suggestion the item keeps the migration's 8000 default and price_source stays 'default'; the queue shows both the same way, as a number nobody has chosen yet. A category is checked against the real table before it is stored. The schema constrains the shape of the answer but cannot enforce membership, and a category the shop does not have would be invisible to every storefront filter — a draft nobody could find, rather than an obvious error. A successful retry clears ai_error, or a draft that eventually worked would still read as broken in the queue. Co-Authored-By: Claude Opus 5 --- backend/src/intake/applyDraft.ts | 78 +++++++++ .../integration/drafting.integration.test.ts | 151 ++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 backend/src/intake/applyDraft.ts create mode 100644 backend/tests/integration/drafting.integration.test.ts diff --git a/backend/src/intake/applyDraft.ts b/backend/src/intake/applyDraft.ts new file mode 100644 index 0000000..a7d07f2 --- /dev/null +++ b/backend/src/intake/applyDraft.ts @@ -0,0 +1,78 @@ +import { PoolClient } from 'pg'; +import { DraftOutcome } from './draftListing'; + +/** + * Writes a finished draft to the database. + * + * The copy goes on `item_drafts`, never on the item. The item keeps its + * placeholder name and description until a person approves them in the review + * queue (#225) — nothing a model wrote reaches the catalogue unreviewed. + * + * The price is the deliberate exception, because #220 chose to price an item on + * arrival rather than leave it unpriced. `price_source` records that the number + * came from a model rather than a person, which is what lets the review queue + * show it as unconfirmed. + */ +export async function applyDraft( + client: PoolClient, + itemId: number, + outcome: DraftOutcome +): Promise { + const { draft } = outcome; + + // Checked against the real table before it is stored. The schema constrains + // the shape of the answer but cannot enforce membership, and a category the + // shop does not have would be invisible to every storefront filter — a draft + // nobody could find, rather than an obvious error. + const categoryId = draft.category === null + ? null + : ( + await client.query<{ id: number }>( + `SELECT id FROM categories WHERE lower(name) = lower($1)`, + [draft.category] + ) + ).rows[0]?.id ?? null; + + const hasPrice = draft.suggestedPriceCents !== null; + + await client.query( + `UPDATE item_drafts + SET state = 'ready', + model = $2, + ai_name = $3, + ai_description = $4, + ai_category_id = $5, + ai_tag_names = $6, + ai_suggested_price_cents = $7, + price_source = $8, + input_tokens = $9, + output_tokens = $10, + cost_micros = $11, + ai_error = NULL, + drafted_at = now() + WHERE item_id = $1`, + [ + itemId, + outcome.model, + draft.name, + draft.description, + categoryId, + draft.tags, + draft.suggestedPriceCents, + hasPrice ? 'ai' : 'default', + outcome.inputTokens, + outcome.outputTokens, + outcome.costMicros + ] + ); + + // Only when there is one. Absent, the item keeps the migration's default and + // price_source stays 'default' — the review queue shows both the same way, as + // a number nobody has chosen yet. + if (hasPrice) { + await client.query(`UPDATE items SET price_cents = $2 WHERE id = $1`, [ + itemId, + draft.suggestedPriceCents + ]); + } +} diff --git a/backend/tests/integration/drafting.integration.test.ts b/backend/tests/integration/drafting.integration.test.ts new file mode 100644 index 0000000..fe0ae03 --- /dev/null +++ b/backend/tests/integration/drafting.integration.test.ts @@ -0,0 +1,151 @@ +import { pool } from '../../src/db'; +import { applyDraft } from '../../src/intake/applyDraft'; +import { DraftOutcome } from '../../src/intake/draftListing'; +import { resetDb, closeDb } from './setup/testDb'; + +beforeEach(async () => { + await resetDb(); +}); + +afterAll(async () => { + await pool.end(); + await closeDb(); +}); + +/** + * An item at 'pending' with a draft row waiting, which is what a submission + * leaves behind. price_cents is not set: it takes the 8000 default from #222's + * migration, and that default is what the no-price case below asserts on. + */ +async function seedSubmission(): Promise { + const { rows } = await pool.query<{ id: number }>( + `INSERT INTO items (name, status) VALUES ('Submission placeholder', 'pending') RETURNING id` + ); + const itemId = rows[0]!.id; + await pool.query(`INSERT INTO item_drafts (item_id, submitter_note) VALUES ($1, 'a note')`, [itemId]); + return itemId; +} + +async function apply(itemId: number, outcome: DraftOutcome): Promise { + const client = await pool.connect(); + try { + await applyDraft(client, itemId, outcome); + } finally { + client.release(); + } +} + +const outcome: DraftOutcome = { + draft: { + name: 'Blue stoneware vase', + description: 'Hand-thrown, chipped base.', + category: null, + tags: ['blue'], + suggestedPriceCents: 4500 + }, + model: 'claude-sonnet-5', + inputTokens: 1000, + outputTokens: 200, + costMicros: 4000 +}; + +describe('applying a draft', () => { + it('writes the copy onto the draft row, not the item', async () => { + const itemId = await seedSubmission(); + await apply(itemId, outcome); + + const { rows } = await pool.query( + `SELECT ai_name, ai_description, state, model, ai_tag_names FROM item_drafts WHERE item_id = $1`, + [itemId] + ); + expect(rows[0]?.ai_name).toBe('Blue stoneware vase'); + expect(rows[0]?.state).toBe('ready'); + expect(rows[0]?.model).toBe('claude-sonnet-5'); + expect(rows[0]?.ai_tag_names).toEqual(['blue']); + + // The item keeps its placeholder name until a person approves the draft. + // Nothing a model wrote should reach the catalogue unreviewed. + const item = await pool.query(`SELECT name FROM items WHERE id = $1`, [itemId]); + expect(item.rows[0]?.name).toBe('Submission placeholder'); + }); + + // The one thing that does reach the item, because #220 chose pre-pricing over + // an unpriced row — with price_source recording that nobody chose it. + it('writes a suggested price onto the item and records where it came from', async () => { + const itemId = await seedSubmission(); + await apply(itemId, outcome); + + const item = await pool.query(`SELECT price_cents FROM items WHERE id = $1`, [itemId]); + expect(item.rows[0]?.price_cents).toBe(4500); + + const draft = await pool.query( + `SELECT price_source, ai_suggested_price_cents FROM item_drafts WHERE item_id = $1`, + [itemId] + ); + expect(draft.rows[0]?.price_source).toBe('ai'); + expect(draft.rows[0]?.ai_suggested_price_cents).toBe(4500); + }); + + it('leaves the default price alone when the model would not guess', async () => { + const itemId = await seedSubmission(); + await apply(itemId, { ...outcome, draft: { ...outcome.draft, suggestedPriceCents: null } }); + + const item = await pool.query(`SELECT price_cents FROM items WHERE id = $1`, [itemId]); + expect(item.rows[0]?.price_cents).toBe(8000); + + const draft = await pool.query(`SELECT price_source FROM item_drafts WHERE item_id = $1`, [itemId]); + expect(draft.rows[0]?.price_source).toBe('default'); + }); + + it('records what the call cost', async () => { + const itemId = await seedSubmission(); + await apply(itemId, outcome); + + const { rows } = await pool.query( + `SELECT input_tokens, output_tokens, cost_micros, drafted_at FROM item_drafts WHERE item_id = $1`, + [itemId] + ); + expect(rows[0]?.input_tokens).toBe(1000); + expect(rows[0]?.output_tokens).toBe(200); + expect(rows[0]?.cost_micros).toBe(4000); + expect(rows[0]?.drafted_at).not.toBeNull(); + }); + + it('stores a category the shop actually has', async () => { + const itemId = await seedSubmission(); + const { rows } = await pool.query<{ id: number }>( + `INSERT INTO categories (name) VALUES ('Ceramics') RETURNING id` + ); + + await apply(itemId, { ...outcome, draft: { ...outcome.draft, category: 'Ceramics' } }); + + const draft = await pool.query(`SELECT ai_category_id FROM item_drafts WHERE item_id = $1`, [itemId]); + expect(draft.rows[0]?.ai_category_id).toBe(rows[0]!.id); + }); + + // A category the shop does not have would be invisible to every storefront + // filter, and the schema cannot enforce membership — so it is checked here. + it('ignores a category that does not exist', async () => { + const itemId = await seedSubmission(); + await apply(itemId, { ...outcome, draft: { ...outcome.draft, category: 'Invented Category' } }); + + const { rows } = await pool.query(`SELECT ai_category_id FROM item_drafts WHERE item_id = $1`, [itemId]); + expect(rows[0]?.ai_category_id).toBeNull(); + }); + + // A retry after a failure has to clear the message, or a draft that + // eventually succeeded still reads as broken in the review queue. + it('clears a previous error when a retry succeeds', async () => { + const itemId = await seedSubmission(); + await pool.query( + `UPDATE item_drafts SET state = 'failed', ai_error = 'timed out', attempts = 1 WHERE item_id = $1`, + [itemId] + ); + + await apply(itemId, outcome); + + const { rows } = await pool.query(`SELECT state, ai_error FROM item_drafts WHERE item_id = $1`, [itemId]); + expect(rows[0]?.state).toBe('ready'); + expect(rows[0]?.ai_error).toBeNull(); + }); +});