From 4491e4c6f10368e422173f8c105d2ba3b99fb514 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 16 Mar 2026 15:11:18 +0100 Subject: [PATCH] feat(09-01): add classification column to setupItems with service layer and tests - Add classification text column (default 'base') to setupItems schema - Add classificationSchema and updateClassificationSchema Zod validators - Add UpdateClassification type inferred from Zod schema - Implement updateItemClassification service function - Modify getSetupWithItems to return classification field - Modify syncSetupItems to preserve classifications across re-sync - Add tests for classification CRUD, preservation, and cross-setup independence - Generate and apply Drizzle migration Co-Authored-By: Claude Opus 4.6 --- drizzle/0003_misty_mongu.sql | 1 + drizzle/meta/0003_snapshot.json | 483 +++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/db/schema.ts | 1 + src/server/services/setup.service.ts | 40 ++- src/shared/schemas.ts | 7 + src/shared/types.ts | 2 + tests/helpers/db.ts | 3 +- tests/services/setup.service.test.ts | 101 ++++++ 9 files changed, 642 insertions(+), 3 deletions(-) create mode 100644 drizzle/0003_misty_mongu.sql create mode 100644 drizzle/meta/0003_snapshot.json diff --git a/drizzle/0003_misty_mongu.sql b/drizzle/0003_misty_mongu.sql new file mode 100644 index 0000000..219de09 --- /dev/null +++ b/drizzle/0003_misty_mongu.sql @@ -0,0 +1 @@ +ALTER TABLE `setup_items` ADD `classification` text DEFAULT 'base' NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..3ef93aa --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,483 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "628b9ef4-c715-4bbe-a118-042d80fde91e", + "prevId": "ad8099fa-5c3f-4918-9e21-a259cae77d4f", + "tables": { + "categories": { + "name": "categories", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'package'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "categories_name_unique": { + "name": "categories_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "items": { + "name": "items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "weight_grams": { + "name": "weight_grams", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "product_url": { + "name": "product_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_filename": { + "name": "image_filename", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "items_category_id_categories_id_fk": { + "name": "items_category_id_categories_id_fk", + "tableFrom": "items", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "setup_items": { + "name": "setup_items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "setup_id": { + "name": "setup_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "classification": { + "name": "classification", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'base'" + } + }, + "indexes": {}, + "foreignKeys": { + "setup_items_setup_id_setups_id_fk": { + "name": "setup_items_setup_id_setups_id_fk", + "tableFrom": "setup_items", + "tableTo": "setups", + "columnsFrom": [ + "setup_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "setup_items_item_id_items_id_fk": { + "name": "setup_items_item_id_items_id_fk", + "tableFrom": "setup_items", + "tableTo": "items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "setups": { + "name": "setups", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_candidates": { + "name": "thread_candidates", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "thread_id": { + "name": "thread_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "weight_grams": { + "name": "weight_grams", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "product_url": { + "name": "product_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_filename": { + "name": "image_filename", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'researching'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_candidates_thread_id_threads_id_fk": { + "name": "thread_candidates_thread_id_threads_id_fk", + "tableFrom": "thread_candidates", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "thread_candidates_category_id_categories_id_fk": { + "name": "thread_candidates_category_id_categories_id_fk", + "tableFrom": "thread_candidates", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "threads": { + "name": "threads", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "resolved_candidate_id": { + "name": "resolved_candidate_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "threads_category_id_categories_id_fk": { + "name": "threads_category_id_categories_id_fk", + "tableFrom": "threads", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index bb9b01d..348ee9d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1773666521689, "tag": "0002_broken_roughhouse", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1773670263013, + "tag": "0003_misty_mongu", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index 3fc6804..d77b841 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -86,6 +86,7 @@ export const setupItems = sqliteTable("setup_items", { itemId: integer("item_id") .notNull() .references(() => items.id, { onDelete: "cascade" }), + classification: text("classification").notNull().default("base"), }); export const settings = sqliteTable("settings", { diff --git a/src/server/services/setup.service.ts b/src/server/services/setup.service.ts index 7150511..a3f2004 100644 --- a/src/server/services/setup.service.ts +++ b/src/server/services/setup.service.ts @@ -53,6 +53,7 @@ export function getSetupWithItems(db: Db = prodDb, setupId: number) { updatedAt: items.updatedAt, categoryName: categories.name, categoryIcon: categories.icon, + classification: setupItems.classification, }) .from(setupItems) .innerJoin(items, eq(setupItems.itemId, items.id)) @@ -101,16 +102,51 @@ export function syncSetupItems( itemIds: number[], ) { return db.transaction((tx) => { + // Save existing classifications before deleting + const existing = tx + .select({ + itemId: setupItems.itemId, + classification: setupItems.classification, + }) + .from(setupItems) + .where(eq(setupItems.setupId, setupId)) + .all(); + + const classificationMap = new Map(); + for (const row of existing) { + classificationMap.set(row.itemId, row.classification); + } + // Delete all existing items for this setup tx.delete(setupItems).where(eq(setupItems.setupId, setupId)).run(); - // Re-insert new items + // Re-insert new items, preserving classifications for retained items for (const itemId of itemIds) { - tx.insert(setupItems).values({ setupId, itemId }).run(); + tx.insert(setupItems) + .values({ + setupId, + itemId, + classification: classificationMap.get(itemId) ?? "base", + }) + .run(); } }); } +export function updateItemClassification( + db: Db = prodDb, + setupId: number, + itemId: number, + classification: string, +) { + db.update(setupItems) + .set({ classification }) + .where( + sql`${setupItems.setupId} = ${setupId} AND ${setupItems.itemId} = ${itemId}`, + ) + .run(); +} + export function removeSetupItem( db: Db = prodDb, setupId: number, diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index cf9e4d6..a33a3fe 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -73,3 +73,10 @@ export const updateSetupSchema = z.object({ export const syncSetupItemsSchema = z.object({ itemIds: z.array(z.number().int().positive()), }); + +// Classification schemas +export const classificationSchema = z.enum(["base", "worn", "consumable"]); + +export const updateClassificationSchema = z.object({ + classification: classificationSchema, +}); diff --git a/src/shared/types.ts b/src/shared/types.ts index 0c867da..23ea5bd 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -17,6 +17,7 @@ import type { syncSetupItemsSchema, updateCandidateSchema, updateCategorySchema, + updateClassificationSchema, updateItemSchema, updateSetupSchema, updateThreadSchema, @@ -37,6 +38,7 @@ export type ResolveThread = z.infer; export type CreateSetup = z.infer; export type UpdateSetup = z.infer; export type SyncSetupItems = z.infer; +export type UpdateClassification = z.infer; // Types inferred from Drizzle schema export type Item = typeof items.$inferSelect; diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts index 08cc89e..76bc08d 100644 --- a/tests/helpers/db.ts +++ b/tests/helpers/db.ts @@ -73,7 +73,8 @@ export function createTestDb() { CREATE TABLE setup_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, setup_id INTEGER NOT NULL REFERENCES setups(id) ON DELETE CASCADE, - item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + classification TEXT NOT NULL DEFAULT 'base' ) `); diff --git a/tests/services/setup.service.test.ts b/tests/services/setup.service.test.ts index f0edecf..2c731a7 100644 --- a/tests/services/setup.service.test.ts +++ b/tests/services/setup.service.test.ts @@ -7,6 +7,7 @@ import { getSetupWithItems, removeSetupItem, syncSetupItems, + updateItemClassification, updateSetup, } from "../../src/server/services/setup.service.ts"; import { createTestDb } from "../helpers/db.ts"; @@ -172,6 +173,106 @@ describe("Setup Service", () => { }); }); + describe("getSetupWithItems - classification", () => { + it("returns classification field defaulting to 'base' for each item", () => { + const setup = createSetup(db, { name: "Day Hike" }); + const item = createItem(db, { + name: "Water Bottle", + categoryId: 1, + weightGrams: 200, + priceCents: 2500, + }); + syncSetupItems(db, setup.id, [item.id]); + + const result = getSetupWithItems(db, setup.id); + expect(result?.items).toHaveLength(1); + expect(result?.items[0].classification).toBe("base"); + }); + }); + + describe("syncSetupItems - classification preservation", () => { + it("preserves existing classifications when re-syncing items", () => { + const setup = createSetup(db, { name: "Kit" }); + const item1 = createItem(db, { name: "Tent", categoryId: 1 }); + const item2 = createItem(db, { name: "Jacket", categoryId: 1 }); + const item3 = createItem(db, { name: "Stove", categoryId: 1 }); + + // Initial sync + syncSetupItems(db, setup.id, [item1.id, item2.id]); + + // Change classifications + updateItemClassification(db, setup.id, item1.id, "worn"); + updateItemClassification(db, setup.id, item2.id, "consumable"); + + // Re-sync with item2 kept and item3 added (item1 removed) + syncSetupItems(db, setup.id, [item2.id, item3.id]); + + const result = getSetupWithItems(db, setup.id); + expect(result?.items).toHaveLength(2); + + const item2Result = result?.items.find((i: any) => i.name === "Jacket"); + const item3Result = result?.items.find((i: any) => i.name === "Stove"); + expect(item2Result?.classification).toBe("consumable"); + expect(item3Result?.classification).toBe("base"); + }); + + it("assigns 'base' to newly added items with no prior classification", () => { + const setup = createSetup(db, { name: "Kit" }); + const item1 = createItem(db, { name: "Item 1", categoryId: 1 }); + + syncSetupItems(db, setup.id, [item1.id]); + const result = getSetupWithItems(db, setup.id); + expect(result?.items[0].classification).toBe("base"); + }); + }); + + describe("updateItemClassification", () => { + it("sets classification for a specific item in a specific setup", () => { + const setup = createSetup(db, { name: "Kit" }); + const item = createItem(db, { name: "Tent", categoryId: 1 }); + syncSetupItems(db, setup.id, [item.id]); + + updateItemClassification(db, setup.id, item.id, "worn"); + + const result = getSetupWithItems(db, setup.id); + expect(result?.items[0].classification).toBe("worn"); + }); + + it("changes item from default 'base' to 'worn'", () => { + const setup = createSetup(db, { name: "Kit" }); + const item = createItem(db, { name: "Jacket", categoryId: 1 }); + syncSetupItems(db, setup.id, [item.id]); + + // Verify default + let result = getSetupWithItems(db, setup.id); + expect(result?.items[0].classification).toBe("base"); + + // Update + updateItemClassification(db, setup.id, item.id, "worn"); + + result = getSetupWithItems(db, setup.id); + expect(result?.items[0].classification).toBe("worn"); + }); + + it("same item in two different setups can have different classifications", () => { + const setup1 = createSetup(db, { name: "Hiking" }); + const setup2 = createSetup(db, { name: "Biking" }); + const item = createItem(db, { name: "Jacket", categoryId: 1 }); + + syncSetupItems(db, setup1.id, [item.id]); + syncSetupItems(db, setup2.id, [item.id]); + + updateItemClassification(db, setup1.id, item.id, "worn"); + updateItemClassification(db, setup2.id, item.id, "base"); + + const result1 = getSetupWithItems(db, setup1.id); + const result2 = getSetupWithItems(db, setup2.id); + + expect(result1?.items[0].classification).toBe("worn"); + expect(result2?.items[0].classification).toBe("base"); + }); + }); + describe("cascade behavior", () => { it("deleting a collection item removes it from all setups", () => { const setup = createSetup(db, { name: "Kit" });