docs(18): create phase plan for global items and public profiles

This commit is contained in:
2026-04-05 12:52:55 +02:00
parent c9117cd51a
commit 37d5711475
6 changed files with 1032 additions and 2 deletions

View File

@@ -0,0 +1,221 @@
---
phase: 18-global-items-public-profiles
plan: 02
type: execute
wave: 2
depends_on: ["18-01"]
files_modified:
- src/server/services/global-item.service.ts
- src/server/routes/global-items.ts
- src/db/seed-global-items.ts
- src/db/seed.ts
- src/server/index.ts
- tests/services/global-item.service.test.ts
- tests/routes/global-items.test.ts
autonomous: true
requirements: [GLOB-01, GLOB-02, GLOB-03, GLOB-04, GLOB-05]
must_haves:
truths:
- "GET /api/global-items returns the full global catalog without authentication"
- "GET /api/global-items?q=revelate returns only items matching brand or model (case-insensitive)"
- "GET /api/global-items/:id returns item details with an ownerCount field"
- "POST /api/items/:id/link links a user item to a global item (requires auth)"
- "DELETE /api/items/:id/link removes the link (requires auth)"
- "Seed data imports on first run and is idempotent on subsequent runs"
artifacts:
- path: "src/server/services/global-item.service.ts"
provides: "searchGlobalItems, getGlobalItemWithOwnerCount, linkItemToGlobal, unlinkItemFromGlobal"
exports: ["searchGlobalItems", "getGlobalItemWithOwnerCount", "linkItemToGlobal", "unlinkItemFromGlobal"]
- path: "src/server/routes/global-items.ts"
provides: "GET /api/global-items, GET /api/global-items/:id"
min_lines: 30
- path: "src/db/seed-global-items.ts"
provides: "seedGlobalItems function"
exports: ["seedGlobalItems"]
- path: "tests/services/global-item.service.test.ts"
provides: "Service tests for GLOB-01 through GLOB-05"
min_lines: 50
- path: "tests/routes/global-items.test.ts"
provides: "Route tests for global item endpoints"
min_lines: 40
key_links:
- from: "src/server/routes/global-items.ts"
to: "src/server/services/global-item.service.ts"
via: "import and call service functions"
pattern: "searchGlobalItems|getGlobalItemWithOwnerCount"
- from: "src/server/index.ts"
to: "src/server/routes/global-items.ts"
via: "app.route registration"
pattern: "app\\.route.*global-items"
- from: "src/db/seed.ts"
to: "src/db/seed-global-items.ts"
via: "seedGlobalItems call in seedDefaults"
pattern: "seedGlobalItems"
---
<objective>
Build the complete global item catalog backend: service layer with ILIKE search and owner count, route handlers for public GET + authenticated link/unlink, seed script integration, and auth middleware updates for public access.
Purpose: Delivers GLOB-01 through GLOB-05 server-side. Users can search gear, view details with owner counts, and link personal items to global entries.
Output: global-item.service.ts, global-items.ts routes, seed-global-items.ts, updated index.ts + seed.ts, service + route tests
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/18-global-items-public-profiles/18-CONTEXT.md
@.planning/phases/18-global-items-public-profiles/18-RESEARCH.md
@.planning/phases/18-global-items-public-profiles/18-01-SUMMARY.md
@src/db/schema.ts
@src/server/services/item.service.ts
@src/server/routes/items.ts
@src/server/index.ts
@src/server/middleware/auth.ts
@src/db/seed.ts
@tests/helpers/db.ts
<interfaces>
<!-- From Plan 01 outputs (schema.ts additions): -->
export const globalItems = pgTable("global_items", {
id: serial("id").primaryKey(),
brand: text("brand").notNull(),
model: text("model").notNull(),
category: text("category"),
weightGrams: doublePrecision("weight_grams"),
priceCents: integer("price_cents"),
imageUrl: text("image_url"),
description: text("description"),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const itemGlobalLinks = pgTable("item_global_links", {
id: serial("id").primaryKey(),
itemId: integer("item_id").notNull().references(() => items.id, { onDelete: "cascade" }).unique(),
globalItemId: integer("global_item_id").notNull().references(() => globalItems.id, { onDelete: "cascade" }),
});
<!-- From schemas.ts: -->
export const searchGlobalItemsSchema = z.object({ q: z.string().optional() });
export const linkItemSchema = z.object({ globalItemId: z.number().int().positive() });
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Global item service + seed script + tests</name>
<files>src/server/services/global-item.service.ts, src/db/seed-global-items.ts, src/db/seed.ts, tests/services/global-item.service.test.ts</files>
<read_first>src/server/services/item.service.ts, src/db/seed.ts, tests/helpers/db.ts, src/db/schema.ts, src/shared/schemas.ts</read_first>
<behavior>
- searchGlobalItems(db) returns all global items when no query provided
- searchGlobalItems(db, "revelate") returns only items with "revelate" in brand or model (case-insensitive)
- searchGlobalItems(db, "100%") does not match everything (wildcard chars escaped)
- getGlobalItemWithOwnerCount(db, id) returns item with ownerCount: 0 when no links exist
- getGlobalItemWithOwnerCount(db, id) returns ownerCount: 2 when 2 user items are linked
- getGlobalItemWithOwnerCount(db, nonExistentId) returns null
- linkItemToGlobal(db, itemId, globalItemId) creates link, returns link row
- linkItemToGlobal(db, itemId, globalItemId) when already linked throws/returns error
- unlinkItemFromGlobal(db, itemId) removes the link
- seedGlobalItems(db) inserts seed data on first call, skips on second call (idempotent)
</behavior>
<action>
**global-item.service.ts**: Create at `src/server/services/global-item.service.ts`. Follow the existing service pattern (import db type from `../../db/index.ts`, use `type Db = typeof prodDb`).
Functions:
1. `searchGlobalItems(db: Db, query?: string)` — No userId param (per D-03, public data). Uses `ilike` from drizzle-orm on brand and model columns. Escape `%` and `_` in query before wrapping in `%..%` pattern. Return `db.select().from(globalItems)` with optional where clause using `or(ilike(brand, pattern), ilike(model, pattern))`.
2. `getGlobalItemWithOwnerCount(db: Db, id: number)` — Select from globalItems where id matches. Then count from itemGlobalLinks where globalItemId matches. Return `{ ...item, ownerCount }` or null.
3. `linkItemToGlobal(db: Db, itemId: number, globalItemId: number)` — Insert into itemGlobalLinks. Let unique constraint on itemId handle duplicates (catch and return 409-style error).
4. `unlinkItemFromGlobal(db: Db, itemId: number)` — Delete from itemGlobalLinks where itemId matches. Return deleted count.
**seed-global-items.ts**: Create at `src/db/seed-global-items.ts`.
- `export async function seedGlobalItems(db: Db)` — Check if any rows exist in globalItems table. If yes, return early. If no, import from `./global-items-seed.json` and insert all rows.
**seed.ts**: Add `seedGlobalItems(prodDb)` call to the existing `seedDefaults()` function (after existing seeds).
**Tests**: Write tests FIRST (TDD). Use `createTestDb()` from test helper. Insert test global items directly in test setup. For owner count tests, create test user items and link them.
</action>
<verify>
<automated>bun test tests/services/global-item.service.test.ts</automated>
</verify>
<acceptance_criteria>
- grep -q "searchGlobalItems" src/server/services/global-item.service.ts
- grep -q "getGlobalItemWithOwnerCount" src/server/services/global-item.service.ts
- grep -q "linkItemToGlobal" src/server/services/global-item.service.ts
- grep -q "unlinkItemFromGlobal" src/server/services/global-item.service.ts
- grep -q "seedGlobalItems" src/db/seed-global-items.ts
- grep -q "seedGlobalItems" src/db/seed.ts
- grep -q "ilike" src/server/services/global-item.service.ts
- test -f tests/services/global-item.service.test.ts
</acceptance_criteria>
<done>All 4 service functions pass tests. Seed script is idempotent. ILIKE search works case-insensitively with wildcard escaping.</done>
</task>
<task type="auto">
<name>Task 2: Global item routes + auth middleware update + route tests</name>
<files>src/server/routes/global-items.ts, src/server/routes/items.ts, src/server/index.ts, tests/routes/global-items.test.ts</files>
<read_first>src/server/routes/items.ts, src/server/index.ts, src/server/middleware/auth.ts, tests/routes/setups.test.ts</read_first>
<action>
**global-items.ts route**: Create at `src/server/routes/global-items.ts`. Follow existing route pattern (Hono app with Env type).
1. `GET /` (maps to `/api/global-items`) — per D-16. Read `q` from query string. Call `searchGlobalItems(db, q)`. Return JSON array. No auth needed.
2. `GET /:id` (maps to `/api/global-items/:id`) — per D-17. Parse id with `parseId`. Call `getGlobalItemWithOwnerCount(db, id)`. Return 404 if null, otherwise JSON with ownerCount.
**items.ts route updates** — per D-18, D-19. Add two new endpoints to existing item routes:
3. `POST /:id/link` — Validate body with `linkItemSchema` via zValidator. Get userId from context. Verify the item belongs to the user (call getItemById first). Call `linkItemToGlobal(db, itemId, globalItemId)`. Return 201 on success, 409 if already linked, 404 if item not found.
4. `DELETE /:id/link` — Get userId. Verify item ownership. Call `unlinkItemFromGlobal(db, itemId)`. Return 200.
**index.ts updates**:
1. Import `globalItemRoutes` from routes/global-items.ts
2. Register: `app.route("/api/global-items", globalItemRoutes)` — place after existing route registrations.
3. Update auth middleware skip: Add `if (c.req.path.startsWith("/api/global-items") && c.req.method === "GET") return next();` before the `requireAuth` call, per Research Pattern 3 recommendation.
**Route tests**: Follow existing route test pattern (from tests/routes/setups.test.ts). Create test Hono app with db middleware + auth middleware. Test:
- GET /api/global-items returns 200 without auth
- GET /api/global-items?q=tent filters results
- GET /api/global-items/:id returns item with ownerCount
- GET /api/global-items/999 returns 404
- POST /api/items/:id/link returns 201
- POST /api/items/:id/link duplicate returns 409
- DELETE /api/items/:id/link returns 200
</action>
<verify>
<automated>bun test tests/routes/global-items.test.ts</automated>
</verify>
<acceptance_criteria>
- grep -q "global-items" src/server/index.ts
- grep -q "globalItemRoutes\|globalItems" src/server/routes/global-items.ts
- grep -q "link" src/server/routes/items.ts
- grep -q "api/global-items" src/server/index.ts
- test -f tests/routes/global-items.test.ts
</acceptance_criteria>
<done>Global item endpoints work: search returns filtered results, detail includes ownerCount, link/unlink modify junction table. Auth middleware allows unauthenticated GET access to /api/global-items. All route tests pass.</done>
</task>
</tasks>
<verification>
- `bun test tests/services/global-item.service.test.ts` — all service tests pass
- `bun test tests/routes/global-items.test.ts` — all route tests pass
- `bun test` — full suite passes (no regressions)
</verification>
<success_criteria>
Global item catalog is fully functional server-side. Search, detail with owner count, link/unlink all work. Seed data imports idempotently. Public GET endpoints work without auth. All tests pass.
</success_criteria>
<output>
After completion, create `.planning/phases/18-global-items-public-profiles/18-02-SUMMARY.md`
</output>