From 6f07e874f9953b19caf6c91e782fec2b42518617 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Apr 2026 07:56:32 +0200 Subject: [PATCH 1/4] test(20-01): add failing tests for tag service and route - Tag service tests: empty array, alphabetical ordering, id+name projection - Tag route tests: GET /api/tags returns 200, correct tag objects --- tests/routes/tags.test.ts | 53 ++++++++++++++++++++++++++++++ tests/services/tag.service.test.ts | 34 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 tests/routes/tags.test.ts create mode 100644 tests/services/tag.service.test.ts diff --git a/tests/routes/tags.test.ts b/tests/routes/tags.test.ts new file mode 100644 index 0000000..feab38d --- /dev/null +++ b/tests/routes/tags.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { Hono } from "hono"; +import { tags } from "../../src/db/schema.ts"; +import { tagRoutes } from "../../src/server/routes/tags.ts"; +import { createTestDb } from "../helpers/db.ts"; + +function createTestApp(db: any) { + const app = new Hono(); + + app.use("*", async (c, next) => { + c.set("db", db); + await next(); + }); + + app.route("/api/tags", tagRoutes); + return app; +} + +describe("Tag Routes", () => { + let app: Hono; + let db: Awaited>["db"]; + + beforeEach(async () => { + const testDb = await createTestDb(); + db = testDb.db; + app = createTestApp(db); + }); + + describe("GET /api/tags", () => { + it("returns 200 with empty array when no tags", async () => { + const res = await app.request("/api/tags"); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body).toEqual([]); + }); + + it("returns 200 with tag objects after seeding", async () => { + await db.insert(tags).values([ + { name: "bikepacking" }, + { name: "ultralight" }, + ]); + + const res = await app.request("/api/tags"); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body).toHaveLength(2); + expect(body[0]).toHaveProperty("id"); + expect(body[0]).toHaveProperty("name"); + }); + }); +}); diff --git a/tests/services/tag.service.test.ts b/tests/services/tag.service.test.ts new file mode 100644 index 0000000..0682c14 --- /dev/null +++ b/tests/services/tag.service.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { tags } from "../../src/db/schema.ts"; +import { getAllTags } from "../../src/server/services/tag.service.ts"; +import { createTestDb } from "../helpers/db.ts"; + +describe("Tag Service", () => { + let db: Awaited>["db"]; + + beforeEach(async () => { + const testDb = await createTestDb(); + db = testDb.db; + }); + + it("returns empty array when no tags exist", async () => { + const result = await getAllTags(db); + expect(result).toEqual([]); + }); + + it("returns all tags as { id, name } ordered alphabetically", async () => { + await db.insert(tags).values([ + { name: "bikepacking" }, + { name: "ultralight" }, + { name: "accessories" }, + ]); + + const result = await getAllTags(db); + expect(result).toHaveLength(3); + expect(result[0].name).toBe("accessories"); + expect(result[1].name).toBe("bikepacking"); + expect(result[2].name).toBe("ultralight"); + // Should NOT include createdAt + expect(result[0]).toEqual({ id: expect.any(Number), name: "accessories" }); + }); +}); From 2ec1276849dad03b29d377a26b1801f08b1dc699 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Apr 2026 07:56:40 +0200 Subject: [PATCH 2/4] feat(20-01): add tags table, tag service/route, register global-items route - Create tags table in schema with id, name (unique), createdAt - Generate migration for tags table - Create tag.service.ts with getAllTags (id+name, alphabetical order) - Create tags.ts route with GET / handler - Register /api/global-items and /api/tags routes in index.ts - Add auth skip for GET /api/tags and GET /api/global-items --- drizzle-pg/0002_square_pyro.sql | 6 + drizzle-pg/meta/0002_snapshot.json | 1133 ++++++++++++++++++++++++++++ drizzle-pg/meta/_journal.json | 7 + src/db/schema.ts | 8 + src/server/index.ts | 10 + src/server/routes/tags.ts | 14 + src/server/services/tag.service.ts | 12 + 7 files changed, 1190 insertions(+) create mode 100644 drizzle-pg/0002_square_pyro.sql create mode 100644 drizzle-pg/meta/0002_snapshot.json create mode 100644 src/server/routes/tags.ts create mode 100644 src/server/services/tag.service.ts diff --git a/drizzle-pg/0002_square_pyro.sql b/drizzle-pg/0002_square_pyro.sql new file mode 100644 index 0000000..d16375d --- /dev/null +++ b/drizzle-pg/0002_square_pyro.sql @@ -0,0 +1,6 @@ +CREATE TABLE "tags" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "tags_name_unique" UNIQUE("name") +); diff --git a/drizzle-pg/meta/0002_snapshot.json b/drizzle-pg/meta/0002_snapshot.json new file mode 100644 index 0000000..caeae93 --- /dev/null +++ b/drizzle-pg/meta/0002_snapshot.json @@ -0,0 +1,1133 @@ +{ + "id": "4b01f839-a5ff-416c-826c-1e37e76d0a78", + "prevId": "8fb47390-ff75-41f7-aa35-fad97b1a097e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'package'" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "categories_user_id_users_id_fk": { + "name": "categories_user_id_users_id_fk", + "tableFrom": "categories", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_user_id_name_unique": { + "name": "categories_user_id_name_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.global_items": { + "name": "global_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weight_grams": { + "name": "weight_grams", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item_global_links": { + "name": "item_global_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "global_item_id": { + "name": "global_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "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": { + "item_global_links_item_id_unique": { + "name": "item_global_links_item_id_unique", + "nullsNotDistinct": false, + "columns": [ + "item_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.items": { + "name": "items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight_grams": { + "name": "weight_grams", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_url": { + "name": "product_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_filename": { + "name": "image_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_source_url": { + "name": "image_source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "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" + }, + "items_user_id_users_id_fk": { + "name": "items_user_id_users_id_fk", + "tableFrom": "items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_clients": { + "name": "oauth_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_clients_client_id_unique": { + "name": "oauth_clients_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_codes": { + "name": "oauth_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'S256'" + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_codes_code_unique": { + "name": "oauth_codes_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_tokens": { + "name": "oauth_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "access_token_hash": { + "name": "access_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "refresh_expires_at": { + "name": "refresh_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_tokens_user_id_users_id_fk": { + "name": "oauth_tokens_user_id_users_id_fk", + "tableFrom": "oauth_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_tokens_access_token_hash_unique": { + "name": "oauth_tokens_access_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "access_token_hash" + ] + }, + "oauth_tokens_refresh_token_hash_unique": { + "name": "oauth_tokens_refresh_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_users_id_fk": { + "name": "settings_user_id_users_id_fk", + "tableFrom": "settings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "settings_user_id_key_pk": { + "name": "settings_user_id_key_pk", + "columns": [ + "user_id", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.setup_items": { + "name": "setup_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "setup_id": { + "name": "setup_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "classification": { + "name": "classification", + "type": "text", + "primaryKey": false, + "notNull": true, + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.setups": { + "name": "setups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "setups_user_id_users_id_fk": { + "name": "setups_user_id_users_id_fk", + "tableFrom": "setups", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_name_unique": { + "name": "tags_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.thread_candidates": { + "name": "thread_candidates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight_grams": { + "name": "weight_grams", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_url": { + "name": "product_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_filename": { + "name": "image_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_source_url": { + "name": "image_source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'researching'" + }, + "pros": { + "name": "pros", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cons": { + "name": "cons", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.threads": { + "name": "threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "resolved_candidate_id": { + "name": "resolved_candidate_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "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" + }, + "threads_user_id_users_id_fk": { + "name": "threads_user_id_users_id_fk", + "tableFrom": "threads", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "logto_sub": { + "name": "logto_sub", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_logto_sub_unique": { + "name": "users_logto_sub_unique", + "nullsNotDistinct": false, + "columns": [ + "logto_sub" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle-pg/meta/_journal.json b/drizzle-pg/meta/_journal.json index ba29933..e63b023 100644 --- a/drizzle-pg/meta/_journal.json +++ b/drizzle-pg/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1775386658636, "tag": "0001_tough_boomerang", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1775454835904, + "tag": "0002_square_pyro", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index b11d6bc..2bbff57 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -141,6 +141,14 @@ export const globalItems = pgTable("global_items", { createdAt: timestamp("created_at").defaultNow().notNull(), }); +// ── Tags ──────────────────────────────────────────────────────────── + +export const tags = pgTable("tags", { + id: serial("id").primaryKey(), + name: text("name").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); + // ── Item Global Links ─────────────────────────────────────────────── export const itemGlobalLinks = pgTable("item_global_links", { diff --git a/src/server/index.ts b/src/server/index.ts index 739d48a..efdea5e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -15,7 +15,9 @@ import { categoryRoutes } from "./routes/categories.ts"; import { imageRoutes } from "./routes/images.ts"; import { itemRoutes } from "./routes/items.ts"; import { oauthRoutes, wellKnownRoute } from "./routes/oauth.ts"; +import { globalItemRoutes } from "./routes/global-items.ts"; import { profileRoutes } from "./routes/profiles.ts"; +import { tagRoutes } from "./routes/tags.ts"; import { settingsRoutes } from "./routes/settings.ts"; import { setupRoutes } from "./routes/setups.ts"; import { threadRoutes } from "./routes/threads.ts"; @@ -98,6 +100,12 @@ app.use("/api/*", async (c, next) => { // Skip public setup view (GET /api/setups/:id/public) if (/^\/api\/setups\/\d+\/public$/.test(c.req.path) && c.req.method === "GET") return next(); + // Skip public tags endpoint (GET /api/tags) + if (c.req.path.startsWith("/api/tags") && c.req.method === "GET") + return next(); + // Skip public global-items endpoint (GET /api/global-items) + if (c.req.path.startsWith("/api/global-items") && c.req.method === "GET") + return next(); // All other methods require auth for userId resolution return requireAuth(c, next); }); @@ -112,6 +120,8 @@ app.route("/api/settings", settingsRoutes); app.route("/api/threads", threadRoutes); app.route("/api/users", profileRoutes); app.route("/api/setups", setupRoutes); +app.route("/api/global-items", globalItemRoutes); +app.route("/api/tags", tagRoutes); // MCP server (conditionally mounted) if (process.env.GEARBOX_MCP !== "false") { diff --git a/src/server/routes/tags.ts b/src/server/routes/tags.ts new file mode 100644 index 0000000..b05d6ba --- /dev/null +++ b/src/server/routes/tags.ts @@ -0,0 +1,14 @@ +import { Hono } from "hono"; +import { getAllTags } from "../services/tag.service.ts"; + +type Env = { Variables: { db?: any } }; + +const app = new Hono(); + +app.get("/", async (c) => { + const db = c.get("db"); + const allTags = await getAllTags(db); + return c.json(allTags); +}); + +export { app as tagRoutes }; diff --git a/src/server/services/tag.service.ts b/src/server/services/tag.service.ts new file mode 100644 index 0000000..17e5f6c --- /dev/null +++ b/src/server/services/tag.service.ts @@ -0,0 +1,12 @@ +import { asc } from "drizzle-orm"; +import { db as prodDb } from "../../db/index.ts"; +import { tags } from "../../db/schema.ts"; + +type Db = typeof prodDb; + +export async function getAllTags(db: Db = prodDb) { + return db + .select({ id: tags.id, name: tags.name }) + .from(tags) + .orderBy(asc(tags.name)); +} From 67facea338fabbaa822d6ba32572321ed11b6db8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Apr 2026 07:57:47 +0200 Subject: [PATCH 3/4] feat(20-01): extend UIStore with FAB/catalog state, add useTags hook, update useGlobalItems - Add fabMenuOpen, openFabMenu, closeFabMenu to UIStore - Add catalogSearchOpen, catalogSearchMode, openCatalogSearch, closeCatalogSearch - openCatalogSearch also closes FAB menu (natural flow) - Create useTags hook with 5-min staleTime cache - Add optional tags parameter to useGlobalItems for tag filtering --- src/client/hooks/useGlobalItems.ts | 13 ++++++++----- src/client/hooks/useTags.ts | 15 +++++++++++++++ src/client/stores/uiStore.ts | 28 ++++++++++++++++++++++++++++ src/server/index.ts | 9 +++------ tests/routes/tags.test.ts | 7 +++---- tests/services/tag.service.test.ts | 12 +++++++----- 6 files changed, 64 insertions(+), 20 deletions(-) create mode 100644 src/client/hooks/useTags.ts diff --git a/src/client/hooks/useGlobalItems.ts b/src/client/hooks/useGlobalItems.ts index f2cb9da..da0c4fc 100644 --- a/src/client/hooks/useGlobalItems.ts +++ b/src/client/hooks/useGlobalItems.ts @@ -23,13 +23,16 @@ interface ItemGlobalLink { globalItemId: number; } -export function useGlobalItems(query?: string) { +export function useGlobalItems(query?: string, tags?: string[]) { + const params = new URLSearchParams(); + if (query) params.set("q", query); + if (tags && tags.length > 0) params.set("tags", tags.join(",")); + const qs = params.toString(); + return useQuery({ - queryKey: ["global-items", query ?? ""], + queryKey: ["global-items", query ?? "", tags ?? []], queryFn: () => - apiGet( - `/api/global-items${query ? `?q=${encodeURIComponent(query)}` : ""}`, - ), + apiGet(`/api/global-items${qs ? `?${qs}` : ""}`), }); } diff --git a/src/client/hooks/useTags.ts b/src/client/hooks/useTags.ts new file mode 100644 index 0000000..6245764 --- /dev/null +++ b/src/client/hooks/useTags.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; +import { apiGet } from "../lib/api"; + +export interface Tag { + id: number; + name: string; +} + +export function useTags() { + return useQuery({ + queryKey: ["tags"], + queryFn: () => apiGet("/api/tags"), + staleTime: 5 * 60 * 1000, + }); +} diff --git a/src/client/stores/uiStore.ts b/src/client/stores/uiStore.ts index 6d2ff42..0ec1058 100644 --- a/src/client/stores/uiStore.ts +++ b/src/client/stores/uiStore.ts @@ -56,6 +56,17 @@ interface UIState { // Setup impact preview selectedSetupId: number | null; setSelectedSetupId: (id: number | null) => void; + + // FAB menu + fabMenuOpen: boolean; + openFabMenu: () => void; + closeFabMenu: () => void; + + // Catalog search overlay + catalogSearchOpen: boolean; + catalogSearchMode: "collection" | "thread" | null; + openCatalogSearch: (mode: "collection" | "thread") => void; + closeCatalogSearch: () => void; } export const useUIStore = create((set) => ({ @@ -119,4 +130,21 @@ export const useUIStore = create((set) => ({ // Setup impact preview selectedSetupId: null, setSelectedSetupId: (id) => set({ selectedSetupId: id }), + + // FAB menu + fabMenuOpen: false, + openFabMenu: () => set({ fabMenuOpen: true }), + closeFabMenu: () => set({ fabMenuOpen: false }), + + // Catalog search overlay + catalogSearchOpen: false, + catalogSearchMode: null, + openCatalogSearch: (mode) => + set({ + catalogSearchOpen: true, + catalogSearchMode: mode, + fabMenuOpen: false, + }), + closeCatalogSearch: () => + set({ catalogSearchOpen: false, catalogSearchMode: null }), })); diff --git a/src/server/index.ts b/src/server/index.ts index efdea5e..f52e664 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -12,14 +12,14 @@ import { mcpRoutes } from "./mcp/index.ts"; import { requireAuth } from "./middleware/auth.ts"; import { authRoutes } from "./routes/auth.ts"; import { categoryRoutes } from "./routes/categories.ts"; +import { globalItemRoutes } from "./routes/global-items.ts"; import { imageRoutes } from "./routes/images.ts"; import { itemRoutes } from "./routes/items.ts"; import { oauthRoutes, wellKnownRoute } from "./routes/oauth.ts"; -import { globalItemRoutes } from "./routes/global-items.ts"; import { profileRoutes } from "./routes/profiles.ts"; -import { tagRoutes } from "./routes/tags.ts"; import { settingsRoutes } from "./routes/settings.ts"; import { setupRoutes } from "./routes/setups.ts"; +import { tagRoutes } from "./routes/tags.ts"; import { threadRoutes } from "./routes/threads.ts"; import { totalRoutes } from "./routes/totals.ts"; @@ -53,10 +53,7 @@ if (process.env.NODE_ENV !== "production") { if (setCookies.length > 0) { c.res.headers.delete("Set-Cookie"); for (const cookie of setCookies) { - c.res.headers.append( - "Set-Cookie", - cookie.replace(/;\s*Secure/gi, ""), - ); + c.res.headers.append("Set-Cookie", cookie.replace(/;\s*Secure/gi, "")); } } }); diff --git a/tests/routes/tags.test.ts b/tests/routes/tags.test.ts index feab38d..53f9cea 100644 --- a/tests/routes/tags.test.ts +++ b/tests/routes/tags.test.ts @@ -36,10 +36,9 @@ describe("Tag Routes", () => { }); it("returns 200 with tag objects after seeding", async () => { - await db.insert(tags).values([ - { name: "bikepacking" }, - { name: "ultralight" }, - ]); + await db + .insert(tags) + .values([{ name: "bikepacking" }, { name: "ultralight" }]); const res = await app.request("/api/tags"); expect(res.status).toBe(200); diff --git a/tests/services/tag.service.test.ts b/tests/services/tag.service.test.ts index 0682c14..50ee4d3 100644 --- a/tests/services/tag.service.test.ts +++ b/tests/services/tag.service.test.ts @@ -17,11 +17,13 @@ describe("Tag Service", () => { }); it("returns all tags as { id, name } ordered alphabetically", async () => { - await db.insert(tags).values([ - { name: "bikepacking" }, - { name: "ultralight" }, - { name: "accessories" }, - ]); + await db + .insert(tags) + .values([ + { name: "bikepacking" }, + { name: "ultralight" }, + { name: "accessories" }, + ]); const result = await getAllTags(db); expect(result).toHaveLength(3); From 256d81e43d753afaae433b70a52e0742a9aab7a5 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Apr 2026 07:59:41 +0200 Subject: [PATCH 4/4] docs(20-01): complete tags API, route registration, and UI state plan - Add 20-01-SUMMARY.md with execution results - Update STATE.md with progress and decisions --- .planning/STATE.md | 13 +- .../20-01-SUMMARY.md | 125 ++++++++++++++++++ 2 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 .planning/phases/20-fab-full-screen-catalog-search/20-01-SUMMARY.md diff --git a/.planning/STATE.md b/.planning/STATE.md index bdacfb0..b62cc4f 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,14 +3,14 @@ gsd_state_version: 1.0 milestone: v1.3 milestone_name: Research & Decision Tools status: planning -stopped_at: Completed 18-05-PLAN.md -last_updated: "2026-04-05T11:22:25.312Z" +stopped_at: Completed 20-01-PLAN.md +last_updated: "2026-04-06T05:59:26.689Z" last_activity: 2026-04-05 progress: - total_phases: 12 + total_phases: 13 completed_phases: 11 total_plans: 33 - completed_plans: 31 + completed_plans: 32 percent: 0 --- @@ -55,6 +55,7 @@ Key decisions made during v2.0 planning: - Separate globalItems table — not a flag on user items table - Single-user SQLite mode diverges at v2.0 boundary - [Phase 18]: Profile data loaded via usePublicProfile(userId) not /auth/me extension +- [Phase 20]: Created tags table in schema (was missing, needed for GET /api/tags endpoint) ### Pending Todos @@ -67,6 +68,6 @@ None active. ## Session Continuity -Last session: 2026-04-05T11:20:56.920Z -Stopped at: Completed 18-05-PLAN.md +Last session: 2026-04-06T05:59:26.687Z +Stopped at: Completed 20-01-PLAN.md Resume file: None diff --git a/.planning/phases/20-fab-full-screen-catalog-search/20-01-SUMMARY.md b/.planning/phases/20-fab-full-screen-catalog-search/20-01-SUMMARY.md new file mode 100644 index 0000000..99f0e9c --- /dev/null +++ b/.planning/phases/20-fab-full-screen-catalog-search/20-01-SUMMARY.md @@ -0,0 +1,125 @@ +--- +phase: 20-fab-full-screen-catalog-search +plan: 01 +subsystem: api, ui +tags: [hono, zustand, tanstack-query, drizzle, tags, global-items] + +requires: + - phase: 19-reference-item-model-tags-schema + provides: global-items service and route, schema foundation +provides: + - GET /api/tags endpoint returning all tags + - GET /api/global-items route registration in index.ts + - UIStore FAB menu and catalog search state slices + - useTags hook with 5-min stale cache + - useGlobalItems hook with optional tags parameter +affects: [20-02-PLAN, phase-21] + +tech-stack: + added: [] + patterns: [public-read auth skip for new GET endpoints] + +key-files: + created: + - src/server/services/tag.service.ts + - src/server/routes/tags.ts + - src/client/hooks/useTags.ts + - tests/services/tag.service.test.ts + - tests/routes/tags.test.ts + - drizzle-pg/0002_square_pyro.sql + modified: + - src/db/schema.ts + - src/server/index.ts + - src/client/stores/uiStore.ts + - src/client/hooks/useGlobalItems.ts + +key-decisions: + - "Created tags table in schema (was missing, needed for GET /api/tags)" + - "Tags endpoint is public-read (no auth), consistent with global-items" + +patterns-established: + - "Tag service pattern: select specific columns (id, name) not full row" + +requirements-completed: [CATFLOW-01, CATFLOW-02] + +duration: 5min +completed: 2026-04-06 +--- + +# Phase 20 Plan 01: Tags API, Route Registration, and UI State Summary + +**Tags endpoint with alphabetical ordering, global-items route registration, UIStore FAB/catalog-search state, and tag-aware useGlobalItems hook** + +## Performance + +- **Duration:** 5 min +- **Started:** 2026-04-06T05:53:35Z +- **Completed:** 2026-04-06T05:58:27Z +- **Tasks:** 2 +- **Files modified:** 10 + +## Accomplishments +- Created tags table, service, and route with full test coverage (4 tests) +- Registered previously unregistered global-items route in index.ts +- Added public-read auth skips for both /api/tags and /api/global-items +- Extended UIStore with FAB menu state (open/close) and catalog search overlay state (open with mode, close) +- Created useTags hook with 5-minute staleTime caching +- Updated useGlobalItems hook to accept optional tags array for filtering + +## Task Commits + +Each task was committed atomically: + +1. **Task 1 (RED): Tag service and route tests** - `6f07e87` (test) +2. **Task 1 (GREEN): Tags table, service, route, registrations** - `2ec1276` (feat) +3. **Task 2: UIStore extension, useTags hook, useGlobalItems update** - `67facea` (feat) + +## Files Created/Modified +- `src/db/schema.ts` - Added tags table definition +- `drizzle-pg/0002_square_pyro.sql` - Migration for tags table +- `src/server/services/tag.service.ts` - getAllTags function (id+name, alphabetical) +- `src/server/routes/tags.ts` - GET / handler returning all tags +- `src/server/index.ts` - Registered global-items and tags routes, added auth skips +- `src/client/stores/uiStore.ts` - Added FAB menu and catalog search state slices +- `src/client/hooks/useTags.ts` - Tag fetching hook with staleTime cache +- `src/client/hooks/useGlobalItems.ts` - Added optional tags parameter +- `tests/services/tag.service.test.ts` - Service-level tests for getAllTags +- `tests/routes/tags.test.ts` - Route-level tests for GET /api/tags + +## Decisions Made +- Created tags table in schema since it was referenced by the plan but didn't exist yet (Rule 3 deviation) +- Made both /api/tags and /api/global-items public-read (GET requests skip auth middleware) + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Created missing tags table in schema** +- **Found during:** Task 1 (Tag service implementation) +- **Issue:** Plan referenced `tags` table from schema.ts but no such table existed in the database schema +- **Fix:** Added tags table definition to schema.ts and generated migration (0002_square_pyro.sql) +- **Files modified:** src/db/schema.ts, drizzle-pg/0002_square_pyro.sql, drizzle-pg/meta/ +- **Verification:** Migration generated successfully, tests pass with PGlite +- **Committed in:** 2ec1276 (Task 1 GREEN commit) + +--- + +**Total deviations:** 1 auto-fixed (1 blocking) +**Impact on plan:** Essential for task completion. Tags table is required by the entire phase. No scope creep. + +## Issues Encountered +- Pre-existing global-items route test failures (9 of 10 tests fail) due to async/sync mismatch in test helper usage. Out of scope for this plan. + +## User Setup Required +None - no external service configuration required. + +## Known Stubs +None - all functionality is fully wired. + +## Next Phase Readiness +- Tags endpoint and UIStore state ready for Plan 02's UI components (FabMenu, CatalogSearchOverlay, TagChips) +- useTags and useGlobalItems hooks ready for consumption by overlay components + +--- +*Phase: 20-fab-full-screen-catalog-search* +*Completed: 2026-04-06*