From 885a78c57255f2aa340fa08d5a813d877f719bc8 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Thu, 3 Sep 2026 12:44:20 -0500 Subject: [PATCH] feat(intake): record whether the submitter asked for a cut-out (#281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public intake POST now reads a `removeBackground` multipart field and stores it on the new `item_drafts.remove_background` column. The checkbox on the submission page is ticked by default, so a client that sends nothing gets `true` — only the exact string `'false'` opts out, so a stray or unexpected value is treated as consent rather than a silent refusal. The GET now also reports `backgroundRemoval: isRembgConfigured()` alongside the label, so the submission page knows up front whether the feature exists in this environment at all. Neither handler calls the sidecar or the AI — this task only records intent for the drafting worker to act on later, and the existing ordering of `requireUsableLink` and `requireCapacity` ahead of `uploadImages` is unchanged. Co-Authored-By: Claude Opus 5 --- backend/src/routes/intake.ts | 21 ++++-- .../integration/intake.integration.test.ts | 66 +++++++++++++++++++ 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/backend/src/routes/intake.ts b/backend/src/routes/intake.ts index e7dda43..372337c 100644 --- a/backend/src/routes/intake.ts +++ b/backend/src/routes/intake.ts @@ -8,6 +8,7 @@ import { draftQueued } from '../intake/draftingWorker'; import { checkCapacity, countForLinkSince, windowStart } from '../intake/capacity'; import { alertCeilingReached, alertLinkThreshold } from '../intake/abuseAlert'; import { getSettings } from '../adminSettings'; +import { isRembgConfigured } from '../intake/rembgClient'; const router = Router(); @@ -128,8 +129,9 @@ router.get('/:token', intakeViewLimiter, asyncRoute(async (req: Request, res: Re if (!link) { return res.status(404).json({ error: 'not found' }); } - // The label only. Nothing about the catalogue, the admin, or other links. - res.json({ label: link.label }); + // The label, and whether the background-removal control has anything behind + // it. Still nothing about the catalogue, the admin, or other links. + res.json({ label: link.label, backgroundRemoval: isRembgConfigured() }); })); router.post( @@ -159,6 +161,15 @@ router.post( const note = typeof req.body?.note === 'string' ? req.body.note.trim() : ''; + // Absent means yes: the checkbox on the page is ticked by default, so a + // client that does not send the field — an older build, or a script — gets + // what every other submission gets rather than silently opting out. + // + // Only the exact string opts out. Multipart fields arrive as strings, and + // reading a stray value as "no" would quietly deny somebody something they + // asked for. + const removeBackground = req.body?.removeBackground !== 'false'; + const client = await pool.connect(); try { await client.query('BEGIN'); @@ -178,9 +189,9 @@ router.post( await insertItemImages(client, itemId, files, 0); await client.query( - `INSERT INTO item_drafts (item_id, upload_link_id, submitter_note) - VALUES ($1, $2, $3)`, - [itemId, link.id, note === '' ? null : note] + `INSERT INTO item_drafts (item_id, upload_link_id, submitter_note, remove_background) + VALUES ($1, $2, $3, $4)`, + [itemId, link.id, note === '' ? null : note, removeBackground] ); // Counted inside the transaction and guarded on the same conditions as diff --git a/backend/tests/integration/intake.integration.test.ts b/backend/tests/integration/intake.integration.test.ts index 044b877..bc212f4 100644 --- a/backend/tests/integration/intake.integration.test.ts +++ b/backend/tests/integration/intake.integration.test.ts @@ -39,6 +39,13 @@ async function issueLink(label = 'Sarah', maxSubmissions?: number | null): Promi return res.body.token as string; } +/** A submission with optional extra multipart fields beside the photo. */ +function postPhoto(token: string, fields: Record = {}) { + const req = request(app).post(`/api/intake/${token}`); + for (const [name, value] of Object.entries(fields)) void req.field(name, value); + return req.attach('images', PNG, 'a.png'); +} + describe('checking a link before showing the form', () => { it('names the link so the page can greet the sender', async () => { const token = await issueLink('Sarah'); @@ -207,3 +214,62 @@ describe('a submitted item does not reach the storefront', () => { expect(res.body).toHaveLength(0); }); }); + +describe('the background-removal intent', () => { + // Ticked by default on the page, so absent means yes. An older client or a + // curl call then behaves like the current default rather than silently + // opting out of something every other submission gets. + it('defaults to true when the field is not sent', async () => { + const token = await issueLink(); + await postPhoto(token); + + const { rows } = await pool.query<{ remove_background: boolean }>( + `SELECT remove_background FROM item_drafts ORDER BY id DESC LIMIT 1` + ); + expect(rows[0]?.remove_background).toBe(true); + }); + + it('records a submitter who unticked it', async () => { + const token = await issueLink(); + await postPhoto(token, { removeBackground: 'false' }); + + const { rows } = await pool.query<{ remove_background: boolean }>( + `SELECT remove_background FROM item_drafts ORDER BY id DESC LIMIT 1` + ); + expect(rows[0]?.remove_background).toBe(false); + }); + + // Only the exact string opts out. A stray value is not a considered "no", + // and reading it as one would quietly deny somebody something they asked for. + it('treats anything other than "false" as consent', async () => { + const token = await issueLink(); + await postPhoto(token, { removeBackground: 'no' }); + + const { rows } = await pool.query<{ remove_background: boolean }>( + `SELECT remove_background FROM item_drafts ORDER BY id DESC LIMIT 1` + ); + expect(rows[0]?.remove_background).toBe(true); + }); +}); + +describe('what the submission page is told', () => { + it('says the feature is off when there is no sidecar', async () => { + delete process.env.REMBG_URL; + const token = await issueLink(); + + const res = await request(app).get(`/api/intake/${token}`); + + expect(res.status).toBe(200); + expect(res.body.backgroundRemoval).toBe(false); + }); + + it('says it is on when there is one', async () => { + process.env.REMBG_URL = 'http://rembg-syn:7000'; + const token = await issueLink(); + + const res = await request(app).get(`/api/intake/${token}`); + + expect(res.body.backgroundRemoval).toBe(true); + delete process.env.REMBG_URL; + }); +});