diff --git a/apps/api/migrations/0028_deep_piledriver.sql b/apps/api/migrations/0028_deep_piledriver.sql new file mode 100644 index 00000000..88dd5108 --- /dev/null +++ b/apps/api/migrations/0028_deep_piledriver.sql @@ -0,0 +1 @@ +ALTER TABLE "user" ADD COLUMN "private" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/apps/api/migrations/meta/0028_snapshot.json b/apps/api/migrations/meta/0028_snapshot.json new file mode 100644 index 00000000..ae5d3d2d --- /dev/null +++ b/apps/api/migrations/meta/0028_snapshot.json @@ -0,0 +1,1208 @@ +{ + "version": "5", + "dialect": "pg", + "id": "d991cf41-dd44-4179-94ca-8ebd362dfb23", + "prevId": "77394d25-dd14-4180-81cf-e88f85c80d9b", + "tables": { + "bottle_tag": { + "name": "bottle_tag", + "schema": "", + "columns": { + "bottle_id": { + "name": "bottle_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "bottle_tag_bottle_id_bottle_id_fk": { + "name": "bottle_tag_bottle_id_bottle_id_fk", + "tableFrom": "bottle_tag", + "tableTo": "bottle", + "columnsFrom": [ + "bottle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bottle_tag_bottle_id_tag": { + "name": "bottle_tag_bottle_id_tag", + "columns": [ + "bottle_id", + "tag" + ] + } + } + }, + "bottle": { + "name": "bottle", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "category", + "primaryKey": false, + "notNull": false + }, + "brand_id": { + "name": "brand_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "bottler_id": { + "name": "bottler_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "stated_age": { + "name": "stated_age", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "series": { + "name": "series", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "total_tastings": { + "name": "total_tastings", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "bottle_brand_unq": { + "name": "bottle_brand_unq", + "columns": [ + "name", + "brand_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bottle_brand_id_entity_id_fk": { + "name": "bottle_brand_id_entity_id_fk", + "tableFrom": "bottle", + "tableTo": "entity", + "columnsFrom": [ + "brand_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "bottle_bottler_id_entity_id_fk": { + "name": "bottle_bottler_id_entity_id_fk", + "tableFrom": "bottle", + "tableTo": "entity", + "columnsFrom": [ + "bottler_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "bottle_created_by_id_user_id_fk": { + "name": "bottle_created_by_id_user_id_fk", + "tableFrom": "bottle", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "bottle_distiller": { + "name": "bottle_distiller", + "schema": "", + "columns": { + "bottle_id": { + "name": "bottle_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "distiller_id": { + "name": "distiller_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "bottle_distiller_bottle_id_bottle_id_fk": { + "name": "bottle_distiller_bottle_id_bottle_id_fk", + "tableFrom": "bottle_distiller", + "tableTo": "bottle", + "columnsFrom": [ + "bottle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "bottle_distiller_distiller_id_entity_id_fk": { + "name": "bottle_distiller_distiller_id_entity_id_fk", + "tableFrom": "bottle_distiller", + "tableTo": "entity", + "columnsFrom": [ + "distiller_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bottle_distiller_bottle_id_distiller_id": { + "name": "bottle_distiller_bottle_id_distiller_id", + "columns": [ + "bottle_id", + "distiller_id" + ] + } + } + }, + "change": { + "name": "change", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "object_type": { + "name": "object_type", + "type": "object_type", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "type", + "primaryKey": false, + "notNull": true, + "default": "'add'" + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "change_created_by_id_user_id_fk": { + "name": "change_created_by_id_user_id_fk", + "tableFrom": "change", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "collection_bottle": { + "name": "collection_bottle", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "bottle_id": { + "name": "bottle_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "vintage_fingerprint": { + "name": "vintage_fingerprint", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "series": { + "name": "series", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "vintage_year": { + "name": "vintage_year", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "barrel": { + "name": "barrel", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "collection_bottle_unq": { + "name": "collection_bottle_unq", + "columns": [ + "collection_id", + "bottle_id", + "vintage_fingerprint" + ], + "isUnique": true + } + }, + "foreignKeys": { + "collection_bottle_collection_id_collection_id_fk": { + "name": "collection_bottle_collection_id_collection_id_fk", + "tableFrom": "collection_bottle", + "tableTo": "collection", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "collection_bottle_bottle_id_bottle_id_fk": { + "name": "collection_bottle_bottle_id_bottle_id_fk", + "tableFrom": "collection_bottle", + "tableTo": "bottle", + "columnsFrom": [ + "bottle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "collection": { + "name": "collection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "total_bottles": { + "name": "total_bottles", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "collection_name_unq": { + "name": "collection_name_unq", + "columns": [ + "name", + "created_by_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "collection_created_by_id_user_id_fk": { + "name": "collection_created_by_id_user_id_fk", + "tableFrom": "collection", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "tasting_id": { + "name": "tasting_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "comment_unq": { + "name": "comment_unq", + "columns": [ + "tasting_id", + "created_by_id", + "created_at" + ], + "isUnique": true + } + }, + "foreignKeys": { + "comments_tasting_id_tasting_id_fk": { + "name": "comments_tasting_id_tasting_id_fk", + "tableFrom": "comments", + "tableTo": "tasting", + "columnsFrom": [ + "tasting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "comments_created_by_id_user_id_fk": { + "name": "comments_created_by_id_user_id_fk", + "tableFrom": "comments", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "entity": { + "name": "entity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "entity_type[]", + "primaryKey": false, + "notNull": true + }, + "total_bottles": { + "name": "total_bottles", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tastings": { + "name": "total_tastings", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "entity_name_unq": { + "name": "entity_name_unq", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "entity_created_by_id_user_id_fk": { + "name": "entity_created_by_id_user_id_fk", + "tableFrom": "entity", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "follow": { + "name": "follow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "from_user_id": { + "name": "from_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "to_user_id": { + "name": "to_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "follow_status", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "follow_unq": { + "name": "follow_unq", + "columns": [ + "from_user_id", + "to_user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "follow_from_user_id_user_id_fk": { + "name": "follow_from_user_id_user_id_fk", + "tableFrom": "follow", + "tableTo": "user", + "columnsFrom": [ + "from_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "follow_to_user_id_user_id_fk": { + "name": "follow_to_user_id_user_id_fk", + "tableFrom": "follow", + "tableTo": "user", + "columnsFrom": [ + "to_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "identity": { + "name": "identity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "identity_provider", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "identity_unq": { + "name": "identity_unq", + "columns": [ + "provider", + "external_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "identity_user_id_user_id_fk": { + "name": "identity_user_id_user_id_fk", + "tableFrom": "identity", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "from_user_id": { + "name": "from_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "object_id": { + "name": "object_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "object_type": { + "name": "object_type", + "type": "object_type", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "notifications_unq": { + "name": "notifications_unq", + "columns": [ + "user_id", + "object_id", + "object_type", + "created_at" + ], + "isUnique": true + } + }, + "foreignKeys": { + "notifications_user_id_user_id_fk": { + "name": "notifications_user_id_user_id_fk", + "tableFrom": "notifications", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "notifications_from_user_id_user_id_fk": { + "name": "notifications_from_user_id_user_id_fk", + "tableFrom": "notifications", + "tableTo": "user", + "columnsFrom": [ + "from_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "tasting": { + "name": "tasting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "bottle_id": { + "name": "bottle_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "varchar(64)[]", + "primaryKey": false, + "notNull": true, + "default": "array[]::varchar[]" + }, + "rating": { + "name": "rating", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "series": { + "name": "series", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "vintage_year": { + "name": "vintage_year", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "barrel": { + "name": "barrel", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "comments": { + "name": "comments", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "toasts": { + "name": "toasts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tasting_unq": { + "name": "tasting_unq", + "columns": [ + "bottle_id", + "created_by_id", + "created_at" + ], + "isUnique": true + } + }, + "foreignKeys": { + "tasting_bottle_id_bottle_id_fk": { + "name": "tasting_bottle_id_bottle_id_fk", + "tableFrom": "tasting", + "tableTo": "bottle", + "columnsFrom": [ + "bottle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasting_created_by_id_user_id_fk": { + "name": "tasting_created_by_id_user_id_fk", + "tableFrom": "tasting", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "toasts": { + "name": "toasts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "tasting_id": { + "name": "tasting_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "toast_unq": { + "name": "toast_unq", + "columns": [ + "tasting_id", + "created_by_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "toasts_tasting_id_tasting_id_fk": { + "name": "toasts_tasting_id_tasting_id_fk", + "tableFrom": "toasts", + "tableTo": "tasting", + "columnsFrom": [ + "tasting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "toasts_created_by_id_user_id_fk": { + "name": "toasts_created_by_id_user_id_fk", + "tableFrom": "toasts", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "picture_url": { + "name": "picture_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private": { + "name": "private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "admin": { + "name": "admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "mod": { + "name": "mod", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_email_unq": { + "name": "user_email_unq", + "columns": [ + "email" + ], + "isUnique": true + }, + "user_username_unq": { + "name": "user_username_unq", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {} + } + }, + "enums": { + "category": { + "name": "category", + "values": { + "blend": "blend", + "bourbon": "bourbon", + "rye": "rye", + "single_grain": "single_grain", + "single_malt": "single_malt", + "spirit": "spirit" + } + }, + "type": { + "name": "type", + "values": { + "add": "add", + "update": "update", + "delete": "delete" + } + }, + "entity_type": { + "name": "entity_type", + "values": { + "brand": "brand", + "distiller": "distiller", + "bottler": "bottler" + } + }, + "follow_status": { + "name": "follow_status", + "values": { + "none": "none", + "pending": "pending", + "following": "following" + } + }, + "identity_provider": { + "name": "identity_provider", + "values": { + "google": "google" + } + }, + "object_type": { + "name": "object_type", + "values": { + "bottle": "bottle", + "comment": "comment", + "entity": "entity", + "tasting": "tasting", + "toast": "toast", + "follow": "follow" + } + } + }, + "schemas": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} \ No newline at end of file diff --git a/apps/api/migrations/meta/_journal.json b/apps/api/migrations/meta/_journal.json index a95a1e11..b8b08fe3 100644 --- a/apps/api/migrations/meta/_journal.json +++ b/apps/api/migrations/meta/_journal.json @@ -197,6 +197,13 @@ "when": 1684981782082, "tag": "0027_flawless_baron_zemo", "breakpoints": false + }, + { + "idx": 28, + "version": "5", + "when": 1685202223644, + "tag": "0028_deep_piledriver", + "breakpoints": false } ] } \ No newline at end of file diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 01d24978..27d4f2ab 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -26,6 +26,7 @@ export const users = pgTable( displayName: text("display_name"), pictureUrl: text("picture_url"), + private: boolean("private").default(false).notNull(), active: boolean("active").default(true).notNull(), admin: boolean("admin").default(false).notNull(), mod: boolean("mod").default(false).notNull(), diff --git a/apps/api/src/lib/api.test.ts b/apps/api/src/lib/api.test.ts index 16863e0d..52b76436 100644 --- a/apps/api/src/lib/api.test.ts +++ b/apps/api/src/lib/api.test.ts @@ -1,4 +1,6 @@ -import { fixBottleName } from "./api"; +import { db } from "../db"; +import * as Fixtures from "../lib/test/fixtures"; +import { fixBottleName, profileVisible } from "./api"; describe("fixBottleName", () => { test("just the age", async () => { @@ -39,3 +41,51 @@ describe("fixBottleName", () => { expect(rv).toBe("10"); }); }); + +describe("profileVisible", () => { + test("not private", async () => { + const user = await Fixtures.User({ private: false }); + const rv = await profileVisible(db, user, DefaultFixtures.user); + expect(rv).toBe(true); + }); + + test("not logged in", async () => { + const user = await Fixtures.User({ private: true }); + const rv = await profileVisible(db, user, null); + expect(rv).toBe(false); + }); + + test("self", async () => { + const user = await Fixtures.User({ private: true }); + const rv = await profileVisible(db, user, user); + expect(rv).toBe(true); + }); + + test("not friends", async () => { + const user = await Fixtures.User({ private: true }); + const rv = await profileVisible(db, user, DefaultFixtures.user); + expect(rv).toBe(false); + }); + + test("not approved", async () => { + const user = await Fixtures.User({ private: true }); + await Fixtures.Follow({ + fromUserId: DefaultFixtures.user.id, + toUserId: user.id, + status: "pending", + }); + const rv = await profileVisible(db, user, DefaultFixtures.user); + expect(rv).toBe(false); + }); + + test("friends", async () => { + const user = await Fixtures.User({ private: true }); + await Fixtures.Follow({ + fromUserId: DefaultFixtures.user.id, + toUserId: user.id, + status: "following", + }); + const rv = await profileVisible(db, user, DefaultFixtures.user); + expect(rv).toBe(true); + }); +}); diff --git a/apps/api/src/lib/api.ts b/apps/api/src/lib/api.ts index 0879c101..b5f80805 100644 --- a/apps/api/src/lib/api.ts +++ b/apps/api/src/lib/api.ts @@ -1,6 +1,6 @@ -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { DatabaseType, TransactionType } from "../db"; -import { User, users } from "../db/schema"; +import { User, follows, users } from "../db/schema"; // export async function getUserFromId( // db: DatabaseType | TransactionType, @@ -56,3 +56,25 @@ export const fixBottleName = (name: string, age?: number | null): string => { } return name.replace(` ${age} `, ` ${age}-year-old `); }; + +export const profileVisible = async ( + db: DatabaseType | TransactionType, + user: User, + currentUser?: User | null, +) => { + if (!user.private) return true; + if (!currentUser) return false; + if (currentUser.id === user.id) return true; + return !!( + await db + .select() + .from(follows) + .where( + and( + eq(follows.fromUserId, currentUser.id), + eq(follows.toUserId, user.id), + eq(follows.status, "following"), + ), + ) + ).find((d) => !!d); +}; diff --git a/apps/api/src/lib/serializers/user.ts b/apps/api/src/lib/serializers/user.ts index 3dfa84bc..15005047 100644 --- a/apps/api/src/lib/serializers/user.ts +++ b/apps/api/src/lib/serializers/user.ts @@ -46,6 +46,7 @@ export const UserSerializer: Serializer = { ? `${config.URL_PREFIX}${item.pictureUrl}` : null, followStatus: attrs.followStatus, + private: item.private, }; if ( diff --git a/apps/api/src/routes/addCollectionBottle.ts b/apps/api/src/routes/addCollectionBottle.ts index 560b87be..77abca73 100644 --- a/apps/api/src/routes/addCollectionBottle.ts +++ b/apps/api/src/routes/addCollectionBottle.ts @@ -6,18 +6,22 @@ import { z } from "zod"; import zodToJsonSchema from "zod-to-json-schema"; import { db } from "../db"; import { bottles, collectionBottles, collections } from "../db/schema"; +import { getUserFromId } from "../lib/api"; import { getDefaultCollection } from "../lib/db"; import { sha1 } from "../lib/hash"; import { requireAuth } from "../middleware/auth"; export default { method: "POST", - url: "/collections/:collectionId/bottles", + url: "/users/:userId/collections/:collectionId/bottles", schema: { params: { type: "object", required: ["collectionId"], properties: { + userId: { + anyOf: [{ type: "number" }, { type: "string" }, { const: "me" }], + }, collectionId: { anyOf: [{ type: "number" }, { const: "default" }] }, }, }, @@ -25,9 +29,20 @@ export default { }, preHandler: [requireAuth], handler: async (req, res) => { + const user = await getUserFromId(db, req.params.userId, req.user); + if (!user) { + return res.status(404).send({ error: "Not found" }); + } + + if (user.id !== req.user.id) { + return res + .status(400) + .send({ error: "Cannot modify another persons collection" }); + } + const collection = req.params.collectionId === "default" - ? await getDefaultCollection(db, req.user.id) + ? await getDefaultCollection(db, user.id) : await db.query.collections.findFirst({ where: (collections, { eq }) => eq(collections.id, req.params.collectionId as number), @@ -83,6 +98,7 @@ export default { ServerResponse, { Params: { + userId: number | string | "me"; collectionId: number | "default"; }; Body: z.infer; diff --git a/apps/api/src/routes/addEntity.test.ts b/apps/api/src/routes/addEntity.test.ts index c8ab86e7..e250a3e5 100644 --- a/apps/api/src/routes/addEntity.test.ts +++ b/apps/api/src/routes/addEntity.test.ts @@ -14,19 +14,6 @@ beforeAll(async () => { }; }); -test("must be mod", async () => { - const response = await app.inject({ - method: "POST", - url: `/entities`, - payload: { - name: "Delicious Wood", - }, - headers: await Fixtures.AuthenticatedHeaders(), - }); - - expect(response).toRespondWith(403); -}); - test("name is required", async () => { const response = await app.inject({ method: "POST", diff --git a/apps/api/src/routes/deleteCollectionBottle.ts b/apps/api/src/routes/deleteCollectionBottle.ts index c22206b2..b4cc9ab5 100644 --- a/apps/api/src/routes/deleteCollectionBottle.ts +++ b/apps/api/src/routes/deleteCollectionBottle.ts @@ -3,17 +3,21 @@ import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; import { db } from "../db"; import { collectionBottles } from "../db/schema"; +import { getUserFromId } from "../lib/api"; import { getDefaultCollection } from "../lib/db"; import { requireAuth } from "../middleware/auth"; export default { method: "DELETE", - url: "/collections/:collectionId/bottles/:bottleId", + url: "/users/:userId/collections/:collectionId/bottles/:bottleId", schema: { params: { type: "object", required: ["collectionId", "bottleId"], properties: { + userId: { + anyOf: [{ type: "number" }, { type: "string" }, { const: "me" }], + }, collectionId: { anyOf: [{ type: "number" }, { const: "default" }] }, bottleId: { type: "number" }, }, @@ -21,6 +25,17 @@ export default { }, preHandler: [requireAuth], handler: async (req, res) => { + const user = await getUserFromId(db, req.params.userId, req.user); + if (!user) { + return res.status(404).send({ error: "Not found" }); + } + + if (user.id !== req.user.id) { + return res + .status(400) + .send({ error: "Cannot modify another persons collection" }); + } + const collection = req.params.collectionId === "default" ? await getDefaultCollection(db, req.user.id) @@ -56,6 +71,7 @@ export default { ServerResponse, { Params: { + userId: number | string | "me"; collectionId: number | "default"; bottleId: number; }; diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 16396b72..9356aff4 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -23,7 +23,6 @@ import listBottleSuggestedTags from "./listBottleSuggestedTags"; import listBottleTags from "./listBottleTags"; import listBottles from "./listBottles"; import listChanges from "./listChanges"; -import listCollections from "./listCollections"; import listComments from "./listComments"; import listEntities from "./listEntities"; import listFollowers from "./listFollowers"; @@ -31,6 +30,7 @@ import listFollowing from "./listFollowing"; import listNotifications from "./listNotifications"; import listTastings from "./listTastings"; import listUserCollectionBottles from "./listUserCollectionBottles"; +import listUserCollections from "./listUserCollections"; import listUserTags from "./listUserTags"; import listUsers from "./listUsers"; import updateBottle from "./updateBottle"; @@ -105,7 +105,7 @@ export const router: FastifyPluginCallback = ( fastify.route(listUserCollectionBottles); fastify.route(listUserTags); - fastify.route(listCollections); + fastify.route(listUserCollections); fastify.route(addCollectionBottle); fastify.route(deleteCollectionBottle); diff --git a/apps/api/src/routes/listTastings.test.ts b/apps/api/src/routes/listTastings.test.ts index 7ae2ad09..0a071313 100644 --- a/apps/api/src/routes/listTastings.test.ts +++ b/apps/api/src/routes/listTastings.test.ts @@ -105,3 +105,46 @@ test("lists tastings filter friends", async () => { expect(results.length).toBe(1); expect(results[0].id).toEqual(lastTasting.id); }); + +test("lists tastings hides private while authenticated", async () => { + const friend = await Fixtures.User({ private: true }); + await Fixtures.Follow({ + fromUserId: DefaultFixtures.user.id, + toUserId: friend.id, + status: "following", + }); + + // should hide tasting from non-friend + await Fixtures.Tasting({ + createdById: (await Fixtures.User({ private: true })).id, + }); + // should show tasting from friend + const tasting = await Fixtures.Tasting({ createdById: friend.id }); + + const response = await app.inject({ + method: "GET", + url: "/tastings", + headers: DefaultFixtures.authHeaders, + }); + + expect(response).toRespondWith(200); + const { results } = JSON.parse(response.payload); + expect(results.length).toBe(1); + expect(results[0].id).toEqual(tasting.id); +}); + +test("lists tastings hides private while anonymous", async () => { + const tasting = await Fixtures.Tasting(); + await Fixtures.Tasting({ + createdById: (await Fixtures.User({ private: true })).id, + }); + const response = await app.inject({ + method: "GET", + url: "/tastings", + }); + + expect(response).toRespondWith(200); + const { results } = JSON.parse(response.payload); + expect(results.length).toBe(1); + expect(results[0].id).toEqual(tasting.id); +}); diff --git a/apps/api/src/routes/listTastings.ts b/apps/api/src/routes/listTastings.ts index f0590f9c..7fc117c0 100644 --- a/apps/api/src/routes/listTastings.ts +++ b/apps/api/src/routes/listTastings.ts @@ -1,11 +1,11 @@ import { PaginatedSchema, TastingSchema } from "@peated/shared/schemas"; -import { SQL, and, desc, eq, sql } from "drizzle-orm"; +import { SQL, and, desc, eq, or, sql } from "drizzle-orm"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; import { z } from "zod"; import zodToJsonSchema from "zod-to-json-schema"; import { db } from "../db"; -import { follows, tastings } from "../db/schema"; +import { follows, tastings, users } from "../db/schema"; import { buildPageLink } from "../lib/paging"; import { serialize } from "../lib/serializers"; import { TastingSerializer } from "../lib/serializers/tasting"; @@ -38,7 +38,7 @@ export default { const limit = req.query.limit || 25; const offset = (page - 1) * limit; - const where: SQL[] = []; + const where: (SQL | undefined)[] = []; if (req.query.bottle) { where.push(eq(tastings.bottleId, req.query.bottle)); } @@ -50,6 +50,8 @@ export default { ), ); } + + const limitPrivate = req.query.filter !== "friends"; if (req.query.filter) { if (req.query.filter === "friends") { if (!req.user) { @@ -61,9 +63,26 @@ export default { } } + if (limitPrivate) { + where.push( + or( + eq(users.private, false), + ...(req.user + ? [ + eq(tastings.createdById, req.user.id), + sql`${tastings.createdById} IN ( + SELECT ${follows.toUserId} FROM ${follows} WHERE ${follows.fromUserId} = ${req.user.id} AND ${follows.status} = 'following' + )`, + ] + : []), + ), + ); + } + const results = await db - .select() + .select({ tastings }) .from(tastings) + .innerJoin(users, eq(users.id, tastings.createdById)) .where(where ? and(...where) : undefined) .limit(limit + 1) .offset(offset) @@ -72,7 +91,7 @@ export default { res.send({ results: await serialize( TastingSerializer, - results.slice(0, limit), + results.map((t) => t.tastings).slice(0, limit), req.user, ), rel: { diff --git a/apps/api/src/routes/listUserCollectionBottles.test.ts b/apps/api/src/routes/listUserCollectionBottles.test.ts new file mode 100644 index 00000000..8799ddcc --- /dev/null +++ b/apps/api/src/routes/listUserCollectionBottles.test.ts @@ -0,0 +1,49 @@ +import { FastifyInstance } from "fastify"; +import buildFastify from "../app"; +import * as Fixtures from "../lib/test/fixtures"; + +let app: FastifyInstance; +beforeAll(async () => { + app = await buildFastify(); + + return async () => { + app.close(); + }; +}); +test("cannot list private without friend", async () => { + const otherUser = await Fixtures.User({ private: true }); + const response = await app.inject({ + method: "GET", + url: `/users/${otherUser.id}/collections/default/bottles`, + headers: DefaultFixtures.authHeaders, + }); + + expect(response).toRespondWith(400); +}); + +test("can list private with friend", async () => { + const otherUser = await Fixtures.User({ private: true }); + await Fixtures.Follow({ + fromUserId: DefaultFixtures.user.id, + toUserId: otherUser.id, + status: "following", + }); + const response = await app.inject({ + method: "GET", + url: `/users/${otherUser.id}/collections/default/bottles`, + headers: DefaultFixtures.authHeaders, + }); + + expect(response).toRespondWith(200); +}); + +test("can list public without friend", async () => { + const otherUser = await Fixtures.User({ private: false }); + const response = await app.inject({ + method: "GET", + url: `/users/${otherUser.id}/collections/default/bottles`, + headers: DefaultFixtures.authHeaders, + }); + + expect(response).toRespondWith(200); +}); diff --git a/apps/api/src/routes/listUserCollectionBottles.ts b/apps/api/src/routes/listUserCollectionBottles.ts index f6328165..4d930d25 100644 --- a/apps/api/src/routes/listUserCollectionBottles.ts +++ b/apps/api/src/routes/listUserCollectionBottles.ts @@ -9,7 +9,7 @@ import { z } from "zod"; import zodToJsonSchema from "zod-to-json-schema"; import { db } from "../db"; import { bottles, collectionBottles, entities } from "../db/schema"; -import { getUserFromId } from "../lib/api"; +import { getUserFromId, profileVisible } from "../lib/api"; import { getDefaultCollection } from "../lib/db"; import { buildPageLink } from "../lib/paging"; import { serialize } from "../lib/serializers"; @@ -49,6 +49,10 @@ export default { return res.status(404).send({ error: "Not found" }); } + if (!(await profileVisible(db, user, req.user))) { + return res.status(400).send({ error: "User's profile is private" }); + } + const collection = req.params.collectionId === "default" ? await getDefaultCollection(db, user.id) diff --git a/apps/api/src/routes/listUserCollections.test.ts b/apps/api/src/routes/listUserCollections.test.ts new file mode 100644 index 00000000..8caeb563 --- /dev/null +++ b/apps/api/src/routes/listUserCollections.test.ts @@ -0,0 +1,49 @@ +import { FastifyInstance } from "fastify"; +import buildFastify from "../app"; +import * as Fixtures from "../lib/test/fixtures"; + +let app: FastifyInstance; +beforeAll(async () => { + app = await buildFastify(); + + return async () => { + app.close(); + }; +}); +test("cannot list private without friend", async () => { + const otherUser = await Fixtures.User({ private: true }); + const response = await app.inject({ + method: "GET", + url: `/users/${otherUser.id}/collections`, + headers: DefaultFixtures.authHeaders, + }); + + expect(response).toRespondWith(400); +}); + +test("can list private with friend", async () => { + const otherUser = await Fixtures.User({ private: true }); + await Fixtures.Follow({ + fromUserId: DefaultFixtures.user.id, + toUserId: otherUser.id, + status: "following", + }); + const response = await app.inject({ + method: "GET", + url: `/users/${otherUser.id}/collections`, + headers: DefaultFixtures.authHeaders, + }); + + expect(response).toRespondWith(200); +}); + +test("can list public without friend", async () => { + const otherUser = await Fixtures.User({ private: false }); + const response = await app.inject({ + method: "GET", + url: `/users/${otherUser.id}/collections`, + headers: DefaultFixtures.authHeaders, + }); + + expect(response).toRespondWith(200); +}); diff --git a/apps/api/src/routes/listCollections.ts b/apps/api/src/routes/listUserCollections.ts similarity index 76% rename from apps/api/src/routes/listCollections.ts rename to apps/api/src/routes/listUserCollections.ts index 55ce8f7c..7355b572 100644 --- a/apps/api/src/routes/listCollections.ts +++ b/apps/api/src/routes/listUserCollections.ts @@ -1,25 +1,32 @@ import { CollectionSchema, PaginatedSchema } from "@peated/shared/schemas"; -import { and, asc, eq, sql } from "drizzle-orm"; +import { and, asc, sql } from "drizzle-orm"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; import { z } from "zod"; import zodToJsonSchema from "zod-to-json-schema"; import { db } from "../db"; import { collectionBottles, collections } from "../db/schema"; +import { getUserFromId, profileVisible } from "../lib/api"; import { buildPageLink } from "../lib/paging"; import { serialize } from "../lib/serializers"; import { CollectionSerializer } from "../lib/serializers/collection"; -import { requireAuth } from "../middleware/auth"; export default { method: "GET", - url: "/collections", + url: "/users/:userId/collections", schema: { + params: { + type: "object", + properties: { + userId: { + anyOf: [{ type: "number" }, { type: "string" }, { const: "me" }], + }, + }, + }, querystring: { type: "object", properties: { page: { type: "number" }, - user: { oneOf: [{ type: "number" }, { const: "me" }] }, bottle: { type: "number" }, }, }, @@ -31,22 +38,21 @@ export default { ), }, }, - preHandler: [requireAuth], handler: async (req, res) => { + const user = await getUserFromId(db, req.params.userId, req.user); + if (!user) { + return res.status(404).send({ error: "Not found" }); + } + + if (!(await profileVisible(db, user, req.user))) { + return res.status(400).send({ error: "User's profile is private" }); + } + const page = req.query.page || 1; const limit = 100; const offset = (page - 1) * limit; const where = []; - if (req.query.user) { - where.push( - eq( - collections.createdById, - req.query.user === "me" ? req.user.id : req.query.user, - ), - ); - } - if (req.query.bottle) { where.push( sql`EXISTS(SELECT 1 FROM ${collectionBottles} WHERE ${collectionBottles.bottleId} = ${req.query.bottle} AND ${collectionBottles.collectionId} = ${collections.id})`, @@ -86,8 +92,10 @@ export default { IncomingMessage, ServerResponse, { + Params: { + userId: number | string | "me"; + }; Querystring: { - user: number | "me"; bottle: number; page?: number; }; diff --git a/apps/api/src/routes/listUserTags.test.ts b/apps/api/src/routes/listUserTags.test.ts index 0449d1c0..78432d04 100644 --- a/apps/api/src/routes/listUserTags.test.ts +++ b/apps/api/src/routes/listUserTags.test.ts @@ -47,3 +47,41 @@ test("lists tags", async () => { { tag: "solvent", count: 1 }, ]); }); + +test("cannot list private without friend", async () => { + const otherUser = await Fixtures.User({ private: true }); + const response = await app.inject({ + method: "GET", + url: `/users/${otherUser.id}/tags`, + headers: DefaultFixtures.authHeaders, + }); + + expect(response).toRespondWith(400); +}); + +test("can list private with friend", async () => { + const otherUser = await Fixtures.User({ private: true }); + await Fixtures.Follow({ + fromUserId: DefaultFixtures.user.id, + toUserId: otherUser.id, + status: "following", + }); + const response = await app.inject({ + method: "GET", + url: `/users/${otherUser.id}/tags`, + headers: DefaultFixtures.authHeaders, + }); + + expect(response).toRespondWith(200); +}); + +test("can list public without friend", async () => { + const otherUser = await Fixtures.User({ private: false }); + const response = await app.inject({ + method: "GET", + url: `/users/${otherUser.id}/tags`, + headers: DefaultFixtures.authHeaders, + }); + + expect(response).toRespondWith(200); +}); diff --git a/apps/api/src/routes/listUserTags.ts b/apps/api/src/routes/listUserTags.ts index 17106fe8..67295cd2 100644 --- a/apps/api/src/routes/listUserTags.ts +++ b/apps/api/src/routes/listUserTags.ts @@ -5,7 +5,7 @@ import { z } from "zod"; import zodToJsonSchema from "zod-to-json-schema"; import { db } from "../db"; import { tastings } from "../db/schema"; -import { getUserFromId } from "../lib/api"; +import { getUserFromId, profileVisible } from "../lib/api"; export default { method: "GET", @@ -40,6 +40,10 @@ export default { return res.status(404).send({ error: "Not found" }); } + if (!(await profileVisible(db, user, req.user))) { + return res.status(400).send({ error: "User's profile is private" }); + } + const results = await db.execute( sql<{ tag: string; count: number }>`SELECT tag, COUNT(tag) as count FROM ( diff --git a/apps/api/src/routes/updateUser.ts b/apps/api/src/routes/updateUser.ts index 7e2f9cc4..57d0b3ad 100644 --- a/apps/api/src/routes/updateUser.ts +++ b/apps/api/src/routes/updateUser.ts @@ -56,6 +56,11 @@ export default { return res.status(400).send({ error: "Invalid username" }); } } + + if (body.private !== undefined && body.private !== user.private) { + data.private = body.private; + } + if (body.admin !== undefined && body.admin !== user.admin) { if (!req.user.admin) { return res.status(403).send({ error: "Forbidden" }); diff --git a/apps/web/src/components/booleanField.tsx b/apps/web/src/components/booleanField.tsx new file mode 100644 index 00000000..12686299 --- /dev/null +++ b/apps/web/src/components/booleanField.tsx @@ -0,0 +1,75 @@ +import { ReactNode } from "react"; + +import { Switch } from "@headlessui/react"; +import { + FieldValues, + UseControllerProps, + useController, +} from "react-hook-form"; +import classNames from "../lib/classNames"; +import FormField from "./formField"; + +type Props = { + label?: string; + helpText?: string; + required?: boolean; + children?: ReactNode; + error?: { + message?: string; + }; + className?: string; +} & UseControllerProps; + +export default function BooleanField({ + helpText, + label, + required, + className, + error, + ...props +}: Props) { + const { + field: { name, value, onChange }, + } = useController(props); + + return ( + + + + + {label} + + + {helpText} + + + + Use setting + + + + ); +} diff --git a/apps/web/src/components/formLabel.tsx b/apps/web/src/components/formLabel.tsx index 43b72298..0c4536f3 100644 --- a/apps/web/src/components/formLabel.tsx +++ b/apps/web/src/components/formLabel.tsx @@ -1,23 +1,29 @@ -import { ReactNode } from "react"; +import { ElementType, ReactNode } from "react"; +import { PolymorphicProps } from "../types"; -export default ({ - className, - required, - labelNote, - ...props -}: { +type Props = PolymorphicProps & { required?: boolean; labelNote?: ReactNode; -} & React.ComponentPropsWithoutRef<"label">) => { +} & React.ComponentPropsWithoutRef<"label">; + +const defaultElement = "label"; + +export default function FormLabel< + E extends ElementType = typeof defaultElement, +>({ className, as, required, labelNote, children, ...props }: Props) { + const Component = as ?? defaultElement; + return ( -
-
+ ); -}; +} diff --git a/apps/web/src/routes/bottleDetails.tsx b/apps/web/src/routes/bottleDetails.tsx index f6a88353..99440742 100644 --- a/apps/web/src/routes/bottleDetails.tsx +++ b/apps/web/src/routes/bottleDetails.tsx @@ -33,9 +33,8 @@ const CollectionAction = ({ bottle }: { bottle: Bottle }) => { } = useSuspenseQuery( ["bottles", bottle.id, "collections"], (): Promise> => - api.get(`/collections`, { + api.get(`/users/me/collections`, { query: { - user: "me", bottle: bottle.id, }, }), @@ -50,7 +49,7 @@ const CollectionAction = ({ bottle }: { bottle: Bottle }) => { if (isCollected) { setLoading(true); try { - await api.delete(`/collections/default/bottles/${bottle.id}`); + await api.delete(`/users/me/collections/default/bottles/${bottle.id}`); setIsCollected(false); } catch (err: any) { logError(err); @@ -74,7 +73,7 @@ const CollectionAction = ({ bottle }: { bottle: Bottle }) => { if (loading) return; setLoading(true); try { - await api.post("/collections/default/bottles", { + await api.post("/users/me/collections/default/bottles", { data, }); setIsCollected(true); diff --git a/apps/web/src/routes/profile.tsx b/apps/web/src/routes/profile.tsx index 7afe9227..58eb3d1b 100644 --- a/apps/web/src/routes/profile.tsx +++ b/apps/web/src/routes/profile.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { Outlet, useParams } from "react-router-dom"; import Button from "../components/button"; import Chip from "../components/chip"; +import EmptyActivity from "../components/emptyActivity"; import Layout from "../components/layout"; import QueryBoundary from "../components/queryBoundary"; import Tabs from "../components/tabs"; @@ -56,6 +57,12 @@ export default function Profile() { setFollowStatus(data.status); }; + const isPrivate = + user.private && + currentUser && + user.id !== currentUser.id && + followStatus !== "following"; + return (
@@ -170,19 +177,25 @@ export default function Profile() {
- + {isPrivate ? ( + This users profile is private. + ) : ( + <> + - - - Activity - - - Collection - - - - - + + + Activity + + + Collection + + + + + + + )}
); } diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index a34a8227..64bac5a0 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -7,6 +7,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { SubmitHandler, useForm } from "react-hook-form"; import { z } from "zod"; +import BooleanField from "../components/booleanField"; import Fieldset from "../components/fieldset"; import FormError from "../components/formError"; import FormHeader from "../components/formHeader"; @@ -63,6 +64,7 @@ export default function Settings() { const [picture, setPicture] = useState(null); const { + control, register, handleSubmit, formState: { errors, isSubmitting }, @@ -116,6 +118,13 @@ export default function Settings() { value={user.pictureUrl} onChange={(value) => setPicture(value)} /> + diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index fd2c20d2..29c384e4 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -70,12 +70,14 @@ export type Friend = { export type User = { id: number; - admin: boolean; - mod: boolean; username: string; displayName: string; - email: string; pictureUrl?: string; + private: boolean; + + admin?: boolean; + mod?: boolean; + email?: string; }; export type Tasting = { diff --git a/packages/shared/schemas.ts b/packages/shared/schemas.ts index 99b3a09b..dfd89e63 100644 --- a/packages/shared/schemas.ts +++ b/packages/shared/schemas.ts @@ -7,6 +7,7 @@ export const UserSchema = z.object({ displayName: z.string().trim().min(1, "Required"), username: z.string().trim().min(1, "Required"), pictureUrl: z.string(), + private: z.boolean(), email: z.string().email().optional(), admin: z.boolean().optional(), @@ -18,6 +19,7 @@ export const UserSchema = z.object({ export const UserInputSchema = z.object({ displayName: z.string().trim().min(1, "Required"), username: z.string().trim().min(1, "Required"), + private: z.boolean().optional(), admin: z.boolean().optional(), mod: z.boolean().optional(), });