chore(18-03): apply 18-01 schema foundation as dependency baseline
This commit is contained in:
133
drizzle-pg/0000_fuzzy_shiva.sql
Normal file
133
drizzle-pg/0000_fuzzy_shiva.sql
Normal file
@@ -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;
|
||||
140
drizzle-pg/0000_thankful_loners.sql
Normal file
140
drizzle-pg/0000_thankful_loners.sql
Normal file
@@ -0,0 +1,140 @@
|
||||
CREATE TABLE "api_keys" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"key_hash" text NOT NULL,
|
||||
"key_prefix" text NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "categories" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"icon" text DEFAULT 'package' NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "categories_user_id_name_unique" UNIQUE("user_id","name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "items" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"weight_grams" double precision,
|
||||
"price_cents" integer,
|
||||
"category_id" integer NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"notes" text,
|
||||
"product_url" text,
|
||||
"image_filename" text,
|
||||
"image_source_url" text,
|
||||
"quantity" integer DEFAULT 1 NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "oauth_clients" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"client_id" text NOT NULL,
|
||||
"client_name" text,
|
||||
"redirect_uris" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "oauth_clients_client_id_unique" UNIQUE("client_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "oauth_codes" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"code" text NOT NULL,
|
||||
"client_id" text NOT NULL,
|
||||
"code_challenge" text NOT NULL,
|
||||
"code_challenge_method" text DEFAULT 'S256' NOT NULL,
|
||||
"redirect_uri" text NOT NULL,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"used" integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT "oauth_codes_code_unique" UNIQUE("code")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "oauth_tokens" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"access_token_hash" text NOT NULL,
|
||||
"refresh_token_hash" text NOT NULL,
|
||||
"client_id" text NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"refresh_expires_at" timestamp NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "oauth_tokens_access_token_hash_unique" UNIQUE("access_token_hash"),
|
||||
CONSTRAINT "oauth_tokens_refresh_token_hash_unique" UNIQUE("refresh_token_hash")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "settings" (
|
||||
"user_id" integer NOT NULL,
|
||||
"key" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
CONSTRAINT "settings_user_id_key_pk" PRIMARY KEY("user_id","key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "setup_items" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"setup_id" integer NOT NULL,
|
||||
"item_id" integer NOT NULL,
|
||||
"classification" text DEFAULT 'base' NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "setups" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "thread_candidates" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"thread_id" integer NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"weight_grams" double precision,
|
||||
"price_cents" integer,
|
||||
"category_id" integer NOT NULL,
|
||||
"notes" text,
|
||||
"product_url" text,
|
||||
"image_filename" text,
|
||||
"image_source_url" text,
|
||||
"status" text DEFAULT 'researching' NOT NULL,
|
||||
"pros" text,
|
||||
"cons" text,
|
||||
"sort_order" double precision DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "threads" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"status" text DEFAULT 'active' NOT NULL,
|
||||
"resolved_candidate_id" integer,
|
||||
"category_id" integer NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"logto_sub" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "users_logto_sub_unique" UNIQUE("logto_sub")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "categories" ADD CONSTRAINT "categories_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "items" ADD CONSTRAINT "items_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "items" ADD CONSTRAINT "items_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "oauth_tokens" ADD CONSTRAINT "oauth_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "settings" ADD CONSTRAINT "settings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "setup_items" ADD CONSTRAINT "setup_items_setup_id_setups_id_fk" FOREIGN KEY ("setup_id") REFERENCES "public"."setups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "setup_items" ADD CONSTRAINT "setup_items_item_id_items_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "setups" ADD CONSTRAINT "setups_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "thread_candidates" ADD CONSTRAINT "thread_candidates_thread_id_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."threads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "thread_candidates" ADD CONSTRAINT "thread_candidates_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "threads" ADD CONSTRAINT "threads_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "threads" ADD CONSTRAINT "threads_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
|
||||
25
drizzle-pg/0001_tough_boomerang.sql
Normal file
25
drizzle-pg/0001_tough_boomerang.sql
Normal file
@@ -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;
|
||||
934
drizzle-pg/meta/0000_snapshot.json
Normal file
934
drizzle-pg/meta/0000_snapshot.json
Normal file
@@ -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": {}
|
||||
}
|
||||
}
|
||||
1093
drizzle-pg/meta/0001_snapshot.json
Normal file
1093
drizzle-pg/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
20
drizzle-pg/meta/_journal.json
Normal file
20
drizzle-pg/meta/_journal.json
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
146
src/db/global-items-seed.json
Normal file
146
src/db/global-items-seed.json
Normal file
@@ -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."
|
||||
}
|
||||
]
|
||||
@@ -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 });
|
||||
|
||||
219
src/db/schema.ts
219
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(),
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Env>();
|
||||
|
||||
// ── 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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Env>();
|
||||
|
||||
// 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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
): 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)));
|
||||
}
|
||||
|
||||
@@ -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<number, string>();
|
||||
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)),
|
||||
);
|
||||
}
|
||||
|
||||
83
src/server/services/storage.service.ts
Normal file
83
src/server/services/storage.service.ts
Normal file
@@ -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<void> {
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: filename,
|
||||
Body: Buffer.from(buffer),
|
||||
ContentType: contentType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteImage(filename: string): Promise<void> {
|
||||
await s3.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: filename,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getImageUrl(filename: string): Promise<string> {
|
||||
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<T & { imageUrl: string | null }> {
|
||||
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)));
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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<typeof updateSetupSchema>;
|
||||
export type SyncSetupItems = z.infer<typeof syncSetupItemsSchema>;
|
||||
export type UpdateClassification = z.infer<typeof updateClassificationSchema>;
|
||||
|
||||
// Global item types
|
||||
export type SearchGlobalItems = z.infer<typeof searchGlobalItemsSchema>;
|
||||
export type LinkItem = z.infer<typeof linkItemSchema>;
|
||||
export type UpdateProfile = z.infer<typeof updateProfileSchema>;
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -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<typeof drizzle<typeof schema>>;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user