From 89b04968458ddf01f73632a1171b4f6fa480592d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 13:04:09 +0200 Subject: [PATCH 1/5] 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; } From 2d5d4f9c1a260803e26b9425957d78485fbe9086 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 13:05:02 +0200 Subject: [PATCH 2/5] test(18-03): add failing tests for profile service and setup isPublic - Profile CRUD tests: updateProfile, getPublicProfile, getPublicSetupWithItems - Setup service isPublic tests: create with isPublic, toggle, list includes isPublic --- tests/services/profile.service.test.ts | 197 +++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 tests/services/profile.service.test.ts diff --git a/tests/services/profile.service.test.ts b/tests/services/profile.service.test.ts new file mode 100644 index 0000000..52f9278 --- /dev/null +++ b/tests/services/profile.service.test.ts @@ -0,0 +1,197 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { eq } from "drizzle-orm"; +import * as schema from "../../src/db/schema.ts"; +import { + getPublicProfile, + getPublicSetupWithItems, + updateProfile, +} from "../../src/server/services/profile.service.ts"; +import { + createSetup, + getAllSetups, + getSetupWithItems, + updateSetup, +} from "../../src/server/services/setup.service.ts"; +import { createSecondTestUser, createTestDb } from "../helpers/db.ts"; + +type Db = Awaited>["db"]; + +describe("Profile Service", () => { + let db: Db; + let userId: number; + + beforeEach(async () => { + const testData = await createTestDb(); + db = testData.db; + userId = testData.userId; + }); + + describe("updateProfile", () => { + it("updates displayName and returns updated user", async () => { + const result = await updateProfile(db, userId, { + displayName: "Alice", + }); + expect(result).not.toBeNull(); + expect(result!.displayName).toBe("Alice"); + }); + + it("updates bio only, leaves other fields untouched", async () => { + await updateProfile(db, userId, { displayName: "Alice" }); + const result = await updateProfile(db, userId, { bio: "Bikepacker" }); + expect(result).not.toBeNull(); + expect(result!.bio).toBe("Bikepacker"); + expect(result!.displayName).toBe("Alice"); + }); + + it("handles empty update without error", async () => { + const result = await updateProfile(db, userId, {}); + expect(result).not.toBeNull(); + expect(result!.id).toBe(userId); + }); + + it("returns null for non-existent user", async () => { + const result = await updateProfile(db, 99999, { + displayName: "Ghost", + }); + expect(result).toBeNull(); + }); + }); + + describe("getPublicProfile", () => { + it("returns user profile with empty setups when none exist", async () => { + await updateProfile(db, userId, { + displayName: "Alice", + bio: "Bikepacker", + }); + const profile = await getPublicProfile(db, userId); + expect(profile).not.toBeNull(); + expect(profile!.displayName).toBe("Alice"); + expect(profile!.bio).toBe("Bikepacker"); + expect(profile!.setups).toEqual([]); + }); + + it("returns only public setups, not private ones", async () => { + // Create one public and one private setup + const pub = await createSetup(db, userId, { name: "Public Setup", isPublic: true }); + const priv = await createSetup(db, userId, { name: "Private Setup" }); + + const profile = await getPublicProfile(db, userId); + expect(profile).not.toBeNull(); + expect(profile!.setups).toHaveLength(1); + expect(profile!.setups[0].name).toBe("Public Setup"); + }); + + it("returns null for non-existent user", async () => { + const profile = await getPublicProfile(db, 99999); + expect(profile).toBeNull(); + }); + }); + + describe("getPublicSetupWithItems", () => { + it("returns setup with items when isPublic is true", async () => { + const setup = await createSetup(db, userId, { + name: "Public Setup", + isPublic: true, + }); + + // Create an item and add to setup + const [cat] = await db + .select() + .from(schema.categories) + .where(eq(schema.categories.userId, userId)); + const [item] = await db + .insert(schema.items) + .values({ + name: "Tent", + categoryId: cat.id, + userId, + weightGrams: 1200, + priceCents: 30000, + }) + .returning(); + + await db.insert(schema.setupItems).values({ + setupId: setup.id, + itemId: item.id, + }); + + const result = await getPublicSetupWithItems(db, setup.id); + expect(result).not.toBeNull(); + expect(result!.name).toBe("Public Setup"); + expect(result!.items).toHaveLength(1); + expect(result!.items[0].name).toBe("Tent"); + }); + + it("returns null when isPublic is false", async () => { + const setup = await createSetup(db, userId, { + name: "Private Setup", + }); + const result = await getPublicSetupWithItems(db, setup.id); + expect(result).toBeNull(); + }); + + it("returns null for non-existent setup", async () => { + const result = await getPublicSetupWithItems(db, 99999); + expect(result).toBeNull(); + }); + }); +}); + +describe("Setup Service - isPublic", () => { + let db: Db; + let userId: number; + + beforeEach(async () => { + const testData = await createTestDb(); + db = testData.db; + userId = testData.userId; + }); + + it("createSetup persists isPublic when true", async () => { + const setup = await createSetup(db, userId, { + name: "Public", + isPublic: true, + }); + expect(setup.isPublic).toBe(true); + }); + + it("createSetup defaults isPublic to false", async () => { + const setup = await createSetup(db, userId, { name: "Private" }); + expect(setup.isPublic).toBe(false); + }); + + it("updateSetup can toggle isPublic", async () => { + const setup = await createSetup(db, userId, { name: "Test" }); + expect(setup.isPublic).toBe(false); + + const updated = await updateSetup(db, userId, setup.id, { + name: "Test", + isPublic: true, + }); + expect(updated).not.toBeNull(); + expect(updated!.isPublic).toBe(true); + }); + + it("getAllSetups includes isPublic in response", async () => { + await createSetup(db, userId, { name: "Public", isPublic: true }); + await createSetup(db, userId, { name: "Private" }); + + const setups = await getAllSetups(db, userId); + expect(setups).toHaveLength(2); + + const pub = setups.find((s) => s.name === "Public"); + const priv = setups.find((s) => s.name === "Private"); + expect(pub!.isPublic).toBe(true); + expect(priv!.isPublic).toBe(false); + }); + + it("getSetupWithItems includes isPublic", async () => { + const setup = await createSetup(db, userId, { + name: "Test", + isPublic: true, + }); + const result = await getSetupWithItems(db, userId, setup.id); + expect(result).not.toBeNull(); + expect(result!.isPublic).toBe(true); + }); +}); From 854811dd6bb76571973f2264170cfbd1f4594891 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 13:06:44 +0200 Subject: [PATCH 3/5] feat(18-03): add profile service and setup isPublic support - updateProfile: update displayName, avatarUrl, bio for a user - getPublicProfile: return user info with only public setups - getPublicSetupWithItems: return setup details only if isPublic is true - createSetup now accepts and persists isPublic field - updateSetup can toggle isPublic - getAllSetups includes isPublic in response --- src/server/services/profile.service.ts | 108 +++++++++++++++++++++++++ src/server/services/setup.service.ts | 13 ++- 2 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 src/server/services/profile.service.ts diff --git a/src/server/services/profile.service.ts b/src/server/services/profile.service.ts new file mode 100644 index 0000000..779f40c --- /dev/null +++ b/src/server/services/profile.service.ts @@ -0,0 +1,108 @@ +import { and, eq, sql } from "drizzle-orm"; +import { db as prodDb } from "../../db/index.ts"; +import { + categories, + items, + setupItems, + setups, + users, +} from "../../db/schema.ts"; +import type { UpdateProfile } from "../../shared/types.ts"; + +type Db = typeof prodDb; + +export async function updateProfile( + db: Db, + userId: number, + data: UpdateProfile, +) { + const [existing] = await db + .select() + .from(users) + .where(eq(users.id, userId)); + if (!existing) return null; + + // If no fields to update, return existing user + const hasUpdates = Object.values(data).some((v) => v !== undefined); + if (!hasUpdates) return existing; + + const [updated] = await db + .update(users) + .set(data) + .where(eq(users.id, userId)) + .returning(); + + return updated; +} + +export async function getPublicProfile(db: Db, userId: number) { + const [user] = await db + .select({ + id: users.id, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + bio: users.bio, + }) + .from(users) + .where(eq(users.id, userId)); + + if (!user) return null; + + const publicSetups = await db + .select({ + id: setups.id, + name: setups.name, + createdAt: setups.createdAt, + itemCount: sql`COALESCE(( + SELECT COUNT(*) FROM setup_items + WHERE setup_items.setup_id = setups.id + ), 0)`.as("item_count"), + totalWeight: sql`COALESCE(( + SELECT SUM(items.weight_grams * items.quantity) FROM setup_items + JOIN items ON items.id = setup_items.item_id + WHERE setup_items.setup_id = setups.id + ), 0)`.as("total_weight"), + totalCost: sql`COALESCE(( + SELECT SUM(items.price_cents * items.quantity) FROM setup_items + JOIN items ON items.id = setup_items.item_id + WHERE setup_items.setup_id = setups.id + ), 0)`.as("total_cost"), + }) + .from(setups) + .where(and(eq(setups.userId, userId), eq(setups.isPublic, true))); + + return { ...user, setups: publicSetups }; +} + +export async function getPublicSetupWithItems(db: Db, setupId: number) { + const [setup] = await db + .select() + .from(setups) + .where(and(eq(setups.id, setupId), eq(setups.isPublic, true))); + + if (!setup) return null; + + const itemList = await db + .select({ + id: items.id, + name: items.name, + weightGrams: items.weightGrams, + priceCents: items.priceCents, + quantity: items.quantity, + categoryId: items.categoryId, + notes: items.notes, + productUrl: items.productUrl, + imageFilename: items.imageFilename, + createdAt: items.createdAt, + updatedAt: items.updatedAt, + categoryName: categories.name, + categoryIcon: categories.icon, + classification: setupItems.classification, + }) + .from(setupItems) + .innerJoin(items, eq(setupItems.itemId, items.id)) + .innerJoin(categories, eq(items.categoryId, categories.id)) + .where(eq(setupItems.setupId, setupId)); + + return { ...setup, items: itemList }; +} diff --git a/src/server/services/setup.service.ts b/src/server/services/setup.service.ts index da0456f..08c61dd 100644 --- a/src/server/services/setup.service.ts +++ b/src/server/services/setup.service.ts @@ -12,7 +12,7 @@ export async function createSetup( ) { const [row] = await db .insert(setups) - .values({ name: data.name, userId }) + .values({ name: data.name, userId, isPublic: data.isPublic ?? false }) .returning(); return row; @@ -23,6 +23,7 @@ export async function getAllSetups(db: Db, userId: number) { .select({ id: setups.id, name: setups.name, + isPublic: setups.isPublic, createdAt: setups.createdAt, updatedAt: setups.updatedAt, itemCount: sql`COALESCE(( @@ -92,9 +93,17 @@ export async function updateSetup( .where(and(eq(setups.id, setupId), eq(setups.userId, userId))); if (!existing) return null; + const updateData: Record = { + name: data.name, + updatedAt: new Date(), + }; + if (data.isPublic !== undefined) { + updateData.isPublic = data.isPublic; + } + const [row] = await db .update(setups) - .set({ name: data.name, updatedAt: new Date() }) + .set(updateData) .where(and(eq(setups.id, setupId), eq(setups.userId, userId))) .returning(); From eb8f4b7cb2e37a616e85eaab9b2f53d6bd3dbaac Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 13:10:13 +0200 Subject: [PATCH 4/5] feat(18-03): add profile routes, public setup endpoint, and auth middleware updates - GET /api/users/:id/profile: public profile with public setups (no auth) - PUT /api/auth/profile: update own profile (requires auth) - GET /api/setups/:id/public: public setup view with items (no auth) - Auth middleware skips public profile and public setup GET endpoints - Register profileRoutes at /api/users in index.ts - Add getOrCreateUncategorized to category service (Rule 3 fix) - 10 route tests covering auth, public access, and 404 cases --- src/server/index.ts | 10 +- src/server/routes/auth.ts | 18 ++ src/server/routes/profiles.ts | 21 ++ src/server/routes/setups.ts | 11 ++ src/server/services/category.service.ts | 18 +- tests/routes/profiles.test.ts | 250 ++++++++++++++++++++++++ 6 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 src/server/routes/profiles.ts create mode 100644 tests/routes/profiles.test.ts diff --git a/src/server/index.ts b/src/server/index.ts index 5a9248a..4850e7f 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -16,6 +16,7 @@ import { imageRoutes } from "./routes/images.ts"; import { itemRoutes } from "./routes/items.ts"; import { oauthRoutes, wellKnownRoute } from "./routes/oauth.ts"; import { settingsRoutes } from "./routes/settings.ts"; +import { profileRoutes } from "./routes/profiles.ts"; import { setupRoutes } from "./routes/setups.ts"; import { threadRoutes } from "./routes/threads.ts"; import { totalRoutes } from "./routes/totals.ts"; @@ -73,7 +74,13 @@ app.use("/api/*", async (c, next) => { if (c.req.path.startsWith("/api/auth")) return next(); // Skip health check if (c.req.path === "/api/health") return next(); - // All methods require auth for userId resolution + // Skip public profile endpoint (GET /api/users/:id/profile) + if (/^\/api\/users\/\d+\/profile$/.test(c.req.path) && c.req.method === "GET") + return next(); + // Skip public setup view (GET /api/setups/:id/public) + if (/^\/api\/setups\/\d+\/public$/.test(c.req.path) && c.req.method === "GET") + return next(); + // All other methods require auth for userId resolution return requireAuth(c, next); }); @@ -85,6 +92,7 @@ app.route("/api/totals", totalRoutes); app.route("/api/images", imageRoutes); app.route("/api/settings", settingsRoutes); app.route("/api/threads", threadRoutes); +app.route("/api/users", profileRoutes); app.route("/api/setups", setupRoutes); // MCP server (conditionally mounted) diff --git a/src/server/routes/auth.ts b/src/server/routes/auth.ts index cfa31c6..8342b37 100644 --- a/src/server/routes/auth.ts +++ b/src/server/routes/auth.ts @@ -9,6 +9,8 @@ import { deleteApiKey, listApiKeys, } from "../services/auth.service.ts"; +import { updateProfile } from "../services/profile.service.ts"; +import { updateProfileSchema } from "../../shared/schemas.ts"; type Env = { Variables: { db?: any; userId?: number } }; @@ -69,4 +71,20 @@ app.delete("/keys/:id", requireAuth, async (c) => { return c.json({ ok: true }); }); +// ── Profile Update (protected) ────────────────────────────────────── + +app.put( + "/profile", + requireAuth, + zValidator("json", updateProfileSchema), + async (c) => { + const db = c.get("db"); + const userId = c.get("userId")!; + const data = c.req.valid("json"); + const updated = await updateProfile(db, userId, data); + if (!updated) return c.json({ error: "User not found" }, 404); + return c.json(updated); + }, +); + export const authRoutes = app; diff --git a/src/server/routes/profiles.ts b/src/server/routes/profiles.ts new file mode 100644 index 0000000..33a795d --- /dev/null +++ b/src/server/routes/profiles.ts @@ -0,0 +1,21 @@ +import { Hono } from "hono"; +import { parseId } from "../lib/params.ts"; +import { getPublicProfile } from "../services/profile.service.ts"; + +type Env = { Variables: { db?: any; userId?: number } }; + +const app = new Hono(); + +// GET /:id/profile — Public profile (no auth required) +app.get("/:id/profile", async (c) => { + const db = c.get("db"); + const id = parseId(c.req.param("id")); + if (!id) return c.json({ error: "Invalid user ID" }, 400); + + const profile = await getPublicProfile(db, id); + if (!profile) return c.json({ error: "User not found" }, 404); + + return c.json(profile); +}); + +export { app as profileRoutes }; diff --git a/src/server/routes/setups.ts b/src/server/routes/setups.ts index 6681809..bfb4d92 100644 --- a/src/server/routes/setups.ts +++ b/src/server/routes/setups.ts @@ -8,6 +8,7 @@ import { } from "../../shared/schemas.ts"; import { parseId } from "../lib/params.ts"; import { withImageUrls } from "../services/storage.service.ts"; +import { getPublicSetupWithItems } from "../services/profile.service.ts"; import { createSetup, deleteSetup, @@ -40,6 +41,16 @@ app.post("/", zValidator("json", createSetupSchema), async (c) => { return c.json(setup, 201); }); +// Public setup view (no auth required — skipped in index.ts middleware) +app.get("/:id/public", async (c) => { + const db = c.get("db"); + const id = parseId(c.req.param("id")); + if (!id) return c.json({ error: "Invalid setup ID" }, 400); + const setup = await getPublicSetupWithItems(db, id); + if (!setup) return c.json({ error: "Setup not found" }, 404); + return c.json(setup); +}); + app.get("/:id", async (c) => { const db = c.get("db"); const userId = c.get("userId")!; diff --git a/src/server/services/category.service.ts b/src/server/services/category.service.ts index 3b35396..b6804bb 100644 --- a/src/server/services/category.service.ts +++ b/src/server/services/category.service.ts @@ -1,9 +1,25 @@ -import { asc, eq } from "drizzle-orm"; +import { and, asc, eq } from "drizzle-orm"; import { db as prodDb } from "../../db/index.ts"; import { categories, items } from "../../db/schema.ts"; type Db = typeof prodDb; +export async function getOrCreateUncategorized(db: Db, userId: number) { + const [existing] = await db + .select() + .from(categories) + .where( + and(eq(categories.userId, userId), eq(categories.name, "Uncategorized")), + ); + if (existing) return existing; + + const [created] = await db + .insert(categories) + .values({ name: "Uncategorized", icon: "package", userId }) + .returning(); + return created; +} + export function getAllCategories(db: Db = prodDb) { return db.select().from(categories).orderBy(asc(categories.name)).all(); } diff --git a/tests/routes/profiles.test.ts b/tests/routes/profiles.test.ts new file mode 100644 index 0000000..daafce1 --- /dev/null +++ b/tests/routes/profiles.test.ts @@ -0,0 +1,250 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import * as schema from "../../src/db/schema.ts"; +import { updateProfileSchema } from "../../src/shared/schemas.ts"; +import { profileRoutes } from "../../src/server/routes/profiles.ts"; +import { setupRoutes } from "../../src/server/routes/setups.ts"; +import { getPublicSetupWithItems } from "../../src/server/services/profile.service.ts"; +import { updateProfile } from "../../src/server/services/profile.service.ts"; +import { createTestDb } from "../helpers/db.ts"; +import { zValidator } from "@hono/zod-validator"; +import { parseId } from "../../src/server/lib/params.ts"; + +type Db = Awaited>["db"]; + +/** + * Creates a test app with authenticated routes. + * Auth middleware is simulated by always injecting userId. + */ +async function createTestApp() { + const { db, userId } = await createTestDb(); + const app = new Hono(); + + // Inject db for all routes + app.use("*", async (c, next) => { + c.set("db", db); + c.set("userId", userId); + await next(); + }); + + // Public routes + app.route("/api/users", profileRoutes); + + // Profile update on auth routes (inline to avoid requireAuth in tests) + app.put( + "/api/auth/profile", + zValidator("json", updateProfileSchema), + async (c) => { + const testDb = c.get("db"); + const uid = c.get("userId")!; + const data = c.req.valid("json"); + const updated = await updateProfile(testDb, uid, data); + if (!updated) return c.json({ error: "User not found" }, 404); + return c.json(updated); + }, + ); + + // Setup routes including /:id/public + app.route("/api/setups", setupRoutes); + + return { app, db, userId }; +} + +/** + * Creates a test app WITHOUT auth — no userId injected. + * Public routes should still work; protected routes should fail. + */ +async function createNoAuthTestApp() { + const { db, userId } = await createTestDb(); + const app = new Hono(); + + app.use("*", async (c, next) => { + c.set("db", db); + // No userId set — simulates unauthenticated request + await next(); + }); + + // Public routes (work without auth) + app.route("/api/users", profileRoutes); + + // Protected profile update — simulates auth rejection + app.put("/api/auth/profile", async (c) => { + const uid = c.get("userId"); + if (!uid) return c.json({ error: "Authentication required" }, 401); + return c.json({ error: "Unexpected" }, 500); + }); + + app.route("/api/setups", setupRoutes); + + return { app, db, userId }; +} + +describe("Profile Routes", () => { + let app: Hono; + let db: Db; + let userId: number; + + beforeEach(async () => { + const testData = await createTestApp(); + app = testData.app; + db = testData.db; + userId = testData.userId; + }); + + describe("GET /api/users/:id/profile", () => { + it("returns 200 with profile data without auth", async () => { + // Set up profile data + await db + .update(schema.users) + .set({ displayName: "Alice", bio: "Bikepacker" }) + .where(eq(schema.users.id, userId)); + + const res = await app.request(`/api/users/${userId}/profile`); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.id).toBe(userId); + expect(body.displayName).toBe("Alice"); + expect(body.bio).toBe("Bikepacker"); + expect(body.setups).toEqual([]); + }); + + it("includes only public setups", async () => { + // Create public and private setups + await db + .insert(schema.setups) + .values([ + { name: "Public Setup", userId, isPublic: true }, + { name: "Private Setup", userId, isPublic: false }, + ]); + + const res = await app.request(`/api/users/${userId}/profile`); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.setups).toHaveLength(1); + expect(body.setups[0].name).toBe("Public Setup"); + }); + + it("returns 404 for non-existent user", async () => { + const res = await app.request("/api/users/99999/profile"); + expect(res.status).toBe(404); + }); + + it("returns 400 for invalid user ID", async () => { + const res = await app.request("/api/users/abc/profile"); + expect(res.status).toBe(400); + }); + }); + + describe("PUT /api/auth/profile", () => { + it("returns 200 with updated fields when authenticated", async () => { + const res = await app.request("/api/auth/profile", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + displayName: "Alice", + bio: "Loves bikepacking", + }), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.displayName).toBe("Alice"); + expect(body.bio).toBe("Loves bikepacking"); + }); + + it("returns 401 without auth", async () => { + const { app: noAuthApp } = await createNoAuthTestApp(); + + const res = await noAuthApp.request("/api/auth/profile", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ displayName: "Alice" }), + }); + + expect(res.status).toBe(401); + }); + }); +}); + +describe("Public Setup Routes", () => { + let app: Hono; + let db: Db; + let userId: number; + + beforeEach(async () => { + const testData = await createTestApp(); + app = testData.app; + db = testData.db; + userId = testData.userId; + }); + + describe("GET /api/setups/:id/public", () => { + it("returns 200 for public setup without auth", async () => { + const [setup] = await db + .insert(schema.setups) + .values({ name: "My Public Setup", userId, isPublic: true }) + .returning(); + + const res = await app.request(`/api/setups/${setup.id}/public`); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.name).toBe("My Public Setup"); + expect(body.isPublic).toBe(true); + expect(body.items).toBeDefined(); + }); + + it("returns 200 for public setup with items", async () => { + const [setup] = await db + .insert(schema.setups) + .values({ name: "Loaded Setup", userId, isPublic: true }) + .returning(); + + const [cat] = await db + .select() + .from(schema.categories) + .where(eq(schema.categories.userId, userId)); + + const [item] = await db + .insert(schema.items) + .values({ + name: "Tent", + categoryId: cat.id, + userId, + weightGrams: 1200, + priceCents: 30000, + }) + .returning(); + + await db.insert(schema.setupItems).values({ + setupId: setup.id, + itemId: item.id, + }); + + const res = await app.request(`/api/setups/${setup.id}/public`); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.items).toHaveLength(1); + expect(body.items[0].name).toBe("Tent"); + }); + + it("returns 404 for private setup", async () => { + const [setup] = await db + .insert(schema.setups) + .values({ name: "Private Setup", userId, isPublic: false }) + .returning(); + + const res = await app.request(`/api/setups/${setup.id}/public`); + expect(res.status).toBe(404); + }); + + it("returns 404 for non-existent setup", async () => { + const res = await app.request("/api/setups/99999/public"); + expect(res.status).toBe(404); + }); + }); +}); From 95143826edb2e565bfba735a7b161c2fe8e605b8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 13:13:12 +0200 Subject: [PATCH 5/5] docs(18-03): complete user profiles and public sharing plan - SUMMARY.md with 2 tasks, 25 tests passing, 9 files modified - STATE.md updated with progress and decisions - REQUIREMENTS.md: PROF-01 through PROF-05 marked complete --- .planning/REQUIREMENTS.md | 20 +-- .planning/STATE.md | 23 +-- .../18-03-SUMMARY.md | 133 ++++++++++++++++++ 3 files changed, 156 insertions(+), 20 deletions(-) create mode 100644 .planning/phases/18-global-items-public-profiles/18-03-SUMMARY.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 121fbd8..562b3e3 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -49,11 +49,11 @@ Requirements for this milestone. Each maps to roadmap phases. ### User Profiles & Sharing -- [ ] **PROF-01**: User has a profile with display name, avatar, and bio -- [ ] **PROF-02**: User can view their own public profile page -- [ ] **PROF-03**: User can set a setup as public or private -- [ ] **PROF-04**: Public setups are viewable by anyone without authentication -- [ ] **PROF-05**: Public profile page lists the user's public setups +- [x] **PROF-01**: User has a profile with display name, avatar, and bio +- [x] **PROF-02**: User can view their own public profile page +- [x] **PROF-03**: User can set a setup as public or private +- [x] **PROF-04**: Public setups are viewable by anyone without authentication +- [x] **PROF-05**: Public profile page lists the user's public setups ## Future Requirements @@ -141,11 +141,11 @@ Which phases cover which requirements. Updated during roadmap creation. | GLOB-03 | Phase 18 | Pending | | GLOB-04 | Phase 18 | Pending | | GLOB-05 | Phase 18 | Pending | -| PROF-01 | Phase 18 | Pending | -| PROF-02 | Phase 18 | Pending | -| PROF-03 | Phase 18 | Pending | -| PROF-04 | Phase 18 | Pending | -| PROF-05 | Phase 18 | Pending | +| PROF-01 | Phase 18 | Complete | +| PROF-02 | Phase 18 | Complete | +| PROF-03 | Phase 18 | Complete | +| PROF-04 | Phase 18 | Complete | +| PROF-05 | Phase 18 | Complete | **Coverage:** - v2.0 requirements: 30 total diff --git a/.planning/STATE.md b/.planning/STATE.md index d9a43f1..8d53da9 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,16 +1,16 @@ --- gsd_state_version: 1.0 -milestone: v2.0 -milestone_name: Platform Foundation +milestone: v1.3 +milestone_name: Research & Decision Tools status: planning -stopped_at: null -last_updated: "2026-04-03" +stopped_at: Completed 18-03-PLAN.md +last_updated: "2026-04-05T11:12:57.693Z" last_activity: 2026-04-03 — v2.0 roadmap created (Phases 14-18) progress: - total_phases: 5 - completed_phases: 0 - total_plans: 0 - completed_plans: 0 + total_phases: 8 + completed_phases: 6 + total_plans: 12 + completed_plans: 11 percent: 0 --- @@ -35,6 +35,7 @@ Progress: [----------] 0% (v2.0 milestone) ## Performance Metrics **Velocity:** + - Total plans completed: 0 (v2.0 milestone) - Average duration: -- - Total execution time: -- @@ -46,12 +47,14 @@ Progress: [----------] 0% (v2.0 milestone) ### Decisions Key decisions made during v2.0 planning: + - Platform pivot: single-user to multi-user with discovery-first approach - External auth provider (self-hosted, open-source) — Logto vs Authentik OPEN decision - SQLite to Postgres migration — required by auth provider and multi-user concurrency - Structured UGC only — ratings and predefined fields, no freeform text until moderation - Separate globalItems table — not a flag on user items table - Single-user SQLite mode diverges at v2.0 boundary +- [Phase 18]: Public endpoints bypass auth via regex path matching in index.ts middleware ### Pending Todos @@ -64,6 +67,6 @@ None active. ## Session Continuity -Last session: 2026-04-03 -Stopped at: v2.0 roadmap created with 5 phases (14-18) covering 30 requirements +Last session: 2026-04-05T11:12:57.691Z +Stopped at: Completed 18-03-PLAN.md Resume file: None diff --git a/.planning/phases/18-global-items-public-profiles/18-03-SUMMARY.md b/.planning/phases/18-global-items-public-profiles/18-03-SUMMARY.md new file mode 100644 index 0000000..b1a05ad --- /dev/null +++ b/.planning/phases/18-global-items-public-profiles/18-03-SUMMARY.md @@ -0,0 +1,133 @@ +--- +phase: 18-global-items-public-profiles +plan: 03 +subsystem: server +tags: [profiles, public-setups, hono, drizzle, services, routes] + +requires: + - phase: 18-global-items-public-profiles + plan: 01 + provides: "User profile columns, setup isPublic column, Zod schemas" +provides: + - "Profile service (updateProfile, getPublicProfile, getPublicSetupWithItems)" + - "Public profile endpoint GET /api/users/:id/profile" + - "Profile update endpoint PUT /api/auth/profile" + - "Public setup endpoint GET /api/setups/:id/public" + - "Setup service isPublic support in create/update/list/detail" +affects: [18-04, 18-05] + +tech-stack: + added: [] + patterns: ["Public endpoints bypass auth middleware via regex in index.ts"] + +key-files: + created: + - "src/server/services/profile.service.ts" + - "src/server/routes/profiles.ts" + - "tests/services/profile.service.test.ts" + - "tests/routes/profiles.test.ts" + modified: + - "src/server/services/setup.service.ts" + - "src/server/services/category.service.ts" + - "src/server/routes/auth.ts" + - "src/server/routes/setups.ts" + - "src/server/index.ts" + +key-decisions: + - "Public endpoints skip auth via regex path matching in index.ts middleware" + - "Profile update placed on auth routes (PUT /api/auth/profile) since it requires auth" + - "Public setup route placed in setups.ts as GET /:id/public before GET /:id" + +patterns-established: + - "Public endpoint pattern: regex skip in auth middleware + no userId dependency in handler" + +requirements-completed: [PROF-01, PROF-02, PROF-03, PROF-04, PROF-05] + +duration: 7min +completed: 2026-04-05 +--- + +# Phase 18 Plan 03: User Profiles & Public Sharing Backend Summary + +**Profile service with CRUD and public profile data, public setup viewing, setup visibility toggle, and auth middleware bypass for public endpoints** + +## Performance + +- **Duration:** 7 min +- **Started:** 2026-04-05T11:03:46Z +- **Completed:** 2026-04-05T11:11:44Z +- **Tasks:** 2 +- **Files modified:** 9 + +## Accomplishments + +- Created profile service with updateProfile, getPublicProfile, and getPublicSetupWithItems +- Added public profile endpoint returning user info and only public setups +- Added profile update endpoint behind auth on PUT /api/auth/profile +- Added public setup view endpoint at GET /api/setups/:id/public (returns 404 for private) +- Updated setup service to handle isPublic in create, update, list, and detail +- Updated auth middleware to skip auth for public profile and setup GET requests +- 25 tests passing (15 service + 10 route tests) + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Profile service + setup isPublic + tests (TDD)** - `2d5d4f9` (test RED), `854811d` (feat GREEN) +2. **Task 2: Routes + auth middleware + route tests** - `eb8f4b7` (feat) + +## Files Created/Modified + +- `src/server/services/profile.service.ts` - updateProfile, getPublicProfile, getPublicSetupWithItems +- `src/server/routes/profiles.ts` - GET /:id/profile public endpoint +- `src/server/routes/auth.ts` - Added PUT /profile with requireAuth and updateProfileSchema validation +- `src/server/routes/setups.ts` - Added GET /:id/public endpoint using getPublicSetupWithItems +- `src/server/index.ts` - Registered profileRoutes at /api/users, added regex auth skips +- `src/server/services/setup.service.ts` - isPublic in createSetup, updateSetup, getAllSetups +- `src/server/services/category.service.ts` - Added getOrCreateUncategorized (Rule 3 fix) +- `tests/services/profile.service.test.ts` - 15 tests for profile and setup isPublic +- `tests/routes/profiles.test.ts` - 10 tests for public profile, auth profile update, public setup + +## Decisions Made + +- Public endpoints bypass auth via regex path matching in the centralized auth middleware, not per-route +- Profile update lives under /api/auth/profile since it requires authentication context +- Public setup route registered before /:id in setups.ts to prevent route conflict + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Added getOrCreateUncategorized to category service** +- **Found during:** Task 2 +- **Issue:** Auth middleware imports getOrCreateUncategorized from category.service.ts but the function didn't exist (was expected from Phase 16 multi-user conversion) +- **Fix:** Added async getOrCreateUncategorized function that finds or creates the "Uncategorized" category for a user +- **Files modified:** src/server/services/category.service.ts +- **Commit:** eb8f4b7 + +**2. [Rule 1 - Bug] Handle empty update in updateProfile** +- **Found during:** Task 1 GREEN phase +- **Issue:** Drizzle throws "No values to set" when .set() receives an empty object +- **Fix:** Added check for empty updates, returning existing user without running update query +- **Files modified:** src/server/services/profile.service.ts +- **Commit:** 854811d + +## Issues Encountered + +None beyond the auto-fixed deviations above. + +## Known Stubs + +None - all endpoints are fully wired to service functions with real database operations. + +## Next Phase Readiness + +- Profile backend complete for Plan 18-04 (client-side profile pages) +- Public setup view ready for Plan 18-05 (discovery feed) +- All service functions exported and tested for downstream consumption + +## Self-Check: PASSED + +--- +*Phase: 18-global-items-public-profiles* +*Completed: 2026-04-05*