diff --git a/backend/migrations/1787600000000_add-background-removal.js b/backend/migrations/1787600000000_add-background-removal.js new file mode 100644 index 0000000..7d0ee0f --- /dev/null +++ b/backend/migrations/1787600000000_add-background-removal.js @@ -0,0 +1,35 @@ +exports.up = (pgm) => { + pgm.sql(` + -- The submitter's intent, per submission, because that is how it is + -- expressed: one checkbox above the send button, ticked by default. + -- + -- The worker acts on it rather than the intake route. Removing inline + -- would make the sender wait, would put a CPU-heavy model run in a path + -- anyone holding a link can trigger — the surface #227 exists to bound — + -- and would force a choice, when the sidecar is unreachable, between + -- failing their submission and silently ignoring what they asked for. + -- + -- NOT NULL DEFAULT true so a row written before this migration, or by any + -- path that does not mention the column, behaves like the new default. + ALTER TABLE item_drafts + ADD COLUMN IF NOT EXISTS remove_background BOOLEAN NOT NULL DEFAULT true; + + -- Where the photo came from, per image, because that is how it is undone. + -- Null until a photo has been cut out, so it is also the answer to "can + -- this be restored?" — one fact in one place rather than a flag that can + -- disagree with a path. + -- + -- Nullable and with no default: an existing image has no original other + -- than itself, and claiming otherwise would offer a Restore that swapped a + -- photo for a copy of itself. + ALTER TABLE item_images + ADD COLUMN IF NOT EXISTS original_image_path TEXT; + `); +}; + +exports.down = (pgm) => { + pgm.sql(` + ALTER TABLE item_drafts DROP COLUMN IF EXISTS remove_background; + ALTER TABLE item_images DROP COLUMN IF EXISTS original_image_path; + `); +}; diff --git a/backend/src/db-drizzle/schema.ts b/backend/src/db-drizzle/schema.ts index 729744c..21f5c07 100644 --- a/backend/src/db-drizzle/schema.ts +++ b/backend/src/db-drizzle/schema.ts @@ -29,6 +29,7 @@ export const itemImages = pgTable("item_images", { itemId: integer("item_id").notNull(), imagePath: text("image_path").notNull(), sortOrder: integer("sort_order").default(0).notNull(), + originalImagePath: text("original_image_path"), createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), }, (table) => [ foreignKey({ @@ -233,6 +234,7 @@ export const itemDrafts = pgTable("item_drafts", { itemId: integer("item_id").notNull(), uploadLinkId: integer("upload_link_id"), submitterNote: text("submitter_note"), + removeBackground: boolean("remove_background").default(true).notNull(), state: text().default('queued').notNull(), attempts: integer().default(0).notNull(), model: text(), diff --git a/backend/tests/integration/backgroundRemoval.integration.test.ts b/backend/tests/integration/backgroundRemoval.integration.test.ts new file mode 100644 index 0000000..4233c07 --- /dev/null +++ b/backend/tests/integration/backgroundRemoval.integration.test.ts @@ -0,0 +1,54 @@ +import { pool } from '../../src/db'; +import { resetDb, closeDb } from './setup/testDb'; + +beforeEach(async () => { + await resetDb(); +}); + +afterAll(async () => { + await pool.end(); + await closeDb(); +}); + +/** An item with a draft row and one image, which is what a submission leaves. */ +export async function seedSubmission( + imagePath = '/uploads/photo.jpg' +): Promise<{ itemId: number; imageId: number }> { + const item = await pool.query<{ id: number }>( + `INSERT INTO items (name, status) VALUES ('Submission placeholder', 'pending') RETURNING id` + ); + const itemId = item.rows[0]!.id; + await pool.query(`INSERT INTO item_drafts (item_id) VALUES ($1)`, [itemId]); + const image = await pool.query<{ id: number }>( + `INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, $2, 0) RETURNING id`, + [itemId, imagePath] + ); + return { itemId, imageId: image.rows[0]!.id }; +} + +describe('the background-removal columns', () => { + // Default true because the submitter's checkbox is ticked by default, and + // because a row written by any path that does not mention the column should + // behave like the new default rather than needing a backfill. + it('defaults remove_background to true', async () => { + const { itemId } = await seedSubmission(); + + const { rows } = await pool.query<{ remove_background: boolean }>( + `SELECT remove_background FROM item_drafts WHERE item_id = $1`, + [itemId] + ); + expect(rows[0]?.remove_background).toBe(true); + }); + + // Null is also the answer to "can this be restored?", which is why there is + // no separate flag: one fact, one place. + it('leaves original_image_path null until a photo has been cut out', async () => { + const { imageId } = await seedSubmission(); + + const { rows } = await pool.query<{ original_image_path: string | null }>( + `SELECT original_image_path FROM item_images WHERE id = $1`, + [imageId] + ); + expect(rows[0]?.original_image_path).toBeNull(); + }); +});