Files
GearBox/.planning/phases/16-multi-user-data-model/16-02-PLAN.md

255 lines
14 KiB
Markdown

---
phase: 16-multi-user-data-model
plan: 02
type: execute
wave: 2
depends_on: ["16-01"]
files_modified:
- src/server/services/item.service.ts
- src/server/services/category.service.ts
- src/server/services/thread.service.ts
- src/server/services/setup.service.ts
- src/server/services/totals.service.ts
- src/server/services/csv.service.ts
- src/server/services/auth.service.ts
autonomous: true
requirements:
- MULTI-01
- MULTI-02
- MULTI-03
- MULTI-06
must_haves:
truths:
- "Every service function that reads or writes user data accepts a userId parameter"
- "All queries filter by userId using eq(table.userId, userId)"
- "Get-by-id queries use and(eq(table.id, id), eq(table.userId, userId)) to prevent cross-user access"
- "Category operations respect composite unique (userId, name)"
- "Settings operations use composite PK (userId, key)"
- "Thread resolution creates the new item with the same userId as the thread"
- "CSV import scopes category creation and lookup to the importing user"
- "API key CRUD is scoped to the owning user"
artifacts:
- path: "src/server/services/item.service.ts"
provides: "User-scoped item CRUD"
contains: "userId: number"
- path: "src/server/services/category.service.ts"
provides: "User-scoped category CRUD with composite unique"
contains: "userId: number"
- path: "src/server/services/thread.service.ts"
provides: "User-scoped thread + candidate CRUD + resolution"
contains: "userId: number"
- path: "src/server/services/setup.service.ts"
provides: "User-scoped setup CRUD + item sync validation"
contains: "userId: number"
- path: "src/server/services/totals.service.ts"
provides: "User-scoped aggregate queries"
contains: "userId: number"
- path: "src/server/services/csv.service.ts"
provides: "User-scoped CSV import/export"
contains: "userId: number"
key_links:
- from: "src/server/services/thread.service.ts"
to: "src/db/schema.ts"
via: "userId on insert(items) during thread resolution"
pattern: "userId.*resolv"
- from: "src/server/services/setup.service.ts"
to: "src/db/schema.ts"
via: "validates item ownership before sync"
pattern: "eq.*items.userId.*userId"
- from: "src/server/services/category.service.ts"
to: "src/db/schema.ts"
via: "getOrCreateUncategorized uses composite unique"
pattern: "getOrCreateUncategorized"
---
<objective>
Add userId parameter to every service function and scope all database queries to the authenticated user.
Purpose: Services are the data access layer. Scoping them to userId is the core of multi-user data isolation (MULTI-02). Without this, routes and MCP tools have no way to enforce per-user boundaries.
Output: All 7 service files updated with userId parameter on every function, all queries filtered by userId.
</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/16-multi-user-data-model/16-CONTEXT.md
@.planning/phases/16-multi-user-data-model/16-RESEARCH.md
@.planning/phases/16-multi-user-data-model/16-01-SUMMARY.md
@src/db/schema.ts
@src/server/services/item.service.ts
@src/server/services/category.service.ts
@src/server/services/thread.service.ts
@src/server/services/setup.service.ts
@src/server/services/totals.service.ts
@src/server/services/csv.service.ts
@src/server/services/auth.service.ts
<interfaces>
<!-- Service function signatures that must change from (db, ...) to (db, userId, ...) -->
<!-- From Plan 01 SUMMARY: schema.ts now has userId on items, categories, threads, setups, settings, apiKeys -->
<!-- getOrCreateUncategorized(db, userId) already created in Plan 01 in category.service.ts -->
Expected new signatures (all gain userId as second param):
- item.service.ts: getAllItems(db, userId), getItemById(db, userId, id), createItem(db, userId, data), updateItem(db, userId, id, data), deleteItem(db, userId, id)
- category.service.ts: getAllCategories(db, userId), getCategoryById(db, userId, id), createCategory(db, userId, data), updateCategory(db, userId, id, data), deleteCategory(db, userId, id)
- getOrCreateUncategorized(db, userId) already exists from Plan 01
- thread.service.ts: getAllThreads(db, userId), getThreadById(db, userId, id), createThread(db, userId, data), updateThread(db, userId, id, data), deleteThread(db, userId, id), resolveThread(db, userId, id, candidateId), addCandidate(db, userId, ...), updateCandidate(db, userId, ...), removeCandidate(db, userId, ...)
- setup.service.ts: getAllSetups(db, userId), getSetupById(db, userId, id), createSetup(db, userId, data), updateSetup(db, userId, id, data), deleteSetup(db, userId, id), syncSetupItems(db, userId, setupId, items)
- totals.service.ts: getTotals(db, userId)
- csv.service.ts: importItemsCsv(db, userId, data), exportItemsCsv(db, userId)
- auth.service.ts: createApiKey(db, userId, name), listApiKeys(db, userId), deleteApiKey(db, userId, id) — verifyApiKey already updated in Plan 01
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Update item, category, totals, and CSV services with userId scoping</name>
<files>src/server/services/item.service.ts, src/server/services/category.service.ts, src/server/services/totals.service.ts, src/server/services/csv.service.ts</files>
<read_first>src/server/services/item.service.ts, src/server/services/category.service.ts, src/server/services/totals.service.ts, src/server/services/csv.service.ts, src/db/schema.ts</read_first>
<action>
**item.service.ts** per D-09:
- Add `userId: number` as second parameter to ALL exported functions
- Remove default `db` parameter values (no more `db: Db = prodDb`) -- db is always injected
- `getAllItems`: add `.where(eq(items.userId, userId))`
- `getItemById`: change `.where(eq(items.id, id))` to `.where(and(eq(items.id, id), eq(items.userId, userId)))` -- CRITICAL for isolation per Research anti-pattern
- `createItem`: include `userId` in the `.values({...})` insert
- `updateItem`: add `eq(items.userId, userId)` to the `.where()` clause using `and()`
- `deleteItem`: add `eq(items.userId, userId)` to the `.where()` clause using `and()`
- Import `and` from `drizzle-orm` if not already imported
**category.service.ts** per D-05/D-09:
- Add `userId: number` as second parameter to ALL exported functions
- Remove default `db` parameter values
- `getAllCategories`: add `.where(eq(categories.userId, userId))`
- `getCategoryById`: use `and(eq(categories.id, id), eq(categories.userId, userId))`
- `createCategory`: include `userId` in the insert values
- `updateCategory`: add userId filter with `and()`
- `deleteCategory`: add userId filter with `and()`. When reassigning items to Uncategorized on delete, use `getOrCreateUncategorized(db, userId)` instead of hardcoded category ID 1. Also scope the item reassignment to only items belonging to this user.
- The `getOrCreateUncategorized` function was already created in Plan 01
**totals.service.ts** per D-09:
- Add `userId: number` parameter
- Filter all aggregate queries by userId
- This file computes weight/cost totals across the collection -- must only sum the user's items
**csv.service.ts** per D-09 and Research pitfall 7:
- Add `userId: number` parameter to `importItemsCsv` and `exportItemsCsv`
- `exportItemsCsv`: filter items query by userId
- `importItemsCsv`:
- Category lookup/creation must filter by userId (use `getOrCreateUncategorized` for fallback)
- When creating new categories from CSV data, include userId
- When creating items, include userId
- Category name matching must be scoped to user's categories
</action>
<verify>
<automated>grep -c "userId: number" src/server/services/item.service.ts && grep -c "userId: number" src/server/services/category.service.ts && grep -c "userId: number" src/server/services/totals.service.ts && grep -c "userId: number" src/server/services/csv.service.ts && grep "and(" src/server/services/item.service.ts | wc -l</automated>
</verify>
<acceptance_criteria>
- Every exported function in item.service.ts has `userId: number` parameter
- Every exported function in category.service.ts has `userId: number` parameter
- totals.service.ts functions have `userId: number` parameter
- csv.service.ts import/export functions have `userId: number` parameter
- `getItemById` uses `and(eq(items.id, id), eq(items.userId, userId))` (not just eq on id)
- `deleteCategory` uses `getOrCreateUncategorized(db, userId)` not hardcoded ID
- `importItemsCsv` scopes category operations to userId
- No `= prodDb` default parameter values remain
- `and` imported from `drizzle-orm` in all files that use it
</acceptance_criteria>
<done>Item, category, totals, and CSV services accept userId and scope all queries to the authenticated user. Get-by-id uses and() for isolation.</done>
</task>
<task type="auto">
<name>Task 2: Update thread, setup, settings, and auth services with userId scoping</name>
<files>src/server/services/thread.service.ts, src/server/services/setup.service.ts, src/server/services/auth.service.ts</files>
<read_first>src/server/services/thread.service.ts, src/server/services/setup.service.ts, src/server/services/auth.service.ts, src/db/schema.ts</read_first>
<action>
**thread.service.ts** per D-09 and Research pitfall 6:
- Add `userId: number` as second parameter to ALL exported functions
- Remove default `db` parameter values
- `getAllThreads`: add `.where(eq(threads.userId, userId))`
- `getThreadById`: use `and(eq(threads.id, id), eq(threads.userId, userId))`
- `createThread`: include `userId` in the insert values
- `updateThread`: add userId filter
- `deleteThread`: add userId filter
- `resolveThread`: CRITICAL -- verify the thread belongs to the user before resolving. When creating the new item from the winning candidate, include `userId` in the `insert(items).values({...})`. Also verify the target category belongs to the user. Use `getOrCreateUncategorized(db, userId)` if category fallback is needed.
- `addCandidate`: verify the parent thread belongs to the user before inserting candidate
- `updateCandidate`: verify the parent thread belongs to the user (join or subquery)
- `removeCandidate`: verify the parent thread belongs to the user
For candidate operations, the pattern should be:
1. Look up the thread with userId filter: `and(eq(threads.id, threadId), eq(threads.userId, userId))`
2. If thread not found, return null/throw (the thread doesn't exist for this user)
3. Proceed with candidate operation on the verified thread
**setup.service.ts** per D-09 and Research pitfall 8:
- Add `userId: number` as second parameter to ALL exported functions
- `getAllSetups`: add `.where(eq(setups.userId, userId))`
- `getSetupById`: use `and(eq(setups.id, id), eq(setups.userId, userId))`
- `createSetup`: include `userId` in insert values
- `updateSetup`: add userId filter
- `deleteSetup`: add userId filter
- `syncSetupItems`: CRITICAL -- verify the setup belongs to the user AND verify each itemId belongs to the user before inserting into setupItems. Filter the incoming item list against user-owned items:
```typescript
const userItemIds = await db.select({ id: items.id }).from(items)
.where(and(eq(items.userId, userId), inArray(items.id, itemIds)));
// Only insert items that belong to this user
```
**auth.service.ts** per D-07:
- `createApiKey`: add `userId: number` parameter, include userId in insert values
- `listApiKeys`: add `userId: number` parameter, filter by `.where(eq(apiKeys.userId, userId))`
- `deleteApiKey`: add `userId: number` parameter, filter by `and(eq(apiKeys.id, id), eq(apiKeys.userId, userId))` to prevent deleting another user's API key
- `verifyApiKey` was already updated in Plan 01 to return `{ userId } | null`
- `getOrCreateUser` was already created in Plan 01
</action>
<verify>
<automated>grep -c "userId: number" src/server/services/thread.service.ts && grep -c "userId: number" src/server/services/setup.service.ts && grep -c "userId: number" src/server/services/auth.service.ts && grep "and(" src/server/services/thread.service.ts | wc -l && grep "and(" src/server/services/setup.service.ts | wc -l</automated>
</verify>
<acceptance_criteria>
- Every exported function in thread.service.ts has `userId: number` parameter
- Every exported function in setup.service.ts has `userId: number` parameter
- `createApiKey`, `listApiKeys`, `deleteApiKey` in auth.service.ts have `userId: number` parameter
- `resolveThread` includes `userId` in the `insert(items).values({...})` call
- `resolveThread` verifies thread ownership before resolving
- Candidate operations (add, update, remove) verify parent thread ownership
- `syncSetupItems` verifies both setup and item ownership
- `getThreadById` uses `and(eq(threads.id, id), eq(threads.userId, userId))`
- `getSetupById` uses `and(eq(setups.id, id), eq(setups.userId, userId))`
- No `= prodDb` default parameter values remain
</acceptance_criteria>
<done>Thread, setup, and auth services accept userId and scope all queries. Thread resolution and setup sync validate ownership. Candidate operations verify parent thread belongs to user.</done>
</task>
</tasks>
<verification>
After all tasks complete:
1. `grep -r "userId: number" src/server/services/ | wc -l` shows userId parameter across all service files
2. `grep -r "= prodDb" src/server/services/` returns no matches (no default db params)
3. `grep -r "and(eq" src/server/services/` shows isolation on get-by-id queries
4. No service function reads or writes user-owned data without userId filtering
</verification>
<success_criteria>
- All 7 service files accept userId as a parameter
- All queries filter by userId (no unscoped reads or writes)
- Get-by-id, update, and delete operations use and() to combine id + userId conditions
- Thread resolution includes userId on new item creation
- Setup item sync validates item ownership
- Category deletion uses dynamic Uncategorized lookup (not hardcoded ID)
- CSV import scopes all operations to the importing user
- API key CRUD is user-scoped
</success_criteria>
<output>
After completion, create `.planning/phases/16-multi-user-data-model/16-02-SUMMARY.md`
</output>