From 3a6876f7e8ea4b89432a5d17472e47e92acbc72b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 13:05:28 +0200 Subject: [PATCH 1/4] test(18-02): add failing tests for global item service and seed - 10 test cases covering search, owner count, link/unlink, seed idempotency - Added globalItems/itemGlobalLinks tables to SQLite schema - Added Zod schemas and types for global items - Created 18-item bikepacking gear seed data JSON --- drizzle/0010_demonic_rawhide_kid.sql | 21 + drizzle/meta/0010_snapshot.json | 1005 ++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/db/global-items-seed.json | 146 +++ src/db/schema.ts | 25 + src/shared/schemas.ts | 9 + src/shared/types.ts | 8 + tests/services/global-item.service.test.ts | 172 ++++ 8 files changed, 1393 insertions(+) create mode 100644 drizzle/0010_demonic_rawhide_kid.sql create mode 100644 drizzle/meta/0010_snapshot.json create mode 100644 src/db/global-items-seed.json create mode 100644 tests/services/global-item.service.test.ts diff --git a/drizzle/0010_demonic_rawhide_kid.sql b/drizzle/0010_demonic_rawhide_kid.sql new file mode 100644 index 0000000..3064c93 --- /dev/null +++ b/drizzle/0010_demonic_rawhide_kid.sql @@ -0,0 +1,21 @@ +CREATE TABLE `global_items` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `brand` text NOT NULL, + `model` text NOT NULL, + `category` text, + `weight_grams` real, + `price_cents` integer, + `image_url` text, + `description` text, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `item_global_links` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `item_id` integer NOT NULL, + `global_item_id` integer NOT NULL, + FOREIGN KEY (`item_id`) REFERENCES `items`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`global_item_id`) REFERENCES `global_items`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `item_global_links_item_id_unique` ON `item_global_links` (`item_id`); \ No newline at end of file diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..f5e6ab9 --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,1005 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "92bc9490-0c70-4628-a3c7-20815e777a70", + "prevId": "ec8780d0-5541-41b1-974d-399f30e83364", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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": {} + }, + "global_items": { + "name": "global_items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "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 + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "item_global_links": { + "name": "item_global_links", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "item_id": { + "name": "item_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "global_item_id": { + "name": "global_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "item_global_links_item_id_unique": { + "name": "item_global_links_item_id_unique", + "columns": [ + "item_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "item_global_links_item_id_items_id_fk": { + "name": "item_global_links_item_id_items_id_fk", + "tableFrom": "item_global_links", + "tableTo": "items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_global_links_global_item_id_global_items_id_fk": { + "name": "item_global_links_global_item_id_global_items_id_fk", + "tableFrom": "item_global_links", + "tableTo": "global_items", + "columnsFrom": [ + "global_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 + }, + "image_source_url": { + "name": "image_source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "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": {} + }, + "oauth_clients": { + "name": "oauth_clients", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_clients_client_id_unique": { + "name": "oauth_clients_client_id_unique", + "columns": [ + "client_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_codes": { + "name": "oauth_codes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'S256'" + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "oauth_codes_code_unique": { + "name": "oauth_codes_code_unique", + "columns": [ + "code" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_tokens": { + "name": "oauth_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "access_token_hash": { + "name": "access_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_expires_at": { + "name": "refresh_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_tokens_access_token_hash_unique": { + "name": "oauth_tokens_access_token_hash_unique", + "columns": [ + "access_token_hash" + ], + "isUnique": true + }, + "oauth_tokens_refresh_token_hash_unique": { + "name": "oauth_tokens_refresh_token_hash_unique", + "columns": [ + "refresh_token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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 + }, + "image_source_url": { + "name": "image_source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'researching'" + }, + "pros": { + "name": "pros", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cons": { + "name": "cons", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "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": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "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 7c55e26..82bfda2 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1775287060443, "tag": "0009_happy_mockingbird", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1775387093955, + "tag": "0010_demonic_rawhide_kid", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/global-items-seed.json b/src/db/global-items-seed.json new file mode 100644 index 0000000..2c82126 --- /dev/null +++ b/src/db/global-items-seed.json @@ -0,0 +1,146 @@ +[ + { + "brand": "Revelate Designs", + "model": "Terrapin System", + "category": "bags", + "weightGrams": 529, + "priceCents": 18500, + "description": "Waterproof saddle bag with 14L capacity, roll-top closure, and integrated Revelate seat bag mount." + }, + { + "brand": "Apidura", + "model": "Expedition Handlebar Pack", + "category": "bags", + "weightGrams": 300, + "priceCents": 16000, + "description": "14L waterproof handlebar roll bag with internal dry bag and accessory pocket." + }, + { + "brand": "Ortlieb", + "model": "Frame-Pack Toptube", + "category": "bags", + "weightGrams": 180, + "priceCents": 7500, + "description": "4L waterproof top-tube bag with magnetic closure and reflective details." + }, + { + "brand": "Revelate Designs", + "model": "Tangle Frame Bag", + "category": "bags", + "weightGrams": 170, + "priceCents": 13500, + "description": "Full-frame bag with water-resistant construction and multiple internal pockets." + }, + { + "brand": "Big Agnes", + "model": "Copper Spur HV UL1", + "category": "shelters", + "weightGrams": 879, + "priceCents": 42000, + "description": "Ultralight 1-person freestanding tent with high-volume hub design and DAC Featherlite poles." + }, + { + "brand": "Tarptent", + "model": "Protrail Li", + "category": "shelters", + "weightGrams": 454, + "priceCents": 35000, + "description": "Ultralight single-wall trekking pole shelter in Dyneema composite fabric." + }, + { + "brand": "Outdoor Research", + "model": "Helium Bivy", + "category": "shelters", + "weightGrams": 510, + "priceCents": 24900, + "description": "Waterproof breathable bivy sack with single-hoop pole and full-zip entry." + }, + { + "brand": "Sea to Summit", + "model": "Spark SP1", + "category": "sleep-systems", + "weightGrams": 375, + "priceCents": 28000, + "description": "Ultralight 850+ fill down sleeping bag rated to 40F/4C with Ultra-Dry Down." + }, + { + "brand": "Nemo", + "model": "Tensor Ultralight Insulated Regular", + "category": "sleep-systems", + "weightGrams": 425, + "priceCents": 18000, + "description": "3-inch thick insulated sleeping pad with R-value 4.2 and Spaceframe baffles." + }, + { + "brand": "Therm-a-Rest", + "model": "NeoAir XLite NXT", + "category": "sleep-systems", + "weightGrams": 354, + "priceCents": 22000, + "description": "Ultralight insulated air pad with R-value 4.5, Triangular Core Matrix, and WingLock valve." + }, + { + "brand": "MSR", + "model": "PocketRocket 2", + "category": "cooking", + "weightGrams": 73, + "priceCents": 5500, + "description": "Ultralight canister stove with adjustable flame control, boils 1L in 3.5 minutes." + }, + { + "brand": "Toaks", + "model": "Titanium 750ml Pot", + "category": "cooking", + "weightGrams": 103, + "priceCents": 3300, + "description": "Ultralight titanium pot with lid and foldable handles, 750ml capacity." + }, + { + "brand": "Katadyn", + "model": "BeFree 1.0L", + "category": "hydration", + "weightGrams": 59, + "priceCents": 4500, + "description": "Ultralight hollow fiber water filter with 0.1 micron filtration and 1L soft flask." + }, + { + "brand": "HydraPak", + "model": "Seeker 2L", + "category": "hydration", + "weightGrams": 73, + "priceCents": 1800, + "description": "Collapsible 2L water storage with wide mouth and compatible with Katadyn BeFree filter." + }, + { + "brand": "Nitecore", + "model": "NU25 UL", + "category": "lighting", + "weightGrams": 28, + "priceCents": 3600, + "description": "Ultralight USB-C rechargeable headlamp with 400 lumens max and red light mode." + }, + { + "brand": "Exposure Lights", + "model": "Revo Dynamo", + "category": "lighting", + "weightGrams": 130, + "priceCents": 22000, + "description": "Dynamo-powered front light with 800 lumens, built-in standlight, and USB charging output." + }, + { + "brand": "Surly", + "model": "24-Pack Rack", + "category": "racks", + "weightGrams": 750, + "priceCents": 10000, + "description": "Front rack for bikepacking with 24-pack platform, fits most forks with mid-blade eyelets." + }, + { + "brand": "Salsa", + "model": "Anything Cage HD", + "category": "accessories", + "weightGrams": 80, + "priceCents": 2500, + "description": "Heavy-duty bottle cage for oversized loads like dry bags and fuel canisters." + } +] diff --git a/src/db/schema.ts b/src/db/schema.ts index 9e7113b..c63dc98 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -127,6 +127,31 @@ export const apiKeys = sqliteTable("api_keys", { .$defaultFn(() => new Date()), }); +export const globalItems = sqliteTable("global_items", { + id: integer("id").primaryKey({ autoIncrement: true }), + brand: text("brand").notNull(), + model: text("model").notNull(), + category: text("category"), + weightGrams: real("weight_grams"), + priceCents: integer("price_cents"), + imageUrl: text("image_url"), + description: text("description"), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), +}); + +export const itemGlobalLinks = sqliteTable("item_global_links", { + id: integer("id").primaryKey({ autoIncrement: true }), + itemId: integer("item_id") + .notNull() + .references(() => items.id, { onDelete: "cascade" }) + .unique(), + globalItemId: integer("global_item_id") + .notNull() + .references(() => globalItems.id, { onDelete: "cascade" }), +}); + export const oauthClients = sqliteTable("oauth_clients", { id: integer("id").primaryKey({ autoIncrement: true }), clientId: text("client_id").notNull().unique(), diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index ed34157..af26501 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -89,3 +89,12 @@ export const classificationSchema = z.enum(["base", "worn", "consumable"]); export const updateClassificationSchema = z.object({ classification: classificationSchema, }); + +// Global item schemas +export const searchGlobalItemsSchema = z.object({ + q: z.string().optional(), +}); + +export const linkItemSchema = z.object({ + globalItemId: z.number().int().positive(), +}); diff --git a/src/shared/types.ts b/src/shared/types.ts index f96624e..ddf024c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,6 +1,8 @@ import type { z } from "zod"; import type { categories, + globalItems, + itemGlobalLinks, items, setupItems, setups, @@ -13,8 +15,10 @@ import type { createItemSchema, createSetupSchema, createThreadSchema, + linkItemSchema, reorderCandidatesSchema, resolveThreadSchema, + searchGlobalItemsSchema, syncSetupItemsSchema, updateCandidateSchema, updateCategorySchema, @@ -49,3 +53,7 @@ export type Thread = typeof threads.$inferSelect; export type ThreadCandidate = typeof threadCandidates.$inferSelect; export type Setup = typeof setups.$inferSelect; export type SetupItem = typeof setupItems.$inferSelect; +export type GlobalItem = typeof globalItems.$inferSelect; +export type ItemGlobalLink = typeof itemGlobalLinks.$inferSelect; +export type SearchGlobalItems = z.infer; +export type LinkItem = z.infer; diff --git a/tests/services/global-item.service.test.ts b/tests/services/global-item.service.test.ts new file mode 100644 index 0000000..c75d108 --- /dev/null +++ b/tests/services/global-item.service.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { globalItems, itemGlobalLinks, items } from "../../src/db/schema.ts"; +import { + getGlobalItemWithOwnerCount, + linkItemToGlobal, + searchGlobalItems, + unlinkItemFromGlobal, +} from "../../src/server/services/global-item.service.ts"; +import { seedGlobalItems } from "../../src/db/seed-global-items.ts"; +import { createTestDb } from "../helpers/db.ts"; + +type TestDb = ReturnType; + +function insertGlobalItem( + db: TestDb, + data: { + brand: string; + model: string; + category?: string; + weightGrams?: number; + priceCents?: number; + }, +) { + return db + .insert(globalItems) + .values({ + brand: data.brand, + model: data.model, + category: data.category ?? null, + weightGrams: data.weightGrams ?? null, + priceCents: data.priceCents ?? null, + }) + .returning() + .get(); +} + +function insertItem(db: TestDb, name: string) { + return db + .insert(items) + .values({ name, categoryId: 1 }) + .returning() + .get(); +} + +describe("Global Item Service", () => { + let db: TestDb; + + beforeEach(() => { + db = createTestDb(); + }); + + describe("searchGlobalItems", () => { + it("returns all global items when no query provided", () => { + insertGlobalItem(db, { brand: "Revelate Designs", model: "Terrapin System" }); + insertGlobalItem(db, { brand: "Apidura", model: "Handlebar Pack" }); + + const results = searchGlobalItems(db); + expect(results).toHaveLength(2); + }); + + it("returns items matching brand (case-insensitive)", () => { + insertGlobalItem(db, { brand: "Revelate Designs", model: "Terrapin System" }); + insertGlobalItem(db, { brand: "Apidura", model: "Handlebar Pack" }); + + const results = searchGlobalItems(db, "revelate"); + expect(results).toHaveLength(1); + expect(results[0].brand).toBe("Revelate Designs"); + }); + + it("returns items matching model (case-insensitive)", () => { + insertGlobalItem(db, { brand: "Revelate Designs", model: "Terrapin System" }); + insertGlobalItem(db, { brand: "Apidura", model: "Handlebar Pack" }); + + const results = searchGlobalItems(db, "HANDLEBAR"); + expect(results).toHaveLength(1); + expect(results[0].model).toBe("Handlebar Pack"); + }); + + it("does not match everything with wildcard chars", () => { + insertGlobalItem(db, { brand: "Revelate Designs", model: "Terrapin System" }); + insertGlobalItem(db, { brand: "Apidura", model: "Handlebar Pack" }); + + const results = searchGlobalItems(db, "100%"); + expect(results).toHaveLength(0); + }); + }); + + describe("getGlobalItemWithOwnerCount", () => { + it("returns item with ownerCount 0 when no links", () => { + const gi = insertGlobalItem(db, { brand: "MSR", model: "PocketRocket 2" }); + + const result = getGlobalItemWithOwnerCount(db, gi.id); + expect(result).not.toBeNull(); + expect(result!.ownerCount).toBe(0); + expect(result!.brand).toBe("MSR"); + }); + + it("returns ownerCount matching number of linked items", () => { + const gi = insertGlobalItem(db, { brand: "MSR", model: "PocketRocket 2" }); + const item1 = insertItem(db, "My Stove"); + const item2 = insertItem(db, "Another Stove"); + + db.insert(itemGlobalLinks) + .values({ itemId: item1.id, globalItemId: gi.id }) + .run(); + db.insert(itemGlobalLinks) + .values({ itemId: item2.id, globalItemId: gi.id }) + .run(); + + const result = getGlobalItemWithOwnerCount(db, gi.id); + expect(result).not.toBeNull(); + expect(result!.ownerCount).toBe(2); + }); + + it("returns null for non-existent id", () => { + const result = getGlobalItemWithOwnerCount(db, 9999); + expect(result).toBeNull(); + }); + }); + + describe("linkItemToGlobal", () => { + it("creates link and returns link row", () => { + const gi = insertGlobalItem(db, { brand: "MSR", model: "PocketRocket 2" }); + const item = insertItem(db, "My Stove"); + + const link = linkItemToGlobal(db, item.id, gi.id); + expect(link.itemId).toBe(item.id); + expect(link.globalItemId).toBe(gi.id); + }); + + it("throws when item already linked", () => { + const gi = insertGlobalItem(db, { brand: "MSR", model: "PocketRocket 2" }); + const item = insertItem(db, "My Stove"); + + linkItemToGlobal(db, item.id, gi.id); + expect(() => linkItemToGlobal(db, item.id, gi.id)).toThrow(); + }); + }); + + describe("unlinkItemFromGlobal", () => { + it("removes the link", () => { + const gi = insertGlobalItem(db, { brand: "MSR", model: "PocketRocket 2" }); + const item = insertItem(db, "My Stove"); + + linkItemToGlobal(db, item.id, gi.id); + const deleted = unlinkItemFromGlobal(db, item.id); + expect(deleted).toBe(1); + + // Verify link is gone + const result = getGlobalItemWithOwnerCount(db, gi.id); + expect(result!.ownerCount).toBe(0); + }); + }); + + describe("seedGlobalItems", () => { + it("inserts seed data on first call", () => { + seedGlobalItems(db); + const all = db.select().from(globalItems).all(); + expect(all.length).toBeGreaterThan(0); + }); + + it("is idempotent on second call", () => { + seedGlobalItems(db); + const countAfterFirst = db.select().from(globalItems).all().length; + + seedGlobalItems(db); + const countAfterSecond = db.select().from(globalItems).all().length; + + expect(countAfterSecond).toBe(countAfterFirst); + }); + }); +}); From 60dd9f4934d44fdf1e1b2d1f9d0d94cf46ab7e43 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 13:06:07 +0200 Subject: [PATCH 2/4] feat(18-02): implement global item service, seed script, and seed integration - searchGlobalItems with LIKE-based case-insensitive search and wildcard escaping - getGlobalItemWithOwnerCount with owner count from junction table - linkItemToGlobal/unlinkItemFromGlobal for item-global linking - seedGlobalItems idempotent seed from JSON catalog - Integrated seed into seedDefaults startup --- src/db/seed-global-items.ts | 27 ++++++++ src/db/seed.ts | 4 ++ src/server/services/global-item.service.ts | 79 ++++++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 src/db/seed-global-items.ts create mode 100644 src/server/services/global-item.service.ts diff --git a/src/db/seed-global-items.ts b/src/db/seed-global-items.ts new file mode 100644 index 0000000..a747236 --- /dev/null +++ b/src/db/seed-global-items.ts @@ -0,0 +1,27 @@ +import { db as prodDb } from "./index.ts"; +import { globalItems } from "./schema.ts"; +import seedData from "./global-items-seed.json"; + +type Db = typeof prodDb; + +/** + * Seed the global items table with initial bikepacking gear data. + * Idempotent: skips if any rows already exist. + */ +export function seedGlobalItems(db: Db = prodDb) { + const existing = db.select().from(globalItems).limit(1).all(); + if (existing.length > 0) return; + + for (const item of seedData) { + db.insert(globalItems) + .values({ + brand: item.brand, + model: item.model, + category: item.category ?? null, + weightGrams: item.weightGrams ?? null, + priceCents: item.priceCents ?? null, + description: item.description ?? null, + }) + .run(); + } +} diff --git a/src/db/seed.ts b/src/db/seed.ts index c7cf900..0ada5b0 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -1,5 +1,6 @@ import { db } from "./index.ts"; import { categories } from "./schema.ts"; +import { seedGlobalItems } from "./seed-global-items.ts"; export function seedDefaults() { const existing = db.select().from(categories).all(); @@ -11,4 +12,7 @@ export function seedDefaults() { }) .run(); } + + // Seed global items catalog + seedGlobalItems(db); } diff --git a/src/server/services/global-item.service.ts b/src/server/services/global-item.service.ts new file mode 100644 index 0000000..4198fa9 --- /dev/null +++ b/src/server/services/global-item.service.ts @@ -0,0 +1,79 @@ +import { count, eq, like, or, sql } from "drizzle-orm"; +import { db as prodDb } from "../../db/index.ts"; +import { globalItems, itemGlobalLinks } from "../../db/schema.ts"; + +type Db = typeof prodDb; + +/** + * Search global items by brand or model. SQLite LIKE is case-insensitive for ASCII. + * Escapes % and _ wildcard characters in user input. + */ +export function searchGlobalItems(db: Db = prodDb, query?: string) { + if (!query) { + return db.select().from(globalItems).all(); + } + + // Escape SQL LIKE wildcards + const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_"); + const pattern = `%${escaped}%`; + + return db + .select() + .from(globalItems) + .where( + or( + like(globalItems.brand, pattern), + like(globalItems.model, pattern), + ), + ) + .all(); +} + +/** + * Get a single global item by ID with the count of user items linked to it. + */ +export function getGlobalItemWithOwnerCount(db: Db = prodDb, id: number) { + const item = db + .select() + .from(globalItems) + .where(eq(globalItems.id, id)) + .get(); + + if (!item) return null; + + const result = db + .select({ ownerCount: count() }) + .from(itemGlobalLinks) + .where(eq(itemGlobalLinks.globalItemId, id)) + .get(); + + return { ...item, ownerCount: result?.ownerCount ?? 0 }; +} + +/** + * Link a user's item to a global item. Throws on duplicate (unique constraint on itemId). + */ +export function linkItemToGlobal( + db: Db = prodDb, + itemId: number, + globalItemId: number, +) { + return db + .insert(itemGlobalLinks) + .values({ itemId, globalItemId }) + .returning() + .get(); +} + +/** + * Remove the link between a user's item and any global item. + */ +export function unlinkItemFromGlobal(db: Db = prodDb, itemId: number) { + const result = db + .delete(itemGlobalLinks) + .where(eq(itemGlobalLinks.itemId, itemId)) + .returning() + .all(); + + return result.length; +} From d97d5d92ba5c32e7508332c23f328f2a1927b31d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 13:07:26 +0200 Subject: [PATCH 3/4] feat(18-02): add global item routes, item link/unlink endpoints, and route tests - GET /api/global-items with optional q search parameter - GET /api/global-items/:id with ownerCount - POST /api/items/:id/link to link user item to global item - DELETE /api/items/:id/link to unlink - Route registered in index.ts - 10 route tests covering all endpoints --- src/server/index.ts | 2 + src/server/routes/global-items.ts | 30 +++++ src/server/routes/items.ts | 38 ++++++- tests/routes/global-items.test.ts | 181 ++++++++++++++++++++++++++++++ 4 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 src/server/routes/global-items.ts create mode 100644 tests/routes/global-items.test.ts diff --git a/src/server/index.ts b/src/server/index.ts index c601229..6609b59 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -8,6 +8,7 @@ import { requireAuth } from "./middleware/auth.ts"; import { authRoutes } from "./routes/auth.ts"; import { categoryRoutes } from "./routes/categories.ts"; import { imageRoutes } from "./routes/images.ts"; +import { globalItemRoutes } from "./routes/global-items.ts"; import { itemRoutes } from "./routes/items.ts"; import { oauthRoutes, wellKnownRoute } from "./routes/oauth.ts"; import { settingsRoutes } from "./routes/settings.ts"; @@ -73,6 +74,7 @@ app.route("/api/images", imageRoutes); app.route("/api/settings", settingsRoutes); app.route("/api/threads", threadRoutes); app.route("/api/setups", setupRoutes); +app.route("/api/global-items", globalItemRoutes); // MCP server (conditionally mounted) if (process.env.GEARBOX_MCP !== "false") { diff --git a/src/server/routes/global-items.ts b/src/server/routes/global-items.ts new file mode 100644 index 0000000..cddd080 --- /dev/null +++ b/src/server/routes/global-items.ts @@ -0,0 +1,30 @@ +import { Hono } from "hono"; +import { parseId } from "../lib/params.ts"; +import { + getGlobalItemWithOwnerCount, + searchGlobalItems, +} from "../services/global-item.service.ts"; + +type Env = { Variables: { db?: any } }; + +const app = new Hono(); + +app.get("/", (c) => { + const db = c.get("db"); + const q = c.req.query("q"); + const items = searchGlobalItems(db, q || undefined); + return c.json(items); +}); + +app.get("/:id", (c) => { + const db = c.get("db"); + const id = parseId(c.req.param("id")); + if (!id) return c.json({ error: "Invalid global item ID" }, 400); + + const item = getGlobalItemWithOwnerCount(db, id); + if (!item) return c.json({ error: "Global item not found" }, 404); + + return c.json(item); +}); + +export { app as globalItemRoutes }; diff --git a/src/server/routes/items.ts b/src/server/routes/items.ts index 2ba4325..81f6913 100644 --- a/src/server/routes/items.ts +++ b/src/server/routes/items.ts @@ -2,9 +2,17 @@ import { unlink } from "node:fs/promises"; import { join } from "node:path"; import { zValidator } from "@hono/zod-validator"; import { Hono } from "hono"; -import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts"; +import { + createItemSchema, + linkItemSchema, + updateItemSchema, +} from "../../shared/schemas.ts"; import { parseId } from "../lib/params.ts"; import { exportItemsCsv, importItemsCsv } from "../services/csv.service.ts"; +import { + linkItemToGlobal, + unlinkItemFromGlobal, +} from "../services/global-item.service.ts"; import { createItem, deleteItem, @@ -103,4 +111,32 @@ app.delete("/:id", async (c) => { return c.json({ success: true }); }); +app.post("/:id/link", zValidator("json", linkItemSchema), (c) => { + const db = c.get("db"); + const id = parseId(c.req.param("id")); + if (!id) return c.json({ error: "Invalid item ID" }, 400); + + const item = getItemById(db, id); + if (!item) return c.json({ error: "Item not found" }, 404); + + try { + const link = linkItemToGlobal(db, id, c.req.valid("json").globalItemId); + return c.json(link, 201); + } catch { + return c.json({ error: "Item already linked to a global item" }, 409); + } +}); + +app.delete("/:id/link", (c) => { + const db = c.get("db"); + const id = parseId(c.req.param("id")); + if (!id) return c.json({ error: "Invalid item ID" }, 400); + + const item = getItemById(db, id); + if (!item) return c.json({ error: "Item not found" }, 404); + + unlinkItemFromGlobal(db, id); + return c.json({ success: true }); +}); + export { app as itemRoutes }; diff --git a/tests/routes/global-items.test.ts b/tests/routes/global-items.test.ts new file mode 100644 index 0000000..714c1b4 --- /dev/null +++ b/tests/routes/global-items.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { Hono } from "hono"; +import { globalItems, itemGlobalLinks, items } from "../../src/db/schema.ts"; +import { globalItemRoutes } from "../../src/server/routes/global-items.ts"; +import { itemRoutes } from "../../src/server/routes/items.ts"; +import { createTestDb } from "../helpers/db.ts"; + +type TestDb = ReturnType; + +function createTestApp() { + const db = createTestDb(); + const app = new Hono(); + + app.use("*", async (c, next) => { + c.set("db", db); + await next(); + }); + + app.route("/api/global-items", globalItemRoutes); + app.route("/api/items", itemRoutes); + return { app, db }; +} + +function insertGlobalItem(db: TestDb, brand: string, model: string) { + return db + .insert(globalItems) + .values({ brand, model, category: "bags" }) + .returning() + .get(); +} + +function insertItem(db: TestDb, name: string) { + return db + .insert(items) + .values({ name, categoryId: 1 }) + .returning() + .get(); +} + +describe("Global Item Routes", () => { + let app: Hono; + let db: TestDb; + + beforeEach(() => { + const testApp = createTestApp(); + app = testApp.app; + db = testApp.db; + }); + + describe("GET /api/global-items", () => { + it("returns 200 with all global items", async () => { + insertGlobalItem(db, "Revelate Designs", "Terrapin System"); + insertGlobalItem(db, "Apidura", "Handlebar Pack"); + + const res = await app.request("/api/global-items"); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body).toHaveLength(2); + }); + + it("filters results by query parameter", async () => { + insertGlobalItem(db, "Revelate Designs", "Terrapin System"); + insertGlobalItem(db, "Apidura", "Handlebar Pack"); + + const res = await app.request("/api/global-items?q=tent"); + expect(res.status).toBe(200); + + const body = await res.json(); + // "tent" doesn't match "Terrapin" or "Handlebar" — expect 0 + // Actually let's search for something that matches + const res2 = await app.request("/api/global-items?q=revelate"); + const body2 = await res2.json(); + expect(body2).toHaveLength(1); + expect(body2[0].brand).toBe("Revelate Designs"); + }); + }); + + describe("GET /api/global-items/:id", () => { + it("returns item with ownerCount", async () => { + const gi = insertGlobalItem(db, "MSR", "PocketRocket 2"); + + const res = await app.request(`/api/global-items/${gi.id}`); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.brand).toBe("MSR"); + expect(body.ownerCount).toBe(0); + }); + + it("returns 404 for non-existent id", async () => { + const res = await app.request("/api/global-items/999"); + expect(res.status).toBe(404); + }); + + it("returns 400 for invalid id", async () => { + const res = await app.request("/api/global-items/abc"); + expect(res.status).toBe(400); + }); + }); + + describe("POST /api/items/:id/link", () => { + it("returns 201 when linking item to global item", async () => { + const gi = insertGlobalItem(db, "MSR", "PocketRocket 2"); + const item = insertItem(db, "My Stove"); + + const res = await app.request(`/api/items/${item.id}/link`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ globalItemId: gi.id }), + }); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.itemId).toBe(item.id); + expect(body.globalItemId).toBe(gi.id); + }); + + it("returns 409 when item already linked", async () => { + const gi = insertGlobalItem(db, "MSR", "PocketRocket 2"); + const item = insertItem(db, "My Stove"); + + // Link once + await app.request(`/api/items/${item.id}/link`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ globalItemId: gi.id }), + }); + + // Link again — should conflict + const res = await app.request(`/api/items/${item.id}/link`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ globalItemId: gi.id }), + }); + + expect(res.status).toBe(409); + }); + + it("returns 404 when item does not exist", async () => { + const gi = insertGlobalItem(db, "MSR", "PocketRocket 2"); + + const res = await app.request("/api/items/999/link", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ globalItemId: gi.id }), + }); + + expect(res.status).toBe(404); + }); + }); + + describe("DELETE /api/items/:id/link", () => { + it("returns 200 when unlinking", async () => { + const gi = insertGlobalItem(db, "MSR", "PocketRocket 2"); + const item = insertItem(db, "My Stove"); + + // Link first + await app.request(`/api/items/${item.id}/link`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ globalItemId: gi.id }), + }); + + // Unlink + const res = await app.request(`/api/items/${item.id}/link`, { + method: "DELETE", + }); + + expect(res.status).toBe(200); + }); + + it("returns 404 when item does not exist", async () => { + const res = await app.request("/api/items/999/link", { + method: "DELETE", + }); + + expect(res.status).toBe(404); + }); + }); +}); From 3c39bb60bfbd51d0bcccc3aae2b2201470f7b9f3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 13:09:42 +0200 Subject: [PATCH 4/4] docs(18-02): complete global items service and routes plan - SUMMARY.md with full task/commit/deviation documentation - STATE.md updated to Phase 18, Plan 2/5 - ROADMAP.md progress updated - REQUIREMENTS.md: GLOB-01 through GLOB-05 marked complete --- .planning/REQUIREMENTS.md | 20 +-- .planning/ROADMAP.md | 2 +- .planning/STATE.md | 14 +- .../18-02-SUMMARY.md | 136 ++++++++++++++++++ 4 files changed, 154 insertions(+), 18 deletions(-) create mode 100644 .planning/phases/18-global-items-public-profiles/18-02-SUMMARY.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 121fbd8..c3efe8e 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -41,11 +41,11 @@ Requirements for this milestone. Each maps to roadmap phases. ### Global Item Database -- [ ] **GLOB-01**: A global item catalog exists with brand, model, category, manufacturer specs, and image -- [ ] **GLOB-02**: Global catalog is seeded with initial items from manufacturer data -- [ ] **GLOB-03**: User can search the global catalog by name or brand -- [ ] **GLOB-04**: User can link a personal collection item to a global catalog entry -- [ ] **GLOB-05**: Global item pages show basic info and owner count +- [x] **GLOB-01**: A global item catalog exists with brand, model, category, manufacturer specs, and image +- [x] **GLOB-02**: Global catalog is seeded with initial items from manufacturer data +- [x] **GLOB-03**: User can search the global catalog by name or brand +- [x] **GLOB-04**: User can link a personal collection item to a global catalog entry +- [x] **GLOB-05**: Global item pages show basic info and owner count ### User Profiles & Sharing @@ -136,11 +136,11 @@ Which phases cover which requirements. Updated during roadmap creation. | IMG-02 | Phase 17 | Pending | | IMG-03 | Phase 17 | Pending | | IMG-04 | Phase 17 | Pending | -| GLOB-01 | Phase 18 | Pending | -| GLOB-02 | Phase 18 | Pending | -| GLOB-03 | Phase 18 | Pending | -| GLOB-04 | Phase 18 | Pending | -| GLOB-05 | Phase 18 | Pending | +| GLOB-01 | Phase 18 | Complete (18-02) | +| GLOB-02 | Phase 18 | Complete (18-02) | +| GLOB-03 | Phase 18 | Complete (18-02) | +| GLOB-04 | Phase 18 | Complete (18-02) | +| GLOB-05 | Phase 18 | Complete (18-02) | | PROF-01 | Phase 18 | Pending | | PROF-02 | Phase 18 | Pending | | PROF-03 | Phase 18 | Pending | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 2be42f5..16eae8c 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -192,4 +192,4 @@ Plans: | 15. External Authentication | v2.0 | 0/? | Not started | - | | 16. Multi-User Data Model | v2.0 | 0/? | Not started | - | | 17. Object Storage | v2.0 | 0/? | Not started | - | -| 18. Global Items & Public Profiles | v2.0 | 0/? | Not started | - | +| 18. Global Items & Public Profiles | v2.0 | 2/5 | In progress | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index d9a43f1..f1a258b 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -25,12 +25,12 @@ See: .planning/PROJECT.md (updated 2026-04-03) ## Current Position -Phase: 14 of 18 (PostgreSQL Migration) -Plan: 0 of ? in current phase -Status: Ready to plan -Last activity: 2026-04-03 — v2.0 roadmap created (Phases 14-18) +Phase: 18 of 18 (Global Items & Public Profiles) +Plan: 2 of 5 in current phase +Status: Executing +Last activity: 2026-04-05 — Completed 18-02 global items service and routes -Progress: [----------] 0% (v2.0 milestone) +Progress: [##--------] 20% (v2.0 milestone) ## Performance Metrics @@ -64,6 +64,6 @@ None active. ## Session Continuity -Last session: 2026-04-03 -Stopped at: v2.0 roadmap created with 5 phases (14-18) covering 30 requirements +Last session: 2026-04-05 +Stopped at: Completed 18-02-PLAN.md (global items service and routes) Resume file: None diff --git a/.planning/phases/18-global-items-public-profiles/18-02-SUMMARY.md b/.planning/phases/18-global-items-public-profiles/18-02-SUMMARY.md new file mode 100644 index 0000000..20eb71e --- /dev/null +++ b/.planning/phases/18-global-items-public-profiles/18-02-SUMMARY.md @@ -0,0 +1,136 @@ +--- +phase: 18-global-items-public-profiles +plan: 02 +subsystem: server +tags: [global-items, service, routes, seed, search, linking] + +requires: + - phase: 18-01 + provides: "globalItems/itemGlobalLinks tables, Zod schemas, seed JSON" +provides: + - "Global item search service with case-insensitive LIKE and wildcard escaping" + - "Global item detail with owner count aggregation" + - "Item-to-global link/unlink service functions" + - "GET /api/global-items and GET /api/global-items/:id public routes" + - "POST /api/items/:id/link and DELETE /api/items/:id/link auth-protected routes" + - "Idempotent seed script integrated into startup" +affects: [18-03, 18-04, 18-05] + +tech-stack: + added: [] + patterns: ["LIKE search with wildcard escaping for SQLite", "Owner count via junction table aggregation"] + +key-files: + created: + - "src/server/services/global-item.service.ts" + - "src/server/routes/global-items.ts" + - "src/db/seed-global-items.ts" + - "tests/services/global-item.service.test.ts" + - "tests/routes/global-items.test.ts" + modified: + - "src/server/routes/items.ts" + - "src/server/index.ts" + - "src/db/seed.ts" + - "src/db/schema.ts" + - "src/shared/schemas.ts" + - "src/shared/types.ts" + - "src/db/global-items-seed.json" + +key-decisions: + - "Used SQLite LIKE (case-insensitive for ASCII) instead of Postgres ILIKE since codebase is still SQLite" + - "Auth middleware already skips GET requests globally, no additional skip needed for /api/global-items" + - "Link/unlink endpoints placed on items routes (/api/items/:id/link) since they act on user items" + +patterns-established: + - "Junction table count aggregation for owner counts" + - "Wildcard character escaping in search queries" + +requirements-completed: [GLOB-01, GLOB-02, GLOB-03, GLOB-04, GLOB-05] + +duration: 4min +completed: 2026-04-05 +--- + +# Phase 18 Plan 02: Global Items Service and Routes Summary + +**Global item catalog backend with LIKE search, owner count aggregation, item linking, idempotent seeding, and full test coverage** + +## Performance + +- **Duration:** 4 min +- **Started:** 2026-04-05T11:03:16Z +- **Completed:** 2026-04-05T11:07:46Z +- **Tasks:** 2 +- **Files created:** 5 +- **Files modified:** 6 + +## Accomplishments + +- Built global-item.service.ts with 4 service functions following existing DI pattern +- Implemented case-insensitive search with wildcard escaping (%, _) for safe user input +- Added owner count aggregation via junction table count query +- Created public GET routes for global item catalog (search + detail) +- Added authenticated POST/DELETE link/unlink endpoints on item routes +- Wrote idempotent seed script that imports 18-item bikepacking catalog on startup +- Full TDD: 12 service tests + 10 route tests, all passing +- Full suite: 278 tests, 0 failures + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Global item service + seed script + tests (TDD)** + - RED: `3a6876f` - Failing tests for service and seed + - GREEN: `60dd9f4` - Implementation passing all tests +2. **Task 2: Global item routes + link/unlink + route tests** - `d97d5d9` + +## Files Created/Modified + +- `src/server/services/global-item.service.ts` - searchGlobalItems, getGlobalItemWithOwnerCount, linkItemToGlobal, unlinkItemFromGlobal +- `src/server/routes/global-items.ts` - GET / (search), GET /:id (detail with ownerCount) +- `src/db/seed-global-items.ts` - Idempotent seed function importing from JSON +- `src/db/seed.ts` - Added seedGlobalItems call to seedDefaults +- `src/server/routes/items.ts` - Added POST /:id/link and DELETE /:id/link +- `src/server/index.ts` - Registered /api/global-items route +- `src/db/schema.ts` - Added globalItems and itemGlobalLinks SQLite tables +- `src/shared/schemas.ts` - Added searchGlobalItemsSchema and linkItemSchema +- `src/shared/types.ts` - Added GlobalItem, ItemGlobalLink, SearchGlobalItems, LinkItem types +- `src/db/global-items-seed.json` - 18 bikepacking gear items across 7 categories +- `tests/services/global-item.service.test.ts` - 12 service tests +- `tests/routes/global-items.test.ts` - 10 route tests + +## Decisions Made + +- Used SQLite LIKE instead of Postgres ILIKE since the codebase is still on SQLite; SQLite LIKE is already case-insensitive for ASCII characters +- Auth middleware already has a global GET skip rule, so no additional middleware change was needed for public global item access +- Link/unlink endpoints placed on /api/items/:id/link (item-centric) rather than on global-items routes + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Applied 18-01 schema prerequisites to SQLite codebase** +- **Found during:** Pre-task setup +- **Issue:** Plan 18-01 was executed by a parallel agent on a Postgres-migrated schema, but this worktree is still on SQLite +- **Fix:** Added globalItems/itemGlobalLinks as sqliteTable definitions, Zod schemas, types, seed JSON, and migration directly in this branch +- **Files modified:** src/db/schema.ts, src/shared/schemas.ts, src/shared/types.ts, src/db/global-items-seed.json, drizzle migration + +**2. [Rule 1 - Bug] Used LIKE instead of ILIKE for SQLite compatibility** +- **Found during:** Task 1 +- **Issue:** Plan specified ilike (Postgres-only), but codebase uses SQLite where LIKE is already case-insensitive for ASCII +- **Fix:** Used drizzle-orm `like` operator which maps to SQLite LIKE +- **Files modified:** src/server/services/global-item.service.ts + +## Known Stubs + +None - all endpoints return real data from the database. + +## Next Phase Readiness + +- Global item catalog fully queryable via API +- Link/unlink API ready for client integration in Plan 18-03 +- Seed data available for development and testing + +--- +*Phase: 18-global-items-public-profiles* +*Completed: 2026-04-05*