255 lines
14 KiB
Markdown
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>
|