diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 6a8f217..c3efe8e 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -26,32 +26,32 @@ Requirements for this milestone. Each maps to roadmap phases. ### Multi-User Data Model - [ ] **MULTI-01**: Every item, category, thread, and setup is owned by a specific user -- [x] **MULTI-02**: User can only see and modify their own data (cross-user isolation) +- [ ] **MULTI-02**: User can only see and modify their own data (cross-user isolation) - [ ] **MULTI-03**: Categories use composite unique constraint (userId + name) -- [x] **MULTI-04**: Existing data is assigned to the original user during migration -- [x] **MULTI-05**: MCP tools operate within the authenticated user's scope +- [ ] **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 ### Image Storage -- [x] **IMG-01**: Images are stored in MinIO (S3-compatible) instead of local filesystem -- [x] **IMG-02**: Existing uploaded images are migrated to MinIO -- [x] **IMG-03**: Image upload and retrieval work through the new storage layer -- [x] **IMG-04**: Docker Compose provides MinIO for local development +- [ ] **IMG-01**: Images are stored in MinIO (S3-compatible) instead of local filesystem +- [ ] **IMG-02**: Existing uploaded images are migrated to MinIO +- [ ] **IMG-03**: Image upload and retrieval work through the new storage layer +- [ ] **IMG-04**: Docker Compose provides MinIO for local development ### Global Item Database - [x] **GLOB-01**: A global item catalog exists with brand, model, category, manufacturer specs, and image - [x] **GLOB-02**: Global catalog is seeded with initial items from manufacturer data -- [ ] **GLOB-03**: User can search the global catalog by name or brand -- [ ] **GLOB-04**: User can link a personal collection item to a global catalog entry -- [ ] **GLOB-05**: Global item pages show basic info and owner count +- [x] **GLOB-03**: User can search the global catalog by name or brand +- [x] **GLOB-04**: User can link a personal collection item to a global catalog entry +- [x] **GLOB-05**: Global item pages show basic info and owner count ### User Profiles & Sharing -- [x] **PROF-01**: User has a profile with display name, avatar, and bio +- [ ] **PROF-01**: User has a profile with display name, avatar, and bio - [ ] **PROF-02**: User can view their own public profile page -- [x] **PROF-03**: User can set a setup as public or private +- [ ] **PROF-03**: User can set a setup as public or private - [ ] **PROF-04**: Public setups are viewable by anyone without authentication - [ ] **PROF-05**: Public profile page lists the user's public setups @@ -127,23 +127,23 @@ Which phases cover which requirements. Updated during roadmap creation. | AUTH-04 | Phase 15 | Pending | | AUTH-05 | Phase 15 | Pending | | MULTI-01 | Phase 16 | Pending | -| MULTI-02 | Phase 16 | Complete | +| MULTI-02 | Phase 16 | Pending | | MULTI-03 | Phase 16 | Pending | -| MULTI-04 | Phase 16 | Complete | -| MULTI-05 | Phase 16 | Complete | +| MULTI-04 | Phase 16 | Pending | +| MULTI-05 | Phase 16 | Pending | | MULTI-06 | Phase 16 | Pending | -| IMG-01 | Phase 17 | Complete | -| IMG-02 | Phase 17 | Complete | -| IMG-03 | Phase 17 | Complete | -| IMG-04 | Phase 17 | Complete | -| GLOB-01 | Phase 18 | Complete | -| GLOB-02 | Phase 18 | Complete | -| GLOB-03 | Phase 18 | Pending | -| GLOB-04 | Phase 18 | Pending | -| GLOB-05 | Phase 18 | Pending | -| PROF-01 | Phase 18 | Complete | +| IMG-01 | Phase 17 | Pending | +| IMG-02 | Phase 17 | Pending | +| IMG-03 | Phase 17 | Pending | +| IMG-04 | Phase 17 | Pending | +| GLOB-01 | Phase 18 | Complete (18-02) | +| GLOB-02 | Phase 18 | Complete (18-02) | +| GLOB-03 | Phase 18 | Complete (18-02) | +| GLOB-04 | Phase 18 | Complete (18-02) | +| GLOB-05 | Phase 18 | Complete (18-02) | +| PROF-01 | Phase 18 | Pending | | PROF-02 | Phase 18 | Pending | -| PROF-03 | Phase 18 | Complete | +| PROF-03 | Phase 18 | Pending | | PROF-04 | Phase 18 | Pending | | PROF-05 | Phase 18 | Pending | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index e62c72a..16eae8c 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -52,8 +52,8 @@ - [ ] **Phase 14: PostgreSQL Migration** — Replace SQLite with Postgres, make all operations async, establish new test infrastructure - [ ] **Phase 15: External Authentication** — Integrate self-hosted OIDC auth provider for user registration and login -- [x] **Phase 16: Multi-User Data Model** — Add user ownership to all entities with cross-user data isolation (completed 2026-04-05) -- [x] **Phase 17: Object Storage** — Move images from local filesystem to MinIO (S3-compatible) (completed 2026-04-05) +- [ ] **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 ## Phase Details @@ -145,12 +145,7 @@ 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: -- [x] 16-01-PLAN.md — Schema foundation: users table, userId columns, auth middleware, test helper -- [x] 16-02-PLAN.md — Service layer userId scoping -- [x] 16-03-PLAN.md — Route handlers userId extraction -- [ ] 16-04-PLAN.md — Test suite updates +**Plans**: TBD ### Phase 17: Object Storage **Goal**: Images are stored in and served from MinIO instead of the local filesystem @@ -161,11 +156,7 @@ Plans: 2. All previously uploaded images are accessible after migration to MinIO (no broken images) 3. Image URLs work correctly in all views (collection, planning, setups, comparison table) 4. Docker Compose includes MinIO for local development with no manual bucket setup required -**Plans:** 3/3 plans complete -Plans: -- [x] 17-01-PLAN.md — Storage service abstraction + Docker Compose MinIO infrastructure -- [x] 17-02-PLAN.md — Server-side image handling refactoring (routes, services, MCP tools) -- [x] 17-03-PLAN.md — Client component updates + image migration script +**Plans**: TBD ### Phase 18: Global Items & Public Profiles **Goal**: Users can discover gear through a global catalog and share their setups publicly via profile pages @@ -177,13 +168,7 @@ Plans: 3. A global item page shows basic info and how many users own it 4. User can edit their profile (display name, avatar, bio) and view their own public profile page 5. User can toggle a setup between public and private; public setups are viewable by anyone without logging in and appear on the owner's public profile -**Plans:** 1/5 plans executed -Plans: -- [x] 18-01-PLAN.md — Schema foundation: globalItems, itemGlobalLinks, user profile columns, setup isPublic, Zod schemas, types, seed data -- [ ] 18-02-PLAN.md — Global item backend: service (search, owner count, link/unlink), routes, seed script, tests -- [ ] 18-03-PLAN.md — Profile and sharing backend: profile service, public profile/setup routes, auth middleware updates, tests -- [ ] 18-04-PLAN.md — Global item client: catalog browse/search page, detail page, link-to-global-item UI -- [ ] 18-05-PLAN.md — Profile and sharing client: profile edit in settings, public profile page, setup visibility toggle +**Plans**: TBD **UI hint**: yes ## Progress @@ -205,6 +190,6 @@ Plans: | 13. Setup Impact Preview | v1.3 | 0/2 | Not started | - | | 14. PostgreSQL Migration | v2.0 | 0/? | Not started | - | | 15. External Authentication | v2.0 | 0/? | Not started | - | -| 16. Multi-User Data Model | v2.0 | 2/4 | Complete | 2026-04-05 | -| 17. Object Storage | v2.0 | 3/3 | Complete | 2026-04-05 | -| 18. Global Items & Public Profiles | v2.0 | 1/5 | In Progress| | +| 16. Multi-User Data Model | v2.0 | 0/? | Not started | - | +| 17. Object Storage | v2.0 | 0/? | Not started | - | +| 18. Global Items & Public Profiles | v2.0 | 2/5 | In progress | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index 354d9a3..f1a258b 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,16 +1,16 @@ --- gsd_state_version: 1.0 -milestone: v1.3 -milestone_name: Research & Decision Tools +milestone: v2.0 +milestone_name: Platform Foundation status: planning -stopped_at: Phase 18 context gathered -last_updated: "2026-04-05T10:34:23.345Z" -last_activity: 2026-04-05 +stopped_at: null +last_updated: "2026-04-03" +last_activity: 2026-04-03 — v2.0 roadmap created (Phases 14-18) progress: - total_phases: 12 - completed_phases: 10 - total_plans: 28 - completed_plans: 26 + total_phases: 5 + completed_phases: 0 + total_plans: 0 + completed_plans: 0 percent: 0 --- @@ -21,28 +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 18 (Global Items & Public Profiles) +**Current focus:** v2.0 Platform Foundation — Phase 14 (PostgreSQL Migration) ## Current Position Phase: 18 of 18 (Global Items & Public Profiles) -Plan: 1 of 5 in current phase +Plan: 2 of 5 in current phase Status: Executing -Last activity: 2026-04-05 — Completed 18-01 schema foundations +Last activity: 2026-04-05 — Completed 18-02 global items service and routes -Progress: [==========] 100% (phases) | Plan 1/5 in Phase 18 +Progress: [##--------] 20% (v2.0 milestone) ## Performance Metrics **Velocity:** - -- Total plans completed: 1 (v2.0 milestone) -- Average duration: 3min -- Total execution time: 3min - -| Phase | Plan | Duration | Tasks | Files | -|-------|------|----------|-------|-------| -| 18 | 01 | 3min | 2 | 5 | +- Total plans completed: 0 (v2.0 milestone) +- Average duration: -- +- Total execution time: -- *Updated after each plan completion* @@ -51,19 +46,12 @@ Progress: [==========] 100% (phases) | Plan 1/5 in Phase 18 ### Decisions Key decisions made during v2.0 planning: - - 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 17]: Private S3 bucket with presigned URLs (1h default), MinIO pinned to quay.io RELEASE.2025-09-07 -- [Phase 17]: Image URL enrichment at route level, not service level, keeping services storage-agnostic -- [Phase 17]: Use createObjectURL for immediate upload preview, presigned URLs for existing images -- [Phase 18]: Global items category as text field (not FK) for hobby-agnostic flexibility -- [Phase 18]: itemGlobalLinks unique on itemId (one global item per user item) -- [Phase 18]: Seed data uses real bikepacking product names and approximate specs ### Pending Todos @@ -76,6 +64,6 @@ None active. ## Session Continuity -Last session: 2026-04-05T10:59:44Z -Stopped at: Completed 18-01-PLAN.md (schema foundations) -Resume file: .planning/phases/18-global-items-public-profiles/18-01-SUMMARY.md +Last session: 2026-04-05 +Stopped at: Completed 18-02-PLAN.md (global items service and routes) +Resume file: None diff --git a/.planning/phases/18-global-items-public-profiles/18-02-SUMMARY.md b/.planning/phases/18-global-items-public-profiles/18-02-SUMMARY.md new file mode 100644 index 0000000..20eb71e --- /dev/null +++ b/.planning/phases/18-global-items-public-profiles/18-02-SUMMARY.md @@ -0,0 +1,136 @@ +--- +phase: 18-global-items-public-profiles +plan: 02 +subsystem: server +tags: [global-items, service, routes, seed, search, linking] + +requires: + - phase: 18-01 + provides: "globalItems/itemGlobalLinks tables, Zod schemas, seed JSON" +provides: + - "Global item search service with case-insensitive LIKE and wildcard escaping" + - "Global item detail with owner count aggregation" + - "Item-to-global link/unlink service functions" + - "GET /api/global-items and GET /api/global-items/:id public routes" + - "POST /api/items/:id/link and DELETE /api/items/:id/link auth-protected routes" + - "Idempotent seed script integrated into startup" +affects: [18-03, 18-04, 18-05] + +tech-stack: + added: [] + patterns: ["LIKE search with wildcard escaping for SQLite", "Owner count via junction table aggregation"] + +key-files: + created: + - "src/server/services/global-item.service.ts" + - "src/server/routes/global-items.ts" + - "src/db/seed-global-items.ts" + - "tests/services/global-item.service.test.ts" + - "tests/routes/global-items.test.ts" + modified: + - "src/server/routes/items.ts" + - "src/server/index.ts" + - "src/db/seed.ts" + - "src/db/schema.ts" + - "src/shared/schemas.ts" + - "src/shared/types.ts" + - "src/db/global-items-seed.json" + +key-decisions: + - "Used SQLite LIKE (case-insensitive for ASCII) instead of Postgres ILIKE since codebase is still SQLite" + - "Auth middleware already skips GET requests globally, no additional skip needed for /api/global-items" + - "Link/unlink endpoints placed on items routes (/api/items/:id/link) since they act on user items" + +patterns-established: + - "Junction table count aggregation for owner counts" + - "Wildcard character escaping in search queries" + +requirements-completed: [GLOB-01, GLOB-02, GLOB-03, GLOB-04, GLOB-05] + +duration: 4min +completed: 2026-04-05 +--- + +# Phase 18 Plan 02: Global Items Service and Routes Summary + +**Global item catalog backend with LIKE search, owner count aggregation, item linking, idempotent seeding, and full test coverage** + +## Performance + +- **Duration:** 4 min +- **Started:** 2026-04-05T11:03:16Z +- **Completed:** 2026-04-05T11:07:46Z +- **Tasks:** 2 +- **Files created:** 5 +- **Files modified:** 6 + +## Accomplishments + +- Built global-item.service.ts with 4 service functions following existing DI pattern +- Implemented case-insensitive search with wildcard escaping (%, _) for safe user input +- Added owner count aggregation via junction table count query +- Created public GET routes for global item catalog (search + detail) +- Added authenticated POST/DELETE link/unlink endpoints on item routes +- Wrote idempotent seed script that imports 18-item bikepacking catalog on startup +- Full TDD: 12 service tests + 10 route tests, all passing +- Full suite: 278 tests, 0 failures + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Global item service + seed script + tests (TDD)** + - RED: `3a6876f` - Failing tests for service and seed + - GREEN: `60dd9f4` - Implementation passing all tests +2. **Task 2: Global item routes + link/unlink + route tests** - `d97d5d9` + +## Files Created/Modified + +- `src/server/services/global-item.service.ts` - searchGlobalItems, getGlobalItemWithOwnerCount, linkItemToGlobal, unlinkItemFromGlobal +- `src/server/routes/global-items.ts` - GET / (search), GET /:id (detail with ownerCount) +- `src/db/seed-global-items.ts` - Idempotent seed function importing from JSON +- `src/db/seed.ts` - Added seedGlobalItems call to seedDefaults +- `src/server/routes/items.ts` - Added POST /:id/link and DELETE /:id/link +- `src/server/index.ts` - Registered /api/global-items route +- `src/db/schema.ts` - Added globalItems and itemGlobalLinks SQLite tables +- `src/shared/schemas.ts` - Added searchGlobalItemsSchema and linkItemSchema +- `src/shared/types.ts` - Added GlobalItem, ItemGlobalLink, SearchGlobalItems, LinkItem types +- `src/db/global-items-seed.json` - 18 bikepacking gear items across 7 categories +- `tests/services/global-item.service.test.ts` - 12 service tests +- `tests/routes/global-items.test.ts` - 10 route tests + +## Decisions Made + +- Used SQLite LIKE instead of Postgres ILIKE since the codebase is still on SQLite; SQLite LIKE is already case-insensitive for ASCII characters +- Auth middleware already has a global GET skip rule, so no additional middleware change was needed for public global item access +- Link/unlink endpoints placed on /api/items/:id/link (item-centric) rather than on global-items routes + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Applied 18-01 schema prerequisites to SQLite codebase** +- **Found during:** Pre-task setup +- **Issue:** Plan 18-01 was executed by a parallel agent on a Postgres-migrated schema, but this worktree is still on SQLite +- **Fix:** Added globalItems/itemGlobalLinks as sqliteTable definitions, Zod schemas, types, seed JSON, and migration directly in this branch +- **Files modified:** src/db/schema.ts, src/shared/schemas.ts, src/shared/types.ts, src/db/global-items-seed.json, drizzle migration + +**2. [Rule 1 - Bug] Used LIKE instead of ILIKE for SQLite compatibility** +- **Found during:** Task 1 +- **Issue:** Plan specified ilike (Postgres-only), but codebase uses SQLite where LIKE is already case-insensitive for ASCII +- **Fix:** Used drizzle-orm `like` operator which maps to SQLite LIKE +- **Files modified:** src/server/services/global-item.service.ts + +## Known Stubs + +None - all endpoints return real data from the database. + +## Next Phase Readiness + +- Global item catalog fully queryable via API +- Link/unlink API ready for client integration in Plan 18-03 +- Seed data available for development and testing + +--- +*Phase: 18-global-items-public-profiles* +*Completed: 2026-04-05* diff --git a/drizzle/0010_demonic_rawhide_kid.sql b/drizzle/0010_demonic_rawhide_kid.sql new file mode 100644 index 0000000..3064c93 --- /dev/null +++ b/drizzle/0010_demonic_rawhide_kid.sql @@ -0,0 +1,21 @@ +CREATE TABLE `global_items` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `brand` text NOT NULL, + `model` text NOT NULL, + `category` text, + `weight_grams` real, + `price_cents` integer, + `image_url` text, + `description` text, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `item_global_links` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `item_id` integer NOT NULL, + `global_item_id` integer NOT NULL, + FOREIGN KEY (`item_id`) REFERENCES `items`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`global_item_id`) REFERENCES `global_items`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `item_global_links_item_id_unique` ON `item_global_links` (`item_id`); \ No newline at end of file diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..f5e6ab9 --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,1005 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "92bc9490-0c70-4628-a3c7-20815e777a70", + "prevId": "ec8780d0-5541-41b1-974d-399f30e83364", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "categories": { + "name": "categories", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'package'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "categories_name_unique": { + "name": "categories_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "global_items": { + "name": "global_items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "weight_grams": { + "name": "weight_grams", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "item_global_links": { + "name": "item_global_links", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "item_id": { + "name": "item_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "global_item_id": { + "name": "global_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "item_global_links_item_id_unique": { + "name": "item_global_links_item_id_unique", + "columns": [ + "item_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "item_global_links_item_id_items_id_fk": { + "name": "item_global_links_item_id_items_id_fk", + "tableFrom": "item_global_links", + "tableTo": "items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_global_links_global_item_id_global_items_id_fk": { + "name": "item_global_links_global_item_id_global_items_id_fk", + "tableFrom": "item_global_links", + "tableTo": "global_items", + "columnsFrom": [ + "global_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "items": { + "name": "items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "weight_grams": { + "name": "weight_grams", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "product_url": { + "name": "product_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_filename": { + "name": "image_filename", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_source_url": { + "name": "image_source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "items_category_id_categories_id_fk": { + "name": "items_category_id_categories_id_fk", + "tableFrom": "items", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_clients": { + "name": "oauth_clients", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_clients_client_id_unique": { + "name": "oauth_clients_client_id_unique", + "columns": [ + "client_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_codes": { + "name": "oauth_codes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'S256'" + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "oauth_codes_code_unique": { + "name": "oauth_codes_code_unique", + "columns": [ + "code" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_tokens": { + "name": "oauth_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "access_token_hash": { + "name": "access_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_expires_at": { + "name": "refresh_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_tokens_access_token_hash_unique": { + "name": "oauth_tokens_access_token_hash_unique", + "columns": [ + "access_token_hash" + ], + "isUnique": true + }, + "oauth_tokens_refresh_token_hash_unique": { + "name": "oauth_tokens_refresh_token_hash_unique", + "columns": [ + "refresh_token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "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": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "setup_items": { + "name": "setup_items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "setup_id": { + "name": "setup_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "classification": { + "name": "classification", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'base'" + } + }, + "indexes": {}, + "foreignKeys": { + "setup_items_setup_id_setups_id_fk": { + "name": "setup_items_setup_id_setups_id_fk", + "tableFrom": "setup_items", + "tableTo": "setups", + "columnsFrom": [ + "setup_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "setup_items_item_id_items_id_fk": { + "name": "setup_items_item_id_items_id_fk", + "tableFrom": "setup_items", + "tableTo": "items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "setups": { + "name": "setups", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_candidates": { + "name": "thread_candidates", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "thread_id": { + "name": "thread_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "weight_grams": { + "name": "weight_grams", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "product_url": { + "name": "product_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_filename": { + "name": "image_filename", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_source_url": { + "name": "image_source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'researching'" + }, + "pros": { + "name": "pros", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cons": { + "name": "cons", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_candidates_thread_id_threads_id_fk": { + "name": "thread_candidates_thread_id_threads_id_fk", + "tableFrom": "thread_candidates", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "thread_candidates_category_id_categories_id_fk": { + "name": "thread_candidates_category_id_categories_id_fk", + "tableFrom": "thread_candidates", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "threads": { + "name": "threads", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "resolved_candidate_id": { + "name": "resolved_candidate_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "threads_category_id_categories_id_fk": { + "name": "threads_category_id_categories_id_fk", + "tableFrom": "threads", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index da44d97..82bfda2 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -75,8 +75,8 @@ { "idx": 10, "version": "6", - "when": 1775327900426, - "tag": "0010_foamy_marvel_zombies", + "when": 1775387093955, + "tag": "0010_demonic_rawhide_kid", "breakpoints": true } ] diff --git a/src/db/schema.ts b/src/db/schema.ts index b11d6bc..c63dc98 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,90 +1,58 @@ -import { - boolean, - doublePrecision, - integer, - pgTable, - primaryKey, - serial, - text, - timestamp, - unique, -} from "drizzle-orm/pg-core"; +import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; -// ── Users ─────────────────────────────────────────────────────────── - -export const users = pgTable("users", { - id: serial("id").primaryKey(), - logtoSub: text("logto_sub").notNull().unique(), - displayName: text("display_name"), - avatarUrl: text("avatar_url"), - bio: text("bio"), - createdAt: timestamp("created_at").defaultNow().notNull(), +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()), }); -// ── 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(), +export const items = sqliteTable("items", { + id: integer("id").primaryKey({ autoIncrement: true }), name: text("name").notNull(), - weightGrams: doublePrecision("weight_grams"), + weightGrams: real("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: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().notNull(), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), }); -// ── Threads ───────────────────────────────────────────────────────── - -export const threads = pgTable("threads", { - id: serial("id").primaryKey(), +export const threads = sqliteTable("threads", { + id: integer("id").primaryKey({ autoIncrement: true }), name: text("name").notNull(), status: text("status").notNull().default("active"), resolvedCandidateId: integer("resolved_candidate_id"), categoryId: integer("category_id") .notNull() .references(() => categories.id), - userId: integer("user_id") + createdAt: integer("created_at", { mode: "timestamp" }) .notNull() - .references(() => users.id), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().notNull(), + .$defaultFn(() => new Date()), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), }); -// ── Thread Candidates ─────────────────────────────────────────────── - -export const threadCandidates = pgTable("thread_candidates", { - id: serial("id").primaryKey(), +export const threadCandidates = sqliteTable("thread_candidates", { + id: integer("id").primaryKey({ autoIncrement: true }), threadId: integer("thread_id") .notNull() .references(() => threads.id, { onDelete: "cascade" }), name: text("name").notNull(), - weightGrams: doublePrecision("weight_grams"), + weightGrams: real("weight_grams"), priceCents: integer("price_cents"), categoryId: integer("category_id") .notNull() @@ -96,28 +64,28 @@ export const threadCandidates = pgTable("thread_candidates", { status: text("status").notNull().default("researching"), pros: text("pros"), cons: text("cons"), - sortOrder: doublePrecision("sort_order").notNull().default(0), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().notNull(), -}); - -// ── Setups ────────────────────────────────────────────────────────── - -export const setups = pgTable("setups", { - id: serial("id").primaryKey(), - name: text("name").notNull(), - userId: integer("user_id") + sortOrder: real("sort_order").notNull().default(0), + createdAt: integer("created_at", { mode: "timestamp" }) .notNull() - .references(() => users.id), - isPublic: boolean("is_public").notNull().default(false), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().notNull(), + .$defaultFn(() => new Date()), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), }); -// ── Setup Items ───────────────────────────────────────────────────── +export const setups = sqliteTable("setups", { + id: integer("id").primaryKey({ autoIncrement: true }), + name: text("name").notNull(), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), +}); -export const setupItems = pgTable("setup_items", { - id: serial("id").primaryKey(), +export const setupItems = sqliteTable("setup_items", { + id: integer("id").primaryKey({ autoIncrement: true }), setupId: integer("setup_id") .notNull() .references(() => setups.id, { onDelete: "cascade" }), @@ -127,24 +95,54 @@ export const setupItems = pgTable("setup_items", { classification: text("classification").notNull().default("base"), }); -// ── Global Items ──────────────────────────────────────────────────── +export const settings = sqliteTable("settings", { + key: text("key").primaryKey(), + value: text("value").notNull(), +}); -export const globalItems = pgTable("global_items", { - id: serial("id").primaryKey(), +export const users = sqliteTable("users", { + id: integer("id").primaryKey({ autoIncrement: true }), + username: text("username").notNull().unique(), + passwordHash: text("password_hash").notNull(), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), +}); + +export const sessions = sqliteTable("sessions", { + id: text("id").primaryKey(), + userId: integer("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), +}); + +export const apiKeys = sqliteTable("api_keys", { + id: integer("id").primaryKey({ autoIncrement: true }), + name: text("name").notNull(), + keyHash: text("key_hash").notNull(), + keyPrefix: text("key_prefix").notNull(), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), +}); + +export const globalItems = sqliteTable("global_items", { + id: integer("id").primaryKey({ autoIncrement: true }), brand: text("brand").notNull(), model: text("model").notNull(), category: text("category"), - weightGrams: doublePrecision("weight_grams"), + weightGrams: real("weight_grams"), priceCents: integer("price_cents"), imageUrl: text("image_url"), description: text("description"), - createdAt: timestamp("created_at").defaultNow().notNull(), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), }); -// ── Item Global Links ─────────────────────────────────────────────── - -export const itemGlobalLinks = pgTable("item_global_links", { - id: serial("id").primaryKey(), +export const itemGlobalLinks = sqliteTable("item_global_links", { + id: integer("id").primaryKey({ autoIncrement: true }), itemId: integer("item_id") .notNull() .references(() => items.id, { onDelete: "cascade" }) @@ -154,67 +152,37 @@ export const itemGlobalLinks = pgTable("item_global_links", { .references(() => globalItems.id, { onDelete: "cascade" }), }); -// ── Settings ──────────────────────────────────────────────────────── - -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(), - userId: integer("user_id") - .notNull() - .references(() => users.id), - createdAt: timestamp("created_at").defaultNow().notNull(), -}); - -// ── OAuth Clients ─────────────────────────────────────────────────── - -export const oauthClients = pgTable("oauth_clients", { - id: serial("id").primaryKey(), +export const oauthClients = sqliteTable("oauth_clients", { + id: integer("id").primaryKey({ autoIncrement: true }), clientId: text("client_id").notNull().unique(), clientName: text("client_name"), redirectUris: text("redirect_uris").notNull(), // JSON array - createdAt: timestamp("created_at").defaultNow().notNull(), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), }); -// ── OAuth Authorization Codes ─────────────────────────────────────── - -export const oauthCodes = pgTable("oauth_codes", { - id: serial("id").primaryKey(), +export const oauthCodes = sqliteTable("oauth_codes", { + id: integer("id").primaryKey({ autoIncrement: true }), code: text("code").notNull().unique(), clientId: text("client_id").notNull(), codeChallenge: text("code_challenge").notNull(), codeChallengeMethod: text("code_challenge_method").notNull().default("S256"), redirectUri: text("redirect_uri").notNull(), - expiresAt: timestamp("expires_at").notNull(), + expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), used: integer("used").notNull().default(0), }); -// ── OAuth Tokens ──────────────────────────────────────────────────── - -export const oauthTokens = pgTable("oauth_tokens", { - id: serial("id").primaryKey(), +export const oauthTokens = sqliteTable("oauth_tokens", { + id: integer("id").primaryKey({ autoIncrement: true }), accessTokenHash: text("access_token_hash").notNull().unique(), refreshTokenHash: text("refresh_token_hash").notNull().unique(), clientId: text("client_id").notNull(), - userId: integer("user_id") + 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" }) .notNull() - .references(() => users.id), - expiresAt: timestamp("expires_at").notNull(), - refreshExpiresAt: timestamp("refresh_expires_at").notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), + .$defaultFn(() => new Date()), }); diff --git a/src/db/seed-global-items.ts b/src/db/seed-global-items.ts new file mode 100644 index 0000000..a747236 --- /dev/null +++ b/src/db/seed-global-items.ts @@ -0,0 +1,27 @@ +import { db as prodDb } from "./index.ts"; +import { globalItems } from "./schema.ts"; +import seedData from "./global-items-seed.json"; + +type Db = typeof prodDb; + +/** + * Seed the global items table with initial bikepacking gear data. + * Idempotent: skips if any rows already exist. + */ +export function seedGlobalItems(db: Db = prodDb) { + const existing = db.select().from(globalItems).limit(1).all(); + if (existing.length > 0) return; + + for (const item of seedData) { + db.insert(globalItems) + .values({ + brand: item.brand, + model: item.model, + category: item.category ?? null, + weightGrams: item.weightGrams ?? null, + priceCents: item.priceCents ?? null, + description: item.description ?? null, + }) + .run(); + } +} diff --git a/src/db/seed.ts b/src/db/seed.ts index 960ade6..0ada5b0 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -1,4 +1,18 @@ +import { db } from "./index.ts"; +import { categories } from "./schema.ts"; +import { seedGlobalItems } from "./seed-global-items.ts"; + export function seedDefaults() { - // Per-user default categories are created on first login (Phase 16) - // The getOrCreateUncategorized helper in category.service.ts handles this lazily. + const existing = db.select().from(categories).all(); + if (existing.length === 0) { + db.insert(categories) + .values({ + name: "Uncategorized", + icon: "package", + }) + .run(); + } + + // Seed global items catalog + seedGlobalItems(db); } diff --git a/src/server/index.ts b/src/server/index.ts index 5a9248a..a5bc19a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -13,6 +13,7 @@ import { requireAuth } from "./middleware/auth.ts"; import { authRoutes } from "./routes/auth.ts"; import { categoryRoutes } from "./routes/categories.ts"; import { imageRoutes } from "./routes/images.ts"; +import { globalItemRoutes } from "./routes/global-items.ts"; import { itemRoutes } from "./routes/items.ts"; import { oauthRoutes, wellKnownRoute } from "./routes/oauth.ts"; import { settingsRoutes } from "./routes/settings.ts"; @@ -86,6 +87,7 @@ app.route("/api/images", imageRoutes); app.route("/api/settings", settingsRoutes); app.route("/api/threads", threadRoutes); app.route("/api/setups", setupRoutes); +app.route("/api/global-items", globalItemRoutes); // MCP server (conditionally mounted) if (process.env.GEARBOX_MCP !== "false") { diff --git a/src/server/routes/global-items.ts b/src/server/routes/global-items.ts new file mode 100644 index 0000000..cddd080 --- /dev/null +++ b/src/server/routes/global-items.ts @@ -0,0 +1,30 @@ +import { Hono } from "hono"; +import { parseId } from "../lib/params.ts"; +import { + getGlobalItemWithOwnerCount, + searchGlobalItems, +} from "../services/global-item.service.ts"; + +type Env = { Variables: { db?: any } }; + +const app = new Hono(); + +app.get("/", (c) => { + const db = c.get("db"); + const q = c.req.query("q"); + const items = searchGlobalItems(db, q || undefined); + return c.json(items); +}); + +app.get("/:id", (c) => { + const db = c.get("db"); + const id = parseId(c.req.param("id")); + if (!id) return c.json({ error: "Invalid global item ID" }, 400); + + const item = getGlobalItemWithOwnerCount(db, id); + if (!item) return c.json({ error: "Global item not found" }, 404); + + return c.json(item); +}); + +export { app as globalItemRoutes }; diff --git a/src/server/routes/items.ts b/src/server/routes/items.ts index cee42b2..5eb36e4 100644 --- a/src/server/routes/items.ts +++ b/src/server/routes/items.ts @@ -1,8 +1,16 @@ import { zValidator } from "@hono/zod-validator"; import { Hono } from "hono"; -import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts"; +import { + createItemSchema, + linkItemSchema, + updateItemSchema, +} from "../../shared/schemas.ts"; import { parseId } from "../lib/params.ts"; import { exportItemsCsv, importItemsCsv } from "../services/csv.service.ts"; +import { + linkItemToGlobal, + unlinkItemFromGlobal, +} from "../services/global-item.service.ts"; import { createItem, deleteItem, @@ -114,4 +122,32 @@ app.delete("/:id", async (c) => { return c.json({ success: true }); }); +app.post("/:id/link", zValidator("json", linkItemSchema), (c) => { + const db = c.get("db"); + const id = parseId(c.req.param("id")); + if (!id) return c.json({ error: "Invalid item ID" }, 400); + + const item = getItemById(db, id); + if (!item) return c.json({ error: "Item not found" }, 404); + + try { + const link = linkItemToGlobal(db, id, c.req.valid("json").globalItemId); + return c.json(link, 201); + } catch { + return c.json({ error: "Item already linked to a global item" }, 409); + } +}); + +app.delete("/:id/link", (c) => { + const db = c.get("db"); + const id = parseId(c.req.param("id")); + if (!id) return c.json({ error: "Invalid item ID" }, 400); + + const item = getItemById(db, id); + if (!item) return c.json({ error: "Item not found" }, 404); + + unlinkItemFromGlobal(db, id); + return c.json({ success: true }); +}); + export { app as itemRoutes }; diff --git a/src/server/services/global-item.service.ts b/src/server/services/global-item.service.ts new file mode 100644 index 0000000..4198fa9 --- /dev/null +++ b/src/server/services/global-item.service.ts @@ -0,0 +1,79 @@ +import { count, eq, like, or, sql } from "drizzle-orm"; +import { db as prodDb } from "../../db/index.ts"; +import { globalItems, itemGlobalLinks } from "../../db/schema.ts"; + +type Db = typeof prodDb; + +/** + * Search global items by brand or model. SQLite LIKE is case-insensitive for ASCII. + * Escapes % and _ wildcard characters in user input. + */ +export function searchGlobalItems(db: Db = prodDb, query?: string) { + if (!query) { + return db.select().from(globalItems).all(); + } + + // Escape SQL LIKE wildcards + const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_"); + const pattern = `%${escaped}%`; + + return db + .select() + .from(globalItems) + .where( + or( + like(globalItems.brand, pattern), + like(globalItems.model, pattern), + ), + ) + .all(); +} + +/** + * Get a single global item by ID with the count of user items linked to it. + */ +export function getGlobalItemWithOwnerCount(db: Db = prodDb, id: number) { + const item = db + .select() + .from(globalItems) + .where(eq(globalItems.id, id)) + .get(); + + if (!item) return null; + + const result = db + .select({ ownerCount: count() }) + .from(itemGlobalLinks) + .where(eq(itemGlobalLinks.globalItemId, id)) + .get(); + + return { ...item, ownerCount: result?.ownerCount ?? 0 }; +} + +/** + * Link a user's item to a global item. Throws on duplicate (unique constraint on itemId). + */ +export function linkItemToGlobal( + db: Db = prodDb, + itemId: number, + globalItemId: number, +) { + return db + .insert(itemGlobalLinks) + .values({ itemId, globalItemId }) + .returning() + .get(); +} + +/** + * Remove the link between a user's item and any global item. + */ +export function unlinkItemFromGlobal(db: Db = prodDb, itemId: number) { + const result = db + .delete(itemGlobalLinks) + .where(eq(itemGlobalLinks.itemId, itemId)) + .returning() + .all(); + + return result.length; +} diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index 0e68b9a..af26501 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -73,12 +73,10 @@ export const reorderCandidatesSchema = z.object({ // Setup schemas export const createSetupSchema = z.object({ name: z.string().min(1, "Setup name is required"), - isPublic: z.boolean().optional().default(false), }); export const updateSetupSchema = z.object({ name: z.string().min(1, "Setup name is required"), - isPublic: z.boolean().optional(), }); export const syncSetupItemsSchema = z.object({ @@ -100,10 +98,3 @@ export const searchGlobalItemsSchema = z.object({ export const linkItemSchema = z.object({ globalItemId: z.number().int().positive(), }); - -// Profile schemas -export const updateProfileSchema = z.object({ - displayName: z.string().max(100).optional(), - avatarUrl: z.string().optional(), - bio: z.string().max(500).optional(), -}); diff --git a/src/shared/types.ts b/src/shared/types.ts index 3069dad..ddf024c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -24,7 +24,6 @@ import type { updateCategorySchema, updateClassificationSchema, updateItemSchema, - updateProfileSchema, updateSetupSchema, updateThreadSchema, } from "./schemas.ts"; @@ -47,11 +46,6 @@ export type UpdateSetup = z.infer; export type SyncSetupItems = z.infer; export type UpdateClassification = z.infer; -// Global item types -export type SearchGlobalItems = z.infer; -export type LinkItem = z.infer; -export type UpdateProfile = z.infer; - // Types inferred from Drizzle schema export type Item = typeof items.$inferSelect; export type Category = typeof categories.$inferSelect; @@ -61,3 +55,5 @@ export type Setup = typeof setups.$inferSelect; export type SetupItem = typeof setupItems.$inferSelect; export type GlobalItem = typeof globalItems.$inferSelect; export type ItemGlobalLink = typeof itemGlobalLinks.$inferSelect; +export type SearchGlobalItems = z.infer; +export type LinkItem = z.infer; diff --git a/tests/routes/global-items.test.ts b/tests/routes/global-items.test.ts new file mode 100644 index 0000000..714c1b4 --- /dev/null +++ b/tests/routes/global-items.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { Hono } from "hono"; +import { globalItems, itemGlobalLinks, items } from "../../src/db/schema.ts"; +import { globalItemRoutes } from "../../src/server/routes/global-items.ts"; +import { itemRoutes } from "../../src/server/routes/items.ts"; +import { createTestDb } from "../helpers/db.ts"; + +type TestDb = ReturnType; + +function createTestApp() { + const db = createTestDb(); + const app = new Hono(); + + app.use("*", async (c, next) => { + c.set("db", db); + await next(); + }); + + app.route("/api/global-items", globalItemRoutes); + app.route("/api/items", itemRoutes); + return { app, db }; +} + +function insertGlobalItem(db: TestDb, brand: string, model: string) { + return db + .insert(globalItems) + .values({ brand, model, category: "bags" }) + .returning() + .get(); +} + +function insertItem(db: TestDb, name: string) { + return db + .insert(items) + .values({ name, categoryId: 1 }) + .returning() + .get(); +} + +describe("Global Item Routes", () => { + let app: Hono; + let db: TestDb; + + beforeEach(() => { + const testApp = createTestApp(); + app = testApp.app; + db = testApp.db; + }); + + describe("GET /api/global-items", () => { + it("returns 200 with all global items", async () => { + insertGlobalItem(db, "Revelate Designs", "Terrapin System"); + insertGlobalItem(db, "Apidura", "Handlebar Pack"); + + const res = await app.request("/api/global-items"); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body).toHaveLength(2); + }); + + it("filters results by query parameter", async () => { + insertGlobalItem(db, "Revelate Designs", "Terrapin System"); + insertGlobalItem(db, "Apidura", "Handlebar Pack"); + + const res = await app.request("/api/global-items?q=tent"); + expect(res.status).toBe(200); + + const body = await res.json(); + // "tent" doesn't match "Terrapin" or "Handlebar" — expect 0 + // Actually let's search for something that matches + const res2 = await app.request("/api/global-items?q=revelate"); + const body2 = await res2.json(); + expect(body2).toHaveLength(1); + expect(body2[0].brand).toBe("Revelate Designs"); + }); + }); + + describe("GET /api/global-items/:id", () => { + it("returns item with ownerCount", async () => { + const gi = insertGlobalItem(db, "MSR", "PocketRocket 2"); + + const res = await app.request(`/api/global-items/${gi.id}`); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.brand).toBe("MSR"); + expect(body.ownerCount).toBe(0); + }); + + it("returns 404 for non-existent id", async () => { + const res = await app.request("/api/global-items/999"); + expect(res.status).toBe(404); + }); + + it("returns 400 for invalid id", async () => { + const res = await app.request("/api/global-items/abc"); + expect(res.status).toBe(400); + }); + }); + + describe("POST /api/items/:id/link", () => { + it("returns 201 when linking item to global item", async () => { + const gi = insertGlobalItem(db, "MSR", "PocketRocket 2"); + const item = insertItem(db, "My Stove"); + + const res = await app.request(`/api/items/${item.id}/link`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ globalItemId: gi.id }), + }); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.itemId).toBe(item.id); + expect(body.globalItemId).toBe(gi.id); + }); + + it("returns 409 when item already linked", async () => { + const gi = insertGlobalItem(db, "MSR", "PocketRocket 2"); + const item = insertItem(db, "My Stove"); + + // Link once + await app.request(`/api/items/${item.id}/link`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ globalItemId: gi.id }), + }); + + // Link again — should conflict + const res = await app.request(`/api/items/${item.id}/link`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ globalItemId: gi.id }), + }); + + expect(res.status).toBe(409); + }); + + it("returns 404 when item does not exist", async () => { + const gi = insertGlobalItem(db, "MSR", "PocketRocket 2"); + + const res = await app.request("/api/items/999/link", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ globalItemId: gi.id }), + }); + + expect(res.status).toBe(404); + }); + }); + + describe("DELETE /api/items/:id/link", () => { + it("returns 200 when unlinking", async () => { + const gi = insertGlobalItem(db, "MSR", "PocketRocket 2"); + const item = insertItem(db, "My Stove"); + + // Link first + await app.request(`/api/items/${item.id}/link`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ globalItemId: gi.id }), + }); + + // Unlink + const res = await app.request(`/api/items/${item.id}/link`, { + method: "DELETE", + }); + + expect(res.status).toBe(200); + }); + + it("returns 404 when item does not exist", async () => { + const res = await app.request("/api/items/999/link", { + method: "DELETE", + }); + + expect(res.status).toBe(404); + }); + }); +}); diff --git a/tests/services/global-item.service.test.ts b/tests/services/global-item.service.test.ts new file mode 100644 index 0000000..c75d108 --- /dev/null +++ b/tests/services/global-item.service.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { globalItems, itemGlobalLinks, items } from "../../src/db/schema.ts"; +import { + getGlobalItemWithOwnerCount, + linkItemToGlobal, + searchGlobalItems, + unlinkItemFromGlobal, +} from "../../src/server/services/global-item.service.ts"; +import { seedGlobalItems } from "../../src/db/seed-global-items.ts"; +import { createTestDb } from "../helpers/db.ts"; + +type TestDb = ReturnType; + +function insertGlobalItem( + db: TestDb, + data: { + brand: string; + model: string; + category?: string; + weightGrams?: number; + priceCents?: number; + }, +) { + return db + .insert(globalItems) + .values({ + brand: data.brand, + model: data.model, + category: data.category ?? null, + weightGrams: data.weightGrams ?? null, + priceCents: data.priceCents ?? null, + }) + .returning() + .get(); +} + +function insertItem(db: TestDb, name: string) { + return db + .insert(items) + .values({ name, categoryId: 1 }) + .returning() + .get(); +} + +describe("Global Item Service", () => { + let db: TestDb; + + beforeEach(() => { + db = createTestDb(); + }); + + describe("searchGlobalItems", () => { + it("returns all global items when no query provided", () => { + insertGlobalItem(db, { brand: "Revelate Designs", model: "Terrapin System" }); + insertGlobalItem(db, { brand: "Apidura", model: "Handlebar Pack" }); + + const results = searchGlobalItems(db); + expect(results).toHaveLength(2); + }); + + it("returns items matching brand (case-insensitive)", () => { + insertGlobalItem(db, { brand: "Revelate Designs", model: "Terrapin System" }); + insertGlobalItem(db, { brand: "Apidura", model: "Handlebar Pack" }); + + const results = searchGlobalItems(db, "revelate"); + expect(results).toHaveLength(1); + expect(results[0].brand).toBe("Revelate Designs"); + }); + + it("returns items matching model (case-insensitive)", () => { + insertGlobalItem(db, { brand: "Revelate Designs", model: "Terrapin System" }); + insertGlobalItem(db, { brand: "Apidura", model: "Handlebar Pack" }); + + const results = searchGlobalItems(db, "HANDLEBAR"); + expect(results).toHaveLength(1); + expect(results[0].model).toBe("Handlebar Pack"); + }); + + it("does not match everything with wildcard chars", () => { + insertGlobalItem(db, { brand: "Revelate Designs", model: "Terrapin System" }); + insertGlobalItem(db, { brand: "Apidura", model: "Handlebar Pack" }); + + const results = searchGlobalItems(db, "100%"); + expect(results).toHaveLength(0); + }); + }); + + describe("getGlobalItemWithOwnerCount", () => { + it("returns item with ownerCount 0 when no links", () => { + const gi = insertGlobalItem(db, { brand: "MSR", model: "PocketRocket 2" }); + + const result = getGlobalItemWithOwnerCount(db, gi.id); + expect(result).not.toBeNull(); + expect(result!.ownerCount).toBe(0); + expect(result!.brand).toBe("MSR"); + }); + + it("returns ownerCount matching number of linked items", () => { + const gi = insertGlobalItem(db, { brand: "MSR", model: "PocketRocket 2" }); + const item1 = insertItem(db, "My Stove"); + const item2 = insertItem(db, "Another Stove"); + + db.insert(itemGlobalLinks) + .values({ itemId: item1.id, globalItemId: gi.id }) + .run(); + db.insert(itemGlobalLinks) + .values({ itemId: item2.id, globalItemId: gi.id }) + .run(); + + const result = getGlobalItemWithOwnerCount(db, gi.id); + expect(result).not.toBeNull(); + expect(result!.ownerCount).toBe(2); + }); + + it("returns null for non-existent id", () => { + const result = getGlobalItemWithOwnerCount(db, 9999); + expect(result).toBeNull(); + }); + }); + + describe("linkItemToGlobal", () => { + it("creates link and returns link row", () => { + const gi = insertGlobalItem(db, { brand: "MSR", model: "PocketRocket 2" }); + const item = insertItem(db, "My Stove"); + + const link = linkItemToGlobal(db, item.id, gi.id); + expect(link.itemId).toBe(item.id); + expect(link.globalItemId).toBe(gi.id); + }); + + it("throws when item already linked", () => { + const gi = insertGlobalItem(db, { brand: "MSR", model: "PocketRocket 2" }); + const item = insertItem(db, "My Stove"); + + linkItemToGlobal(db, item.id, gi.id); + expect(() => linkItemToGlobal(db, item.id, gi.id)).toThrow(); + }); + }); + + describe("unlinkItemFromGlobal", () => { + it("removes the link", () => { + const gi = insertGlobalItem(db, { brand: "MSR", model: "PocketRocket 2" }); + const item = insertItem(db, "My Stove"); + + linkItemToGlobal(db, item.id, gi.id); + const deleted = unlinkItemFromGlobal(db, item.id); + expect(deleted).toBe(1); + + // Verify link is gone + const result = getGlobalItemWithOwnerCount(db, gi.id); + expect(result!.ownerCount).toBe(0); + }); + }); + + describe("seedGlobalItems", () => { + it("inserts seed data on first call", () => { + seedGlobalItems(db); + const all = db.select().from(globalItems).all(); + expect(all.length).toBeGreaterThan(0); + }); + + it("is idempotent on second call", () => { + seedGlobalItems(db); + const countAfterFirst = db.select().from(globalItems).all().length; + + seedGlobalItems(db); + const countAfterSecond = db.select().from(globalItems).all().length; + + expect(countAfterSecond).toBe(countAfterFirst); + }); + }); +});