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 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 15:11:18 +01:00
parent 0e23996986
commit 4491e4c6f1
9 changed files with 642 additions and 3 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE `setup_items` ADD `classification` text DEFAULT 'base' NOT NULL;

View File

@@ -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": {}
}
}

View File

@@ -22,6 +22,13 @@
"when": 1773666521689, "when": 1773666521689,
"tag": "0002_broken_roughhouse", "tag": "0002_broken_roughhouse",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1773670263013,
"tag": "0003_misty_mongu",
"breakpoints": true
} }
] ]
} }

View File

@@ -86,6 +86,7 @@ export const setupItems = sqliteTable("setup_items", {
itemId: integer("item_id") itemId: integer("item_id")
.notNull() .notNull()
.references(() => items.id, { onDelete: "cascade" }), .references(() => items.id, { onDelete: "cascade" }),
classification: text("classification").notNull().default("base"),
}); });
export const settings = sqliteTable("settings", { export const settings = sqliteTable("settings", {

View File

@@ -53,6 +53,7 @@ export function getSetupWithItems(db: Db = prodDb, setupId: number) {
updatedAt: items.updatedAt, updatedAt: items.updatedAt,
categoryName: categories.name, categoryName: categories.name,
categoryIcon: categories.icon, categoryIcon: categories.icon,
classification: setupItems.classification,
}) })
.from(setupItems) .from(setupItems)
.innerJoin(items, eq(setupItems.itemId, items.id)) .innerJoin(items, eq(setupItems.itemId, items.id))
@@ -101,16 +102,51 @@ export function syncSetupItems(
itemIds: number[], itemIds: number[],
) { ) {
return db.transaction((tx) => { 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<number, string>();
for (const row of existing) {
classificationMap.set(row.itemId, row.classification);
}
// Delete all existing items for this setup // Delete all existing items for this setup
tx.delete(setupItems).where(eq(setupItems.setupId, setupId)).run(); 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) { 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( export function removeSetupItem(
db: Db = prodDb, db: Db = prodDb,
setupId: number, setupId: number,

View File

@@ -73,3 +73,10 @@ export const updateSetupSchema = z.object({
export const syncSetupItemsSchema = z.object({ export const syncSetupItemsSchema = z.object({
itemIds: z.array(z.number().int().positive()), itemIds: z.array(z.number().int().positive()),
}); });
// Classification schemas
export const classificationSchema = z.enum(["base", "worn", "consumable"]);
export const updateClassificationSchema = z.object({
classification: classificationSchema,
});

View File

@@ -17,6 +17,7 @@ import type {
syncSetupItemsSchema, syncSetupItemsSchema,
updateCandidateSchema, updateCandidateSchema,
updateCategorySchema, updateCategorySchema,
updateClassificationSchema,
updateItemSchema, updateItemSchema,
updateSetupSchema, updateSetupSchema,
updateThreadSchema, updateThreadSchema,
@@ -37,6 +38,7 @@ export type ResolveThread = z.infer<typeof resolveThreadSchema>;
export type CreateSetup = z.infer<typeof createSetupSchema>; export type CreateSetup = z.infer<typeof createSetupSchema>;
export type UpdateSetup = z.infer<typeof updateSetupSchema>; export type UpdateSetup = z.infer<typeof updateSetupSchema>;
export type SyncSetupItems = z.infer<typeof syncSetupItemsSchema>; export type SyncSetupItems = z.infer<typeof syncSetupItemsSchema>;
export type UpdateClassification = z.infer<typeof updateClassificationSchema>;
// Types inferred from Drizzle schema // Types inferred from Drizzle schema
export type Item = typeof items.$inferSelect; export type Item = typeof items.$inferSelect;

View File

@@ -73,7 +73,8 @@ export function createTestDb() {
CREATE TABLE setup_items ( CREATE TABLE setup_items (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
setup_id INTEGER NOT NULL REFERENCES setups(id) ON DELETE CASCADE, 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'
) )
`); `);

View File

@@ -7,6 +7,7 @@ import {
getSetupWithItems, getSetupWithItems,
removeSetupItem, removeSetupItem,
syncSetupItems, syncSetupItems,
updateItemClassification,
updateSetup, updateSetup,
} from "../../src/server/services/setup.service.ts"; } from "../../src/server/services/setup.service.ts";
import { createTestDb } from "../helpers/db.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", () => { describe("cascade behavior", () => {
it("deleting a collection item removes it from all setups", () => { it("deleting a collection item removes it from all setups", () => {
const setup = createSetup(db, { name: "Kit" }); const setup = createSetup(db, { name: "Kit" });