feat(intake): record the background-removal intent and the original path (#281)

Adds the two columns the background-removal feature (#281) is built on: item_drafts.remove_background (boolean, not null, default true) records the submitter's per-submission intent, and item_images.original_image_path (nullable text, no default) records where a cut-out photo came from so it can be restored. The default on remove_background is load-bearing — any row written by a path that does not mention the column behaves like the new default, so no backfill is needed. original_image_path stays null until a photo has actually been cut out, which doubles as the answer to "can this be restored?" rather than needing a separate flag. Also updates the Drizzle mirror in src/db-drizzle/schema.ts by hand (the local dev database was not running to re-pull from) and adds the integration test backgroundRemoval.integration.test.ts, including the exported seedSubmission helper that Task 3 will reuse.

Closes #281

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 12:15:38 -05:00
co-authored by Claude Opus 5
parent 1c265840a9
commit 534e3d6228
3 changed files with 91 additions and 0 deletions
@@ -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;
`);
};
+2
View File
@@ -29,6 +29,7 @@ export const itemImages = pgTable("item_images", {
itemId: integer("item_id").notNull(), itemId: integer("item_id").notNull(),
imagePath: text("image_path").notNull(), imagePath: text("image_path").notNull(),
sortOrder: integer("sort_order").default(0).notNull(), sortOrder: integer("sort_order").default(0).notNull(),
originalImagePath: text("original_image_path"),
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
}, (table) => [ }, (table) => [
foreignKey({ foreignKey({
@@ -233,6 +234,7 @@ export const itemDrafts = pgTable("item_drafts", {
itemId: integer("item_id").notNull(), itemId: integer("item_id").notNull(),
uploadLinkId: integer("upload_link_id"), uploadLinkId: integer("upload_link_id"),
submitterNote: text("submitter_note"), submitterNote: text("submitter_note"),
removeBackground: boolean("remove_background").default(true).notNull(),
state: text().default('queued').notNull(), state: text().default('queued').notNull(),
attempts: integer().default(0).notNull(), attempts: integer().default(0).notNull(),
model: text(), model: text(),
@@ -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();
});
});