Merge branch 'worktree-agent-a9a8b0dc' into Develop
# Conflicts: # .planning/REQUIREMENTS.md # .planning/ROADMAP.md # .planning/STATE.md # drizzle-pg/meta/0000_snapshot.json # drizzle-pg/meta/_journal.json # src/db/schema.ts # src/db/seed.ts # src/server/middleware/auth.ts # src/server/services/auth.service.ts # src/server/services/category.service.ts # src/server/services/oauth.service.ts # tests/helpers/db.ts
This commit is contained in:
@@ -17,20 +17,20 @@ Requirements for this milestone. Each maps to roadmap phases.
|
||||
|
||||
### Authentication
|
||||
|
||||
- [x] **AUTH-01**: User can register an account via external OIDC auth provider
|
||||
- [x] **AUTH-02**: User can log in via external auth provider and access their data
|
||||
- [ ] **AUTH-01**: User can register an account via external OIDC auth provider
|
||||
- [ ] **AUTH-02**: User can log in via external auth provider and access their data
|
||||
- [ ] **AUTH-03**: API keys remain functional for programmatic access (MCP, scripts)
|
||||
- [ ] **AUTH-04**: Auth provider runs self-hosted alongside the application
|
||||
- [x] **AUTH-05**: E2E tests authenticate via API keys without depending on the auth provider
|
||||
- [ ] **AUTH-05**: E2E tests authenticate via API keys without depending on the auth provider
|
||||
|
||||
### Multi-User Data Model
|
||||
|
||||
- [ ] **MULTI-01**: Every item, category, thread, and setup is owned by a specific user
|
||||
- [x] **MULTI-01**: Every item, category, thread, and setup is owned by a specific user
|
||||
- [ ] **MULTI-02**: User can only see and modify their own data (cross-user isolation)
|
||||
- [ ] **MULTI-03**: Categories use composite unique constraint (userId + name)
|
||||
- [ ] **MULTI-04**: Existing data is assigned to the original user during migration
|
||||
- [x] **MULTI-04**: Existing data is assigned to the original user during migration
|
||||
- [ ] **MULTI-05**: MCP tools operate within the authenticated user's scope
|
||||
- [ ] **MULTI-06**: Settings are per-user rather than global
|
||||
- [x] **MULTI-06**: Settings are per-user rather than global
|
||||
|
||||
### Image Storage
|
||||
|
||||
@@ -121,17 +121,17 @@ Which phases cover which requirements. Updated during roadmap creation.
|
||||
| DB-03 | Phase 14 | Pending |
|
||||
| DB-04 | Phase 14 | Pending |
|
||||
| DB-05 | Phase 14 | Pending |
|
||||
| AUTH-01 | Phase 15 | Complete |
|
||||
| AUTH-02 | Phase 15 | Complete |
|
||||
| AUTH-01 | Phase 15 | Pending |
|
||||
| AUTH-02 | Phase 15 | Pending |
|
||||
| AUTH-03 | Phase 15 | Pending |
|
||||
| AUTH-04 | Phase 15 | Pending |
|
||||
| AUTH-05 | Phase 15 | Complete |
|
||||
| MULTI-01 | Phase 16 | Pending |
|
||||
| AUTH-05 | Phase 15 | Pending |
|
||||
| MULTI-01 | Phase 16 | Complete |
|
||||
| MULTI-02 | Phase 16 | Pending |
|
||||
| MULTI-03 | Phase 16 | Pending |
|
||||
| MULTI-04 | Phase 16 | Pending |
|
||||
| MULTI-04 | Phase 16 | Complete |
|
||||
| MULTI-05 | Phase 16 | Pending |
|
||||
| MULTI-06 | Phase 16 | Pending |
|
||||
| MULTI-06 | Phase 16 | Complete |
|
||||
| IMG-01 | Phase 17 | Pending |
|
||||
| IMG-02 | Phase 17 | Pending |
|
||||
| IMG-03 | Phase 17 | Pending |
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
**Milestone Goal:** Transform GearBox from a single-user gear tracker into a multi-user platform where people discover gear, research purchases using crowd-verified data, and share their setups.
|
||||
|
||||
- [ ] **Phase 14: PostgreSQL Migration** — Replace SQLite with Postgres, make all operations async, establish new test infrastructure
|
||||
- [x] **Phase 15: External Authentication** — Integrate self-hosted OIDC auth provider for user registration and login (completed 2026-04-04)
|
||||
- [ ] **Phase 15: External Authentication** — Integrate self-hosted OIDC auth provider for user registration and login
|
||||
- [ ] **Phase 16: Multi-User Data Model** — Add user ownership to all entities with cross-user data isolation
|
||||
- [ ] **Phase 17: Object Storage** — Move images from local filesystem to MinIO (S3-compatible)
|
||||
- [ ] **Phase 18: Global Items & Public Profiles** — Global item catalog, user profiles, and public setup sharing
|
||||
@@ -145,12 +145,12 @@ Plans:
|
||||
3. Existing data from the single-user era is assigned to the original user account after migration
|
||||
4. MCP tools return only data belonging to the authenticated API key's owner
|
||||
5. Each user has independent settings (weight unit, onboarding state) that do not affect other users
|
||||
**Plans:** 4 plans
|
||||
**Plans**: 4 plans
|
||||
Plans:
|
||||
- [ ] 16-01-PLAN.md — Schema (pgTable + users table + userId columns), auth middleware userId resolution, test helper
|
||||
- [ ] 16-02-PLAN.md — All services updated with userId parameter and per-user query scoping
|
||||
- [ ] 16-03-PLAN.md — Routes + MCP tools wired to pass userId from context to services
|
||||
- [ ] 16-04-PLAN.md — All tests updated with userId, cross-user isolation tests added
|
||||
- [x] 16-01-PLAN.md — Schema foundation: users table, userId columns, auth middleware, test helper
|
||||
- [ ] 16-02-PLAN.md — Service layer userId scoping
|
||||
- [ ] 16-03-PLAN.md — Route handlers userId extraction
|
||||
- [ ] 16-04-PLAN.md — Test suite updates
|
||||
|
||||
### Phase 17: Object Storage
|
||||
**Goal**: Images are stored in and served from MinIO instead of the local filesystem
|
||||
@@ -194,7 +194,7 @@ Plans:
|
||||
| 12. Comparison View | v1.3 | 1/1 | Complete | 2026-03-17 |
|
||||
| 13. Setup Impact Preview | v1.3 | 0/2 | Not started | - |
|
||||
| 14. PostgreSQL Migration | v2.0 | 0/? | Not started | - |
|
||||
| 15. External Authentication | v2.0 | 1/1 | Complete | 2026-04-04 |
|
||||
| 16. Multi-User Data Model | v2.0 | 0/4 | Not started | - |
|
||||
| 15. External Authentication | v2.0 | 0/? | Not started | - |
|
||||
| 16. Multi-User Data Model | v2.0 | 1/4 | In progress | - |
|
||||
| 17. Object Storage | v2.0 | 0/? | Not started | - |
|
||||
| 18. Global Items & Public Profiles | v2.0 | 0/? | Not started | - |
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
---
|
||||
gsd_state_version: 1.0
|
||||
milestone: v1.3
|
||||
milestone_name: Research & Decision Tools
|
||||
status: planning
|
||||
stopped_at: Phase 16 context gathered
|
||||
last_updated: "2026-04-05T08:11:29.526Z"
|
||||
last_activity: 2026-04-04
|
||||
milestone: v2.0
|
||||
milestone_name: Platform Foundation
|
||||
status: executing
|
||||
stopped_at: null
|
||||
last_updated: "2026-04-05"
|
||||
last_activity: 2026-04-05 — Completed 16-01-PLAN.md (multi-user data model foundation)
|
||||
progress:
|
||||
total_phases: 10
|
||||
completed_phases: 8
|
||||
total_plans: 21
|
||||
completed_plans: 19
|
||||
percent: 0
|
||||
total_phases: 5
|
||||
completed_phases: 0
|
||||
total_plans: 4
|
||||
completed_plans: 1
|
||||
percent: 5
|
||||
---
|
||||
|
||||
# Project State
|
||||
@@ -21,24 +21,23 @@ progress:
|
||||
See: .planning/PROJECT.md (updated 2026-04-03)
|
||||
|
||||
**Core value:** Help people make better gear decisions — discover what others use, compare real-world data, and see how a potential buy affects your setup before committing.
|
||||
**Current focus:** v2.0 Platform Foundation — Phase 14 (PostgreSQL Migration)
|
||||
**Current focus:** v2.0 Platform Foundation — Phase 16 (Multi-User Data Model)
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 15 of 18 (PostgreSQL Migration)
|
||||
Plan: Not started
|
||||
Status: Ready to plan
|
||||
Last activity: 2026-04-04
|
||||
Phase: 16 of 18 (Multi-User Data Model)
|
||||
Plan: 1 of 4 in current phase
|
||||
Status: Plan 16-01 complete, executing remaining plans
|
||||
Last activity: 2026-04-05 — Completed 16-01 (multi-user data model foundation)
|
||||
|
||||
Progress: [----------] 0% (v2.0 milestone)
|
||||
Progress: [#---------] 5% (v2.0 milestone)
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
**Velocity:**
|
||||
|
||||
- Total plans completed: 0 (v2.0 milestone)
|
||||
- Average duration: --
|
||||
- Total execution time: --
|
||||
- Total plans completed: 1 (v2.0 milestone)
|
||||
- Average duration: 8min
|
||||
- Total execution time: 8min
|
||||
|
||||
*Updated after each plan completion*
|
||||
|
||||
@@ -46,16 +45,16 @@ Progress: [----------] 0% (v2.0 milestone)
|
||||
|
||||
### Decisions
|
||||
|
||||
Key decisions made during v2.0 planning:
|
||||
|
||||
Key decisions made during v2.0 execution:
|
||||
- Platform pivot: single-user to multi-user with discovery-first approach
|
||||
- External auth provider (self-hosted, open-source) — Logto vs Authentik OPEN decision
|
||||
- SQLite to Postgres migration — required by auth provider and multi-user concurrency
|
||||
- Structured UGC only — ratings and predefined fields, no freeform text until moderation
|
||||
- Separate globalItems table — not a flag on user items table
|
||||
- Single-user SQLite mode diverges at v2.0 boundary
|
||||
- [Phase 15]: Login page redirects to Logto OIDC (no credential form), useLogout uses redirect not mutation
|
||||
- [Phase 15]: E2E tests use static API key for auth, no dependency on Logto provider
|
||||
- All API routes require auth (no GET bypass) for per-user data scoping (16-01)
|
||||
- OAuth service converted from sync to async for pg compatibility (16-01)
|
||||
- getOrCreateUncategorized placed in category.service.ts (16-01)
|
||||
|
||||
### Pending Todos
|
||||
|
||||
@@ -68,6 +67,6 @@ None active.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-04-05T08:11:29.524Z
|
||||
Stopped at: Phase 16 context gathered
|
||||
Resume file: .planning/phases/16-multi-user-data-model/16-CONTEXT.md
|
||||
Last session: 2026-04-05
|
||||
Stopped at: Completed 16-01-PLAN.md (multi-user data model foundation)
|
||||
Resume file: None
|
||||
|
||||
145
.planning/phases/16-multi-user-data-model/16-01-SUMMARY.md
Normal file
145
.planning/phases/16-multi-user-data-model/16-01-SUMMARY.md
Normal file
@@ -0,0 +1,145 @@
|
||||
---
|
||||
phase: 16-multi-user-data-model
|
||||
plan: 01
|
||||
subsystem: database
|
||||
tags: [drizzle, pgTable, multi-user, userId, postgresql, auth-middleware]
|
||||
|
||||
requires:
|
||||
- phase: 14-postgresql-migration
|
||||
provides: PostgreSQL infrastructure and PGlite test setup
|
||||
- phase: 15-external-authentication
|
||||
provides: OIDC auth via Logto, API key and OAuth Bearer auth methods
|
||||
provides:
|
||||
- users table with logtoSub for OIDC mapping
|
||||
- userId FK columns on all entity tables (items, categories, threads, setups, apiKeys, oauthTokens)
|
||||
- composite unique constraint on categories(userId, name)
|
||||
- composite primary key on settings(userId, key)
|
||||
- requireAuth middleware resolving userId onto Hono context
|
||||
- getOrCreateUser upsert function for OIDC login
|
||||
- getOrCreateUncategorized lazy category creation
|
||||
- test helper returning { db, userId } with seeded user
|
||||
affects: [16-02, 16-03, 16-04, services, routes, mcp-tools, tests]
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [userId-on-context, per-user-data-isolation, lazy-uncategorized-creation, upsert-on-first-login]
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- drizzle-pg.config.ts
|
||||
- drizzle-pg/0000_thankful_loners.sql
|
||||
modified:
|
||||
- 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/services/category.service.ts
|
||||
- src/server/index.ts
|
||||
- tests/helpers/db.ts
|
||||
|
||||
key-decisions:
|
||||
- "All API routes require auth (no GET bypass) so userId is always available for per-user scoping"
|
||||
- "OAuth service functions converted from sync (.get/.run) to async (await) for pg compatibility"
|
||||
- "getOrCreateUncategorized placed in category.service.ts since it is category-related"
|
||||
|
||||
patterns-established:
|
||||
- "userId resolution: requireAuth sets c.set('userId', ...) for all three auth methods"
|
||||
- "verifyApiKey/verifyAccessToken return { userId } | null instead of boolean"
|
||||
- "createTestDb returns { db, userId } -- all tests must destructure"
|
||||
- "Lazy per-user Uncategorized category creation on first OIDC login"
|
||||
|
||||
requirements-completed: [MULTI-01, MULTI-04, MULTI-06]
|
||||
|
||||
duration: 8min
|
||||
completed: 2026-04-05
|
||||
---
|
||||
|
||||
# Phase 16 Plan 01: Multi-User Data Model Foundation Summary
|
||||
|
||||
**pgTable schema with users table, userId FK on 6 entity tables, composite constraints, and auth middleware resolving userId for all auth methods**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 8 min
|
||||
- **Started:** 2026-04-05T08:31:24Z
|
||||
- **Completed:** 2026-04-05T08:39:00Z
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 10
|
||||
|
||||
## Accomplishments
|
||||
- Migrated entire schema.ts from sqlite-core to pg-core (pgTable, serial, timestamp, doublePrecision)
|
||||
- Added users table with logtoSub unique identifier for OIDC mapping and userId FK to items, categories, threads, setups, apiKeys, oauthTokens
|
||||
- Auth middleware now resolves userId for API key, Bearer token, and OIDC session; all routes require auth
|
||||
- Test infrastructure returns { db, userId } with seeded user and createSecondTestUser helper
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Migrate schema.ts to pgTable and add users table + userId columns** - `91e93a3` (feat)
|
||||
2. **Task 2: Update auth middleware and auth services to resolve userId** - `b6d562f` (feat)
|
||||
3. **Task 3: Update test helper to seed user and return { db, userId }** - `050478c` (feat)
|
||||
|
||||
## Files Created/Modified
|
||||
- `src/db/schema.ts` - Rewritten from sqlite-core to pg-core; users table, userId columns, composite constraints
|
||||
- `src/db/seed.ts` - Emptied global seed; per-user categories created lazily
|
||||
- `src/server/middleware/auth.ts` - Rewritten to resolve userId for all 3 auth methods
|
||||
- `src/server/services/auth.service.ts` - Rewritten: getOrCreateUser, verifyApiKey returns userId, scoped API key CRUD
|
||||
- `src/server/services/oauth.service.ts` - Rewritten: all functions async, verifyAccessToken returns userId, generateTokens accepts userId
|
||||
- `src/server/services/category.service.ts` - Added getOrCreateUncategorized helper
|
||||
- `src/server/index.ts` - Removed GET bypass; all API routes require auth
|
||||
- `tests/helpers/db.ts` - PGlite-based, seeds user, returns { db, userId }, createSecondTestUser helper
|
||||
- `drizzle-pg.config.ts` - Drizzle config for PostgreSQL dialect
|
||||
- `drizzle-pg/0000_thankful_loners.sql` - Generated migration with full schema
|
||||
|
||||
## Decisions Made
|
||||
- All API routes require auth (removed GET bypass) so userId is always available on context for per-user data scoping
|
||||
- OAuth service functions converted from synchronous (.get/.run/.all) to async/await for PostgreSQL compatibility
|
||||
- getOrCreateUncategorized placed in category.service.ts since it is category-domain logic
|
||||
- Old user/session management functions removed from auth.service.ts (replaced by Logto OIDC)
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 2 - Missing Critical] Converted all oauth.service.ts functions to async**
|
||||
- **Found during:** Task 2 (auth service updates)
|
||||
- **Issue:** All oauth.service functions used synchronous .get()/.run()/.all() calls from bun-sqlite; these do not work with pg/PGlite which is async-only
|
||||
- **Fix:** Rewrote all oauth.service functions to use async/await with array destructuring instead of .get()
|
||||
- **Files modified:** src/server/services/oauth.service.ts
|
||||
- **Verification:** Code compiles correctly with pg-core types
|
||||
- **Committed in:** b6d562f (Task 2 commit)
|
||||
|
||||
**2. [Rule 3 - Blocking] Created drizzle-pg.config.ts for migration generation**
|
||||
- **Found during:** Task 1 (schema migration)
|
||||
- **Issue:** Existing drizzle.config.ts was SQLite-only; needed PostgreSQL config to generate migrations
|
||||
- **Fix:** Created drizzle-pg.config.ts pointing to drizzle-pg/ output directory
|
||||
- **Files modified:** drizzle-pg.config.ts (new)
|
||||
- **Verification:** Migration generated successfully with 12 tables
|
||||
- **Committed in:** 91e93a3 (Task 1 commit)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 2 auto-fixed (1 missing critical, 1 blocking)
|
||||
**Impact on plan:** Both fixes essential for pg compatibility. No scope creep.
|
||||
|
||||
## Issues Encountered
|
||||
None
|
||||
|
||||
## Known Stubs
|
||||
None - all data model changes are structural (schema, middleware, test infrastructure). No UI rendering involved.
|
||||
|
||||
## User Setup Required
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
- Schema foundation complete with users table and userId columns on all entity tables
|
||||
- Auth middleware resolves userId for all auth methods
|
||||
- Test helper ready with seeded user
|
||||
- Next: Plan 16-02 updates all service files to accept userId parameter and filter queries
|
||||
- Note: createTestDb return type changed from `db` to `{ db, userId }` -- existing tests will need updating in Plan 16-04
|
||||
|
||||
---
|
||||
*Phase: 16-multi-user-data-model*
|
||||
*Completed: 2026-04-05*
|
||||
7
drizzle-pg.config.ts
Normal file
7
drizzle-pg.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
out: "./drizzle-pg",
|
||||
schema: "./src/db/schema.ts",
|
||||
dialect: "postgresql",
|
||||
});
|
||||
140
drizzle-pg/0000_thankful_loners.sql
Normal file
140
drizzle-pg/0000_thankful_loners.sql
Normal file
@@ -0,0 +1,140 @@
|
||||
CREATE TABLE "api_keys" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"key_hash" text NOT NULL,
|
||||
"key_prefix" text NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "categories" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"icon" text DEFAULT 'package' NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "categories_user_id_name_unique" UNIQUE("user_id","name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "items" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"weight_grams" double precision,
|
||||
"price_cents" integer,
|
||||
"category_id" integer NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"notes" text,
|
||||
"product_url" text,
|
||||
"image_filename" text,
|
||||
"image_source_url" text,
|
||||
"quantity" integer DEFAULT 1 NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "oauth_clients" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"client_id" text NOT NULL,
|
||||
"client_name" text,
|
||||
"redirect_uris" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "oauth_clients_client_id_unique" UNIQUE("client_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "oauth_codes" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"code" text NOT NULL,
|
||||
"client_id" text NOT NULL,
|
||||
"code_challenge" text NOT NULL,
|
||||
"code_challenge_method" text DEFAULT 'S256' NOT NULL,
|
||||
"redirect_uri" text NOT NULL,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"used" integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT "oauth_codes_code_unique" UNIQUE("code")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "oauth_tokens" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"access_token_hash" text NOT NULL,
|
||||
"refresh_token_hash" text NOT NULL,
|
||||
"client_id" text NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"refresh_expires_at" timestamp NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "oauth_tokens_access_token_hash_unique" UNIQUE("access_token_hash"),
|
||||
CONSTRAINT "oauth_tokens_refresh_token_hash_unique" UNIQUE("refresh_token_hash")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "settings" (
|
||||
"user_id" integer NOT NULL,
|
||||
"key" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
CONSTRAINT "settings_user_id_key_pk" PRIMARY KEY("user_id","key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "setup_items" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"setup_id" integer NOT NULL,
|
||||
"item_id" integer NOT NULL,
|
||||
"classification" text DEFAULT 'base' NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "setups" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "thread_candidates" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"thread_id" integer NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"weight_grams" double precision,
|
||||
"price_cents" integer,
|
||||
"category_id" integer NOT NULL,
|
||||
"notes" text,
|
||||
"product_url" text,
|
||||
"image_filename" text,
|
||||
"image_source_url" text,
|
||||
"status" text DEFAULT 'researching' NOT NULL,
|
||||
"pros" text,
|
||||
"cons" text,
|
||||
"sort_order" double precision DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "threads" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"status" text DEFAULT 'active' NOT NULL,
|
||||
"resolved_candidate_id" integer,
|
||||
"category_id" integer NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"logto_sub" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "users_logto_sub_unique" UNIQUE("logto_sub")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "categories" ADD CONSTRAINT "categories_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "items" ADD CONSTRAINT "items_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "items" ADD CONSTRAINT "items_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "oauth_tokens" ADD CONSTRAINT "oauth_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "settings" ADD CONSTRAINT "settings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "setup_items" ADD CONSTRAINT "setup_items_setup_id_setups_id_fk" FOREIGN KEY ("setup_id") REFERENCES "public"."setups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "setup_items" ADD CONSTRAINT "setup_items_item_id_items_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "setups" ADD CONSTRAINT "setups_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "thread_candidates" ADD CONSTRAINT "thread_candidates_thread_id_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."threads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "thread_candidates" ADD CONSTRAINT "thread_candidates_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "threads" ADD CONSTRAINT "threads_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "threads" ADD CONSTRAINT "threads_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "2b9c2a13-0ec1-4011-b34a-13c710cf6c34",
|
||||
"id": "2f3f44c0-0fd3-4ac5-b1fb-51bc709342df",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -32,6 +32,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
@@ -41,7 +47,21 @@
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"foreignKeys": {
|
||||
"api_keys_user_id_users_id_fk": {
|
||||
"name": "api_keys_user_id_users_id_fk",
|
||||
"tableFrom": "api_keys",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
@@ -71,6 +91,12 @@
|
||||
"notNull": true,
|
||||
"default": "'package'"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
@@ -80,13 +106,28 @@
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"foreignKeys": {
|
||||
"categories_user_id_users_id_fk": {
|
||||
"name": "categories_user_id_users_id_fk",
|
||||
"tableFrom": "categories",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"categories_name_unique": {
|
||||
"name": "categories_name_unique",
|
||||
"categories_user_id_name_unique": {
|
||||
"name": "categories_user_id_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"user_id",
|
||||
"name"
|
||||
]
|
||||
}
|
||||
@@ -129,6 +170,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
@@ -189,6 +236,19 @@
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"items_user_id_users_id_fk": {
|
||||
"name": "items_user_id_users_id_fk",
|
||||
"tableFrom": "items",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
@@ -298,10 +358,10 @@
|
||||
},
|
||||
"used": {
|
||||
"name": "used",
|
||||
"type": "boolean",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@@ -348,6 +408,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
@@ -369,7 +435,21 @@
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"foreignKeys": {
|
||||
"oauth_tokens_user_id_users_id_fk": {
|
||||
"name": "oauth_tokens_user_id_users_id_fk",
|
||||
"tableFrom": "oauth_tokens",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"oauth_tokens_access_token_hash_unique": {
|
||||
@@ -391,59 +471,20 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"public.settings": {
|
||||
"name": "settings",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.settings": {
|
||||
"name": "settings",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
@@ -454,8 +495,30 @@
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"foreignKeys": {
|
||||
"settings_user_id_users_id_fk": {
|
||||
"name": "settings_user_id_users_id_fk",
|
||||
"tableFrom": "settings",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"settings_user_id_key_pk": {
|
||||
"name": "settings_user_id_key_pk",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"key"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
@@ -542,6 +605,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
@@ -558,7 +627,21 @@
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"foreignKeys": {
|
||||
"setups_user_id_users_id_fk": {
|
||||
"name": "setups_user_id_users_id_fk",
|
||||
"tableFrom": "setups",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
@@ -740,6 +823,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
@@ -769,6 +858,19 @@
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"threads_user_id_users_id_fk": {
|
||||
"name": "threads_user_id_users_id_fk",
|
||||
"tableFrom": "threads",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
@@ -787,14 +889,8 @@
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"logto_sub": {
|
||||
"name": "logto_sub",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
@@ -811,11 +907,11 @@
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"users_logto_sub_unique": {
|
||||
"name": "users_logto_sub_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
"logto_sub"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1775297839520,
|
||||
"tag": "0000_fuzzy_shiva",
|
||||
"when": 1775377947759,
|
||||
"tag": "0000_thankful_loners",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
180
src/db/schema.ts
180
src/db/schema.ts
@@ -1,58 +1,86 @@
|
||||
import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import {
|
||||
doublePrecision,
|
||||
integer,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
serial,
|
||||
text,
|
||||
timestamp,
|
||||
unique,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const categories = sqliteTable("categories", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull().unique(),
|
||||
icon: text("icon").notNull().default("package"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
// ── Users ───────────────────────────────────────────────────────────
|
||||
|
||||
export const users = pgTable("users", {
|
||||
id: serial("id").primaryKey(),
|
||||
logtoSub: text("logto_sub").notNull().unique(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const items = sqliteTable("items", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
// ── Categories ──────────────────────────────────────────────────────
|
||||
|
||||
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)],
|
||||
);
|
||||
|
||||
// ── Items ───────────────────────────────────────────────────────────
|
||||
|
||||
export const items = pgTable("items", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
weightGrams: real("weight_grams"),
|
||||
weightGrams: doublePrecision("weight_grams"),
|
||||
priceCents: integer("price_cents"),
|
||||
categoryId: integer("category_id")
|
||||
.notNull()
|
||||
.references(() => categories.id),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
notes: text("notes"),
|
||||
productUrl: text("product_url"),
|
||||
imageFilename: text("image_filename"),
|
||||
imageSourceUrl: text("image_source_url"),
|
||||
quantity: integer("quantity").notNull().default(1),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const threads = sqliteTable("threads", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
// ── Threads ─────────────────────────────────────────────────────────
|
||||
|
||||
export const threads = pgTable("threads", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
status: text("status").notNull().default("active"),
|
||||
resolvedCandidateId: integer("resolved_candidate_id"),
|
||||
categoryId: integer("category_id")
|
||||
.notNull()
|
||||
.references(() => categories.id),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
.references(() => users.id),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const threadCandidates = sqliteTable("thread_candidates", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
// ── Thread Candidates ───────────────────────────────────────────────
|
||||
|
||||
export const threadCandidates = pgTable("thread_candidates", {
|
||||
id: serial("id").primaryKey(),
|
||||
threadId: integer("thread_id")
|
||||
.notNull()
|
||||
.references(() => threads.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
weightGrams: real("weight_grams"),
|
||||
weightGrams: doublePrecision("weight_grams"),
|
||||
priceCents: integer("price_cents"),
|
||||
categoryId: integer("category_id")
|
||||
.notNull()
|
||||
@@ -64,28 +92,27 @@ export const threadCandidates = sqliteTable("thread_candidates", {
|
||||
status: text("status").notNull().default("researching"),
|
||||
pros: text("pros"),
|
||||
cons: text("cons"),
|
||||
sortOrder: real("sort_order").notNull().default(0),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
sortOrder: doublePrecision("sort_order").notNull().default(0),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const setups = sqliteTable("setups", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
// ── Setups ──────────────────────────────────────────────────────────
|
||||
|
||||
export const setups = pgTable("setups", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
.references(() => users.id),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const setupItems = sqliteTable("setup_items", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
// ── Setup Items ─────────────────────────────────────────────────────
|
||||
|
||||
export const setupItems = pgTable("setup_items", {
|
||||
id: serial("id").primaryKey(),
|
||||
setupId: integer("setup_id")
|
||||
.notNull()
|
||||
.references(() => setups.id, { onDelete: "cascade" }),
|
||||
@@ -95,52 +122,69 @@ export const setupItems = sqliteTable("setup_items", {
|
||||
classification: text("classification").notNull().default("base"),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable("settings", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
});
|
||||
// ── Settings ────────────────────────────────────────────────────────
|
||||
|
||||
export const apiKeys = sqliteTable("api_keys", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
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] })],
|
||||
);
|
||||
|
||||
// ── API Keys ────────────────────────────────────────────────────────
|
||||
|
||||
export const apiKeys = pgTable("api_keys", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
keyHash: text("key_hash").notNull(),
|
||||
keyPrefix: text("key_prefix").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
.references(() => users.id),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const oauthClients = sqliteTable("oauth_clients", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
// ── OAuth Clients ───────────────────────────────────────────────────
|
||||
|
||||
export const oauthClients = pgTable("oauth_clients", {
|
||||
id: serial("id").primaryKey(),
|
||||
clientId: text("client_id").notNull().unique(),
|
||||
clientName: text("client_name"),
|
||||
redirectUris: text("redirect_uris").notNull(), // JSON array
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const oauthCodes = sqliteTable("oauth_codes", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
// ── OAuth Authorization Codes ───────────────────────────────────────
|
||||
|
||||
export const oauthCodes = pgTable("oauth_codes", {
|
||||
id: serial("id").primaryKey(),
|
||||
code: text("code").notNull().unique(),
|
||||
clientId: text("client_id").notNull(),
|
||||
codeChallenge: text("code_challenge").notNull(),
|
||||
codeChallengeMethod: text("code_challenge_method").notNull().default("S256"),
|
||||
codeChallengeMethod: text("code_challenge_method")
|
||||
.notNull()
|
||||
.default("S256"),
|
||||
redirectUri: text("redirect_uri").notNull(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
used: integer("used").notNull().default(0),
|
||||
});
|
||||
|
||||
export const oauthTokens = sqliteTable("oauth_tokens", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
// ── OAuth Tokens ────────────────────────────────────────────────────
|
||||
|
||||
export const oauthTokens = pgTable("oauth_tokens", {
|
||||
id: serial("id").primaryKey(),
|
||||
accessTokenHash: text("access_token_hash").notNull().unique(),
|
||||
refreshTokenHash: text("refresh_token_hash").notNull().unique(),
|
||||
clientId: text("client_id").notNull(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), // access token expiry
|
||||
refreshExpiresAt: integer("refresh_expires_at", {
|
||||
mode: "timestamp",
|
||||
}).notNull(), // refresh token expiry
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
.references(() => users.id),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
refreshExpiresAt: timestamp("refresh_expires_at").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import { db } from "./index.ts";
|
||||
import { categories } from "./schema.ts";
|
||||
|
||||
export async function seedDefaults() {
|
||||
const existing = await db.select().from(categories);
|
||||
if (existing.length === 0) {
|
||||
await db.insert(categories).values({
|
||||
name: "Uncategorized",
|
||||
icon: "package",
|
||||
});
|
||||
}
|
||||
export function seedDefaults() {
|
||||
// Per-user default categories are created on first login (Phase 16)
|
||||
// The getOrCreateUncategorized helper in category.service.ts handles this lazily.
|
||||
}
|
||||
|
||||
@@ -67,13 +67,13 @@ app.use("/api/*", async (c, next) => {
|
||||
return next();
|
||||
});
|
||||
|
||||
// Auth middleware for write operations (POST/PUT/PATCH/DELETE) on non-auth routes
|
||||
// Auth middleware for all data routes (userId must be available for per-user scoping)
|
||||
app.use("/api/*", async (c, next) => {
|
||||
// Skip auth routes — they handle their own auth
|
||||
if (c.req.path.startsWith("/api/auth")) return next();
|
||||
// Skip GET requests — read is public
|
||||
if (c.req.method === "GET") return next();
|
||||
// All other methods require auth
|
||||
// Skip health check
|
||||
if (c.req.path === "/api/health") return next();
|
||||
// All methods require auth for userId resolution
|
||||
return requireAuth(c, next);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,30 +1,47 @@
|
||||
import type { Context, Next } from "hono";
|
||||
import { getAuth } from "@hono/oidc-auth";
|
||||
import { verifyApiKey } from "../services/auth.service";
|
||||
import { getOrCreateUser, verifyApiKey } from "../services/auth.service";
|
||||
import { getOrCreateUncategorized } from "../services/category.service";
|
||||
import { verifyAccessToken } from "../services/oauth.service";
|
||||
|
||||
export async function requireAuth(c: Context, next: Next) {
|
||||
const db = c.get("db");
|
||||
|
||||
// 1. Check API key (programmatic access)
|
||||
// Check API key first
|
||||
const apiKey = c.req.header("X-API-Key");
|
||||
if (apiKey) {
|
||||
const valid = await verifyApiKey(db, apiKey);
|
||||
if (valid) return next();
|
||||
const result = await verifyApiKey(db, apiKey);
|
||||
if (result) {
|
||||
c.set("userId", result.userId);
|
||||
return next();
|
||||
}
|
||||
return c.json({ error: "Invalid API key" }, 401);
|
||||
}
|
||||
|
||||
// 2. Check MCP OAuth Bearer token
|
||||
// Check OAuth Bearer token
|
||||
const authHeader = c.req.header("Authorization");
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
const token = authHeader.slice(7);
|
||||
if (await verifyAccessToken(db, token)) return next();
|
||||
return c.json({ error: "invalid_token" }, 401);
|
||||
const result = await verifyAccessToken(db, token);
|
||||
if (result) {
|
||||
c.set("userId", result.userId);
|
||||
return next();
|
||||
}
|
||||
return c.json({ error: "Invalid or expired token" }, 401);
|
||||
}
|
||||
|
||||
// 3. Check OIDC session (browser users)
|
||||
const auth = await getAuth(c);
|
||||
if (auth) return next();
|
||||
// Check OIDC session (browser users via Logto)
|
||||
try {
|
||||
const { getAuth } = await import("@hono/oidc-auth");
|
||||
const auth = await getAuth(c);
|
||||
if (auth?.sub) {
|
||||
const user = await getOrCreateUser(db, auth.sub);
|
||||
await getOrCreateUncategorized(db, user.id);
|
||||
c.set("userId", user.id);
|
||||
return next();
|
||||
}
|
||||
} catch {
|
||||
// OIDC not configured or session invalid — fall through
|
||||
}
|
||||
|
||||
return c.json({ error: "Authentication required" }, 401);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,41 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import { apiKeys } from "../../db/schema.ts";
|
||||
import { apiKeys, users } from "../../db/schema.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
// ── User Management ──────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ── API Key Management ───────────────────────────────────────────────
|
||||
|
||||
export async function createApiKey(db: Db = prodDb, name: string) {
|
||||
export async function createApiKey(
|
||||
db: Db = prodDb,
|
||||
name: string,
|
||||
userId: number,
|
||||
) {
|
||||
const rawKey = randomBytes(32).toString("hex");
|
||||
const keyHash = await Bun.password.hash(rawKey);
|
||||
const keyPrefix = rawKey.slice(0, 8);
|
||||
|
||||
const [record] = await db
|
||||
.insert(apiKeys)
|
||||
.values({ name, keyHash, keyPrefix })
|
||||
.values({ name, keyHash, keyPrefix, userId })
|
||||
.returning();
|
||||
|
||||
return { ...record, rawKey };
|
||||
@@ -23,7 +44,7 @@ export async function createApiKey(db: Db = prodDb, name: string) {
|
||||
export async function verifyApiKey(
|
||||
db: Db = prodDb,
|
||||
rawKey: string,
|
||||
): Promise<boolean> {
|
||||
): Promise<{ userId: number } | null> {
|
||||
const prefix = rawKey.slice(0, 8);
|
||||
const candidates = await db
|
||||
.select()
|
||||
@@ -32,13 +53,13 @@ export async function verifyApiKey(
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const valid = await Bun.password.verify(rawKey, candidate.keyHash);
|
||||
if (valid) return true;
|
||||
if (valid) return { userId: candidate.userId };
|
||||
}
|
||||
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function listApiKeys(db: Db = prodDb) {
|
||||
export async function listApiKeys(db: Db = prodDb, userId: number) {
|
||||
return db
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
@@ -46,9 +67,16 @@ export async function listApiKeys(db: Db = prodDb) {
|
||||
keyPrefix: apiKeys.keyPrefix,
|
||||
createdAt: apiKeys.createdAt,
|
||||
})
|
||||
.from(apiKeys);
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.userId, userId));
|
||||
}
|
||||
|
||||
export async function deleteApiKey(db: Db = prodDb, id: number) {
|
||||
await db.delete(apiKeys).where(eq(apiKeys.id, id));
|
||||
export async function deleteApiKey(
|
||||
db: Db = prodDb,
|
||||
id: number,
|
||||
userId: number,
|
||||
) {
|
||||
await db
|
||||
.delete(apiKeys)
|
||||
.where(and(eq(apiKeys.id, id), eq(apiKeys.userId, userId)));
|
||||
}
|
||||
|
||||
@@ -1,53 +1,68 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import { categories, items } from "../../db/schema.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
export async function getAllCategories(db: Db = prodDb) {
|
||||
return db.select().from(categories).orderBy(asc(categories.name));
|
||||
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;
|
||||
}
|
||||
|
||||
export async function createCategory(
|
||||
export function getAllCategories(db: Db = prodDb) {
|
||||
return db.select().from(categories).orderBy(asc(categories.name)).all();
|
||||
}
|
||||
|
||||
export function createCategory(
|
||||
db: Db = prodDb,
|
||||
data: { name: string; icon?: string },
|
||||
) {
|
||||
const [row] = await db
|
||||
return db
|
||||
.insert(categories)
|
||||
.values({
|
||||
name: data.name,
|
||||
...(data.icon ? { icon: data.icon } : {}),
|
||||
})
|
||||
.returning();
|
||||
|
||||
return row;
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
export async function updateCategory(
|
||||
export function updateCategory(
|
||||
db: Db = prodDb,
|
||||
id: number,
|
||||
data: { name?: string; icon?: string },
|
||||
) {
|
||||
const [existing] = await db
|
||||
const existing = db
|
||||
.select({ id: categories.id })
|
||||
.from(categories)
|
||||
.where(eq(categories.id, id));
|
||||
.where(eq(categories.id, id))
|
||||
.get();
|
||||
|
||||
if (!existing) return null;
|
||||
|
||||
const [row] = await db
|
||||
return db
|
||||
.update(categories)
|
||||
.set(data)
|
||||
.where(eq(categories.id, id))
|
||||
.returning();
|
||||
|
||||
return row;
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
export async function deleteCategory(
|
||||
export function deleteCategory(
|
||||
db: Db = prodDb,
|
||||
id: number,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
): { success: boolean; error?: string } {
|
||||
// Guard: cannot delete Uncategorized (id=1)
|
||||
if (id === 1) {
|
||||
return {
|
||||
@@ -57,23 +72,24 @@ export async function deleteCategory(
|
||||
}
|
||||
|
||||
// Check if category exists
|
||||
const [existing] = await db
|
||||
const existing = db
|
||||
.select({ id: categories.id })
|
||||
.from(categories)
|
||||
.where(eq(categories.id, id));
|
||||
.where(eq(categories.id, id))
|
||||
.get();
|
||||
|
||||
if (!existing) {
|
||||
return { success: false, error: "Category not found" };
|
||||
}
|
||||
|
||||
// Reassign items to Uncategorized (id=1), then delete atomically
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(items)
|
||||
db.transaction(() => {
|
||||
db.update(items)
|
||||
.set({ categoryId: 1 })
|
||||
.where(eq(items.categoryId, id));
|
||||
.where(eq(items.categoryId, id))
|
||||
.run();
|
||||
|
||||
await tx.delete(categories).where(eq(categories.id, id));
|
||||
db.delete(categories).where(eq(categories.id, id)).run();
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -23,12 +23,12 @@ export async function registerClient(
|
||||
}
|
||||
|
||||
export async function getClient(db: Db = prodDb, clientId: string) {
|
||||
const [row] = await db
|
||||
const [record] = await db
|
||||
.select()
|
||||
.from(oauthClients)
|
||||
.where(eq(oauthClients.clientId, clientId));
|
||||
|
||||
return row ?? null;
|
||||
return record ?? null;
|
||||
}
|
||||
|
||||
// ── Authorization Code ───────────────────────────────────────────────
|
||||
@@ -61,6 +61,7 @@ export async function exchangeCode(
|
||||
codeVerifier: string,
|
||||
clientId: string,
|
||||
redirectUri: string,
|
||||
userId: number,
|
||||
): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
@@ -72,7 +73,7 @@ export async function exchangeCode(
|
||||
.where(eq(oauthCodes.code, code));
|
||||
|
||||
if (!record) return null;
|
||||
if (record.used !== false) return null;
|
||||
if (record.used !== 0) return null;
|
||||
if (record.clientId !== clientId) return null;
|
||||
if (record.redirectUri !== redirectUri) return null;
|
||||
if (record.expiresAt < new Date()) return null;
|
||||
@@ -87,10 +88,10 @@ export async function exchangeCode(
|
||||
// Mark code as used
|
||||
await db
|
||||
.update(oauthCodes)
|
||||
.set({ used: true })
|
||||
.set({ used: 1 })
|
||||
.where(eq(oauthCodes.code, code));
|
||||
|
||||
return generateTokens(db, clientId);
|
||||
return generateTokens(db, clientId, userId);
|
||||
}
|
||||
|
||||
// ── Token Management ─────────────────────────────────────────────────
|
||||
@@ -98,6 +99,7 @@ export async function exchangeCode(
|
||||
async function generateTokens(
|
||||
db: Db,
|
||||
clientId: string,
|
||||
userId: number,
|
||||
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
|
||||
const accessToken = randomBytes(32).toString("hex");
|
||||
const refreshToken = randomBytes(32).toString("hex");
|
||||
@@ -116,6 +118,7 @@ async function generateTokens(
|
||||
accessTokenHash,
|
||||
refreshTokenHash,
|
||||
clientId,
|
||||
userId,
|
||||
expiresAt,
|
||||
refreshExpiresAt,
|
||||
});
|
||||
@@ -126,7 +129,7 @@ async function generateTokens(
|
||||
export async function verifyAccessToken(
|
||||
db: Db = prodDb,
|
||||
token: string,
|
||||
): Promise<boolean> {
|
||||
): Promise<{ userId: number } | null> {
|
||||
const tokenHash = createHash("sha256").update(token).digest("hex");
|
||||
|
||||
const [record] = await db
|
||||
@@ -134,16 +137,17 @@ export async function verifyAccessToken(
|
||||
.from(oauthTokens)
|
||||
.where(eq(oauthTokens.accessTokenHash, tokenHash));
|
||||
|
||||
if (!record) return false;
|
||||
if (record.expiresAt < new Date()) return false;
|
||||
if (!record) return null;
|
||||
if (record.expiresAt < new Date()) return null;
|
||||
|
||||
return true;
|
||||
return { userId: record.userId };
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(
|
||||
db: Db = prodDb,
|
||||
refreshToken: string,
|
||||
clientId: string,
|
||||
userId: number,
|
||||
): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
@@ -167,7 +171,7 @@ export async function refreshAccessToken(
|
||||
// Delete old token pair
|
||||
await db.delete(oauthTokens).where(eq(oauthTokens.id, record.id));
|
||||
|
||||
return generateTokens(db, clientId);
|
||||
return generateTokens(db, clientId, userId);
|
||||
}
|
||||
|
||||
// ── Cleanup ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,17 +1,40 @@
|
||||
import { PGlite } from "@electric-sql/pglite";
|
||||
import { drizzle } from "drizzle-orm/pglite";
|
||||
import { migrate } from "drizzle-orm/pglite/migrator";
|
||||
import * as schema from "../../src/db/schema.ts";
|
||||
|
||||
export async function createTestDb() {
|
||||
const db = drizzle({ schema });
|
||||
type Db = ReturnType<typeof drizzle<typeof schema>>;
|
||||
|
||||
// Apply migrations from the new PostgreSQL migration directory
|
||||
export async function createTestDb() {
|
||||
const client = new PGlite();
|
||||
const db = drizzle(client, { schema });
|
||||
|
||||
// Apply all migrations to create tables
|
||||
await migrate(db, { migrationsFolder: "./drizzle-pg" });
|
||||
|
||||
// Seed default Uncategorized category
|
||||
// Seed a test user
|
||||
const [user] = await db
|
||||
.insert(schema.users)
|
||||
.values({ logtoSub: "test-user-sub" })
|
||||
.returning();
|
||||
|
||||
// Seed per-user Uncategorized category
|
||||
await db
|
||||
.insert(schema.categories)
|
||||
.values({ name: "Uncategorized", icon: "package" });
|
||||
.values({ name: "Uncategorized", icon: "package", userId: user.id });
|
||||
|
||||
return db;
|
||||
return { db, userId: user.id };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user