feat(intake): record a draft without publishing it (#223)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<void> {
|
||||
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
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user