docs(16): create multi-user data model phase plan
This commit is contained in:
254
.planning/phases/16-multi-user-data-model/16-02-PLAN.md
Normal file
254
.planning/phases/16-multi-user-data-model/16-02-PLAN.md
Normal file
@@ -0,0 +1,254 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user