From bbf8271e0a137c8123a3ea73dddffdb06ba0b62e Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 24 May 2023 11:00:51 -0700 Subject: [PATCH] Add bottler to forms and APIs --- apps/api/migrations/0021_lush_redwing.sql | 6 + apps/api/migrations/meta/0021_snapshot.json | 1122 +++++++++++++++++ apps/api/migrations/meta/_journal.json | 7 + apps/api/src/db/schema.ts | 7 + apps/api/src/lib/db.ts | 11 +- apps/api/src/lib/serializers/bottle.ts | 18 +- apps/api/src/routes/addBottle.ts | 47 +- apps/api/src/routes/listBottles.test.ts | 24 + apps/api/src/routes/listBottles.ts | 6 + apps/api/src/routes/updateBottle.ts | 61 +- apps/scraper/.gitignore | 2 + apps/scraper/package.json | 2 +- apps/scraper/src/import.ts | 8 + apps/scraper/src/main.ts | 102 +- apps/web/src/components/bottleForm.tsx | 24 + apps/web/src/components/selectField/index.tsx | 2 +- apps/web/src/routes/addBottle.tsx | 14 +- apps/web/src/types.ts | 1 + package.json | 1 + packages/shared/schemas.ts | 10 +- 20 files changed, 1390 insertions(+), 85 deletions(-) create mode 100644 apps/api/migrations/0021_lush_redwing.sql create mode 100644 apps/api/migrations/meta/0021_snapshot.json diff --git a/apps/api/migrations/0021_lush_redwing.sql b/apps/api/migrations/0021_lush_redwing.sql new file mode 100644 index 00000000..dfa18308 --- /dev/null +++ b/apps/api/migrations/0021_lush_redwing.sql @@ -0,0 +1,6 @@ +ALTER TABLE "bottle" ADD COLUMN "bottler_id" bigint; +DO $$ BEGIN + ALTER TABLE "bottle" ADD CONSTRAINT "bottle_bottler_id_entity_id_fk" FOREIGN KEY ("bottler_id") REFERENCES "entity"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/apps/api/migrations/meta/0021_snapshot.json b/apps/api/migrations/meta/0021_snapshot.json new file mode 100644 index 00000000..98e1f3ef --- /dev/null +++ b/apps/api/migrations/meta/0021_snapshot.json @@ -0,0 +1,1122 @@ +{ + "version": "5", + "dialect": "pg", + "id": "a8aea8ab-2773-4bf9-8806-2a5933bd4eb9", + "prevId": "6baa394f-8ebd-4aaf-9a4d-f1e96266988f", + "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 + }, + "bottler_id": { + "name": "bottler_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "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_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 + }, + "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 + }, + "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": "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 + }, + "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 + }, + "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", + "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 7d8606e6..16d64a3c 100644 --- a/apps/api/migrations/meta/_journal.json +++ b/apps/api/migrations/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1684872883926, "tag": "0020_handy_inhumans", "breakpoints": false + }, + { + "idx": 21, + "version": "5", + "when": 1684947618684, + "tag": "0021_lush_redwing", + "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 f3455e6f..cf76f4bf 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -191,6 +191,9 @@ export const bottles = pgTable( brandId: bigint("brand_id", { mode: "number" }) .references(() => entities.id) .notNull(), + bottlerId: bigint("bottler_id", { mode: "number" }).references( + () => entities.id, + ), statedAge: smallint("stated_age"), totalTastings: bigint("total_tastings", { mode: "number" }) @@ -217,6 +220,10 @@ export const bottlesRelations = relations(bottles, ({ one, many }) => ({ fields: [bottles.brandId], references: [entities.id], }), + bottler: one(entities, { + fields: [bottles.bottlerId], + references: [entities.id], + }), bottlesToDistillers: many(bottlesToDistillers), createdBy: one(users, { fields: [bottles.createdById], diff --git a/apps/api/src/lib/db.ts b/apps/api/src/lib/db.ts index ff1ceb87..97527d05 100644 --- a/apps/api/src/lib/db.ts +++ b/apps/api/src/lib/db.ts @@ -1,8 +1,8 @@ import { eq } from "drizzle-orm"; import { DatabaseType, TransactionType } from "../db"; import { + Entity, EntityType, - NewEntity, changes, collections, entities, @@ -18,15 +18,10 @@ export type EntityInput = }; export type UpsertOutcome = - | { - id: number; - result?: T; - created: false; - } | { id: number; result: T; - created: true; + created: boolean; } | undefined; @@ -40,7 +35,7 @@ export const upsertEntity = async ({ data: EntityInput; userId: number; type?: EntityType; -}): Promise> => { +}): Promise> => { if (!data) return undefined; if (typeof data === "number") { diff --git a/apps/api/src/lib/serializers/bottle.ts b/apps/api/src/lib/serializers/bottle.ts index af6f2e04..54145149 100644 --- a/apps/api/src/lib/serializers/bottle.ts +++ b/apps/api/src/lib/serializers/bottle.ts @@ -1,7 +1,8 @@ import { inArray } from "drizzle-orm"; -import { Result, Serializer, serialize } from "."; +import { Serializer, serialize } from "."; import { db } from "../../db"; import { Bottle, User, bottlesToDistillers, entities } from "../../db/schema"; +import { notEmpty } from "../filter"; import { EntitySerializer } from "./entity"; export const BottleSerializer: Serializer = { @@ -14,10 +15,13 @@ export const BottleSerializer: Serializer = { .where(inArray(bottlesToDistillers.bottleId, itemIds)); const entityIds = Array.from( - new Set([ - ...itemList.map((i) => i.brandId), - ...distillerList.map((d) => d.distillerId), - ]), + new Set( + [ + ...itemList.map((i) => i.brandId), + ...itemList.map((i) => i.bottlerId), + ...distillerList.map((d) => d.distillerId), + ].filter(notEmpty), + ), ); const entityList = await db @@ -31,7 +35,7 @@ export const BottleSerializer: Serializer = { ); const distillersByBottleId: { - [bottleId: number]: Result; + [bottleId: number]: ReturnType<(typeof EntitySerializer)["item"]>; } = {}; distillerList.forEach((d) => { if (!distillersByBottleId[d.bottleId]) @@ -46,6 +50,7 @@ export const BottleSerializer: Serializer = { { brand: entitiesById[item.brandId], distillers: distillersByBottleId[item.id] || [], + bottler: item.bottlerId ? entitiesById[item.bottlerId] : null, }, ]; }), @@ -60,6 +65,7 @@ export const BottleSerializer: Serializer = { category: item.category, brand: attrs.brand, distillers: attrs.distillers, + bottler: attrs.bottler, }; }, }; diff --git a/apps/api/src/routes/addBottle.ts b/apps/api/src/routes/addBottle.ts index aafe7e43..9c2cbf2e 100644 --- a/apps/api/src/routes/addBottle.ts +++ b/apps/api/src/routes/addBottle.ts @@ -1,5 +1,5 @@ import { BottleInputSchema, BottleSchema } from "@peated/shared/schemas"; -import { eq, inArray, sql } from "drizzle-orm"; +import { inArray, sql } from "drizzle-orm"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; import { z } from "zod"; @@ -7,6 +7,7 @@ import zodToJsonSchema from "zod-to-json-schema"; import { db } from "../db"; import { Bottle, + Entity, bottles, bottlesToDistillers, changes, @@ -52,33 +53,34 @@ export default { } const bottle: Bottle | undefined = await db.transaction(async (tx) => { - const [brand] = - typeof body.brand === "number" - ? await tx.select().from(entities).where(eq(entities.id, body.brand)) - : await tx - .insert(entities) - .values({ - name: body.brand.name, - country: body.brand.country || null, - region: body.brand.region || null, - type: ["brand"], - createdById: req.user.id, - }) - .onConflictDoNothing() - .returning(); + const brandUpsert = await upsertEntity({ + db: tx, + data: body.brand, + type: "brand", + userId: req.user.id, + }); - if (!brand) { + if (!brandUpsert) { res.status(400).send({ error: "Could not identify brand" }); return; } - if (typeof body.brand !== "number") { - await tx.insert(changes).values({ - objectType: "entity", - objectId: brand.id, - createdById: req.user.id, - data: JSON.stringify(body.brand), + const brand = brandUpsert.result; + + let bottler: Entity | null = null; + if (body.bottler) { + const bottlerUpsert = await upsertEntity({ + db: tx, + data: body.bottler, + type: "bottler", + userId: req.user.id, }); + if (bottlerUpsert) { + bottler = bottlerUpsert.result; + } else { + res.status(400).send({ error: "Could not identify bottler" }); + return; + } } let bottle: Bottle | undefined; @@ -90,6 +92,7 @@ export default { statedAge: body.statedAge || null, category: body.category || null, brandId: brand.id, + bottlerId: bottler?.id || null, createdById: req.user.id, }) .returning(); diff --git a/apps/api/src/routes/listBottles.test.ts b/apps/api/src/routes/listBottles.test.ts index 77485743..ab12ffd2 100644 --- a/apps/api/src/routes/listBottles.test.ts +++ b/apps/api/src/routes/listBottles.test.ts @@ -86,3 +86,27 @@ test("lists bottles with brand", async () => { expect(results.length).toBe(1); expect(results[0].id).toBe(bottle1.id); }); + +test("lists bottles with bottler", async () => { + const bottler = await Fixtures.Entity({ + type: ["bottler"], + }); + const bottle1 = await Fixtures.Bottle({ + name: "Delicious Wood", + bottlerId: bottler.id, + }); + await Fixtures.Bottle({ name: "Something Else" }); + + const response = await app.inject({ + method: "GET", + url: "/bottles", + query: { + bottler: `${bottler.id}`, + }, + }); + + expect(response).toRespondWith(200); + const { results } = JSON.parse(response.payload); + expect(results.length).toBe(1); + expect(results[0].id).toBe(bottle1.id); +}); diff --git a/apps/api/src/routes/listBottles.ts b/apps/api/src/routes/listBottles.ts index 64078d86..b94687bd 100644 --- a/apps/api/src/routes/listBottles.ts +++ b/apps/api/src/routes/listBottles.ts @@ -22,6 +22,7 @@ export default { sort: { type: "string" }, brand: { type: "number" }, distiller: { type: "number" }, + bottler: { type: "number" }, entity: { type: "number" }, category: { type: "string", @@ -82,10 +83,14 @@ export default { sql`EXISTS(SELECT 1 FROM ${bottlesToDistillers} WHERE ${bottlesToDistillers.distillerId} = ${req.query.distiller} AND ${bottlesToDistillers.bottleId} = ${bottles.id})`, ); } + if (req.query.bottler) { + where.push(eq(bottles.bottlerId, req.query.bottler)); + } if (req.query.entity) { where.push( or( eq(bottles.brandId, req.query.entity), + eq(bottles.bottlerId, req.query.entity), sql`EXISTS(SELECT 1 FROM ${bottlesToDistillers} WHERE ${bottlesToDistillers.distillerId} = ${req.query.entity} AND ${bottlesToDistillers.bottleId} = ${bottles.id})`, ), ); @@ -144,6 +149,7 @@ export default { page?: number; brand?: number; distiller?: number; + bottler?: number; entity?: number; category?: Category; age?: number; diff --git a/apps/api/src/routes/updateBottle.ts b/apps/api/src/routes/updateBottle.ts index 9460532c..cc376372 100644 --- a/apps/api/src/routes/updateBottle.ts +++ b/apps/api/src/routes/updateBottle.ts @@ -35,15 +35,18 @@ export default { }, preHandler: [requireMod], handler: async (req, res) => { - const [{ bottle, brand }] = await db - .select({ - bottle: bottles, - brand: entities, - }) - .from(bottles) - .innerJoin(entities, eq(entities.id, bottles.brandId)) - .where(eq(bottles.id, req.params.bottleId)); - + const bottle = await db.query.bottles.findFirst({ + where: (bottles, { eq }) => eq(entities.id, req.params.bottleId), + with: { + brand: true, + bottler: true, + bottlesToDistillers: { + with: { + distiller: true, + }, + }, + }, + }); if (!bottle) { return res.status(404).send({ error: "Not found" }); } @@ -61,19 +64,6 @@ export default { bottleData.statedAge = body.statedAge; } - const currentDistillers = ( - await db - .select({ - distiller: entities, - }) - .from(entities) - .innerJoin( - bottlesToDistillers, - eq(bottlesToDistillers.distillerId, entities.id), - ) - .where(eq(bottlesToDistillers.bottleId, bottle.id)) - ).map(({ distiller }) => distiller); - const newBottle = await db.transaction(async (tx) => { let newBottle: Bottle | undefined; try { @@ -99,8 +89,8 @@ export default { if (body.brand) { if ( typeof body.brand === "number" - ? body.brand !== bottle.brandId - : body.brand.name !== brand.name + ? body.brand !== bottle.brand.id + : body.brand.name !== bottle.brand.name ) { const brandUpsert = await upsertEntity({ db: tx, @@ -116,8 +106,31 @@ export default { } } + if (body.bottler) { + if ( + typeof body.bottler === "number" + ? body.bottler !== bottle.bottler?.id + : body.bottler.name !== bottle.bottler?.name + ) { + const bottlerUpsert = await upsertEntity({ + db: tx, + data: body.bottler, + userId: req.user.id, + type: "bottler", + }); + if (!bottlerUpsert) + throw new Error(`Unable to find entity: ${body.bottler}`); + if (bottlerUpsert.id !== bottle.bottlerId) { + bottleData.bottlerId = bottlerUpsert.id; + } + } + } + const distillerIds: number[] = []; const newDistillerIds: number[] = []; + const currentDistillers = bottle.bottlesToDistillers.map( + (d) => d.distiller, + ); // find newly added distillers and connect them if (body.distillers) { diff --git a/apps/scraper/.gitignore b/apps/scraper/.gitignore index 6f70b3fd..efcee38e 100644 --- a/apps/scraper/.gitignore +++ b/apps/scraper/.gitignore @@ -2,3 +2,5 @@ distillers.json brands.json +bottlers.json +bottles.json diff --git a/apps/scraper/package.json b/apps/scraper/package.json index b11a0fa9..aad53993 100644 --- a/apps/scraper/package.json +++ b/apps/scraper/package.json @@ -3,7 +3,7 @@ "name": "@peated/scraper", "scripts": { "build": "tsc", - "start": "ts-node --require dotenv/config ./src/main.ts", + "scraper": "ts-node --require dotenv/config ./src/main.ts", "import": "ts-node --require dotenv/config ./src/import.ts", "logos": "ts-node --require dotenv/config ./src/logos.ts" }, diff --git a/apps/scraper/src/import.ts b/apps/scraper/src/import.ts index 855d6445..23c9e931 100644 --- a/apps/scraper/src/import.ts +++ b/apps/scraper/src/import.ts @@ -28,9 +28,17 @@ const importDistillers = async () => { }); }; +const importBottlers = async () => { + await importJson("bottlers.json", async (row) => { + console.log(row.name); + await submitEntity({ ...row, type: ["bottler"] }); + }); +}; + async function main() { await importBrands(); await importDistillers(); + await importBottlers(); } main(); diff --git a/apps/scraper/src/main.ts b/apps/scraper/src/main.ts index 154a40b5..bf91f74d 100644 --- a/apps/scraper/src/main.ts +++ b/apps/scraper/src/main.ts @@ -31,6 +31,8 @@ async function scrapeWhisky(id: number) { const maybeRegion = $("ul.breadcrumb > li:nth-child(3)").text(); const region = maybeRegion !== brandName ? maybeRegion : null; + bottle.votes = parseInt($(".votes-count").text(), 10); + bottle.brand = { name: brandName, country: $("ul.breadcrumb > li:nth-child(2)").text(), @@ -46,16 +48,16 @@ async function scrapeWhisky(id: number) { region, }; - // bottle.bottler = { - // name: $("dt:contains('Bottler') + dd").text(), - // }; - // bottle.series = $("dt:contains('Bottling serie') + dd").text(); + bottle.bottler = { + name: $("dt:contains('Bottler') + dd").text(), + }; + bottle.series = $("dt:contains('Bottling serie') + dd").text(); - // bottle.vintageYear = parseYear($("dt:contains('Vintage') + dd").text()); - // bottle.bottleYear = parseYear($("dt:contains('Bottled') + dd").text()); + bottle.vintageYear = parseYear($("dt:contains('Vintage') + dd").text()); + bottle.bottleYear = parseYear($("dt:contains('Bottled') + dd").text()); - // bottle.caskType = $("dt:contains('Casktype') + dd").text(); - // bottle.caskNumber = $("dt:contains('Casknumber') + dd").text(); + bottle.caskType = $("dt:contains('Casktype') + dd").text(); + bottle.caskNumber = $("dt:contains('Casknumber') + dd").text(); bottle.abv = parseAbv($("dt:contains('Strength') + dd").text()); @@ -115,6 +117,31 @@ async function scrapeBrand(id: number) { return result; } +// e.g. https://www.whiskybase.com/whiskies/bottlers/2 +async function scrapeBottler(id: number) { + console.log(`Processing Bottler ${id}`); + + const data = await getUrl( + `https://www.whiskybase.com/whiskies/bottler/${id}/about`, + ); + + const $ = cheerio(data); + + const maybeRegion = $("ul.breadcrumb > li:last-child").text(); + const country = $("ul.breadcrumb > li:first-child").text(); + + const result = { + name: $("#company-name > h1").text(), + country, + region: maybeRegion !== country ? maybeRegion : null, + }; + + console.log( + `[Bottler ${id}] Identified as ${result.name} (${result.country} - ${result.region})`, + ); + return result; +} + function parseName(brandName: string, bottleName: string) { const bottleNameWithoutAge = bottleName.split("-year-old")[0]; if (bottleNameWithoutAge !== bottleName) { @@ -169,8 +196,8 @@ async function scrapeTable( async function scrapeDistillers() { const tableUrl = - "https://www.whiskybase.com/whiskies/distilleries?style=table&search=null&chr=null&country_id=®ion_id=&wbRanking=&sort=companies.name&direction=asc&h=companies.country,companies.whiskies,style"; - const distillerList: any[] = []; + "https://www.whiskybase.com/whiskies/distilleries?style=table&search=null&chr=null&wbRanking=&sort=companies.name&direction=asc&h=companies.country,companies.whiskies,style"; + const results: any[] = []; await scrapeTable(tableUrl, async (url, totalBottles) => { if (totalBottles < 20) { console.warn(`Discarding ${url} - too few bottles`); @@ -180,17 +207,17 @@ async function scrapeDistillers() { if (!match) return; const id = parseInt(match[1], 10); const result = await scrapeDistiller(id); - if (result) distillerList.push(result); + if (result) results.push(result); }); - console.log(`Found ${distillerList.length} distillers`); - saveResults("distillers.json", distillerList); + console.log(`Found ${results.length} distillers`); + saveResults("distillers.json", results); } async function scrapeBrands() { const tableUrl = - "https://www.whiskybase.com/whiskies/brands?style=table&search=null&chr=null&country_id=®ion_id=&wbRanking=&sort=companies.name&direction=asc&h=companies.country,companies.whiskies,style"; - const distillerList: any[] = []; + "https://www.whiskybase.com/whiskies/brands?style=table&search=null&chr=null&wbRanking=&sort=companies.name&direction=asc&h=companies.country,companies.whiskies,style"; + const results: any[] = []; await scrapeTable(tableUrl, async (url, totalBottles) => { if (totalBottles < 5) { console.warn(`Discarding ${url} - too few bottles`); @@ -201,11 +228,49 @@ async function scrapeBrands() { if (!match) return; const id = parseInt(match[1], 10); const result = await scrapeBrand(id); - if (result) distillerList.push(result); + if (result) results.push(result); + }); + + console.log(`Found ${results.length} brands`); + saveResults("brands.json", results); +} + +async function scrapeBottlers() { + const tableUrl = + "https://www.whiskybase.com/whiskies/bottlers?search=null&chr=null&country_id=®ion_id=&wbRanking=&sort=companies.country,companies.whiskies&direction=desc"; + const results: any[] = []; + await scrapeTable(tableUrl, async (url, totalBottles) => { + if (totalBottles < 5) { + console.warn(`Discarding ${url} - too few bottles`); + return; + } + + const match = url.match(/\/bottler\/(\d+)\//); + if (!match) return; + const id = parseInt(match[1], 10); + const result = await scrapeBottler(id); + if (result) results.push(result); + }); + + console.log(`Found ${results.length} bottlers`); + saveResults("bottlers.json", results); +} + +async function scrapeBottles() { + const year = 2023; + const tableUrl = `https://www.whiskybase.com/whiskies/new-releases?bottle_date_year=${year}&sort=whisky.name&direction=asc`; + const results: any[] = []; + await scrapeTable(tableUrl, async (url) => { + const match = url.match(/\/bottler\/(\d+)\//); + if (!match) return; + const id = parseInt(match[1], 10); + console.log(id); + // const result = await scrapeBottler(id); + // if (result) results.push(result); }); - console.log(`Found ${distillerList.length} brands`); - saveResults("brands.json", distillerList); + console.log(`Found ${results.length} bottlers`); + saveResults("bottlers.json", results); } async function saveResults(filename: string, results: any) { @@ -221,6 +286,7 @@ function sleep(ms: number) { async function main() { await scrapeDistillers(); await scrapeBrands(); + await scrapeBottlers(); } main(); diff --git a/apps/web/src/components/bottleForm.tsx b/apps/web/src/components/bottleForm.tsx index 4ca5bcb1..6db7d653 100644 --- a/apps/web/src/components/bottleForm.tsx +++ b/apps/web/src/components/bottleForm.tsx @@ -64,6 +64,7 @@ export default ({ defaultValues: { name: initialData.name, category: initialData.category, + bottler: initialData.bottler?.id, brand: initialData.brand?.id, distillers: initialData.distillers ? initialData.distillers.map((d) => d.id) @@ -93,6 +94,9 @@ export default ({ const [distillersValue, setDistillersValue] = useState( initialData.distillers ? initialData.distillers.map(entityToOption) : [], ); + const [bottlerValue, setBottlerValue] = useState