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:
@@ -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;
|
||||
`);
|
||||
};
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user