diff --git a/apps/api/migrations/0010_faithful_maestro.sql b/apps/api/migrations/0010_faithful_maestro.sql index 50bde881..b4a9ad5c 100644 --- a/apps/api/migrations/0010_faithful_maestro.sql +++ b/apps/api/migrations/0010_faithful_maestro.sql @@ -45,4 +45,4 @@ EXCEPTION WHEN duplicate_object THEN null; END $$; -CREATE UNIQUE INDEX IF NOT EXISTS "collection_name_unq" ON "collection" ("name","created_by_id"); \ No newline at end of file +CREATE UNIQUE INDEX IF NOT EXISTS "collection_name_unq" ON "collection" ("name","created_by_id"); diff --git a/apps/api/migrations/0017_nappy_captain_stacy.sql b/apps/api/migrations/0017_nappy_captain_stacy.sql new file mode 100644 index 00000000..5f36da46 --- /dev/null +++ b/apps/api/migrations/0017_nappy_captain_stacy.sql @@ -0,0 +1,2 @@ +ALTER TABLE "collection_bottle" ADD COLUMN "id" bigserial NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS "collection_bottle_unq" ON "collection_bottle" ("collection_id","bottle_id","edition_id"); diff --git a/apps/api/migrations/meta/0017_snapshot.json b/apps/api/migrations/meta/0017_snapshot.json new file mode 100644 index 00000000..f6ffc777 --- /dev/null +++ b/apps/api/migrations/meta/0017_snapshot.json @@ -0,0 +1,1176 @@ +{ + "version": "5", + "dialect": "pg", + "id": "7c3ca93c-45ed-4cff-9edc-c199cd047c05", + "prevId": "34138aed-05e1-44e2-9ae8-ebd109511507", + "tables": { + "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 + }, + "stated_age": { + "name": "stated_age", + "type": "smallint", + "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_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 + }, + "data": { + "name": "data", + "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": {}, + "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 + }, + "edition_id": { + "name": "edition_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "collection_bottle_unq": { + "name": "collection_bottle_unq", + "columns": [ + "collection_id", + "bottle_id", + "edition_id" + ], + "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" + }, + "collection_bottle_edition_id_edition_id_fk": { + "name": "collection_bottle_edition_id_edition_id_fk", + "tableFrom": "collection_bottle", + "tableTo": "edition", + "columnsFrom": [ + "edition_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 + }, + "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": {} + }, + "edition": { + "name": "edition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "barrel": { + "name": "barrel", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "vintage_year": { + "name": "vintage_year", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "bottle_id": { + "name": "bottle_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": { + "edition_unq": { + "name": "edition_unq", + "columns": [ + "bottle_id", + "name", + "barrel", + "vintage_year" + ], + "isUnique": true + } + }, + "foreignKeys": { + "edition_bottle_id_bottle_id_fk": { + "name": "edition_bottle_id_bottle_id_fk", + "tableFrom": "edition", + "tableTo": "bottle", + "columnsFrom": [ + "bottle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "edition_created_by_id_user_id_fk": { + "name": "edition_created_by_id_user_id_fk", + "tableFrom": "edition", + "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 + }, + "edition_id": { + "name": "edition_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "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_edition_id_edition_id_fk": { + "name": "tasting_edition_id_edition_id_fk", + "tableFrom": "tasting", + "tableTo": "edition", + "columnsFrom": [ + "edition_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 + }, + "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" + } + }, + "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", + "edition": "edition", + "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 a8b81053..6cd46f46 100644 --- a/apps/api/migrations/meta/_journal.json +++ b/apps/api/migrations/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1684612776482, "tag": "0016_cute_lyja", "breakpoints": false + }, + { + "idx": 17, + "version": "5", + "when": 1684619488823, + "tag": "0017_nappy_captain_stacy", + "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 4fcdcc14..468c2758 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -268,6 +268,7 @@ export type NewCollection = InferModel; export const collectionBottles = pgTable( "collection_bottle", { + id: bigserial("id", { mode: "number" }).primaryKey(), collectionId: bigint("collection_id", { mode: "number" }) .references(() => collections.id) .notNull(), @@ -280,7 +281,7 @@ export const collectionBottles = pgTable( }, (collectionBottles) => { return { - collectionDistillerId: primaryKey( + collectionDistillerId: uniqueIndex("collection_bottle_unq").on( collectionBottles.collectionId, collectionBottles.bottleId, collectionBottles.editionId, diff --git a/apps/api/src/lib/db.ts b/apps/api/src/lib/db.ts index a17482f2..dedbbfee 100644 --- a/apps/api/src/lib/db.ts +++ b/apps/api/src/lib/db.ts @@ -1,6 +1,15 @@ import { eq } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { first } from "../db"; -import { Entity, EntityType, NewEntity, changes, entities } from "../db/schema"; +import { + Collection, + Entity, + EntityType, + NewEntity, + changes, + collections, + entities, +} from "../db/schema"; export type EntityInput = | number @@ -30,7 +39,7 @@ export const upsertEntity = async ({ userId, type, }: { - db: any; + db: NodePgDatabase; data: EntityInput; userId: number; type?: EntityType; @@ -58,7 +67,9 @@ export const upsertEntity = async ({ name: data.name, country: data.country || null, region: data.region || null, - type: Array.from(new Set([type, ...(data.type || [])])), + type: Array.from( + new Set([...(type ? [type] : []), ...(data.type || [])]), + ), createdById: userId, }) .onConflictDoNothing() @@ -84,3 +95,35 @@ export const upsertEntity = async ({ return { id: resultConflict.id, result: resultConflict, created: false }; throw new Error("We should never hit this case in upsert"); }; + +export const getDefaultCollection = async ( + db: NodePgDatabase, + userId: number, +) => { + return ( + first( + await db + .select() + .from(collections) + .where(eq(collections.createdById, userId)) + .limit(1), + ) || + first( + await db + .insert(collections) + .values({ + name: "Default", + createdById: userId, + }) + .onConflictDoNothing() + .returning(), + ) || + ( + await db + .select() + .from(collections) + .where(eq(collections.createdById, userId)) + .limit(1) + )[0] + ); +}; diff --git a/apps/api/src/routes/addBottle.test.ts b/apps/api/src/routes/addBottle.test.ts index f64429e9..a36d2b81 100644 --- a/apps/api/src/routes/addBottle.test.ts +++ b/apps/api/src/routes/addBottle.test.ts @@ -69,7 +69,7 @@ test("creates a new bottle with all params", async () => { method: "POST", url: "/bottles", payload: { - name: "Delicious Wood", + name: "Delicious Wood 12-year-old", brand: brand.id, distillers: [distiller.id], statedAge: 12, @@ -85,7 +85,7 @@ test("creates a new bottle with all params", async () => { .select() .from(bottles) .where(eq(bottles.id, data.id)); - expect(bottle.name).toEqual("Delicious Wood"); + expect(bottle.name).toEqual("Delicious Wood 12-year-old"); expect(bottle.brandId).toEqual(brand.id); expect(bottle.statedAge).toEqual(12); expect(bottle.createdById).toBe(DefaultFixtures.user.id); @@ -460,3 +460,20 @@ test("creates a new bottle with new distiller name which is duplicated as brand ); expect(changeList.length).toBe(1); }); + +test("refuses bottle w/ age signal", async () => { + const brand = await Fixtures.Entity(); + const distiller = await Fixtures.Entity(); + const response = await app.inject({ + method: "POST", + url: "/bottles", + payload: { + name: "Delicious Wood 12-year-old", + brand: brand.id, + distillers: [distiller.id], + }, + headers: DefaultFixtures.authHeaders, + }); + + expect(response).toRespondWith(400); +}); diff --git a/apps/api/src/routes/addBottle.ts b/apps/api/src/routes/addBottle.ts index e833e2c0..aafe7e43 100644 --- a/apps/api/src/routes/addBottle.ts +++ b/apps/api/src/routes/addBottle.ts @@ -17,6 +17,14 @@ import { serialize } from "../lib/serializers"; import { BottleSerializer } from "../lib/serializers/bottle"; import { requireAuth } from "../middleware/auth"; +const fixBottleName = (name: string, age?: number | null): string => { + // try to ease UX and normalize common name components + return name + .replace(" years old", "-year-old") + .replace(" year old", "-year-old") + .replace("-years-old", "-year-old"); +}; + export default { method: "POST", url: "/bottles", @@ -30,6 +38,19 @@ export default { handler: async (req, res) => { const body = req.body; + if ( + (body.name.indexOf("-year-old") !== -1 || + body.name.indexOf("-years-old") !== -1 || + body.name.indexOf("year old") !== -1 || + body.name.indexOf("years old") !== -1) && + !body.statedAge + ) { + res + .status(400) + .send({ error: "You should include the Stated Age of the bottle" }); + return; + } + const bottle: Bottle | undefined = await db.transaction(async (tx) => { const [brand] = typeof body.brand === "number" @@ -65,7 +86,7 @@ export default { [bottle] = await tx .insert(bottles) .values({ - name: body.name, + name: fixBottleName(body.name, body.statedAge), statedAge: body.statedAge || null, category: body.category || null, brandId: brand.id, diff --git a/apps/api/src/routes/addCollectionBottle.ts b/apps/api/src/routes/addCollectionBottle.ts new file mode 100644 index 00000000..48452ba4 --- /dev/null +++ b/apps/api/src/routes/addCollectionBottle.ts @@ -0,0 +1,99 @@ +import { eq, inArray } from "drizzle-orm"; +import type { RouteOptions } from "fastify"; +import { IncomingMessage, Server, ServerResponse } from "http"; +import { db, first } from "../db"; +import { + Collection, + bottles, + collectionBottles, + collections, +} from "../db/schema"; +import { getDefaultCollection } from "../lib/db"; +import { requireAuth } from "../middleware/auth"; + +export default { + method: "POST", + url: "/collections/:collectionId/bottles", + schema: { + params: { + type: "object", + required: ["collectionId"], + properties: { + collectionId: { anyOf: [{ type: "number" }, { const: "default" }] }, + }, + }, + body: { + type: "object", + required: ["bottle"], + properties: { + bottle: { + anyOf: [ + { type: "number" }, + { type: "array", items: { type: "number" } }, + ], + }, + }, + }, + }, + preHandler: [requireAuth], + handler: async (req, res) => { + const collection = + req.params.collectionId === "default" + ? await getDefaultCollection(db, req.user.id) + : first( + await db + .select() + .from(collections) + .where(eq(collections.id, req.params.collectionId)), + ); + + if (!collection) { + return res.status(404).send({ error: "Not found" }); + } + + if (req.user.id !== collection.createdById) { + return res + .status(400) + .send({ error: "Cannot modify another persons collection" }); + } + + // find bottles + const bottleIds = Array.from( + new Set( + typeof req.body.bottle === "number" + ? [req.body.bottle] + : req.body.bottle, + ), + ); + const bottleList = await db + .select() + .from(bottles) + .where(inArray(bottles.id, bottleIds)); + if (bottleList.length !== bottleIds.length) { + // could error out here + } + + await db.transaction(async (tx) => { + for (const bottle of bottleList) { + await tx.insert(collectionBottles).values({ + collectionId: collection.id, + bottleId: bottle.id, + }); + } + }); + + res.status(200).send({}); + }, +} as RouteOptions< + Server, + IncomingMessage, + ServerResponse, + { + Params: { + collectionId: number | "default"; + }; + Body: { + bottle: number | number[]; + }; + } +>; diff --git a/apps/api/src/routes/deleteCollectionBottle.ts b/apps/api/src/routes/deleteCollectionBottle.ts new file mode 100644 index 00000000..3e988534 --- /dev/null +++ b/apps/api/src/routes/deleteCollectionBottle.ts @@ -0,0 +1,65 @@ +import { and, eq } from "drizzle-orm"; +import type { RouteOptions } from "fastify"; +import { IncomingMessage, Server, ServerResponse } from "http"; +import { db, first } from "../db"; +import { Collection, collectionBottles, collections } from "../db/schema"; +import { getDefaultCollection } from "../lib/db"; +import { requireAuth } from "../middleware/auth"; + +export default { + method: "DELETE", + url: "/collections/:collectionId/bottles/:bottleId", + schema: { + params: { + type: "object", + required: ["collectionId", "bottleId"], + properties: { + collectionId: { anyOf: [{ type: "number" }, { const: "default" }] }, + bottleId: { type: "number" }, + }, + }, + }, + preHandler: [requireAuth], + handler: async (req, res) => { + const collection = + req.params.collectionId === "default" + ? await getDefaultCollection(db, req.user.id) + : first( + await db + .select() + .from(collections) + .where(eq(collections.id, req.params.collectionId)), + ); + + if (!collection) { + return res.status(404).send({ error: "Not found" }); + } + + if (req.user.id !== collection.createdById) { + return res + .status(400) + .send({ error: "Cannot modify another persons collection" }); + } + + await db + .delete(collectionBottles) + .where( + and( + eq(collectionBottles.bottleId, req.params.bottleId), + eq(collectionBottles.collectionId, collection.id), + ), + ); + + res.status(204).send({}); + }, +} as RouteOptions< + Server, + IncomingMessage, + ServerResponse, + { + Params: { + collectionId: number | "default"; + bottleId: number; + }; + } +>; diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 9dd2c42c..d901fb79 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -1,6 +1,7 @@ import { FastifyInstance, FastifyPluginCallback } from "fastify"; import addBottle from "./addBottle"; +import addCollectionBottle from "./addCollectionBottle"; import addEntity from "./addEntity"; import addTasting from "./addTasting"; import addTastingComment from "./addTastingComment"; @@ -9,6 +10,7 @@ import addUserFollow from "./addUserFollow"; import authBasic from "./authBasic"; import authDetails from "./authDetails"; import authGoogle from "./authGoogle"; +import deleteCollectionBottle from "./deleteCollectionBottle"; import deleteComment from "./deleteComment"; import deleteNotification from "./deleteNotification"; import deleteTasting from "./deleteTasting"; @@ -17,6 +19,7 @@ import getBottle from "./getBottle"; import getEntity from "./getEntity"; import getTasting from "./getTasting"; import getUser from "./getUser"; +import listBottleEditions from "./listBottleEditions"; import listBottleSuggestedTags from "./listBottleSuggestedTags"; import listBottles from "./listBottles"; import listCollections from "./listCollections"; @@ -63,8 +66,11 @@ export const router: FastifyPluginCallback = ( fastify.route(addBottle); fastify.route(getBottle); fastify.route(updateBottle); + fastify.route(listBottleSuggestedTags); + fastify.route(listBottleEditions); + fastify.route(listEntities); fastify.route(addEntity); fastify.route(getEntity); @@ -93,6 +99,8 @@ export const router: FastifyPluginCallback = ( fastify.route(deleteUserFollow); fastify.route(listCollections); + fastify.route(addCollectionBottle); + fastify.route(deleteCollectionBottle); fastify.route(listComments); fastify.route(deleteComment); diff --git a/apps/api/src/routes/listBottleEditions.ts b/apps/api/src/routes/listBottleEditions.ts new file mode 100644 index 00000000..023d086c --- /dev/null +++ b/apps/api/src/routes/listBottleEditions.ts @@ -0,0 +1,90 @@ +import { EditionSchema } from "@peated/shared/schemas"; +import { asc, eq } 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 { bottles, editions } from "../db/schema"; +import { buildPageLink } from "../lib/paging"; +import { serialize } from "../lib/serializers"; +import { EditionSerializer } from "../lib/serializers/edition"; + +export default { + method: "GET", + url: "/bottles/:bottleId/editions", + schema: { + params: { + type: "object", + required: ["bottleId"], + properties: { + bottleId: { type: "number" }, + }, + }, + response: { + 200: zodToJsonSchema( + z.object({ + results: z.array(EditionSchema), + }), + ), + }, + }, + handler: async (req, res) => { + const [bottle] = await db + .select() + .from(bottles) + .where(eq(bottles.id, req.params.bottleId)); + + if (!bottle) { + return res.status(404).send({ error: "Not found" }); + } + + const page = req.query.page || 1; + const limit = 100; + const offset = (page - 1) * limit; + + const results = await db + .select() + .from(editions) + .where(eq(editions.bottleId, bottle.id)) + .limit(limit + 1) + .offset(offset) + .orderBy( + asc(editions.name), + asc(editions.vintageYear), + asc(editions.barrel), + ); + + res.send({ + results: await serialize( + EditionSerializer, + results.slice(0, limit), + req.user, + ), + rel: { + nextPage: results.length > limit ? page + 1 : null, + prevPage: page > 1 ? page - 1 : null, + next: + results.length > limit + ? buildPageLink(req.routeOptions.url, req.query, page + 1) + : null, + prev: + page > 1 + ? buildPageLink(req.routeOptions.url, req.query, page - 1) + : null, + }, + }); + }, +} as RouteOptions< + Server, + IncomingMessage, + ServerResponse, + { + Params: { + bottleId: number; + }; + Querystring: { + page?: number; + }; + } +>; diff --git a/apps/api/src/routes/listBottles.ts b/apps/api/src/routes/listBottles.ts index a98efaf1..f781f754 100644 --- a/apps/api/src/routes/listBottles.ts +++ b/apps/api/src/routes/listBottles.ts @@ -5,7 +5,13 @@ import { IncomingMessage, Server, ServerResponse } from "http"; import { z } from "zod"; import zodToJsonSchema from "zod-to-json-schema"; import { db } from "../db"; -import { bottles, bottlesToDistillers, entities } from "../db/schema"; +import { + bottles, + bottlesToDistillers, + collectionBottles, + entities, +} from "../db/schema"; +import { getDefaultCollection } from "../lib/db"; import { buildPageLink } from "../lib/paging"; import { serialize } from "../lib/serializers"; import { BottleSerializer } from "../lib/serializers/bottle"; @@ -23,6 +29,8 @@ export default { brand: { type: "number" }, distiller: { type: "number" }, entity: { type: "number" }, + user: { type: "number" }, + collection: { anyOf: [{ type: "number" }, { const: "default" }] }, }, }, response: { @@ -78,6 +86,19 @@ export default { ), ); } + if (req.query.collection) { + const userId = req.query.user || req.user?.id; + if (req.query.collection === "default" && !userId) { + return res.status(401).send({}); + } + const collectionId = + req.query.collection === "default" + ? (await getDefaultCollection(db, userId)).id + : req.query.collection; + where.push( + sql`EXISTS(SELECT 1 FROM ${collectionBottles} WHERE ${collectionBottles.bottleId} = ${bottles.id} AND ${collectionBottles.collectionId} = ${collectionId})`, + ); + } let orderBy: SQL; switch (req.query.sort) { @@ -127,6 +148,8 @@ export default { brand?: number; distiller?: number; entity?: number; + collection?: number | "default"; + user?: number; sort?: "name"; }; } diff --git a/apps/api/src/routes/listCollections.ts b/apps/api/src/routes/listCollections.ts index 235d5a1e..55ce8f7c 100644 --- a/apps/api/src/routes/listCollections.ts +++ b/apps/api/src/routes/listCollections.ts @@ -1,11 +1,11 @@ import { CollectionSchema, PaginatedSchema } from "@peated/shared/schemas"; -import { and, asc, eq } from "drizzle-orm"; +import { and, asc, eq, 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 { collections } from "../db/schema"; +import { collectionBottles, collections } from "../db/schema"; import { buildPageLink } from "../lib/paging"; import { serialize } from "../lib/serializers"; import { CollectionSerializer } from "../lib/serializers/collection"; @@ -20,6 +20,7 @@ export default { properties: { page: { type: "number" }, user: { oneOf: [{ type: "number" }, { const: "me" }] }, + bottle: { type: "number" }, }, }, response: { @@ -46,6 +47,12 @@ export default { ); } + if (req.query.bottle) { + where.push( + sql`EXISTS(SELECT 1 FROM ${collectionBottles} WHERE ${collectionBottles.bottleId} = ${req.query.bottle} AND ${collectionBottles.collectionId} = ${collections.id})`, + ); + } + const results = await db .select() .from(collections) @@ -81,6 +88,7 @@ export default { { Querystring: { user: number | "me"; + bottle: number; page?: number; }; } diff --git a/apps/web/src/components/appHeader.tsx b/apps/web/src/components/appHeader.tsx index 68c296bf..55e474f7 100644 --- a/apps/web/src/components/appHeader.tsx +++ b/apps/web/src/components/appHeader.tsx @@ -46,7 +46,7 @@ export default function AppHeader() { onChange={(e) => setQuery(e.target.value)} placeholder="Search for anything" autoComplete="off" - className="w-full transform rounded bg-slate-900 px-2 py-1.5 text-white placeholder:text-slate-600 focus:outline focus:outline-slate-700 sm:px-3 sm:py-2" + className="w-full transform rounded bg-slate-900 px-2 py-1.5 text-white placeholder:text-slate-500 focus:outline focus:outline-slate-700 sm:px-3 sm:py-2" /> {user && ( diff --git a/apps/web/src/components/bottleForm.tsx b/apps/web/src/components/bottleForm.tsx index 7acfce09..868982d3 100644 --- a/apps/web/src/components/bottleForm.tsx +++ b/apps/web/src/components/bottleForm.tsx @@ -121,7 +121,7 @@ export default ({ label="Bottle" required helpText="The full name of the bottle, excluding its specific cask information." - placeholder="e.g. Macallan 12" + placeholder="e.g. 12-year-old" /> ( diff --git a/apps/web/src/components/searchHeader.tsx b/apps/web/src/components/searchHeader.tsx index cc0439ab..679a846c 100644 --- a/apps/web/src/components/searchHeader.tsx +++ b/apps/web/src/components/searchHeader.tsx @@ -55,7 +55,7 @@ export default function SearchHeader({ setValue(e.target.value); if (onChange) onChange(e.target.value); }} - className="w-full transform rounded bg-slate-900 px-2 py-1.5 text-white placeholder:text-slate-700 focus:outline focus:outline-slate-700 sm:px-3 sm:py-2" + className="w-full transform rounded bg-slate-900 px-2 py-1.5 text-white placeholder:text-slate-500 focus:outline focus:outline-slate-700 sm:px-3 sm:py-2" /> {onDone && ( diff --git a/apps/web/src/components/tastingListItem.tsx b/apps/web/src/components/tastingListItem.tsx index d44c3d32..f5fdc192 100644 --- a/apps/web/src/components/tastingListItem.tsx +++ b/apps/web/src/components/tastingListItem.tsx @@ -57,7 +57,9 @@ export default ({
- + {tasting.rating && ( + + )}
diff --git a/apps/web/src/components/textArea.tsx b/apps/web/src/components/textArea.tsx index ac3fc42a..c1c53aab 100644 --- a/apps/web/src/components/textArea.tsx +++ b/apps/web/src/components/textArea.tsx @@ -5,7 +5,7 @@ export default forwardRef< React.ComponentPropsWithoutRef<"textarea"> >((props, ref) => { const baseStyles = "bg-inherit rounded border-0 focus:ring-0"; - const inputStyles = "placeholder:text-slate-700 sm:leading-6"; + const inputStyles = "placeholder:text-slate-500 sm:leading-6"; return (