diff --git a/.planning/STATE.md b/.planning/STATE.md index e13c5a7..7e46721 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,12 +2,12 @@ gsd_state_version: 1.0 milestone: v1.3 milestone_name: Research & Decision Tools -status: Ready to discuss/plan -stopped_at: Phase 19 context gathered -last_updated: "2026-04-05T18:04:19.312Z" +status: planning +stopped_at: Completed 19-01-PLAN.md +last_updated: "2026-04-05T11:22:25.312Z" last_activity: 2026-04-05 progress: - total_phases: 13 + total_phases: 12 completed_phases: 11 total_plans: 33 completed_plans: 31 @@ -21,16 +21,16 @@ 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 19 (Reference Item Model & Tags Schema) +**Current focus:** v2.0 Platform Foundation — Phase 14 (PostgreSQL Migration) ## Current Position -Phase: 19 of 22 (Reference Item Model & Tags Schema) -Plan: Not started -Status: Ready to discuss/plan +Phase: 19 of 19 (Reference Item Model & Tags Schema) +Plan: 1 of 3 +Status: Executing Last activity: 2026-04-05 -Progress: [----------] 0% (v2.0 milestone) +Progress: [#---------] 3% (v2.0 milestone) ## Performance Metrics @@ -55,6 +55,9 @@ Key decisions made during v2.0 planning: - Separate globalItems table — not a flag on user items table - Single-user SQLite mode diverges at v2.0 boundary - [Phase 18]: Profile data loaded via usePublicProfile(userId) not /auth/me extension +- [Phase 19]: Direct globalItemId FK on items replaces itemGlobalLinks junction table +- [Phase 19]: Data migration SQL: UPDATE items before DROP TABLE item_global_links +- [Phase 19]: Flat tags system without type categorization per D-16 ### Pending Todos @@ -67,6 +70,6 @@ None active. ## Session Continuity -Last session: 2026-04-05T18:04:19.310Z -Stopped at: Phase 19 context gathered -Resume file: .planning/phases/19-reference-item-model-tags-schema/19-CONTEXT.md +Last session: 2026-04-05T18:28:00Z +Stopped at: Completed 19-01-PLAN.md +Resume file: None diff --git a/.planning/phases/19-reference-item-model-tags-schema/19-01-SUMMARY.md b/.planning/phases/19-reference-item-model-tags-schema/19-01-SUMMARY.md new file mode 100644 index 0000000..41332df --- /dev/null +++ b/.planning/phases/19-reference-item-model-tags-schema/19-01-SUMMARY.md @@ -0,0 +1,117 @@ +--- +phase: 19-reference-item-model-tags-schema +plan: 01 +subsystem: database +tags: [drizzle, postgres, schema, migration, tags, reference-items] + +requires: + - phase: 18-global-items-public-profiles + provides: globalItems table and itemGlobalLinks junction table +provides: + - items.globalItemId direct FK replacing itemGlobalLinks junction table + - items.purchasePriceCents for user-specific purchase price tracking + - threadCandidates.globalItemId for catalog-linked candidates + - tags and globalItemTags tables for tag-based discovery + - Zod schemas with globalItemId and purchasePriceCents fields + - Tag and GlobalItemTag TypeScript types + - 30 curated seed tags for outdoor/adventure gear +affects: [19-02, 19-03, global-item-service, item-service, thread-service] + +tech-stack: + added: [] + patterns: + - "Reference item model: nullable globalItemId FK on items replaces junction table" + - "Tag system: flat tags table with many-to-many via globalItemTags" + +key-files: + created: + - drizzle-pg/0002_wakeful_vermin.sql + modified: + - src/db/schema.ts + - src/shared/schemas.ts + - src/shared/types.ts + - src/db/seed-global-items.ts + +key-decisions: + - "Data migration in SQL: UPDATE items SET global_item_id before DROP TABLE item_global_links" + - "Seed tags as flat list without type categorization per D-16" + +patterns-established: + - "Reference items: globalItemId nullable FK on items table, when set base data comes from global item" + - "Tag seeding: idempotent async seedTags function alongside seedGlobalItems" + +requirements-completed: [CATFLOW-03, TAG-01, TAG-02] + +duration: 4min +completed: 2026-04-05 +--- + +# Phase 19 Plan 01: Reference Item Model & Tags Schema Summary + +**Database schema updated with direct globalItemId FK on items/candidates, tags system tables, and data migration from itemGlobalLinks** + +## Performance + +- **Duration:** 4 min +- **Started:** 2026-04-05T18:23:49Z +- **Completed:** 2026-04-05T18:28:00Z +- **Tasks:** 2 +- **Files modified:** 5 + +## Accomplishments +- Added globalItemId and purchasePriceCents columns to items table, globalItemId to threadCandidates +- Created tags and globalItemTags tables for tag-based global item discovery +- Removed itemGlobalLinks junction table with safe data migration in SQL +- Updated Zod schemas with new fields, removed linkItemSchema +- Converted seed script to async with 30 curated outdoor/adventure tags + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Update schema.ts, generate migration with data migration step** - `5df513c` (feat) +2. **Task 2: Update Zod schemas, types, test helpers, and seed script** - `e9baa8d` (feat) + +## Files Created/Modified +- `src/db/schema.ts` - Added globalItemId/purchasePriceCents to items, globalItemId to threadCandidates, tags + globalItemTags tables, removed itemGlobalLinks +- `src/shared/schemas.ts` - Added globalItemId/purchasePriceCents to createItemSchema, globalItemId to createCandidateSchema, tags to searchGlobalItemsSchema, removed linkItemSchema +- `src/shared/types.ts` - Added Tag and GlobalItemTag types, removed ItemGlobalLink and LinkItem +- `src/db/seed-global-items.ts` - Converted to async, added seedTags with 30 curated tags +- `drizzle-pg/0002_wakeful_vermin.sql` - Migration with ADD COLUMN, data migration UPDATE, DROP TABLE + +## Decisions Made +- Reordered generated migration SQL to ensure data migration (UPDATE items SET global_item_id) runs before DROP TABLE item_global_links +- Kept seed tags as flat list per D-16 (no type categorization) + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] drizzle-kit generate interactive prompt** +- **Found during:** Task 1 (migration generation) +- **Issue:** drizzle-kit detected table rename ambiguity between itemGlobalLinks and globalItemTags, prompting interactively +- **Fix:** Used Bun.spawn with piped stdin to programmatically select "create table" option +- **Files modified:** None (tooling workaround) +- **Verification:** Migration file generated correctly +- **Committed in:** 5df513c (Task 1 commit) + +--- + +**Total deviations:** 1 auto-fixed (1 blocking) +**Impact on plan:** Tooling workaround only, no code impact. + +## Issues Encountered +None beyond the drizzle-kit interactive prompt handled above. + +## User Setup Required +None - no external service configuration required. + +## Next Phase Readiness +- Schema foundation ready for service layer updates (plan 19-02) +- Services referencing itemGlobalLinks, linkItemToGlobal, unlinkItemFromGlobal need updating +- Test files referencing removed schema entities need updating +- Client code referencing LinkToGlobalItem component needs updating + +--- +*Phase: 19-reference-item-model-tags-schema* +*Completed: 2026-04-05* diff --git a/drizzle-pg/0002_wakeful_vermin.sql b/drizzle-pg/0002_wakeful_vermin.sql new file mode 100644 index 0000000..c55099d --- /dev/null +++ b/drizzle-pg/0002_wakeful_vermin.sql @@ -0,0 +1,27 @@ +CREATE TABLE "tags" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "tags_name_unique" UNIQUE("name") +); +--> statement-breakpoint +CREATE TABLE "global_item_tags" ( + "global_item_id" integer NOT NULL, + "tag_id" integer NOT NULL, + CONSTRAINT "global_item_tags_global_item_id_tag_id_pk" PRIMARY KEY("global_item_id","tag_id") +); +--> statement-breakpoint +ALTER TABLE "items" ADD COLUMN "global_item_id" integer;--> statement-breakpoint +ALTER TABLE "items" ADD COLUMN "purchase_price_cents" integer;--> statement-breakpoint +ALTER TABLE "thread_candidates" ADD COLUMN "global_item_id" integer;--> statement-breakpoint +UPDATE "items" SET "global_item_id" = ( + SELECT "global_item_id" FROM "item_global_links" + WHERE "item_global_links"."item_id" = "items"."id" +); +--> statement-breakpoint +ALTER TABLE "item_global_links" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +DROP TABLE "item_global_links" CASCADE;--> statement-breakpoint +ALTER TABLE "global_item_tags" ADD CONSTRAINT "global_item_tags_global_item_id_global_items_id_fk" FOREIGN KEY ("global_item_id") REFERENCES "public"."global_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "global_item_tags" ADD CONSTRAINT "global_item_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "items" ADD CONSTRAINT "items_global_item_id_global_items_id_fk" FOREIGN KEY ("global_item_id") REFERENCES "public"."global_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "thread_candidates" ADD CONSTRAINT "thread_candidates_global_item_id_global_items_id_fk" FOREIGN KEY ("global_item_id") REFERENCES "public"."global_items"("id") ON DELETE no action ON UPDATE no action; diff --git a/drizzle-pg/meta/0002_snapshot.json b/drizzle-pg/meta/0002_snapshot.json new file mode 100644 index 0000000..6aaaaa4 --- /dev/null +++ b/drizzle-pg/meta/0002_snapshot.json @@ -0,0 +1,1171 @@ +{ + "id": "1c8fbda2-e486-4f57-a6d5-1a2e7042e413", + "prevId": "8fb47390-ff75-41f7-aa35-fad97b1a097e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "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": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'package'" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "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_user_id_name_unique": { + "name": "categories_user_id_name_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.global_item_tags": { + "name": "global_item_tags", + "schema": "", + "columns": { + "global_item_id": { + "name": "global_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "global_item_tags_global_item_id_global_items_id_fk": { + "name": "global_item_tags_global_item_id_global_items_id_fk", + "tableFrom": "global_item_tags", + "tableTo": "global_items", + "columnsFrom": [ + "global_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "global_item_tags_tag_id_tags_id_fk": { + "name": "global_item_tags_tag_id_tags_id_fk", + "tableFrom": "global_item_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "global_item_tags_global_item_id_tag_id_pk": { + "name": "global_item_tags_global_item_id_tag_id_pk", + "columns": [ + "global_item_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.global_items": { + "name": "global_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weight_grams": { + "name": "weight_grams", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.items": { + "name": "items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight_grams": { + "name": "weight_grams", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_url": { + "name": "product_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_filename": { + "name": "image_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_source_url": { + "name": "image_source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "global_item_id": { + "name": "global_item_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "purchase_price_cents": { + "name": "purchase_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "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" + }, + "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" + }, + "items_global_item_id_global_items_id_fk": { + "name": "items_global_item_id_global_items_id_fk", + "tableFrom": "items", + "tableTo": "global_items", + "columnsFrom": [ + "global_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_clients": { + "name": "oauth_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_clients_client_id_unique": { + "name": "oauth_clients_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_codes": { + "name": "oauth_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'S256'" + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_codes_code_unique": { + "name": "oauth_codes_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_tokens": { + "name": "oauth_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "access_token_hash": { + "name": "access_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "refresh_expires_at": { + "name": "refresh_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "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": { + "name": "oauth_tokens_access_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "access_token_hash" + ] + }, + "oauth_tokens_refresh_token_hash_unique": { + "name": "oauth_tokens_refresh_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "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": {}, + "isRLSEnabled": false + }, + "public.setup_items": { + "name": "setup_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "setup_id": { + "name": "setup_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "classification": { + "name": "classification", + "type": "text", + "primaryKey": false, + "notNull": true, + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.setups": { + "name": "setups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "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": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_name_unique": { + "name": "tags_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.thread_candidates": { + "name": "thread_candidates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight_grams": { + "name": "weight_grams", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_url": { + "name": "product_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_filename": { + "name": "image_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_source_url": { + "name": "image_source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'researching'" + }, + "pros": { + "name": "pros", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cons": { + "name": "cons", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "global_item_id": { + "name": "global_item_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "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" + }, + "thread_candidates_global_item_id_global_items_id_fk": { + "name": "thread_candidates_global_item_id_global_items_id_fk", + "tableFrom": "thread_candidates", + "tableTo": "global_items", + "columnsFrom": [ + "global_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.threads": { + "name": "threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "resolved_candidate_id": { + "name": "resolved_candidate_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "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" + }, + "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": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "logto_sub": { + "name": "logto_sub", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_logto_sub_unique": { + "name": "users_logto_sub_unique", + "nullsNotDistinct": false, + "columns": [ + "logto_sub" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle-pg/meta/_journal.json b/drizzle-pg/meta/_journal.json index ba29933..746a6fb 100644 --- a/drizzle-pg/meta/_journal.json +++ b/drizzle-pg/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1775386658636, "tag": "0001_tough_boomerang", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1775413526643, + "tag": "0002_wakeful_vermin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index b11d6bc..cf06baf 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -55,6 +55,8 @@ export const items = pgTable("items", { imageFilename: text("image_filename"), imageSourceUrl: text("image_source_url"), quantity: integer("quantity").notNull().default(1), + globalItemId: integer("global_item_id").references(() => globalItems.id), + purchasePriceCents: integer("purchase_price_cents"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); @@ -97,6 +99,7 @@ export const threadCandidates = pgTable("thread_candidates", { pros: text("pros"), cons: text("cons"), sortOrder: doublePrecision("sort_order").notNull().default(0), + globalItemId: integer("global_item_id").references(() => globalItems.id), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); @@ -141,19 +144,29 @@ export const globalItems = pgTable("global_items", { createdAt: timestamp("created_at").defaultNow().notNull(), }); -// ── Item Global Links ─────────────────────────────────────────────── +// ── Tags ─────────────────────────────────────────────────────────── -export const itemGlobalLinks = pgTable("item_global_links", { +export const tags = pgTable("tags", { id: serial("id").primaryKey(), - itemId: integer("item_id") - .notNull() - .references(() => items.id, { onDelete: "cascade" }) - .unique(), - globalItemId: integer("global_item_id") - .notNull() - .references(() => globalItems.id, { onDelete: "cascade" }), + name: text("name").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), }); +// ── Global Item Tags ─────────────────────────────────────────────── + +export const globalItemTags = pgTable( + "global_item_tags", + { + globalItemId: integer("global_item_id") + .notNull() + .references(() => globalItems.id, { onDelete: "cascade" }), + tagId: integer("tag_id") + .notNull() + .references(() => tags.id, { onDelete: "cascade" }), + }, + (table) => [primaryKey({ columns: [table.globalItemId, table.tagId] })], +); + // ── Settings ──────────────────────────────────────────────────────── export const settings = pgTable( diff --git a/src/db/seed-global-items.ts b/src/db/seed-global-items.ts index 162a801..7ca0f2e 100644 --- a/src/db/seed-global-items.ts +++ b/src/db/seed-global-items.ts @@ -1,27 +1,73 @@ import seedData from "./global-items-seed.json"; import { db as prodDb } from "./index.ts"; -import { globalItems } from "./schema.ts"; +import { globalItems, tags } from "./schema.ts"; type Db = typeof prodDb; +const SEED_TAGS = [ + "handlebar-bag", + "framebag", + "saddlebag", + "top-tube-bag", + "stem-bag", + "fork-bag", + "hip-pack", + "backpack", + "tent", + "bivy", + "tarp", + "hammock", + "sleeping-bag", + "sleeping-pad", + "quilt", + "pillow", + "stove", + "cookware", + "water-filter", + "water-bottle", + "headlamp", + "bike-light", + "ultralight", + "waterproof", + "budget", + "premium", + "bikepacking", + "hiking", + "camping", + "touring", +]; + +/** + * Seed curated tags for outdoor/adventure gear. + * Idempotent: skips if any tags already exist. + */ +export async function seedTags(db: Db = prodDb) { + const existing = await db.select().from(tags).limit(1); + if (existing.length > 0) return; + + for (const name of SEED_TAGS) { + await db.insert(tags).values({ name }); + } +} + /** * 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(); +export async function seedGlobalItems(db: Db = prodDb) { + const existing = await db.select().from(globalItems).limit(1); 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(); + await 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, + }); } + + await seedTags(db); } diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index 0e68b9a..0a02453 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -10,6 +10,8 @@ export const createItemSchema = z.object({ imageFilename: z.string().optional(), imageSourceUrl: z.string().url().optional().or(z.literal("")), quantity: z.number().int().positive().optional(), + globalItemId: z.number().int().positive().optional(), + purchasePriceCents: z.number().int().nonnegative().optional(), }); export const updateItemSchema = createItemSchema.partial().extend({ @@ -58,6 +60,7 @@ export const createCandidateSchema = z.object({ status: candidateStatusSchema.optional(), pros: z.string().optional(), cons: z.string().optional(), + globalItemId: z.number().int().positive().optional(), }); export const updateCandidateSchema = createCandidateSchema.partial(); @@ -95,10 +98,7 @@ export const updateClassificationSchema = z.object({ // Global item schemas export const searchGlobalItemsSchema = z.object({ q: z.string().optional(), -}); - -export const linkItemSchema = z.object({ - globalItemId: z.number().int().positive(), + tags: z.string().optional(), }); // Profile schemas diff --git a/src/shared/types.ts b/src/shared/types.ts index 3069dad..a27d75b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2,10 +2,11 @@ import type { z } from "zod"; import type { categories, globalItems, - itemGlobalLinks, + globalItemTags, items, setupItems, setups, + tags, threadCandidates, threads, } from "../db/schema.ts"; @@ -15,7 +16,6 @@ import type { createItemSchema, createSetupSchema, createThreadSchema, - linkItemSchema, reorderCandidatesSchema, resolveThreadSchema, searchGlobalItemsSchema, @@ -49,7 +49,6 @@ 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 @@ -60,4 +59,5 @@ export type ThreadCandidate = typeof threadCandidates.$inferSelect; 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 Tag = typeof tags.$inferSelect; +export type GlobalItemTag = typeof globalItemTags.$inferSelect;