From edc9793c2d0b5f366a23066b1ac4fc4db1ff71f7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 13 Apr 2026 17:55:46 +0200 Subject: [PATCH] feat: migrate setup visibility from boolean to three-tier system Replace isPublic boolean with visibility enum (private/link/public) across the full stack. Add shares table to schema for future share link support. Update all services, routes, schemas, hooks, components, and tests. Plan: 32-01 (Setup Sharing System - Schema Migration) Co-Authored-By: Claude Opus 4.6 (1M context) --- drizzle-pg/0005_true_green_goblin.sql | 17 + drizzle-pg/meta/0005_snapshot.json | 1394 ++++++++++++++++++++++ drizzle-pg/meta/_journal.json | 7 + src/client/components/SetupCard.tsx | 16 +- src/client/components/SetupsView.tsx | 2 +- src/client/hooks/useSetups.ts | 10 +- src/client/routes/setups/$setupId.tsx | 75 +- src/db/dev-seed-data.ts | 4 +- src/db/dev-seed.ts | 2 +- src/db/schema.ts | 18 +- src/server/routes/account.ts | 2 +- src/server/services/discovery.service.ts | 4 +- src/server/services/profile.service.ts | 4 +- src/server/services/setup.service.ts | 12 +- src/shared/schemas.ts | 7 +- tests/helpers/db.ts | 1 + tests/routes/discovery.test.ts | 4 +- tests/routes/profiles.test.ts | 12 +- tests/services/discovery.service.test.ts | 4 +- tests/services/profile.service.test.ts | 42 +- 20 files changed, 1556 insertions(+), 81 deletions(-) create mode 100644 drizzle-pg/0005_true_green_goblin.sql create mode 100644 drizzle-pg/meta/0005_snapshot.json diff --git a/drizzle-pg/0005_true_green_goblin.sql b/drizzle-pg/0005_true_green_goblin.sql new file mode 100644 index 0000000..3bce4b2 --- /dev/null +++ b/drizzle-pg/0005_true_green_goblin.sql @@ -0,0 +1,17 @@ +CREATE TABLE "shares" ( + "id" serial PRIMARY KEY NOT NULL, + "setup_id" integer NOT NULL, + "token" text NOT NULL, + "permission" text DEFAULT 'read' NOT NULL, + "expires_at" timestamp, + "user_id" integer, + "created_at" timestamp DEFAULT now() NOT NULL, + "revoked_at" timestamp, + CONSTRAINT "shares_token_unique" UNIQUE("token") +); +--> statement-breakpoint +ALTER TABLE "setups" ADD COLUMN "visibility" text DEFAULT 'private' NOT NULL;--> statement-breakpoint +UPDATE "setups" SET "visibility" = 'public' WHERE "is_public" = true;--> statement-breakpoint +ALTER TABLE "shares" ADD CONSTRAINT "shares_setup_id_setups_id_fk" FOREIGN KEY ("setup_id") REFERENCES "public"."setups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "shares" ADD CONSTRAINT "shares_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 "setups" DROP COLUMN "is_public"; \ No newline at end of file diff --git a/drizzle-pg/meta/0005_snapshot.json b/drizzle-pg/meta/0005_snapshot.json new file mode 100644 index 0000000..14081a6 --- /dev/null +++ b/drizzle-pg/meta/0005_snapshot.json @@ -0,0 +1,1394 @@ +{ + "id": "f4c19898-17c8-42f1-a3da-9858970356d2", + "prevId": "a342938b-8d10-4f00-bd15-d13b7a1e5551", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'package'" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "categories_user_id_users_id_fk": { + "name": "categories_user_id_users_id_fk", + "tableFrom": "categories", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_user_id_name_unique": { + "name": "categories_user_id_name_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.global_item_tags": { + "name": "global_item_tags", + "schema": "", + "columns": { + "global_item_id": { + "name": "global_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "global_item_tags_global_item_id_global_items_id_fk": { + "name": "global_item_tags_global_item_id_global_items_id_fk", + "tableFrom": "global_item_tags", + "tableTo": "global_items", + "columnsFrom": [ + "global_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "global_item_tags_tag_id_tags_id_fk": { + "name": "global_item_tags_tag_id_tags_id_fk", + "tableFrom": "global_item_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "global_item_tags_global_item_id_tag_id_pk": { + "name": "global_item_tags_global_item_id_tag_id_pk", + "columns": [ + "global_item_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.global_items": { + "name": "global_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weight_grams": { + "name": "weight_grams", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_credit": { + "name": "image_credit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_source_url": { + "name": "image_source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dominant_color": { + "name": "dominant_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crop_zoom": { + "name": "crop_zoom", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "crop_x": { + "name": "crop_x", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "crop_y": { + "name": "crop_y", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "global_items_brand_model_unique": { + "name": "global_items_brand_model_unique", + "nullsNotDistinct": false, + "columns": [ + "brand", + "model" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.items": { + "name": "items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight_grams": { + "name": "weight_grams", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_url": { + "name": "product_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_filename": { + "name": "image_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_source_url": { + "name": "image_source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "global_item_id": { + "name": "global_item_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "purchase_price_cents": { + "name": "purchase_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dominant_color": { + "name": "dominant_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crop_zoom": { + "name": "crop_zoom", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "crop_x": { + "name": "crop_x", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "crop_y": { + "name": "crop_y", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "items_category_id_categories_id_fk": { + "name": "items_category_id_categories_id_fk", + "tableFrom": "items", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "items_user_id_users_id_fk": { + "name": "items_user_id_users_id_fk", + "tableFrom": "items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "items_global_item_id_global_items_id_fk": { + "name": "items_global_item_id_global_items_id_fk", + "tableFrom": "items", + "tableTo": "global_items", + "columnsFrom": [ + "global_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_clients": { + "name": "oauth_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_clients_client_id_unique": { + "name": "oauth_clients_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_codes": { + "name": "oauth_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "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": { + "oauth_codes_user_id_users_id_fk": { + "name": "oauth_codes_user_id_users_id_fk", + "tableFrom": "oauth_codes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "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 + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "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.shares": { + "name": "shares", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "setup_id": { + "name": "setup_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'read'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "shares_setup_id_setups_id_fk": { + "name": "shares_setup_id_setups_id_fk", + "tableFrom": "shares", + "tableTo": "setups", + "columnsFrom": [ + "setup_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shares_user_id_users_id_fk": { + "name": "shares_user_id_users_id_fk", + "tableFrom": "shares", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "shares_token_unique": { + "name": "shares_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_name_unique": { + "name": "tags_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.thread_candidates": { + "name": "thread_candidates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight_grams": { + "name": "weight_grams", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_url": { + "name": "product_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_filename": { + "name": "image_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_source_url": { + "name": "image_source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dominant_color": { + "name": "dominant_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crop_zoom": { + "name": "crop_zoom", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "crop_x": { + "name": "crop_x", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "crop_y": { + "name": "crop_y", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'researching'" + }, + "pros": { + "name": "pros", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cons": { + "name": "cons", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "global_item_id": { + "name": "global_item_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "thread_candidates_thread_id_threads_id_fk": { + "name": "thread_candidates_thread_id_threads_id_fk", + "tableFrom": "thread_candidates", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "thread_candidates_category_id_categories_id_fk": { + "name": "thread_candidates_category_id_categories_id_fk", + "tableFrom": "thread_candidates", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "thread_candidates_global_item_id_global_items_id_fk": { + "name": "thread_candidates_global_item_id_global_items_id_fk", + "tableFrom": "thread_candidates", + "tableTo": "global_items", + "columnsFrom": [ + "global_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.threads": { + "name": "threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "resolved_candidate_id": { + "name": "resolved_candidate_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "threads_category_id_categories_id_fk": { + "name": "threads_category_id_categories_id_fk", + "tableFrom": "threads", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "threads_user_id_users_id_fk": { + "name": "threads_user_id_users_id_fk", + "tableFrom": "threads", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "logto_sub": { + "name": "logto_sub", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_logto_sub_unique": { + "name": "users_logto_sub_unique", + "nullsNotDistinct": false, + "columns": [ + "logto_sub" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle-pg/meta/_journal.json b/drizzle-pg/meta/_journal.json index abe44e9..171ae22 100644 --- a/drizzle-pg/meta/_journal.json +++ b/drizzle-pg/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1776016552627, "tag": "0004_smiling_night_nurse", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1776095449827, + "tag": "0005_true_green_goblin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/client/components/SetupCard.tsx b/src/client/components/SetupCard.tsx index f46b691..266570d 100644 --- a/src/client/components/SetupCard.tsx +++ b/src/client/components/SetupCard.tsx @@ -4,7 +4,7 @@ import { useFormatters } from "../hooks/useFormatters"; interface SetupCardProps { id: number; name: string; - isPublic?: boolean; + visibility?: "private" | "link" | "public"; itemCount: number; totalWeight: number; totalCost: number; @@ -13,7 +13,7 @@ interface SetupCardProps { export function SetupCard({ id, name, - isPublic, + visibility, itemCount, totalWeight, totalCost, @@ -30,9 +30,15 @@ export function SetupCard({

{name}

- {isPublic && ( - - Public + {visibility && visibility !== "private" && ( + + {visibility === "public" ? "Public" : "Link"} )} diff --git a/src/client/components/SetupsView.tsx b/src/client/components/SetupsView.tsx index 3babd0f..8254974 100644 --- a/src/client/components/SetupsView.tsx +++ b/src/client/components/SetupsView.tsx @@ -100,7 +100,7 @@ export function SetupsView() { key={setup.id} id={setup.id} name={setup.name} - isPublic={setup.isPublic} + visibility={setup.visibility} itemCount={setup.itemCount} totalWeight={setup.totalWeight} totalCost={setup.totalCost} diff --git a/src/client/hooks/useSetups.ts b/src/client/hooks/useSetups.ts index 33591e8..5ef7b1b 100644 --- a/src/client/hooks/useSetups.ts +++ b/src/client/hooks/useSetups.ts @@ -11,7 +11,7 @@ import { interface SetupListItem { id: number; name: string; - isPublic: boolean; + visibility: "private" | "link" | "public"; createdAt: string; updatedAt: string; itemCount: number; @@ -39,7 +39,7 @@ interface SetupItemWithCategory { interface SetupWithItems { id: number; name: string; - isPublic: boolean; + visibility: "private" | "link" | "public"; createdAt: string; updatedAt: string; items: SetupItemWithCategory[]; @@ -88,8 +88,10 @@ export function useCreateSetup() { export function useUpdateSetup(setupId: number) { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (data: { name?: string; isPublic?: boolean }) => - apiPut(`/api/setups/${setupId}`, data), + mutationFn: (data: { + name?: string; + visibility?: "private" | "link" | "public"; + }) => apiPut(`/api/setups/${setupId}`, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["setups"] }); }, diff --git a/src/client/routes/setups/$setupId.tsx b/src/client/routes/setups/$setupId.tsx index 66ed52a..433dae8 100644 --- a/src/client/routes/setups/$setupId.tsx +++ b/src/client/routes/setups/$setupId.tsx @@ -36,7 +36,7 @@ function SetupDetailPage() { : publicSetup; const deleteSetup = useDeleteSetup(); - const updateSetup = useUpdateSetup(numericId); + const _updateSetup = useUpdateSetup(numericId); const removeItem = useRemoveSetupItem(numericId); const updateClassification = useUpdateItemClassification(numericId); @@ -174,33 +174,60 @@ function SetupDetailPage() { - {/* Public toggle — desktop */} - - {/* Public toggle — mobile */} - + +
{/* Delete Setup — desktop */} diff --git a/src/db/dev-seed-data.ts b/src/db/dev-seed-data.ts index 76cf638..6d77849 100644 --- a/src/db/dev-seed-data.ts +++ b/src/db/dev-seed-data.ts @@ -857,7 +857,7 @@ export const DEV_THREADS = [ export const DEV_SETUPS = [ { name: "Weekend Overnighter", - isPublic: true, + visibility: "public" as const, items: [ { userItemIndex: 0, classification: "base" }, // Terrapin saddle bag { userItemIndex: 3, classification: "base" }, // X-Mid 1 @@ -871,7 +871,7 @@ export const DEV_SETUPS = [ }, { name: "Ultra-Light Day Ride", - isPublic: false, + visibility: "private" as const, items: [ { userItemIndex: 2, classification: "base" }, // Top tube pack { userItemIndex: 7, classification: "worn" }, // Nitecore NU25 diff --git a/src/db/dev-seed.ts b/src/db/dev-seed.ts index 3c0fa77..0cd0ef3 100644 --- a/src/db/dev-seed.ts +++ b/src/db/dev-seed.ts @@ -252,7 +252,7 @@ async function seedDevData(database: Db = db) { .values({ name: setupDef.name, userId, - isPublic: setupDef.isPublic, + visibility: setupDef.visibility, }) .returning(); if (!setup) throw new Error(`Failed to insert setup: ${setupDef.name}`); diff --git a/src/db/schema.ts b/src/db/schema.ts index f2457ba..b93f042 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,5 +1,4 @@ import { - boolean, doublePrecision, integer, pgTable, @@ -121,7 +120,7 @@ export const setups = pgTable("setups", { userId: integer("user_id") .notNull() .references(() => users.id), - isPublic: boolean("is_public").notNull().default(false), + visibility: text("visibility").notNull().default("private"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); @@ -139,6 +138,21 @@ export const setupItems = pgTable("setup_items", { classification: text("classification").notNull().default("base"), }); +// ── Shares ───────────────────────────────────────────────────────── + +export const shares = pgTable("shares", { + id: serial("id").primaryKey(), + setupId: integer("setup_id") + .notNull() + .references(() => setups.id, { onDelete: "cascade" }), + token: text("token").notNull().unique(), + permission: text("permission").notNull().default("read"), + expiresAt: timestamp("expires_at"), + userId: integer("user_id").references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), + revokedAt: timestamp("revoked_at"), +}); + // ── Global Items ──────────────────────────────────────────────────── export const globalItems = pgTable( diff --git a/src/server/routes/account.ts b/src/server/routes/account.ts index d9cd450..8a12efa 100644 --- a/src/server/routes/account.ts +++ b/src/server/routes/account.ts @@ -114,7 +114,7 @@ app.post("/delete", zValidator("json", deleteAccountSchema), async (c) => { await tx .update(setups) .set({ userId: sentinel.id }) - .where(and(eq(setups.userId, userId), eq(setups.isPublic, true))); + .where(and(eq(setups.userId, userId), eq(setups.visibility, "public"))); // 3. Get private setup IDs for cleanup const privateSetups = await tx diff --git a/src/server/services/discovery.service.ts b/src/server/services/discovery.service.ts index cd41697..93f9c00 100644 --- a/src/server/services/discovery.service.ts +++ b/src/server/services/discovery.service.ts @@ -21,7 +21,7 @@ interface CursorPage { /** * Get popular public setups ordered by item count descending. * Cursor format: "{itemCount}_{id}" for stable composite pagination. - * Only public setups (isPublic=true) are returned. + * Only public setups (visibility='public') are returned. */ export async function getPopularSetups( db: Db = prodDb, @@ -50,7 +50,7 @@ export async function getPopularSetups( .from(setups) .leftJoin(setupItems, eq(setupItems.setupId, setups.id)) .leftJoin(users, eq(users.id, setups.userId)) - .where(eq(setups.isPublic, true)) + .where(eq(setups.visibility, "public")) .groupBy(setups.id, setups.name, setups.createdAt, users.displayName) .orderBy(desc(sql`COUNT(${setupItems.id})`), desc(setups.id)) .limit(fetchLimit); diff --git a/src/server/services/profile.service.ts b/src/server/services/profile.service.ts index 5c0f31e..ab10d1e 100644 --- a/src/server/services/profile.service.ts +++ b/src/server/services/profile.service.ts @@ -79,7 +79,7 @@ export async function getPublicProfile(db: Db, userId: number) { ), 0)`.as("total_cost"), }) .from(setups) - .where(and(eq(setups.userId, userId), eq(setups.isPublic, true))); + .where(and(eq(setups.userId, userId), eq(setups.visibility, "public"))); return { ...user, setups: publicSetups }; } @@ -88,7 +88,7 @@ export async function getPublicSetupWithItems(db: Db, setupId: number) { const [setup] = await db .select() .from(setups) - .where(and(eq(setups.id, setupId), eq(setups.isPublic, true))); + .where(and(eq(setups.id, setupId), eq(setups.visibility, "public"))); if (!setup) return null; diff --git a/src/server/services/setup.service.ts b/src/server/services/setup.service.ts index 8798377..32a5bc0 100644 --- a/src/server/services/setup.service.ts +++ b/src/server/services/setup.service.ts @@ -14,7 +14,11 @@ type Db = typeof prodDb; export async function createSetup(db: Db, userId: number, data: CreateSetup) { const [row] = await db .insert(setups) - .values({ name: data.name, userId, isPublic: data.isPublic ?? false }) + .values({ + name: data.name, + userId, + visibility: data.visibility ?? "private", + }) .returning(); return row; @@ -25,7 +29,7 @@ export async function getAllSetups(db: Db, userId: number) { .select({ id: setups.id, name: setups.name, - isPublic: setups.isPublic, + visibility: setups.visibility, createdAt: setups.createdAt, updatedAt: setups.updatedAt, itemCount: sql`COALESCE(( @@ -129,8 +133,8 @@ export async function updateSetup( name: data.name, updatedAt: new Date(), }; - if (data.isPublic !== undefined) { - updateData.isPublic = data.isPublic; + if (data.visibility !== undefined) { + updateData.visibility = data.visibility; } const [row] = await db diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index b0108d2..9d83088 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -85,12 +85,15 @@ 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), + visibility: z + .enum(["private", "link", "public"]) + .optional() + .default("private"), }); export const updateSetupSchema = z.object({ name: z.string().min(1, "Setup name is required"), - isPublic: z.boolean().optional(), + visibility: z.enum(["private", "link", "public"]).optional(), }); export const syncSetupItemsSchema = z.object({ diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts index eb70986..fa7c093 100644 --- a/tests/helpers/db.ts +++ b/tests/helpers/db.ts @@ -20,6 +20,7 @@ async function getOrCreateDb(): Promise { // Truncation order respects foreign keys (children first) const TRUNCATE_TABLES = [ + "shares", "setup_items", "setups", "thread_candidates", diff --git a/tests/routes/discovery.test.ts b/tests/routes/discovery.test.ts index 8f02b8d..f6e29dc 100644 --- a/tests/routes/discovery.test.ts +++ b/tests/routes/discovery.test.ts @@ -40,7 +40,7 @@ async function insertPublicSetup( ) { const [row] = await db .insert(setups) - .values({ name, userId, isPublic: true }) + .values({ name, userId, visibility: "public" }) .returning(); return row; } @@ -76,7 +76,7 @@ describe("Discovery Routes", () => { // Insert a private setup await db .insert(setups) - .values({ name: "Private Setup", userId, isPublic: false }); + .values({ name: "Private Setup", userId, visibility: "private" }); const res = await app.request("/api/discovery/setups"); expect(res.status).toBe(200); diff --git a/tests/routes/profiles.test.ts b/tests/routes/profiles.test.ts index f7071d6..d63cfc1 100644 --- a/tests/routes/profiles.test.ts +++ b/tests/routes/profiles.test.ts @@ -111,8 +111,8 @@ describe("Profile Routes", () => { it("includes only public setups", async () => { // Create public and private setups await db.insert(schema.setups).values([ - { name: "Public Setup", userId, isPublic: true }, - { name: "Private Setup", userId, isPublic: false }, + { name: "Public Setup", userId, visibility: "public" }, + { name: "Private Setup", userId, visibility: "private" }, ]); const res = await app.request(`/api/users/${userId}/profile`); @@ -181,7 +181,7 @@ describe("Public Setup Routes", () => { it("returns 200 for public setup without auth", async () => { const [setup] = await db .insert(schema.setups) - .values({ name: "My Public Setup", userId, isPublic: true }) + .values({ name: "My Public Setup", userId, visibility: "public" }) .returning(); const res = await app.request(`/api/setups/${setup.id}/public`); @@ -189,14 +189,14 @@ describe("Public Setup Routes", () => { const body = await res.json(); expect(body.name).toBe("My Public Setup"); - expect(body.isPublic).toBe(true); + expect(body.visibility).toBe("public"); expect(body.items).toBeDefined(); }); it("returns 200 for public setup with items", async () => { const [setup] = await db .insert(schema.setups) - .values({ name: "Loaded Setup", userId, isPublic: true }) + .values({ name: "Loaded Setup", userId, visibility: "public" }) .returning(); const [cat] = await db @@ -231,7 +231,7 @@ describe("Public Setup Routes", () => { it("returns 404 for private setup", async () => { const [setup] = await db .insert(schema.setups) - .values({ name: "Private Setup", userId, isPublic: false }) + .values({ name: "Private Setup", userId, visibility: "private" }) .returning(); const res = await app.request(`/api/setups/${setup.id}/public`); diff --git a/tests/services/discovery.service.test.ts b/tests/services/discovery.service.test.ts index dd632b7..f046a99 100644 --- a/tests/services/discovery.service.test.ts +++ b/tests/services/discovery.service.test.ts @@ -47,7 +47,7 @@ async function insertPublicSetup( ) { const [setup] = await db .insert(setups) - .values({ name, userId, isPublic: true }) + .values({ name, userId, visibility: "public" }) .returning(); for (const itemId of itemIds) { await db.insert(setupItems).values({ setupId: setup.id, itemId }); @@ -62,7 +62,7 @@ async function insertPrivateSetup( ) { const [setup] = await db .insert(setups) - .values({ name, userId, isPublic: false }) + .values({ name, userId, visibility: "private" }) .returning(); return setup; } diff --git a/tests/services/profile.service.test.ts b/tests/services/profile.service.test.ts index 0cbc421..21800f5 100644 --- a/tests/services/profile.service.test.ts +++ b/tests/services/profile.service.test.ts @@ -74,7 +74,7 @@ describe("Profile Service", () => { // Create one public and one private setup const _pub = await createSetup(db, userId, { name: "Public Setup", - isPublic: true, + visibility: "public", }); const _priv = await createSetup(db, userId, { name: "Private Setup" }); @@ -91,10 +91,10 @@ describe("Profile Service", () => { }); describe("getPublicSetupWithItems", () => { - it("returns setup with items when isPublic is true", async () => { + it("returns setup with items when visibility is public", async () => { const setup = await createSetup(db, userId, { name: "Public Setup", - isPublic: true, + visibility: "public", }); // Create an item and add to setup @@ -125,7 +125,7 @@ describe("Profile Service", () => { expect(result!.items[0].name).toBe("Tent"); }); - it("returns null when isPublic is false", async () => { + it("returns null when visibility is private", async () => { const setup = await createSetup(db, userId, { name: "Private Setup", }); @@ -140,7 +140,7 @@ describe("Profile Service", () => { }); }); -describe("Setup Service - isPublic", () => { +describe("Setup Service - visibility", () => { let db: Db; let userId: number; @@ -150,33 +150,33 @@ describe("Setup Service - isPublic", () => { userId = testData.userId; }); - it("createSetup persists isPublic when true", async () => { + it("createSetup persists visibility when public", async () => { const setup = await createSetup(db, userId, { name: "Public", - isPublic: true, + visibility: "public", }); - expect(setup.isPublic).toBe(true); + expect(setup.visibility).toBe("public"); }); - it("createSetup defaults isPublic to false", async () => { + it("createSetup defaults visibility to private", async () => { const setup = await createSetup(db, userId, { name: "Private" }); - expect(setup.isPublic).toBe(false); + expect(setup.visibility).toBe("private"); }); - it("updateSetup can toggle isPublic", async () => { + it("updateSetup can change visibility", async () => { const setup = await createSetup(db, userId, { name: "Test" }); - expect(setup.isPublic).toBe(false); + expect(setup.visibility).toBe("private"); const updated = await updateSetup(db, userId, setup.id, { name: "Test", - isPublic: true, + visibility: "public", }); expect(updated).not.toBeNull(); - expect(updated!.isPublic).toBe(true); + expect(updated!.visibility).toBe("public"); }); - it("getAllSetups includes isPublic in response", async () => { - await createSetup(db, userId, { name: "Public", isPublic: true }); + it("getAllSetups includes visibility in response", async () => { + await createSetup(db, userId, { name: "Public", visibility: "public" }); await createSetup(db, userId, { name: "Private" }); const setups = await getAllSetups(db, userId); @@ -184,17 +184,17 @@ describe("Setup Service - isPublic", () => { const pub = setups.find((s) => s.name === "Public"); const priv = setups.find((s) => s.name === "Private"); - expect(pub!.isPublic).toBe(true); - expect(priv!.isPublic).toBe(false); + expect(pub!.visibility).toBe("public"); + expect(priv!.visibility).toBe("private"); }); - it("getSetupWithItems includes isPublic", async () => { + it("getSetupWithItems includes visibility", async () => { const setup = await createSetup(db, userId, { name: "Test", - isPublic: true, + visibility: "public", }); const result = await getSetupWithItems(db, userId, setup.id); expect(result).not.toBeNull(); - expect(result!.isPublic).toBe(true); + expect(result!.visibility).toBe("public"); }); });