From 89b04968458ddf01f73632a1171b4f6fa480592d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 13:04:09 +0200 Subject: [PATCH] chore(18-03): apply 18-01 schema foundation as dependency baseline --- drizzle-pg/0000_fuzzy_shiva.sql | 133 +++ drizzle-pg/0000_thankful_loners.sql | 140 +++ drizzle-pg/0001_tough_boomerang.sql | 25 + drizzle-pg/meta/0000_snapshot.json | 934 ++++++++++++++++++++ drizzle-pg/meta/0001_snapshot.json | 1093 ++++++++++++++++++++++++ drizzle-pg/meta/_journal.json | 20 + src/db/global-items-seed.json | 146 ++++ src/db/index.ts | 14 +- src/db/schema.ts | 219 +++-- src/db/seed.ts | 14 +- src/server/index.ts | 26 +- src/server/middleware/auth.ts | 51 +- src/server/routes/auth.ts | 167 +--- src/server/routes/setups.ts | 48 +- src/server/services/auth.service.ts | 150 +--- src/server/services/setup.service.ts | 157 ++-- src/server/services/storage.service.ts | 83 ++ src/shared/schemas.ts | 18 + src/shared/types.ts | 12 + tests/helpers/db.ts | 45 +- 20 files changed, 3022 insertions(+), 473 deletions(-) create mode 100644 drizzle-pg/0000_fuzzy_shiva.sql create mode 100644 drizzle-pg/0000_thankful_loners.sql create mode 100644 drizzle-pg/0001_tough_boomerang.sql create mode 100644 drizzle-pg/meta/0000_snapshot.json create mode 100644 drizzle-pg/meta/0001_snapshot.json create mode 100644 drizzle-pg/meta/_journal.json create mode 100644 src/db/global-items-seed.json create mode 100644 src/server/services/storage.service.ts diff --git a/drizzle-pg/0000_fuzzy_shiva.sql b/drizzle-pg/0000_fuzzy_shiva.sql new file mode 100644 index 0000000..e000f25 --- /dev/null +++ b/drizzle-pg/0000_fuzzy_shiva.sql @@ -0,0 +1,133 @@ +CREATE TABLE "api_keys" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "key_hash" text NOT NULL, + "key_prefix" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "categories" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "icon" text DEFAULT 'package' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "categories_name_unique" UNIQUE("name") +); +--> statement-breakpoint +CREATE TABLE "items" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "weight_grams" double precision, + "price_cents" integer, + "category_id" integer NOT NULL, + "notes" text, + "product_url" text, + "image_filename" text, + "image_source_url" text, + "quantity" integer DEFAULT 1 NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "oauth_clients" ( + "id" serial PRIMARY KEY NOT NULL, + "client_id" text NOT NULL, + "client_name" text, + "redirect_uris" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "oauth_clients_client_id_unique" UNIQUE("client_id") +); +--> statement-breakpoint +CREATE TABLE "oauth_codes" ( + "id" serial PRIMARY KEY NOT NULL, + "code" text NOT NULL, + "client_id" text NOT NULL, + "code_challenge" text NOT NULL, + "code_challenge_method" text DEFAULT 'S256' NOT NULL, + "redirect_uri" text NOT NULL, + "expires_at" timestamp NOT NULL, + "used" boolean DEFAULT false NOT NULL, + CONSTRAINT "oauth_codes_code_unique" UNIQUE("code") +); +--> statement-breakpoint +CREATE TABLE "oauth_tokens" ( + "id" serial PRIMARY KEY NOT NULL, + "access_token_hash" text NOT NULL, + "refresh_token_hash" text NOT NULL, + "client_id" text NOT NULL, + "expires_at" timestamp NOT NULL, + "refresh_expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "oauth_tokens_access_token_hash_unique" UNIQUE("access_token_hash"), + CONSTRAINT "oauth_tokens_refresh_token_hash_unique" UNIQUE("refresh_token_hash") +); +--> statement-breakpoint +CREATE TABLE "sessions" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "expires_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "settings" ( + "key" text PRIMARY KEY NOT NULL, + "value" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "setup_items" ( + "id" serial PRIMARY KEY NOT NULL, + "setup_id" integer NOT NULL, + "item_id" integer NOT NULL, + "classification" text DEFAULT 'base' NOT NULL +); +--> statement-breakpoint +CREATE TABLE "setups" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "thread_candidates" ( + "id" serial PRIMARY KEY NOT NULL, + "thread_id" integer NOT NULL, + "name" text NOT NULL, + "weight_grams" double precision, + "price_cents" integer, + "category_id" integer NOT NULL, + "notes" text, + "product_url" text, + "image_filename" text, + "image_source_url" text, + "status" text DEFAULT 'researching' NOT NULL, + "pros" text, + "cons" text, + "sort_order" double precision DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "threads" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "resolved_candidate_id" integer, + "category_id" integer NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" serial PRIMARY KEY NOT NULL, + "username" text NOT NULL, + "password_hash" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "users_username_unique" UNIQUE("username") +); +--> statement-breakpoint +ALTER TABLE "items" ADD CONSTRAINT "items_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "setup_items" ADD CONSTRAINT "setup_items_setup_id_setups_id_fk" FOREIGN KEY ("setup_id") REFERENCES "public"."setups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "setup_items" ADD CONSTRAINT "setup_items_item_id_items_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "thread_candidates" ADD CONSTRAINT "thread_candidates_thread_id_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."threads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "thread_candidates" ADD CONSTRAINT "thread_candidates_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "threads" ADD CONSTRAINT "threads_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/drizzle-pg/0000_thankful_loners.sql b/drizzle-pg/0000_thankful_loners.sql new file mode 100644 index 0000000..ef33028 --- /dev/null +++ b/drizzle-pg/0000_thankful_loners.sql @@ -0,0 +1,140 @@ +CREATE TABLE "api_keys" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "key_hash" text NOT NULL, + "key_prefix" text NOT NULL, + "user_id" integer NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "categories" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "icon" text DEFAULT 'package' NOT NULL, + "user_id" integer NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "categories_user_id_name_unique" UNIQUE("user_id","name") +); +--> statement-breakpoint +CREATE TABLE "items" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "weight_grams" double precision, + "price_cents" integer, + "category_id" integer NOT NULL, + "user_id" integer NOT NULL, + "notes" text, + "product_url" text, + "image_filename" text, + "image_source_url" text, + "quantity" integer DEFAULT 1 NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "oauth_clients" ( + "id" serial PRIMARY KEY NOT NULL, + "client_id" text NOT NULL, + "client_name" text, + "redirect_uris" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "oauth_clients_client_id_unique" UNIQUE("client_id") +); +--> statement-breakpoint +CREATE TABLE "oauth_codes" ( + "id" serial PRIMARY KEY NOT NULL, + "code" text NOT NULL, + "client_id" text NOT NULL, + "code_challenge" text NOT NULL, + "code_challenge_method" text DEFAULT 'S256' NOT NULL, + "redirect_uri" text NOT NULL, + "expires_at" timestamp NOT NULL, + "used" integer DEFAULT 0 NOT NULL, + CONSTRAINT "oauth_codes_code_unique" UNIQUE("code") +); +--> statement-breakpoint +CREATE TABLE "oauth_tokens" ( + "id" serial PRIMARY KEY NOT NULL, + "access_token_hash" text NOT NULL, + "refresh_token_hash" text NOT NULL, + "client_id" text NOT NULL, + "user_id" integer NOT NULL, + "expires_at" timestamp NOT NULL, + "refresh_expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "oauth_tokens_access_token_hash_unique" UNIQUE("access_token_hash"), + CONSTRAINT "oauth_tokens_refresh_token_hash_unique" UNIQUE("refresh_token_hash") +); +--> statement-breakpoint +CREATE TABLE "settings" ( + "user_id" integer NOT NULL, + "key" text NOT NULL, + "value" text NOT NULL, + CONSTRAINT "settings_user_id_key_pk" PRIMARY KEY("user_id","key") +); +--> statement-breakpoint +CREATE TABLE "setup_items" ( + "id" serial PRIMARY KEY NOT NULL, + "setup_id" integer NOT NULL, + "item_id" integer NOT NULL, + "classification" text DEFAULT 'base' NOT NULL +); +--> statement-breakpoint +CREATE TABLE "setups" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "user_id" integer NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "thread_candidates" ( + "id" serial PRIMARY KEY NOT NULL, + "thread_id" integer NOT NULL, + "name" text NOT NULL, + "weight_grams" double precision, + "price_cents" integer, + "category_id" integer NOT NULL, + "notes" text, + "product_url" text, + "image_filename" text, + "image_source_url" text, + "status" text DEFAULT 'researching' NOT NULL, + "pros" text, + "cons" text, + "sort_order" double precision DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "threads" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "resolved_candidate_id" integer, + "category_id" integer NOT NULL, + "user_id" integer NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" serial PRIMARY KEY NOT NULL, + "logto_sub" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "users_logto_sub_unique" UNIQUE("logto_sub") +); +--> statement-breakpoint +ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "categories" ADD CONSTRAINT "categories_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "items" ADD CONSTRAINT "items_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "items" ADD CONSTRAINT "items_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_tokens" ADD CONSTRAINT "oauth_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "settings" ADD CONSTRAINT "settings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "setup_items" ADD CONSTRAINT "setup_items_setup_id_setups_id_fk" FOREIGN KEY ("setup_id") REFERENCES "public"."setups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "setup_items" ADD CONSTRAINT "setup_items_item_id_items_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "setups" ADD CONSTRAINT "setups_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "thread_candidates" ADD CONSTRAINT "thread_candidates_thread_id_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."threads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "thread_candidates" ADD CONSTRAINT "thread_candidates_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "threads" ADD CONSTRAINT "threads_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "threads" ADD CONSTRAINT "threads_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/drizzle-pg/0001_tough_boomerang.sql b/drizzle-pg/0001_tough_boomerang.sql new file mode 100644 index 0000000..3125930 --- /dev/null +++ b/drizzle-pg/0001_tough_boomerang.sql @@ -0,0 +1,25 @@ +CREATE TABLE "global_items" ( + "id" serial PRIMARY KEY NOT NULL, + "brand" text NOT NULL, + "model" text NOT NULL, + "category" text, + "weight_grams" double precision, + "price_cents" integer, + "image_url" text, + "description" text, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "item_global_links" ( + "id" serial PRIMARY KEY NOT NULL, + "item_id" integer NOT NULL, + "global_item_id" integer NOT NULL, + CONSTRAINT "item_global_links_item_id_unique" UNIQUE("item_id") +); +--> statement-breakpoint +ALTER TABLE "setups" ADD COLUMN "is_public" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "display_name" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "avatar_url" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "bio" text;--> statement-breakpoint +ALTER TABLE "item_global_links" ADD CONSTRAINT "item_global_links_item_id_items_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "item_global_links" ADD CONSTRAINT "item_global_links_global_item_id_global_items_id_fk" FOREIGN KEY ("global_item_id") REFERENCES "public"."global_items"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/drizzle-pg/meta/0000_snapshot.json b/drizzle-pg/meta/0000_snapshot.json new file mode 100644 index 0000000..fd0b859 --- /dev/null +++ b/drizzle-pg/meta/0000_snapshot.json @@ -0,0 +1,934 @@ +{ + "id": "2f3f44c0-0fd3-4ac5-b1fb-51bc709342df", + "prevId": "00000000-0000-0000-0000-000000000000", + "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.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 + }, + "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" + } + }, + "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 + }, + "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.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 + }, + "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" + } + }, + "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 + }, + "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/0001_snapshot.json b/drizzle-pg/meta/0001_snapshot.json new file mode 100644 index 0000000..283f541 --- /dev/null +++ b/drizzle-pg/meta/0001_snapshot.json @@ -0,0 +1,1093 @@ +{ + "id": "8fb47390-ff75-41f7-aa35-fad97b1a097e", + "prevId": "2f3f44c0-0fd3-4ac5-b1fb-51bc709342df", + "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_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.item_global_links": { + "name": "item_global_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "global_item_id": { + "name": "global_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "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": { + "item_global_links_item_id_unique": { + "name": "item_global_links_item_id_unique", + "nullsNotDistinct": false, + "columns": [ + "item_id" + ] + } + }, + "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 + }, + "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" + } + }, + "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.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 + }, + "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" + } + }, + "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 new file mode 100644 index 0000000..ba29933 --- /dev/null +++ b/drizzle-pg/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1775377947759, + "tag": "0000_thankful_loners", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1775386658636, + "tag": "0001_tough_boomerang", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/src/db/global-items-seed.json b/src/db/global-items-seed.json new file mode 100644 index 0000000..2c82126 --- /dev/null +++ b/src/db/global-items-seed.json @@ -0,0 +1,146 @@ +[ + { + "brand": "Revelate Designs", + "model": "Terrapin System", + "category": "bags", + "weightGrams": 529, + "priceCents": 18500, + "description": "Waterproof saddle bag with 14L capacity, roll-top closure, and integrated Revelate seat bag mount." + }, + { + "brand": "Apidura", + "model": "Expedition Handlebar Pack", + "category": "bags", + "weightGrams": 300, + "priceCents": 16000, + "description": "14L waterproof handlebar roll bag with internal dry bag and accessory pocket." + }, + { + "brand": "Ortlieb", + "model": "Frame-Pack Toptube", + "category": "bags", + "weightGrams": 180, + "priceCents": 7500, + "description": "4L waterproof top-tube bag with magnetic closure and reflective details." + }, + { + "brand": "Revelate Designs", + "model": "Tangle Frame Bag", + "category": "bags", + "weightGrams": 170, + "priceCents": 13500, + "description": "Full-frame bag with water-resistant construction and multiple internal pockets." + }, + { + "brand": "Big Agnes", + "model": "Copper Spur HV UL1", + "category": "shelters", + "weightGrams": 879, + "priceCents": 42000, + "description": "Ultralight 1-person freestanding tent with high-volume hub design and DAC Featherlite poles." + }, + { + "brand": "Tarptent", + "model": "Protrail Li", + "category": "shelters", + "weightGrams": 454, + "priceCents": 35000, + "description": "Ultralight single-wall trekking pole shelter in Dyneema composite fabric." + }, + { + "brand": "Outdoor Research", + "model": "Helium Bivy", + "category": "shelters", + "weightGrams": 510, + "priceCents": 24900, + "description": "Waterproof breathable bivy sack with single-hoop pole and full-zip entry." + }, + { + "brand": "Sea to Summit", + "model": "Spark SP1", + "category": "sleep-systems", + "weightGrams": 375, + "priceCents": 28000, + "description": "Ultralight 850+ fill down sleeping bag rated to 40F/4C with Ultra-Dry Down." + }, + { + "brand": "Nemo", + "model": "Tensor Ultralight Insulated Regular", + "category": "sleep-systems", + "weightGrams": 425, + "priceCents": 18000, + "description": "3-inch thick insulated sleeping pad with R-value 4.2 and Spaceframe baffles." + }, + { + "brand": "Therm-a-Rest", + "model": "NeoAir XLite NXT", + "category": "sleep-systems", + "weightGrams": 354, + "priceCents": 22000, + "description": "Ultralight insulated air pad with R-value 4.5, Triangular Core Matrix, and WingLock valve." + }, + { + "brand": "MSR", + "model": "PocketRocket 2", + "category": "cooking", + "weightGrams": 73, + "priceCents": 5500, + "description": "Ultralight canister stove with adjustable flame control, boils 1L in 3.5 minutes." + }, + { + "brand": "Toaks", + "model": "Titanium 750ml Pot", + "category": "cooking", + "weightGrams": 103, + "priceCents": 3300, + "description": "Ultralight titanium pot with lid and foldable handles, 750ml capacity." + }, + { + "brand": "Katadyn", + "model": "BeFree 1.0L", + "category": "hydration", + "weightGrams": 59, + "priceCents": 4500, + "description": "Ultralight hollow fiber water filter with 0.1 micron filtration and 1L soft flask." + }, + { + "brand": "HydraPak", + "model": "Seeker 2L", + "category": "hydration", + "weightGrams": 73, + "priceCents": 1800, + "description": "Collapsible 2L water storage with wide mouth and compatible with Katadyn BeFree filter." + }, + { + "brand": "Nitecore", + "model": "NU25 UL", + "category": "lighting", + "weightGrams": 28, + "priceCents": 3600, + "description": "Ultralight USB-C rechargeable headlamp with 400 lumens max and red light mode." + }, + { + "brand": "Exposure Lights", + "model": "Revo Dynamo", + "category": "lighting", + "weightGrams": 130, + "priceCents": 22000, + "description": "Dynamo-powered front light with 800 lumens, built-in standlight, and USB charging output." + }, + { + "brand": "Surly", + "model": "24-Pack Rack", + "category": "racks", + "weightGrams": 750, + "priceCents": 10000, + "description": "Front rack for bikepacking with 24-pack platform, fits most forks with mid-blade eyelets." + }, + { + "brand": "Salsa", + "model": "Anything Cage HD", + "category": "accessories", + "weightGrams": 80, + "priceCents": 2500, + "description": "Heavy-duty bottle cage for oversized loads like dry bags and fuel canisters." + } +] diff --git a/src/db/index.ts b/src/db/index.ts index f50ccf4..f0424b3 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,9 +1,9 @@ -import { Database } from "bun:sqlite"; -import { drizzle } from "drizzle-orm/bun-sqlite"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; import * as schema from "./schema.ts"; -const sqlite = new Database(process.env.DATABASE_PATH || "gearbox.db"); -sqlite.run("PRAGMA journal_mode = WAL"); -sqlite.run("PRAGMA foreign_keys = ON"); - -export const db = drizzle(sqlite, { schema }); +const connectionString = + process.env.DATABASE_URL || + "postgresql://gearbox:gearbox@localhost:5432/gearbox"; +const queryClient = postgres(connectionString); +export const db = drizzle(queryClient, { schema }); diff --git a/src/db/schema.ts b/src/db/schema.ts index 9e7113b..24b66ca 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,58 +1,90 @@ -import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { + boolean, + doublePrecision, + integer, + pgTable, + primaryKey, + serial, + text, + timestamp, + unique, +} from "drizzle-orm/pg-core"; -export const categories = sqliteTable("categories", { - id: integer("id").primaryKey({ autoIncrement: true }), - name: text("name").notNull().unique(), - icon: text("icon").notNull().default("package"), - createdAt: integer("created_at", { mode: "timestamp" }) - .notNull() - .$defaultFn(() => new Date()), +// ── Users ─────────────────────────────────────────────────────────── + +export const users = pgTable("users", { + id: serial("id").primaryKey(), + logtoSub: text("logto_sub").notNull().unique(), + displayName: text("display_name"), + avatarUrl: text("avatar_url"), + bio: text("bio"), + createdAt: timestamp("created_at").defaultNow().notNull(), }); -export const items = sqliteTable("items", { - id: integer("id").primaryKey({ autoIncrement: true }), +// ── Categories ────────────────────────────────────────────────────── + +export const categories = pgTable( + "categories", + { + id: serial("id").primaryKey(), + name: text("name").notNull(), + icon: text("icon").notNull().default("package"), + userId: integer("user_id") + .notNull() + .references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [unique().on(table.userId, table.name)], +); + +// ── Items ─────────────────────────────────────────────────────────── + +export const items = pgTable("items", { + id: serial("id").primaryKey(), name: text("name").notNull(), - weightGrams: real("weight_grams"), + weightGrams: doublePrecision("weight_grams"), priceCents: integer("price_cents"), categoryId: integer("category_id") .notNull() .references(() => categories.id), + userId: integer("user_id") + .notNull() + .references(() => users.id), notes: text("notes"), productUrl: text("product_url"), imageFilename: text("image_filename"), imageSourceUrl: text("image_source_url"), quantity: integer("quantity").notNull().default(1), - createdAt: integer("created_at", { mode: "timestamp" }) - .notNull() - .$defaultFn(() => new Date()), - updatedAt: integer("updated_at", { mode: "timestamp" }) - .notNull() - .$defaultFn(() => new Date()), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), }); -export const threads = sqliteTable("threads", { - id: integer("id").primaryKey({ autoIncrement: true }), +// ── Threads ───────────────────────────────────────────────────────── + +export const threads = pgTable("threads", { + id: serial("id").primaryKey(), name: text("name").notNull(), status: text("status").notNull().default("active"), resolvedCandidateId: integer("resolved_candidate_id"), categoryId: integer("category_id") .notNull() .references(() => categories.id), - createdAt: integer("created_at", { mode: "timestamp" }) + userId: integer("user_id") .notNull() - .$defaultFn(() => new Date()), - updatedAt: integer("updated_at", { mode: "timestamp" }) - .notNull() - .$defaultFn(() => new Date()), + .references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), }); -export const threadCandidates = sqliteTable("thread_candidates", { - id: integer("id").primaryKey({ autoIncrement: true }), +// ── Thread Candidates ─────────────────────────────────────────────── + +export const threadCandidates = pgTable("thread_candidates", { + id: serial("id").primaryKey(), threadId: integer("thread_id") .notNull() .references(() => threads.id, { onDelete: "cascade" }), name: text("name").notNull(), - weightGrams: real("weight_grams"), + weightGrams: doublePrecision("weight_grams"), priceCents: integer("price_cents"), categoryId: integer("category_id") .notNull() @@ -64,28 +96,28 @@ export const threadCandidates = sqliteTable("thread_candidates", { status: text("status").notNull().default("researching"), pros: text("pros"), cons: text("cons"), - sortOrder: real("sort_order").notNull().default(0), - createdAt: integer("created_at", { mode: "timestamp" }) - .notNull() - .$defaultFn(() => new Date()), - updatedAt: integer("updated_at", { mode: "timestamp" }) - .notNull() - .$defaultFn(() => new Date()), + sortOrder: doublePrecision("sort_order").notNull().default(0), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), }); -export const setups = sqliteTable("setups", { - id: integer("id").primaryKey({ autoIncrement: true }), +// ── Setups ────────────────────────────────────────────────────────── + +export const setups = pgTable("setups", { + id: serial("id").primaryKey(), name: text("name").notNull(), - createdAt: integer("created_at", { mode: "timestamp" }) + userId: integer("user_id") .notNull() - .$defaultFn(() => new Date()), - updatedAt: integer("updated_at", { mode: "timestamp" }) - .notNull() - .$defaultFn(() => new Date()), + .references(() => users.id), + isPublic: boolean("is_public").notNull().default(false), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), }); -export const setupItems = sqliteTable("setup_items", { - id: integer("id").primaryKey({ autoIncrement: true }), +// ── Setup Items ───────────────────────────────────────────────────── + +export const setupItems = pgTable("setup_items", { + id: serial("id").primaryKey(), setupId: integer("setup_id") .notNull() .references(() => setups.id, { onDelete: "cascade" }), @@ -95,69 +127,96 @@ export const setupItems = sqliteTable("setup_items", { classification: text("classification").notNull().default("base"), }); -export const settings = sqliteTable("settings", { - key: text("key").primaryKey(), - value: text("value").notNull(), +// ── Global Items ──────────────────────────────────────────────────── + +export const globalItems = pgTable("global_items", { + id: serial("id").primaryKey(), + brand: text("brand").notNull(), + model: text("model").notNull(), + category: text("category"), + weightGrams: doublePrecision("weight_grams"), + priceCents: integer("price_cents"), + imageUrl: text("image_url"), + description: text("description"), + createdAt: timestamp("created_at").defaultNow().notNull(), }); -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" }) +// ── Item Global Links ─────────────────────────────────────────────── + +export const itemGlobalLinks = pgTable("item_global_links", { + id: serial("id").primaryKey(), + itemId: integer("item_id") .notNull() - .$defaultFn(() => new Date()), -}); - -export const sessions = sqliteTable("sessions", { - id: text("id").primaryKey(), - userId: integer("user_id") + .references(() => items.id, { onDelete: "cascade" }) + .unique(), + globalItemId: integer("global_item_id") .notNull() - .references(() => users.id, { onDelete: "cascade" }), - expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), + .references(() => globalItems.id, { onDelete: "cascade" }), }); -export const apiKeys = sqliteTable("api_keys", { - id: integer("id").primaryKey({ autoIncrement: true }), +// ── 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(), - createdAt: integer("created_at", { mode: "timestamp" }) + userId: integer("user_id") .notNull() - .$defaultFn(() => new Date()), + .references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), }); -export const oauthClients = sqliteTable("oauth_clients", { - id: integer("id").primaryKey({ autoIncrement: true }), +// ── OAuth Clients ─────────────────────────────────────────────────── + +export const oauthClients = pgTable("oauth_clients", { + id: serial("id").primaryKey(), clientId: text("client_id").notNull().unique(), clientName: text("client_name"), redirectUris: text("redirect_uris").notNull(), // JSON array - createdAt: integer("created_at", { mode: "timestamp" }) - .notNull() - .$defaultFn(() => new Date()), + createdAt: timestamp("created_at").defaultNow().notNull(), }); -export const oauthCodes = sqliteTable("oauth_codes", { - id: integer("id").primaryKey({ autoIncrement: true }), +// ── OAuth Authorization Codes ─────────────────────────────────────── + +export const oauthCodes = pgTable("oauth_codes", { + id: serial("id").primaryKey(), code: text("code").notNull().unique(), clientId: text("client_id").notNull(), codeChallenge: text("code_challenge").notNull(), - codeChallengeMethod: text("code_challenge_method").notNull().default("S256"), + codeChallengeMethod: text("code_challenge_method") + .notNull() + .default("S256"), redirectUri: text("redirect_uri").notNull(), - expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), + expiresAt: timestamp("expires_at").notNull(), used: integer("used").notNull().default(0), }); -export const oauthTokens = sqliteTable("oauth_tokens", { - id: integer("id").primaryKey({ autoIncrement: true }), +// ── OAuth Tokens ──────────────────────────────────────────────────── + +export const oauthTokens = pgTable("oauth_tokens", { + id: serial("id").primaryKey(), accessTokenHash: text("access_token_hash").notNull().unique(), refreshTokenHash: text("refresh_token_hash").notNull().unique(), clientId: text("client_id").notNull(), - expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), // access token expiry - refreshExpiresAt: integer("refresh_expires_at", { - mode: "timestamp", - }).notNull(), // refresh token expiry - createdAt: integer("created_at", { mode: "timestamp" }) + userId: integer("user_id") .notNull() - .$defaultFn(() => new Date()), + .references(() => users.id), + expiresAt: timestamp("expires_at").notNull(), + refreshExpiresAt: timestamp("refresh_expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), }); diff --git a/src/db/seed.ts b/src/db/seed.ts index c7cf900..960ade6 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -1,14 +1,4 @@ -import { db } from "./index.ts"; -import { categories } from "./schema.ts"; - export function seedDefaults() { - const existing = db.select().from(categories).all(); - if (existing.length === 0) { - db.insert(categories) - .values({ - name: "Uncategorized", - icon: "package", - }) - .run(); - } + // Per-user default categories are created on first login (Phase 16) + // The getOrCreateUncategorized helper in category.service.ts handles this lazily. } diff --git a/src/server/index.ts b/src/server/index.ts index c601229..5a9248a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,6 +1,11 @@ import { Hono } from "hono"; import { serveStatic } from "hono/bun"; import { cors } from "hono/cors"; +import { + oidcAuthMiddleware, + processOAuthCallback, + revokeSession, +} from "@hono/oidc-auth"; import { db as prodDb } from "../db/index.ts"; import { seedDefaults } from "../db/seed.ts"; import { mcpRoutes } from "./mcp/index.ts"; @@ -16,7 +21,7 @@ import { threadRoutes } from "./routes/threads.ts"; import { totalRoutes } from "./routes/totals.ts"; // Seed default data on startup -seedDefaults(); +await seedDefaults(); const app = new Hono(); @@ -35,6 +40,14 @@ app.get("/api/health", (c) => { return c.json({ status: "ok" }); }); +// ── OIDC Browser Auth (top-level, before /api/* middleware) ─────────── +app.get("/login", oidcAuthMiddleware(), async (c) => c.redirect("/")); +app.get("/callback", async (c) => processOAuthCallback(c)); +app.get("/logout", async (c) => { + await revokeSession(c); + return c.redirect("/login"); +}); + // CORS for OAuth and MCP endpoints (required for claude.ai browser-based flows) app.use("/.well-known/*", cors()); app.use("/oauth/*", cors()); @@ -54,13 +67,13 @@ app.use("/api/*", async (c, next) => { return next(); }); -// Auth middleware for write operations (POST/PUT/PATCH/DELETE) on non-auth routes +// Auth middleware for all data routes (userId must be available for per-user scoping) app.use("/api/*", async (c, next) => { // Skip auth routes — they handle their own auth if (c.req.path.startsWith("/api/auth")) return next(); - // Skip GET requests — read is public - if (c.req.method === "GET") return next(); - // All other methods require auth + // Skip health check + if (c.req.path === "/api/health") return next(); + // All methods require auth for userId resolution return requireAuth(c, next); }); @@ -79,9 +92,6 @@ if (process.env.GEARBOX_MCP !== "false") { app.route("/mcp", mcpRoutes); } -// Serve uploaded images -app.use("/uploads/*", serveStatic({ root: "./" })); - // Serve Vite-built SPA in production if (process.env.NODE_ENV === "production") { app.use("/*", serveStatic({ root: "./dist/client" })); diff --git a/src/server/middleware/auth.ts b/src/server/middleware/auth.ts index b9e36de..ca1c0e1 100644 --- a/src/server/middleware/auth.ts +++ b/src/server/middleware/auth.ts @@ -1,37 +1,46 @@ import type { Context, Next } from "hono"; -import { getCookie } from "hono/cookie"; -import { - getSession, - getUserCount, - refreshSession, - verifyApiKey, -} from "../services/auth.service"; +import { getOrCreateUser, verifyApiKey } from "../services/auth.service"; +import { getOrCreateUncategorized } from "../services/category.service"; +import { verifyAccessToken } from "../services/oauth.service"; export async function requireAuth(c: Context, next: Next) { const db = c.get("db"); - // Check if any users exist at all - if (getUserCount(db) === 0) { - return c.json({ error: "setup_required" }, 403); - } - // Check API key first const apiKey = c.req.header("X-API-Key"); if (apiKey) { - const valid = await verifyApiKey(db, apiKey); - if (valid) return next(); + const result = await verifyApiKey(db, apiKey); + if (result) { + c.set("userId", result.userId); + return next(); + } return c.json({ error: "Invalid API key" }, 401); } - // Check session cookie - const sessionId = getCookie(c, "gearbox_session"); - if (sessionId) { - const session = getSession(db, sessionId); - if (session) { - // Refresh session expiry on use - refreshSession(db, sessionId); + // Check OAuth Bearer token + const authHeader = c.req.header("Authorization"); + if (authHeader?.startsWith("Bearer ")) { + const token = authHeader.slice(7); + const result = await verifyAccessToken(db, token); + if (result) { + c.set("userId", result.userId); return next(); } + return c.json({ error: "Invalid or expired token" }, 401); + } + + // Check OIDC session (browser users via Logto) + try { + const { getAuth } = await import("@hono/oidc-auth"); + const auth = await getAuth(c); + if (auth?.sub) { + const user = await getOrCreateUser(db, auth.sub); + await getOrCreateUncategorized(db, user.id); + c.set("userId", user.id); + return next(); + } + } catch { + // OIDC not configured or session invalid — fall through } return c.json({ error: "Authentication required" }, 401); diff --git a/src/server/routes/auth.ts b/src/server/routes/auth.ts index 827e544..cfa31c6 100644 --- a/src/server/routes/auth.ts +++ b/src/server/routes/auth.ts @@ -1,167 +1,40 @@ import { zValidator } from "@hono/zod-validator"; -import { eq } from "drizzle-orm"; +import { getAuth } from "@hono/oidc-auth"; import { Hono } from "hono"; -import { deleteCookie, getCookie, setCookie } from "hono/cookie"; import { z } from "zod"; -import { users } from "../../db/schema.ts"; import { parseId } from "../lib/params.ts"; import { requireAuth } from "../middleware/auth.ts"; -import { rateLimit } from "../middleware/rateLimit.ts"; import { - changePassword, createApiKey, - createSession, - createUser, deleteApiKey, - deleteSession, - getSession, - getUserCount, listApiKeys, - verifyPassword, } from "../services/auth.service.ts"; -type Env = { Variables: { db?: any } }; +type Env = { Variables: { db?: any; userId?: number } }; -const loginSchema = z.object({ - username: z.string().min(1), - password: z.string().min(1), -}); -const setupSchema = z.object({ - username: z.string().min(1), - password: z.string().min(6), -}); -const changePasswordSchema = z.object({ - currentPassword: z.string().min(1), - newPassword: z.string().min(6), -}); const createKeySchema = z.object({ name: z.string().min(1) }); -const COOKIE_NAME = "gearbox_session"; -const COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds - const app = new Hono(); -// ── Public routes ─────────────────────────────────────────────────── +// ── Auth Status ────────────────────────────────────────────────────── -app.get("/me", (c) => { - const db = c.get("db"); - const sessionId = getCookie(c, COOKIE_NAME); - - if (sessionId) { - const session = getSession(db, sessionId); - if (session) { - return c.json({ - user: { id: session.userId }, - setupRequired: false, - }); - } +app.get("/me", async (c) => { + const auth = await getAuth(c); + if (auth) { + return c.json({ + user: { id: auth.sub, email: auth.email }, + authenticated: true, + }); } - - const setupRequired = getUserCount(db) === 0; - return c.json({ user: null, setupRequired }); + return c.json({ user: null, authenticated: false }); }); -app.post("/setup", rateLimit, zValidator("json", setupSchema), async (c) => { +// ── API Key Management (protected) ─────────────────────────────────── + +app.get("/keys", requireAuth, async (c) => { const db = c.get("db"); - - if (getUserCount(db) > 0) { - return c.json({ error: "Setup already completed" }, 403); - } - - const { username, password } = c.req.valid("json"); - const user = await createUser(db, username, password); - const session = createSession(db, user.id); - - setCookie(c, COOKIE_NAME, session.id, { - httpOnly: true, - sameSite: "Lax", - path: "/", - maxAge: COOKIE_MAX_AGE, - }); - - return c.json({ username: user.username }, 201); -}); - -app.post("/login", rateLimit, zValidator("json", loginSchema), async (c) => { - const db = c.get("db"); - const { username, password } = c.req.valid("json"); - - const user = await verifyPassword(db, username, password); - if (!user) { - return c.json({ error: "Invalid credentials" }, 401); - } - - const session = createSession(db, user.id); - - setCookie(c, COOKIE_NAME, session.id, { - httpOnly: true, - sameSite: "Lax", - path: "/", - maxAge: COOKIE_MAX_AGE, - }); - - return c.json({ username: user.username }); -}); - -app.post("/logout", (c) => { - const db = c.get("db"); - const sessionId = getCookie(c, COOKIE_NAME); - - if (sessionId) { - deleteSession(db, sessionId); - } - - deleteCookie(c, COOKIE_NAME, { path: "/" }); - return c.json({ ok: true }); -}); - -// ── Protected routes ──────────────────────────────────────────────── - -app.put( - "/password", - requireAuth, - zValidator("json", changePasswordSchema), - async (c) => { - const db = c.get("db"); - const sessionId = getCookie(c, COOKIE_NAME); - if (!sessionId) { - return c.json({ error: "Session required for password change" }, 401); - } - const session = getSession(db, sessionId); - - if (!session) { - return c.json({ error: "Session required for password change" }, 401); - } - - const userRecord = db - .select() - .from(users) - .where(eq(users.id, session.userId)) - .get(); - - if (!userRecord) { - return c.json({ error: "User not found" }, 404); - } - - const { currentPassword, newPassword } = c.req.valid("json"); - const changed = await changePassword( - db, - userRecord.username, - currentPassword, - newPassword, - ); - - if (!changed) { - return c.json({ error: "Invalid current password" }, 401); - } - - return c.json({ ok: true }); - }, -); - -app.get("/keys", requireAuth, (c) => { - const db = c.get("db"); - const keys = listApiKeys(db); + const userId = c.get("userId")!; + const keys = await listApiKeys(db, userId); return c.json(keys); }); @@ -171,8 +44,9 @@ app.post( zValidator("json", createKeySchema), async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const { name } = c.req.valid("json"); - const result = await createApiKey(db, name); + const result = await createApiKey(db, userId, name); return c.json( { @@ -186,11 +60,12 @@ app.post( }, ); -app.delete("/keys/:id", requireAuth, (c) => { +app.delete("/keys/:id", requireAuth, async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const id = parseId(c.req.param("id")); if (!id) return c.json({ error: "Invalid key ID" }, 400); - deleteApiKey(db, id); + await deleteApiKey(db, userId, id); return c.json({ ok: true }); }); diff --git a/src/server/routes/setups.ts b/src/server/routes/setups.ts index d5e2af2..6681809 100644 --- a/src/server/routes/setups.ts +++ b/src/server/routes/setups.ts @@ -7,6 +7,7 @@ import { updateSetupSchema, } from "../../shared/schemas.ts"; import { parseId } from "../lib/params.ts"; +import { withImageUrls } from "../services/storage.service.ts"; import { createSetup, deleteSetup, @@ -18,88 +19,97 @@ import { updateSetup, } from "../services/setup.service.ts"; -type Env = { Variables: { db?: any } }; +type Env = { Variables: { db?: any; userId?: number } }; const app = new Hono(); // Setup CRUD -app.get("/", (c) => { +app.get("/", async (c) => { const db = c.get("db"); - const setups = getAllSetups(db); + const userId = c.get("userId")!; + const setups = await getAllSetups(db, userId); return c.json(setups); }); -app.post("/", zValidator("json", createSetupSchema), (c) => { +app.post("/", zValidator("json", createSetupSchema), async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const data = c.req.valid("json"); - const setup = createSetup(db, data); + const setup = await createSetup(db, userId, data); return c.json(setup, 201); }); -app.get("/:id", (c) => { +app.get("/:id", async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const id = parseId(c.req.param("id")); if (!id) return c.json({ error: "Invalid setup ID" }, 400); - const setup = getSetupWithItems(db, id); + const setup = await getSetupWithItems(db, userId, id); if (!setup) return c.json({ error: "Setup not found" }, 404); - return c.json(setup); + const enrichedItems = await withImageUrls(setup.items); + return c.json({ ...setup, items: enrichedItems }); }); -app.put("/:id", zValidator("json", updateSetupSchema), (c) => { +app.put("/:id", zValidator("json", updateSetupSchema), async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const id = parseId(c.req.param("id")); if (!id) return c.json({ error: "Invalid setup ID" }, 400); const data = c.req.valid("json"); - const setup = updateSetup(db, id, data); + const setup = await updateSetup(db, userId, id, data); if (!setup) return c.json({ error: "Setup not found" }, 404); return c.json(setup); }); -app.delete("/:id", (c) => { +app.delete("/:id", async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const id = parseId(c.req.param("id")); if (!id) return c.json({ error: "Invalid setup ID" }, 400); - const deleted = deleteSetup(db, id); + const deleted = await deleteSetup(db, userId, id); if (!deleted) return c.json({ error: "Setup not found" }, 404); return c.json({ success: true }); }); // Setup Items -app.put("/:id/items", zValidator("json", syncSetupItemsSchema), (c) => { +app.put("/:id/items", zValidator("json", syncSetupItemsSchema), async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const id = parseId(c.req.param("id")); if (!id) return c.json({ error: "Invalid setup ID" }, 400); const { itemIds } = c.req.valid("json"); - const setup = getSetupWithItems(db, id); + const setup = await getSetupWithItems(db, userId, id); if (!setup) return c.json({ error: "Setup not found" }, 404); - syncSetupItems(db, id, itemIds); + await syncSetupItems(db, userId, id, itemIds); return c.json({ success: true }); }); app.patch( "/:id/items/:itemId/classification", zValidator("json", updateClassificationSchema), - (c) => { + async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const setupId = parseId(c.req.param("id")); const itemId = parseId(c.req.param("itemId")); if (!setupId || !itemId) return c.json({ error: "Invalid ID" }, 400); const { classification } = c.req.valid("json"); - updateItemClassification(db, setupId, itemId, classification); + await updateItemClassification(db, userId, setupId, itemId, classification); return c.json({ success: true }); }, ); -app.delete("/:id/items/:itemId", (c) => { +app.delete("/:id/items/:itemId", async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const setupId = parseId(c.req.param("id")); const itemId = parseId(c.req.param("itemId")); if (!setupId || !itemId) return c.json({ error: "Invalid ID" }, 400); - removeSetupItem(db, setupId, itemId); + await removeSetupItem(db, userId, setupId, itemId); return c.json({ success: true }); }); diff --git a/src/server/services/auth.service.ts b/src/server/services/auth.service.ts index 1ed003e..b5c9ced 100644 --- a/src/server/services/auth.service.ts +++ b/src/server/services/auth.service.ts @@ -1,147 +1,65 @@ import { randomBytes } from "node:crypto"; -import { count, eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { db as prodDb } from "../../db/index.ts"; -import { apiKeys, sessions, users } from "../../db/schema.ts"; +import { apiKeys, users } from "../../db/schema.ts"; type Db = typeof prodDb; // ── User Management ────────────────────────────────────────────────── -export async function createUser( - db: Db = prodDb, - username: string, - password: string, -) { - const passwordHash = await Bun.password.hash(password); - return db.insert(users).values({ username, passwordHash }).returning().get(); -} - -export async function verifyPassword( - db: Db = prodDb, - username: string, - password: string, -) { - const user = db - .select() - .from(users) - .where(eq(users.username, username)) - .get(); - - if (!user) return null; - - const valid = await Bun.password.verify(password, user.passwordHash); - return valid ? user : null; -} - -export function getUserCount(db: Db = prodDb): number { - const result = db.select({ value: count() }).from(users).get(); - return result?.value ?? 0; -} - -export async function changePassword( - db: Db = prodDb, - username: string, - currentPassword: string, - newPassword: string, -): Promise { - const user = await verifyPassword(db, username, currentPassword); - if (!user) return false; - - const newHash = await Bun.password.hash(newPassword); - db.update(users) - .set({ passwordHash: newHash }) - .where(eq(users.id, user.id)) - .run(); - - return true; -} - -// ── Session Management ─────────────────────────────────────────────── - -export function createSession( - db: Db = prodDb, - userId: number, - expiryDays = 30, -) { - const id = randomBytes(32).toString("hex"); - const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000); - - return db - .insert(sessions) - .values({ id, userId, expiresAt }) - .returning() - .get(); -} - -export function getSession(db: Db = prodDb, sessionId: string) { - const session = db - .select() - .from(sessions) - .where(eq(sessions.id, sessionId)) - .get(); - - if (!session) return null; - - if (session.expiresAt < new Date()) { - db.delete(sessions).where(eq(sessions.id, sessionId)).run(); - return null; - } - - return session; -} - -export function deleteSession(db: Db = prodDb, sessionId: string) { - db.delete(sessions).where(eq(sessions.id, sessionId)).run(); -} - -export function refreshSession( - db: Db = prodDb, - sessionId: string, - expiryDays = 30, -) { - const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000); - db.update(sessions) - .set({ expiresAt }) - .where(eq(sessions.id, sessionId)) - .run(); +export async function getOrCreateUser( + db: Db, + logtoSub: string, +): Promise<{ id: number }> { + const [user] = await db + .insert(users) + .values({ logtoSub }) + .onConflictDoUpdate({ + target: users.logtoSub, + set: { logtoSub }, + }) + .returning({ id: users.id }); + return user; } // ── API Key Management ─────────────────────────────────────────────── -export async function createApiKey(db: Db = prodDb, name: string) { +export async function createApiKey( + db: Db, + userId: number, + name: string, +) { const rawKey = randomBytes(32).toString("hex"); const keyHash = await Bun.password.hash(rawKey); const keyPrefix = rawKey.slice(0, 8); - const record = db + const [record] = await db .insert(apiKeys) - .values({ name, keyHash, keyPrefix }) - .returning() - .get(); + .values({ name, keyHash, keyPrefix, userId }) + .returning(); return { ...record, rawKey }; } export async function verifyApiKey( - db: Db = prodDb, + db: Db, rawKey: string, -): Promise { +): Promise<{ userId: number } | null> { const prefix = rawKey.slice(0, 8); - const candidates = db + const candidates = await db .select() .from(apiKeys) - .where(eq(apiKeys.keyPrefix, prefix)) - .all(); + .where(eq(apiKeys.keyPrefix, prefix)); for (const candidate of candidates) { const valid = await Bun.password.verify(rawKey, candidate.keyHash); - if (valid) return true; + if (valid) return { userId: candidate.userId }; } - return false; + return null; } -export function listApiKeys(db: Db = prodDb) { +export async function listApiKeys(db: Db, userId: number) { return db .select({ id: apiKeys.id, @@ -150,9 +68,11 @@ export function listApiKeys(db: Db = prodDb) { createdAt: apiKeys.createdAt, }) .from(apiKeys) - .all(); + .where(eq(apiKeys.userId, userId)); } -export function deleteApiKey(db: Db = prodDb, id: number) { - db.delete(apiKeys).where(eq(apiKeys.id, id)).run(); +export async function deleteApiKey(db: Db, userId: number, id: number) { + await db + .delete(apiKeys) + .where(and(eq(apiKeys.id, id), eq(apiKeys.userId, userId))); } diff --git a/src/server/services/setup.service.ts b/src/server/services/setup.service.ts index 1128ab7..da0456f 100644 --- a/src/server/services/setup.service.ts +++ b/src/server/services/setup.service.ts @@ -1,15 +1,24 @@ -import { eq, sql } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import { db as prodDb } from "../../db/index.ts"; import { categories, items, setupItems, setups } from "../../db/schema.ts"; import type { CreateSetup, UpdateSetup } from "../../shared/types.ts"; type Db = typeof prodDb; -export function createSetup(db: Db = prodDb, data: CreateSetup) { - return db.insert(setups).values({ name: data.name }).returning().get(); +export async function createSetup( + db: Db, + userId: number, + data: CreateSetup, +) { + const [row] = await db + .insert(setups) + .values({ name: data.name, userId }) + .returning(); + + return row; } -export function getAllSetups(db: Db = prodDb) { +export async function getAllSetups(db: Db, userId: number) { return db .select({ id: setups.id, @@ -32,14 +41,21 @@ export function getAllSetups(db: Db = prodDb) { ), 0)`.as("total_cost"), }) .from(setups) - .all(); + .where(eq(setups.userId, userId)); } -export function getSetupWithItems(db: Db = prodDb, setupId: number) { - const setup = db.select().from(setups).where(eq(setups.id, setupId)).get(); +export async function getSetupWithItems( + db: Db, + userId: number, + setupId: number, +) { + const [setup] = await db + .select() + .from(setups) + .where(and(eq(setups.id, setupId), eq(setups.userId, userId))); if (!setup) return null; - const itemList = db + const itemList = await db .select({ id: items.id, name: items.name, @@ -59,59 +75,82 @@ export function getSetupWithItems(db: Db = prodDb, setupId: number) { .from(setupItems) .innerJoin(items, eq(setupItems.itemId, items.id)) .innerJoin(categories, eq(items.categoryId, categories.id)) - .where(eq(setupItems.setupId, setupId)) - .all(); + .where(eq(setupItems.setupId, setupId)); return { ...setup, items: itemList }; } -export function updateSetup( - db: Db = prodDb, +export async function updateSetup( + db: Db, + userId: number, setupId: number, data: UpdateSetup, ) { - const existing = db + const [existing] = await db .select({ id: setups.id }) .from(setups) - .where(eq(setups.id, setupId)) - .get(); + .where(and(eq(setups.id, setupId), eq(setups.userId, userId))); if (!existing) return null; - return db + const [row] = await db .update(setups) .set({ name: data.name, updatedAt: new Date() }) - .where(eq(setups.id, setupId)) - .returning() - .get(); + .where(and(eq(setups.id, setupId), eq(setups.userId, userId))) + .returning(); + + return row; } -export function deleteSetup(db: Db = prodDb, setupId: number) { - const existing = db +export async function deleteSetup( + db: Db, + userId: number, + setupId: number, +) { + const [existing] = await db .select({ id: setups.id }) .from(setups) - .where(eq(setups.id, setupId)) - .get(); + .where(and(eq(setups.id, setupId), eq(setups.userId, userId))); if (!existing) return false; - db.delete(setups).where(eq(setups.id, setupId)).run(); + await db + .delete(setups) + .where(and(eq(setups.id, setupId), eq(setups.userId, userId))); return true; } -export function syncSetupItems( - db: Db = prodDb, +export async function syncSetupItems( + db: Db, + userId: number, setupId: number, itemIds: number[], ) { - return db.transaction((tx) => { + return await db.transaction(async (tx) => { + // Verify the setup belongs to this user + const [setup] = await tx + .select({ id: setups.id }) + .from(setups) + .where(and(eq(setups.id, setupId), eq(setups.userId, userId))); + if (!setup) return null; + + // Verify all itemIds belong to this user + const validItems = + itemIds.length > 0 + ? await tx + .select({ id: items.id }) + .from(items) + .where(and(eq(items.userId, userId), inArray(items.id, itemIds))) + : []; + const validItemIds = new Set(validItems.map((i) => i.id)); + const filteredItemIds = itemIds.filter((id) => validItemIds.has(id)); + // Save existing classifications before deleting - const existing = tx + const existing = await tx .select({ itemId: setupItems.itemId, classification: setupItems.classification, }) .from(setupItems) - .where(eq(setupItems.setupId, setupId)) - .all(); + .where(eq(setupItems.setupId, setupId)); const classificationMap = new Map(); for (const row of existing) { @@ -119,43 +158,57 @@ export function syncSetupItems( } // Delete all existing items for this setup - tx.delete(setupItems).where(eq(setupItems.setupId, setupId)).run(); + await tx.delete(setupItems).where(eq(setupItems.setupId, setupId)); - // Re-insert new items, preserving classifications for retained items - for (const itemId of itemIds) { - tx.insert(setupItems) - .values({ - setupId, - itemId, - classification: classificationMap.get(itemId) ?? "base", - }) - .run(); + // Re-insert only user-owned items, preserving classifications + for (const itemId of filteredItemIds) { + await tx.insert(setupItems).values({ + setupId, + itemId, + classification: classificationMap.get(itemId) ?? "base", + }); } }); } -export function updateItemClassification( - db: Db = prodDb, +export async function updateItemClassification( + db: Db, + userId: number, setupId: number, itemId: number, classification: string, ) { - db.update(setupItems) + // Verify setup belongs to user + const [setup] = await db + .select({ id: setups.id }) + .from(setups) + .where(and(eq(setups.id, setupId), eq(setups.userId, userId))); + if (!setup) return null; + + await db + .update(setupItems) .set({ classification }) .where( - sql`${setupItems.setupId} = ${setupId} AND ${setupItems.itemId} = ${itemId}`, - ) - .run(); + and(eq(setupItems.setupId, setupId), eq(setupItems.itemId, itemId)), + ); } -export function removeSetupItem( - db: Db = prodDb, +export async function removeSetupItem( + db: Db, + userId: number, setupId: number, itemId: number, ) { - db.delete(setupItems) + // Verify setup belongs to user + const [setup] = await db + .select({ id: setups.id }) + .from(setups) + .where(and(eq(setups.id, setupId), eq(setups.userId, userId))); + if (!setup) return null; + + await db + .delete(setupItems) .where( - sql`${setupItems.setupId} = ${setupId} AND ${setupItems.itemId} = ${itemId}`, - ) - .run(); + and(eq(setupItems.setupId, setupId), eq(setupItems.itemId, itemId)), + ); } diff --git a/src/server/services/storage.service.ts b/src/server/services/storage.service.ts new file mode 100644 index 0000000..d2f7674 --- /dev/null +++ b/src/server/services/storage.service.ts @@ -0,0 +1,83 @@ +import { + DeleteObjectCommand, + GetObjectCommand, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; + +// MinIO GitHub repository was archived Feb 2026. The S3 API abstraction +// makes the underlying provider swappable (SeaweedFS, Garage, AWS S3, etc.) +// without code changes. + +const s3 = new S3Client({ + endpoint: process.env.S3_ENDPOINT, + region: process.env.S3_REGION ?? "us-east-1", + credentials: { + accessKeyId: process.env.S3_ACCESS_KEY!, + secretAccessKey: process.env.S3_SECRET_KEY!, + }, + forcePathStyle: true, // REQUIRED for MinIO and most S3-compatible services +}); + +const bucket = process.env.S3_BUCKET ?? "gearbox-images"; +const presignExpiry = Number.parseInt( + process.env.S3_PRESIGN_EXPIRY ?? "3600", + 10, +); + +export async function uploadImage( + buffer: Buffer | ArrayBuffer, + filename: string, + contentType: string, +): Promise { + await s3.send( + new PutObjectCommand({ + Bucket: bucket, + Key: filename, + Body: Buffer.from(buffer), + ContentType: contentType, + }), + ); +} + +export async function deleteImage(filename: string): Promise { + await s3.send( + new DeleteObjectCommand({ + Bucket: bucket, + Key: filename, + }), + ); +} + +export async function getImageUrl(filename: string): Promise { + const command = new GetObjectCommand({ + Bucket: bucket, + Key: filename, + }); + return getSignedUrl(s3, command, { expiresIn: presignExpiry }); +} + +/** + * Enrich a record that has an imageFilename with a presigned imageUrl. + * Returns null imageUrl when imageFilename is null. + */ +export async function withImageUrl< + T extends { imageFilename: string | null }, +>(record: T): Promise { + return { + ...record, + imageUrl: record.imageFilename + ? await getImageUrl(record.imageFilename) + : null, + }; +} + +/** + * Batch version of withImageUrl. Uses Promise.all for parallelism. + */ +export async function withImageUrls< + T extends { imageFilename: string | null }, +>(records: T[]): Promise<(T & { imageUrl: string | null })[]> { + return Promise.all(records.map((record) => withImageUrl(record))); +} diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index ed34157..0e68b9a 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -73,10 +73,12 @@ 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({ @@ -89,3 +91,19 @@ export const classificationSchema = z.enum(["base", "worn", "consumable"]); export const updateClassificationSchema = z.object({ classification: classificationSchema, }); + +// Global item schemas +export const searchGlobalItemsSchema = z.object({ + q: z.string().optional(), +}); + +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 f96624e..3069dad 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,6 +1,8 @@ import type { z } from "zod"; import type { categories, + globalItems, + itemGlobalLinks, items, setupItems, setups, @@ -13,13 +15,16 @@ import type { createItemSchema, createSetupSchema, createThreadSchema, + linkItemSchema, reorderCandidatesSchema, resolveThreadSchema, + searchGlobalItemsSchema, syncSetupItemsSchema, updateCandidateSchema, updateCategorySchema, updateClassificationSchema, updateItemSchema, + updateProfileSchema, updateSetupSchema, updateThreadSchema, } from "./schemas.ts"; @@ -42,6 +47,11 @@ 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; @@ -49,3 +59,5 @@ export type Thread = typeof threads.$inferSelect; 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; diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts index 3235e3c..28e9323 100644 --- a/tests/helpers/db.ts +++ b/tests/helpers/db.ts @@ -1,21 +1,40 @@ -import { Database } from "bun:sqlite"; -import { drizzle } from "drizzle-orm/bun-sqlite"; -import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; import * as schema from "../../src/db/schema.ts"; -export function createTestDb() { - const sqlite = new Database(":memory:"); - sqlite.run("PRAGMA foreign_keys = ON"); +type Db = ReturnType>; - const db = drizzle(sqlite, { schema }); +export async function createTestDb() { + const client = new PGlite(); + const db = drizzle(client, { schema }); // Apply all migrations to create tables - migrate(db, { migrationsFolder: "./drizzle" }); + await migrate(db, { migrationsFolder: "./drizzle-pg" }); - // Seed default Uncategorized category - db.insert(schema.categories) - .values({ name: "Uncategorized", icon: "package" }) - .run(); + // Seed a test user + const [user] = await db + .insert(schema.users) + .values({ logtoSub: "test-user-sub" }) + .returning(); - return db; + // Seed per-user Uncategorized category + await db + .insert(schema.categories) + .values({ name: "Uncategorized", icon: "package", userId: user.id }); + + return { db, userId: user.id }; +} + +export async function createSecondTestUser(db: Db) { + const [user] = await db + .insert(schema.users) + .values({ logtoSub: "test-user-2-sub" }) + .returning(); + + await db + .insert(schema.categories) + .values({ name: "Uncategorized", icon: "package", userId: user.id }); + + return user.id; }