docs(16): create multi-user data model phase plan
This commit is contained in:
355
.planning/phases/16-multi-user-data-model/16-01-PLAN.md
Normal file
355
.planning/phases/16-multi-user-data-model/16-01-PLAN.md
Normal file
@@ -0,0 +1,355 @@
|
||||
---
|
||||
phase: 16-multi-user-data-model
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- src/db/schema.ts
|
||||
- src/db/seed.ts
|
||||
- tests/helpers/db.ts
|
||||
- src/server/middleware/auth.ts
|
||||
- src/server/services/auth.service.ts
|
||||
- src/server/services/oauth.service.ts
|
||||
- src/server/index.ts
|
||||
autonomous: true
|
||||
requirements:
|
||||
- MULTI-01
|
||||
- MULTI-04
|
||||
- MULTI-06
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A users table exists with id (serial PK), logtoSub (text unique), createdAt (timestamp)"
|
||||
- "Every entity table (items, categories, threads, setups, settings, apiKeys) has a userId integer FK column"
|
||||
- "Categories have a composite unique constraint on (userId, name) instead of unique(name)"
|
||||
- "Settings have a composite primary key on (userId, key) instead of just (key)"
|
||||
- "requireAuth middleware resolves userId and sets it on Hono context"
|
||||
- "verifyApiKey returns { userId } | null instead of boolean"
|
||||
- "verifyAccessToken returns { userId } | null instead of boolean"
|
||||
- "createTestDb returns { db, userId } with a seeded test user and per-user Uncategorized category"
|
||||
- "All API routes require auth (no GET bypass) so userId is always available"
|
||||
artifacts:
|
||||
- path: "src/db/schema.ts"
|
||||
provides: "Users table + userId columns on all entity tables + composite constraints"
|
||||
contains: "export const users"
|
||||
- path: "tests/helpers/db.ts"
|
||||
provides: "Test DB with seeded user"
|
||||
contains: "logtoSub"
|
||||
- path: "src/server/middleware/auth.ts"
|
||||
provides: "userId resolution middleware"
|
||||
contains: "c.set(\"userId\""
|
||||
key_links:
|
||||
- from: "src/server/middleware/auth.ts"
|
||||
to: "src/server/services/auth.service.ts"
|
||||
via: "verifyApiKey returning userId"
|
||||
pattern: "verifyApiKey.*userId"
|
||||
- from: "src/server/middleware/auth.ts"
|
||||
to: "src/server/services/oauth.service.ts"
|
||||
via: "verifyAccessToken returning userId"
|
||||
pattern: "verifyAccessToken.*userId"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create the multi-user data model foundation: users table, userId columns on all entity tables, updated auth middleware that resolves userId onto Hono context, and updated test infrastructure.
|
||||
|
||||
Purpose: This is the foundation that all subsequent plans depend on. Without the schema changes, userId columns, and middleware resolution, no service or route can be updated to scope data per-user.
|
||||
|
||||
Output: Updated schema.ts with pgTable imports and users table, migration generated, auth middleware resolving userId, test helper returning { db, 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/STATE.md
|
||||
@.planning/phases/16-multi-user-data-model/16-CONTEXT.md
|
||||
@.planning/phases/16-multi-user-data-model/16-RESEARCH.md
|
||||
@src/db/schema.ts
|
||||
@src/db/seed.ts
|
||||
@src/server/middleware/auth.ts
|
||||
@src/server/services/auth.service.ts
|
||||
@src/server/services/oauth.service.ts
|
||||
@src/server/index.ts
|
||||
@tests/helpers/db.ts
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs -->
|
||||
|
||||
From src/db/schema.ts (current - uses sqliteTable, must switch to pgTable):
|
||||
- categories: id, name (unique), icon, createdAt
|
||||
- items: id, name, weightGrams, priceCents, categoryId, notes, productUrl, imageFilename, imageSourceUrl, quantity, createdAt, updatedAt
|
||||
- threads: id, name, status, resolvedCandidateId, categoryId, createdAt, updatedAt
|
||||
- threadCandidates: id, threadId, name, weightGrams, priceCents, categoryId, notes, productUrl, imageFilename, imageSourceUrl, status, pros, cons, sortOrder, createdAt, updatedAt
|
||||
- setups: id, name, createdAt, updatedAt
|
||||
- setupItems: id, setupId, itemId, classification
|
||||
- settings: key (PK), value
|
||||
- apiKeys: id, name, keyHash, keyPrefix, createdAt
|
||||
- oauthClients: id, clientId, clientName, redirectUris, createdAt
|
||||
- oauthCodes: id, code, clientId, codeChallenge, codeChallengeMethod, redirectUri, expiresAt, used
|
||||
- oauthTokens: id, accessTokenHash, refreshTokenHash, clientId, expiresAt, refreshExpiresAt, createdAt
|
||||
|
||||
From src/server/services/auth.service.ts:
|
||||
```typescript
|
||||
export async function verifyApiKey(db: Db, rawKey: string): Promise<boolean>
|
||||
export async function createApiKey(db: Db, name: string)
|
||||
export async function listApiKeys(db: Db)
|
||||
export async function deleteApiKey(db: Db, id: number)
|
||||
```
|
||||
|
||||
From src/server/services/oauth.service.ts:
|
||||
```typescript
|
||||
export async function verifyAccessToken(db: Db, token: string): Promise<boolean>
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Migrate schema.ts to pgTable and add users table + userId columns</name>
|
||||
<files>src/db/schema.ts, src/db/seed.ts</files>
|
||||
<read_first>src/db/schema.ts, src/db/seed.ts, .planning/phases/16-multi-user-data-model/16-RESEARCH.md</read_first>
|
||||
<action>
|
||||
1. Rewrite `src/db/schema.ts` to use `drizzle-orm/pg-core` imports instead of `drizzle-orm/sqlite-core`. Replace `sqliteTable` with `pgTable`, `integer("id").primaryKey({ autoIncrement: true })` with `serial("id").primaryKey()`, `integer` with `integer` from pg-core, `real` with `doublePrecision`, and `integer("...", { mode: "timestamp" })` with `timestamp("...").defaultNow()` (or equivalent).
|
||||
|
||||
2. Add the new `users` table per D-01:
|
||||
```typescript
|
||||
export const users = pgTable("users", {
|
||||
id: serial("id").primaryKey(),
|
||||
logtoSub: text("logto_sub").notNull().unique(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
```
|
||||
|
||||
3. Add `userId` column (integer, NOT NULL, FK to users.id) to these tables per D-04:
|
||||
- `items`: `userId: integer("user_id").notNull().references(() => users.id)`
|
||||
- `categories`: `userId: integer("user_id").notNull().references(() => users.id)`
|
||||
- `threads`: `userId: integer("user_id").notNull().references(() => users.id)`
|
||||
- `setups`: `userId: integer("user_id").notNull().references(() => users.id)`
|
||||
- `apiKeys`: `userId: integer("user_id").notNull().references(() => users.id)` per D-07
|
||||
- `oauthTokens`: `userId: integer("user_id").notNull().references(() => users.id)` (per Research open question 2)
|
||||
|
||||
4. Per D-05, change `categories` unique constraint from `name` alone to composite `(userId, name)`:
|
||||
```typescript
|
||||
export const categories = pgTable("categories", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
icon: text("icon").notNull().default("package"),
|
||||
userId: integer("user_id").notNull().references(() => users.id),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
unique().on(table.userId, table.name),
|
||||
]);
|
||||
```
|
||||
|
||||
5. Per D-06, change `settings` PK from `key` alone to composite `(userId, key)`:
|
||||
```typescript
|
||||
export const settings = pgTable("settings", {
|
||||
userId: integer("user_id").notNull().references(() => users.id),
|
||||
key: text("key").notNull(),
|
||||
value: text("value").notNull(),
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.userId, table.key] }),
|
||||
]);
|
||||
```
|
||||
|
||||
6. Per D-08, do NOT add userId to `threadCandidates` or `setupItems` (they inherit ownership via parent FK).
|
||||
|
||||
7. Update `src/db/seed.ts`: The `seedDefaults()` function currently seeds a global Uncategorized category. Since categories now require userId, this global seed no longer works. Change `seedDefaults()` to be a no-op or remove the category seeding entirely (per-user Uncategorized will be created lazily or on first login per D-12). The function can remain as an empty function for now:
|
||||
```typescript
|
||||
export async function seedDefaults() {
|
||||
// Per-user default categories are created on first login (Phase 16)
|
||||
}
|
||||
```
|
||||
|
||||
8. Run `bun run db:generate` to generate the new Drizzle migration into `drizzle-pg/`. Then verify the generated SQL includes the users table, userId columns, composite constraints, and FK relationships.
|
||||
|
||||
IMPORTANT: The schema.ts file MUST use pg-core imports (`pgTable`, `serial`, `text`, `timestamp`, `integer`, `doublePrecision`, `unique`, `primaryKey`). The existing `drizzle-pg/` migration directory already has PostgreSQL DDL from Phase 14.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -c "pgTable" src/db/schema.ts && grep -c "export const users" src/db/schema.ts && grep "userId" src/db/schema.ts | wc -l && grep -c "unique().on" src/db/schema.ts && grep -c "primaryKey" src/db/schema.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `src/db/schema.ts` contains `import.*from "drizzle-orm/pg-core"` (no sqlite-core imports)
|
||||
- `export const users = pgTable("users"` exists with `logtoSub`, `id`, `createdAt`
|
||||
- `userId: integer("user_id").notNull().references(() => users.id)` appears in items, categories, threads, setups, apiKeys, oauthTokens
|
||||
- `unique().on(table.userId, table.name)` exists in categories table definition
|
||||
- `primaryKey({ columns: [table.userId, table.key] })` exists in settings table definition
|
||||
- `threadCandidates` and `setupItems` do NOT have a userId column
|
||||
- `src/db/seed.ts` no longer inserts a global Uncategorized category
|
||||
- A new migration file exists in `drizzle-pg/` with the users table and userId column additions
|
||||
</acceptance_criteria>
|
||||
<done>Schema uses pg-core imports, users table exists, all 6 entity tables have userId FK, categories has composite unique, settings has composite PK, migration generated</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Update auth middleware and auth services to resolve userId</name>
|
||||
<files>src/server/middleware/auth.ts, src/server/services/auth.service.ts, src/server/services/oauth.service.ts, src/server/index.ts</files>
|
||||
<read_first>src/server/middleware/auth.ts, src/server/services/auth.service.ts, src/server/services/oauth.service.ts, src/server/index.ts, src/db/schema.ts</read_first>
|
||||
<action>
|
||||
1. **Update `verifyApiKey` in `src/server/services/auth.service.ts`** per D-03/D-07:
|
||||
Change return type from `Promise<boolean>` to `Promise<{ userId: number } | null>`. The function queries apiKeys by keyPrefix, verifies the hash, and now returns `{ userId: candidate.userId }` on match or `null` on failure. Also update `createApiKey` to accept and store `userId`, `listApiKeys` to filter by `userId`, and `deleteApiKey` to filter by `userId` (using `and(eq(apiKeys.id, id), eq(apiKeys.userId, userId))`).
|
||||
|
||||
2. **Update `verifyAccessToken` in `src/server/services/oauth.service.ts`**:
|
||||
Change return type from `Promise<boolean>` to `Promise<{ userId: number } | null>`. Select `userId` from the oauthTokens record and return `{ userId: record.userId }` on success, `null` on failure. Also update `createTokens` to accept and store `userId`.
|
||||
|
||||
3. **Create `getOrCreateUser` function** in `src/server/services/auth.service.ts` per D-01:
|
||||
```typescript
|
||||
export async function getOrCreateUser(db: Db, logtoSub: string): Promise<{ id: number }> {
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values({ logtoSub })
|
||||
.onConflictDoUpdate({
|
||||
target: users.logtoSub,
|
||||
set: { logtoSub },
|
||||
})
|
||||
.returning({ id: users.id });
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
4. **Create `getOrCreateUncategorized` helper** in `src/server/services/category.service.ts` (or auth.service.ts):
|
||||
```typescript
|
||||
export async function getOrCreateUncategorized(db: Db, userId: number): Promise<number> {
|
||||
const [existing] = await db
|
||||
.select({ id: categories.id })
|
||||
.from(categories)
|
||||
.where(and(eq(categories.userId, userId), eq(categories.name, "Uncategorized")));
|
||||
if (existing) return existing.id;
|
||||
const [created] = await db
|
||||
.insert(categories)
|
||||
.values({ name: "Uncategorized", icon: "package", userId })
|
||||
.returning({ id: categories.id });
|
||||
return created.id;
|
||||
}
|
||||
```
|
||||
Place this in `category.service.ts` since it's category-related.
|
||||
|
||||
5. **Rewrite `requireAuth` in `src/server/middleware/auth.ts`** per D-03/D-10:
|
||||
- For API key auth: call `verifyApiKey(db, apiKey)` which now returns `{ userId } | null`. On success, `c.set("userId", result.userId)` and call `next()`.
|
||||
- For OAuth Bearer: call `verifyAccessToken(db, token)` which now returns `{ userId } | null`. On success, `c.set("userId", result.userId)`.
|
||||
- For OIDC session: call `getAuth(c)` for the sub claim, then `getOrCreateUser(db, auth.sub)` to get the local userId. Then call `getOrCreateUncategorized(db, user.id)` to ensure the user has a default category. Set `c.set("userId", user.id)`.
|
||||
- Import `getOrCreateUser` from auth.service and `getOrCreateUncategorized` from category.service.
|
||||
|
||||
6. **Update auth middleware configuration in `src/server/index.ts`** per Research pitfall 2:
|
||||
Change the `/api/*` middleware from:
|
||||
```typescript
|
||||
if (c.req.method === "GET") return next();
|
||||
```
|
||||
to apply `requireAuth` to ALL methods on data routes (remove the GET bypass). This ensures userId is always available on context for read operations. Keep the `/api/auth` bypass and add a bypass for `/api/health`.
|
||||
|
||||
The new middleware block should be:
|
||||
```typescript
|
||||
app.use("/api/*", async (c, next) => {
|
||||
if (c.req.path.startsWith("/api/auth")) return next();
|
||||
if (c.req.path === "/api/health") return next();
|
||||
return requireAuth(c, next);
|
||||
});
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -c "c.set(\"userId\"" src/server/middleware/auth.ts && grep "Promise<{ userId: number } | null>" src/server/services/auth.service.ts | wc -l && grep "Promise<{ userId: number } | null>" src/server/services/oauth.service.ts | wc -l && grep -c "getOrCreateUser" src/server/services/auth.service.ts && ! grep "GET.*return next" src/server/index.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `src/server/middleware/auth.ts` calls `c.set("userId", ...)` in all three auth paths (API key, Bearer, OIDC)
|
||||
- `verifyApiKey` in auth.service.ts has return type `Promise<{ userId: number } | null>`
|
||||
- `verifyAccessToken` in oauth.service.ts has return type `Promise<{ userId: number } | null>`
|
||||
- `getOrCreateUser` function exists in auth.service.ts with `onConflictDoUpdate` pattern
|
||||
- `getOrCreateUncategorized` function exists in category.service.ts
|
||||
- `src/server/index.ts` does NOT contain `if (c.req.method === "GET") return next()`
|
||||
- `src/server/index.ts` still bypasses auth for `/api/auth` and `/api/health` paths
|
||||
</acceptance_criteria>
|
||||
<done>Auth middleware resolves userId for all auth methods, verifyApiKey and verifyAccessToken return userId, GET routes require auth, getOrCreateUser and getOrCreateUncategorized helpers exist</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Update test helper to seed user and return { db, userId }</name>
|
||||
<files>tests/helpers/db.ts</files>
|
||||
<read_first>tests/helpers/db.ts, src/db/schema.ts</read_first>
|
||||
<action>
|
||||
Update `createTestDb()` in `tests/helpers/db.ts` to:
|
||||
1. After running migrations, insert a test user: `await db.insert(schema.users).values({ logtoSub: "test-user-sub" }).returning()`
|
||||
2. Insert the per-user Uncategorized category with the test user's ID: `await db.insert(schema.categories).values({ name: "Uncategorized", icon: "package", userId: user.id })`
|
||||
3. Change the return type from just `db` to `{ db, userId: user.id }` so all tests can destructure it.
|
||||
4. Also add a helper function `createSecondTestUser(db)` that creates a second user for cross-user isolation tests:
|
||||
```typescript
|
||||
export async function createSecondTestUser(db: Db) {
|
||||
const [user] = await db
|
||||
.insert(schema.users)
|
||||
.values({ logtoSub: "test-user-2-sub" })
|
||||
.returning();
|
||||
await db
|
||||
.insert(schema.categories)
|
||||
.values({ name: "Uncategorized", icon: "package", userId: user.id });
|
||||
return user.id;
|
||||
}
|
||||
```
|
||||
|
||||
The updated `createTestDb` should look like:
|
||||
```typescript
|
||||
export async function createTestDb() {
|
||||
const db = drizzle({ schema });
|
||||
await migrate(db, { migrationsFolder: "./drizzle-pg" });
|
||||
|
||||
const [user] = await db
|
||||
.insert(schema.users)
|
||||
.values({ logtoSub: "test-user-sub" })
|
||||
.returning();
|
||||
|
||||
await db
|
||||
.insert(schema.categories)
|
||||
.values({ name: "Uncategorized", icon: "package", userId: user.id });
|
||||
|
||||
return { db, userId: user.id };
|
||||
}
|
||||
```
|
||||
|
||||
IMPORTANT: This changes the return type of createTestDb from `db` to `{ db, userId }`. All existing test files that call `createTestDb()` will need updating in Plan 04 to destructure the result.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -c "logtoSub" tests/helpers/db.ts && grep -c "userId: user.id" tests/helpers/db.ts && grep "return { db, userId" tests/helpers/db.ts | wc -l && grep -c "createSecondTestUser" tests/helpers/db.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `createTestDb()` inserts a user with `logtoSub: "test-user-sub"`
|
||||
- `createTestDb()` returns `{ db, userId: user.id }` (not just `db`)
|
||||
- Uncategorized category is created with the test user's ID
|
||||
- `createSecondTestUser` function exists and is exported
|
||||
- Import of `schema.users` is present
|
||||
</acceptance_criteria>
|
||||
<done>Test helper seeds a user, returns { db, userId }, has a createSecondTestUser helper for isolation tests</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
After all tasks complete:
|
||||
1. `grep "pgTable" src/db/schema.ts` shows pg-core usage throughout
|
||||
2. `grep "export const users" src/db/schema.ts` confirms users table
|
||||
3. `grep "userId" src/db/schema.ts` shows userId on items, categories, threads, setups, settings, apiKeys, oauthTokens
|
||||
4. `grep "c.set(\"userId\"" src/server/middleware/auth.ts` shows userId set in middleware
|
||||
5. `grep "getOrCreateUser" src/server/services/auth.service.ts` confirms user upsert helper
|
||||
6. `grep "return { db, userId" tests/helpers/db.ts` confirms new test helper return type
|
||||
7. No `sqliteTable` imports remain in schema.ts
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Schema uses pg-core imports exclusively (no sqlite-core)
|
||||
- users table with id, logtoSub (unique), createdAt defined
|
||||
- userId FK column present on items, categories, threads, setups, settings, apiKeys, oauthTokens
|
||||
- categories: composite unique on (userId, name)
|
||||
- settings: composite PK on (userId, key)
|
||||
- requireAuth resolves userId for API key, Bearer token, and OIDC session
|
||||
- verifyApiKey and verifyAccessToken return { userId } | null
|
||||
- Test helper returns { db, userId } with seeded user
|
||||
- All API routes require auth (no GET bypass)
|
||||
- Drizzle migration generated in drizzle-pg/
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/16-multi-user-data-model/16-01-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user