diff --git a/.planning/STATE.md b/.planning/STATE.md index 547f2f6..b62cc4f 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,16 +2,16 @@ gsd_state_version: 1.0 milestone: v1.3 milestone_name: Research & Decision Tools -status: executing -stopped_at: Phase 20 context gathered -last_updated: "2026-04-06T05:52:12.097Z" -last_activity: 2026-04-06 -- Phase 20 execution started +status: planning +stopped_at: Completed 20-01-PLAN.md +last_updated: "2026-04-06T05:59:26.689Z" +last_activity: 2026-04-05 progress: - total_phases: 14 - completed_phases: 12 - total_plans: 38 - completed_plans: 34 - percent: 3 + total_phases: 13 + completed_phases: 11 + total_plans: 33 + completed_plans: 32 + percent: 0 --- # Project State @@ -21,16 +21,16 @@ progress: See: .planning/PROJECT.md (updated 2026-04-03) **Core value:** Help people make better gear decisions — discover what others use, compare real-world data, and see how a potential buy affects your setup before committing. -**Current focus:** Phase 20 — fab-full-screen-catalog-search +**Current focus:** v2.0 Platform Foundation — Phase 14 (PostgreSQL Migration) ## Current Position -Phase: 20 (fab-full-screen-catalog-search) — EXECUTING -Plan: 1 of 2 -Status: Executing Phase 20 -Last activity: 2026-04-06 -- Phase 20 execution started +Phase: 18 of 18 (PostgreSQL Migration) +Plan: Not started +Status: Ready to plan +Last activity: 2026-04-05 -Progress: [#---------] 3% (v2.0 milestone) +Progress: [----------] 0% (v2.0 milestone) ## Performance Metrics @@ -55,11 +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 19]: Direct globalItemId FK on items replaces itemGlobalLinks junction table -- [Phase 19]: Data migration SQL: UPDATE items before DROP TABLE item_global_links -- [Phase 19]: Flat tags system without type categorization per D-16 -- [Phase 19-reference-item-model-tags-schema]: COALESCE merge pattern for transparent reference item data in item/thread services -- [Phase 19-reference-item-model-tags-schema]: COALESCE merge pattern propagated to all secondary services (setup, totals, profile, CSV) +- [Phase 20]: Created tags table in schema (was missing, needed for GET /api/tags endpoint) ### Pending Todos @@ -72,6 +68,6 @@ None active. ## Session Continuity -Last session: 2026-04-06T05:38:18.950Z -Stopped at: Phase 20 context gathered -Resume file: .planning/phases/20-fab-full-screen-catalog-search/20-CONTEXT.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* diff --git a/.superpowers/brainstorm/1268660-1775403447/content/catalog-search-v2.html b/.superpowers/brainstorm/1268660-1775403447/content/catalog-search-v2.html new file mode 100644 index 0000000..fd2cdfd --- /dev/null +++ b/.superpowers/brainstorm/1268660-1775403447/content/catalog-search-v2.html @@ -0,0 +1,145 @@ +

Catalog Browse & Search Experience

+

How should users find gear in the global catalog?

+ +
+
+
A
+
+

Search-First

+

Big search bar dominates. Tags below as quick filters. Type naturally: "waterproof handlebar bag under 200g". Results appear as you type.

+
+
Global Catalog — Search-First
+
+
+ +
+
+ Bikepacking + Bags + Waterproof + Ultralight + Shelter + Cooking +
+
Type to search or click tags to filter
+
+
+
+
+
Revelate Designs Pika
+
Handlebar Bag
+
+
★ 4.6
+
+
+ 128g + $85 + 24 owners +
+
+ bikepacking + handlebar + waterproof +
+
+
+
+
+
Apidura Racing HB Pack
+
Handlebar Bag
+
+
★ 4.4
+
+
+ 105g + $72 + 18 owners +
+
+ bikepacking + handlebar + ultralight +
+
+
+
+
+
+

Pros

  • Natural language friendly
  • Fast for users who know what they want
  • Tags as quick filters below search
+

Cons

  • Empty state before typing
  • Browsing requires typing first
+
+
+
+ +
+
B
+
+

Browse-First with Search

+

Category tiles for discovery with trending items. Search bar prominent but browsing is the default. Great for "I don't know exactly what I need yet."

+
+
Global Catalog — Browse-First
+
+
+ +
+
+
Popular Categories
+
+
+
🎒
+
Bags
+
342 items
+
+
+
🏕️
+
Shelter
+
128 items
+
+
+
🍳
+
Cooking
+
94 items
+
+
+
🚲
+
Bike Parts
+
215 items
+
+
+
💡
+
Lights
+
67 items
+
+
+
🧭
+
Navigation
+
43 items
+
+
+
+
+
Trending
+
+
+
Revelate Designs Pika
+
128g · $85 · ★ 4.6
+
+
24 owners
+
+
+
+
Ortlieb Fork-Pack
+
310g · $55 · ★ 4.3
+
+
19 owners
+
+
+
+
+
+

Pros

  • Great for discovery and browsing
  • Never an empty state
  • Shows what's popular in the community
+

Cons

  • Categories need curation
  • Slower for targeted searches
+
+
+
+
diff --git a/.superpowers/brainstorm/1268660-1775403447/content/catalog-search.html b/.superpowers/brainstorm/1268660-1775403447/content/catalog-search.html new file mode 100644 index 0000000..e5629b7 --- /dev/null +++ b/.superpowers/brainstorm/1268660-1775403447/content/catalog-search.html @@ -0,0 +1,128 @@ +

Catalog Browse & Search Experience

+

How should users find gear in the global catalog?

+ +
+
+
A
+
+

Search-First

+
+
Global Catalog
+
+
+ +
+
+ Bikepacking + Bags + Waterproof + Ultralight + Sim Racing + Wheels +
+
Type to search or click tags to filter
+
+
+
+
+
Revelate Designs Pika
+
Handlebar Bag
+
+
★ 4.6
+
+
+ 128g + $85 + 24 owners +
+
+ bikepacking + handlebar + waterproof +
+
+
+
+
+
Apidura Racing HB Pack
+
Handlebar Bag
+
+
★ 4.4
+
+
+ 105g + $72 + 18 owners +
+
+ bikepacking + handlebar + ultralight +
+
+
+
+
+
+

Pros

  • Natural language friendly
  • Fast for users who know what they want
  • Tags as quick filters below search
+

Cons

  • Empty state can feel blank
  • Browsing requires typing first
+
+
+
+ +
+
B
+
+

Browse-First with Search

+
+
Global Catalog
+
+
+ +
+
+
Popular Categories
+
+
+
🎒
+
Bags
+
342 items
+
+
+
🏕️
+
Shelter
+
128 items
+
+
+
🎮
+
Sim Gear
+
89 items
+
+
+
+
+
Trending
+
+
+
Revelate Designs Pika
+
128g · $85 · ★ 4.6
+
+
24 owners
+
+
+
+
Fanatec DD Pro
+
2.4kg · $350 · ★ 4.8
+
+
31 owners
+
+
+
+
+
+

Pros

  • Great for discovery — see what's popular
  • Categories give structure without complexity
  • Never an empty state
+

Cons

  • More complex landing page
  • Categories need curation
+
+
+
+
diff --git a/.superpowers/brainstorm/1268660-1775403447/content/fab-flow.html b/.superpowers/brainstorm/1268660-1775403447/content/fab-flow.html new file mode 100644 index 0000000..d4396ea --- /dev/null +++ b/.superpowers/brainstorm/1268660-1775403447/content/fab-flow.html @@ -0,0 +1,172 @@ +

Add Item Flow — From FAB to Search to Results

+

The journey from clicking + to finding gear

+ +
+

Step 1: FAB Menu

+

Click + → two options appear above the button

+
+
Collection View
+
+ +
+
+
Revelate Designs Pika
+
128g · Bags
+
+
+
MSR Hubba NX
+
1,540g · Shelter
+
+
+ + +
+
+
+ 📦 Add to Collection +
+
+
+ 🔍 Start New Thread +
+
+
+
+
+
+
+
+ +
+

Step 2: Search Overlay

+

After choosing an action, a search overlay appears

+
+
+
A
+
+

Floating Search Bar

+

Compact overlay centered on screen. Feels quick and light.

+
+
Search Overlay — Floating
+
+ +
+ +
+
Adding to Collection
+
+ + +
+
Search the catalog or press Enter to see results
+
+ Bags + Shelter + Lights + Cooking +
+
+
+
+
+
+ +
+
B
+
+

Full-Screen Search

+

Takes over the screen. More room for instant suggestions and recent searches.

+
+
Search Overlay — Full Screen
+
+
+ + +
+
+
Quick Tags
+
+ Bags + Shelter + Lights + Cooking + Bike Parts + Waterproof +
+
Adding to Collection
+
Type to search the gear catalog, or add manually if your item isn't listed.
+
+
+
+
+
+
+
+ +
+

Step 3: Results (always full screen)

+

After hitting Enter/Search, full results page with actions

+
+
Search Results — "handlebar bag"
+
+
+ +
handlebar bag
+
+
+
+ handlebar + + waterproof + + ultralight +
+
12 results
+ + +
+
+
+
Revelate Designs Pika
+
★ 4.6
+
+
+ 128g$8524 owners +
+
+ +
+ +
+
+
+
Apidura Racing HB Pack
+
★ 4.4
+
+
+ 105g$7218 owners +
+
+ +
+ +
+
+
+
Ortlieb Handlebar-Pack QR
+
★ 4.2
+
+
+ 340g$9531 owners +
+
+ +
+ + +
+
Can't find what you're looking for?
+
Add it manually and submit it to the catalog for review
+ +
+
+
+
+
diff --git a/.superpowers/brainstorm/1268660-1775403447/content/waiting-2.html b/.superpowers/brainstorm/1268660-1775403447/content/waiting-2.html new file mode 100644 index 0000000..ef07652 --- /dev/null +++ b/.superpowers/brainstorm/1268660-1775403447/content/waiting-2.html @@ -0,0 +1,3 @@ +
+

Continuing in terminal...

+
diff --git a/.superpowers/brainstorm/1268660-1775403447/content/waiting.html b/.superpowers/brainstorm/1268660-1775403447/content/waiting.html new file mode 100644 index 0000000..ef07652 --- /dev/null +++ b/.superpowers/brainstorm/1268660-1775403447/content/waiting.html @@ -0,0 +1,3 @@ +
+

Continuing in terminal...

+
diff --git a/.superpowers/brainstorm/1268660-1775403447/state/server-stopped b/.superpowers/brainstorm/1268660-1775403447/state/server-stopped new file mode 100644 index 0000000..87d2801 --- /dev/null +++ b/.superpowers/brainstorm/1268660-1775403447/state/server-stopped @@ -0,0 +1 @@ +{"reason":"idle timeout","timestamp":1775407587614} 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 index 6aaaaa4..64f225a 100644 --- a/drizzle-pg/meta/0002_snapshot.json +++ b/drizzle-pg/meta/0002_snapshot.json @@ -1,5 +1,9 @@ { +<<<<<<< HEAD "id": "1c8fbda2-e486-4f57-a6d5-1a2e7042e413", +======= + "id": "4b01f839-a5ff-416c-826c-1e37e76d0a78", +>>>>>>> worktree-agent-adbc35a5 "prevId": "8fb47390-ff75-41f7-aa35-fad97b1a097e", "version": "7", "dialect": "postgresql", @@ -136,6 +140,7 @@ "checkConstraints": {}, "isRLSEnabled": false }, +<<<<<<< HEAD "public.global_item_tags": { "name": "global_item_tags", "schema": "", @@ -196,6 +201,8 @@ "checkConstraints": {}, "isRLSEnabled": false }, +======= +>>>>>>> worktree-agent-adbc35a5 "public.global_items": { "name": "global_items", "schema": "", @@ -264,6 +271,75 @@ "checkConstraints": {}, "isRLSEnabled": false }, +<<<<<<< HEAD +======= + "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 + }, +>>>>>>> worktree-agent-adbc35a5 "public.items": { "name": "items", "schema": "", @@ -335,6 +411,7 @@ "notNull": true, "default": 1 }, +<<<<<<< HEAD "global_item_id": { "name": "global_item_id", "type": "integer", @@ -347,6 +424,8 @@ "primaryKey": false, "notNull": false }, +======= +>>>>>>> worktree-agent-adbc35a5 "created_at": { "name": "created_at", "type": "timestamp", @@ -389,6 +468,7 @@ ], "onDelete": "no action", "onUpdate": "no action" +<<<<<<< HEAD }, "items_global_item_id_global_items_id_fk": { "name": "items_global_item_id_global_items_id_fk", @@ -402,6 +482,8 @@ ], "onDelete": "no action", "onUpdate": "no action" +======= +>>>>>>> worktree-agent-adbc35a5 } }, "compositePrimaryKeys": {}, @@ -938,12 +1020,15 @@ "notNull": true, "default": 0 }, +<<<<<<< HEAD "global_item_id": { "name": "global_item_id", "type": "integer", "primaryKey": false, "notNull": false }, +======= +>>>>>>> worktree-agent-adbc35a5 "created_at": { "name": "created_at", "type": "timestamp", @@ -986,6 +1071,7 @@ ], "onDelete": "no action", "onUpdate": "no action" +<<<<<<< HEAD }, "thread_candidates_global_item_id_global_items_id_fk": { "name": "thread_candidates_global_item_id_global_items_id_fk", @@ -999,6 +1085,8 @@ ], "onDelete": "no action", "onUpdate": "no action" +======= +>>>>>>> worktree-agent-adbc35a5 } }, "compositePrimaryKeys": {}, 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 739d48a..f52e664 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -12,12 +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 { profileRoutes } from "./routes/profiles.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"; @@ -51,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, "")); } } }); @@ -98,6 +97,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 +117,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)); +} diff --git a/tests/routes/tags.test.ts b/tests/routes/tags.test.ts new file mode 100644 index 0000000..53f9cea --- /dev/null +++ b/tests/routes/tags.test.ts @@ -0,0 +1,52 @@ +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..50ee4d3 --- /dev/null +++ b/tests/services/tag.service.test.ts @@ -0,0 +1,36 @@ +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" }); + }); +});