From 81db81b9e8a9e5e67d3ea81cac9aebc8a362eaf0 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 22 May 2023 10:41:12 -0700 Subject: [PATCH] feat: Overhaul vintages - Migrate back to denormalized schema to simplify db - Rename edition to series - Add optional vintage information to collections --- .github/workflows/ci.yml | 4 +- apps/api/migrations/0019_lucky_wild_child.sql | 16 + apps/api/migrations/meta/0019_snapshot.json | 1096 +++++++++++++++++ apps/api/migrations/meta/_journal.json | 7 + apps/api/src/db/schema.ts | 58 +- apps/api/src/lib/hash.ts | 11 + apps/api/src/lib/notifications.ts | 3 - apps/api/src/lib/serializers/collection.ts | 1 + .../src/lib/serializers/collectionBottle.ts | 50 + apps/api/src/lib/serializers/edition.ts | 15 - apps/api/src/lib/serializers/tasting.ts | 18 +- apps/api/src/lib/test/fixtures.ts | 11 +- apps/api/src/routes/addCollectionBottle.ts | 65 +- apps/api/src/routes/addTasting.test.ts | 19 +- apps/api/src/routes/addTasting.ts | 71 +- apps/api/src/routes/index.ts | 6 +- apps/api/src/routes/listBottleEditions.ts | 90 -- apps/api/src/routes/listBottles.ts | 25 +- .../src/routes/listUserCollectionBottles.ts | 128 ++ .../src/components/addToCollectionModal.tsx | 118 ++ apps/web/src/components/bottleForm.tsx | 7 +- apps/web/src/components/bottleTable.tsx | 22 +- apps/web/src/components/entityField.tsx | 93 +- apps/web/src/components/fieldset.tsx | 20 +- .../selectField/createOptionDialog.tsx | 3 +- .../components/selectField/selectDialog.tsx | 6 +- apps/web/src/components/vintageName.tsx | 20 + apps/web/src/components/vintageTable.tsx | 69 -- apps/web/src/lib/log.ts | 18 + apps/web/src/lib/strings.tsx | 9 +- apps/web/src/main.tsx | 1 + apps/web/src/routes.tsx | 5 - apps/web/src/routes/addBottle.tsx | 5 +- apps/web/src/routes/addTasting.tsx | 8 +- apps/web/src/routes/bottleDetails.tsx | 151 ++- apps/web/src/routes/bottleVintages.tsx | 27 - apps/web/src/routes/editEntity.tsx | 2 +- apps/web/src/routes/profileCollections.tsx | 7 +- apps/web/src/routes/search.tsx | 10 +- apps/web/src/types.ts | 32 +- packages/shared/lib/strings.ts | 7 + packages/shared/package.json | 1 + packages/shared/schemas.ts | 93 +- 43 files changed, 1792 insertions(+), 636 deletions(-) create mode 100644 apps/api/migrations/0019_lucky_wild_child.sql create mode 100644 apps/api/migrations/meta/0019_snapshot.json create mode 100644 apps/api/src/lib/hash.ts create mode 100644 apps/api/src/lib/serializers/collectionBottle.ts delete mode 100644 apps/api/src/lib/serializers/edition.ts delete mode 100644 apps/api/src/routes/listBottleEditions.ts create mode 100644 apps/api/src/routes/listUserCollectionBottles.ts create mode 100644 apps/web/src/components/addToCollectionModal.tsx create mode 100644 apps/web/src/components/vintageName.tsx delete mode 100644 apps/web/src/components/vintageTable.tsx create mode 100644 apps/web/src/lib/log.ts delete mode 100644 apps/web/src/routes/bottleVintages.tsx create mode 100644 packages/shared/lib/strings.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2496023..d783da67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,11 @@ -name: CI +name: Test on: push: branches: - main pull_request: jobs: - main: + test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 diff --git a/apps/api/migrations/0019_lucky_wild_child.sql b/apps/api/migrations/0019_lucky_wild_child.sql new file mode 100644 index 00000000..21b365e5 --- /dev/null +++ b/apps/api/migrations/0019_lucky_wild_child.sql @@ -0,0 +1,16 @@ +ALTER TABLE "collection_bottle" ADD COLUMN "vintage_fingerprint" varchar(128); +ALTER TABLE "collection_bottle" ADD COLUMN "series" varchar(255); +ALTER TABLE "collection_bottle" ADD COLUMN "vintage_year" smallint; +ALTER TABLE "collection_bottle" ADD COLUMN "barrel" smallint; +ALTER TABLE "collection" ADD COLUMN "total_bottles" bigint DEFAULT 0 NOT NULL; +ALTER TABLE "tasting" ADD COLUMN "series" varchar(255); +ALTER TABLE "tasting" ADD COLUMN "vintage_year" smallint; +ALTER TABLE "tasting" ADD COLUMN "barrel" smallint; +ALTER TABLE "collection_bottle" DROP CONSTRAINT "collection_bottle_edition_id_edition_id_fk"; + +ALTER TABLE "tasting" DROP CONSTRAINT "tasting_edition_id_edition_id_fk"; + +ALTER TABLE "collection_bottle" DROP COLUMN IF EXISTS "edition_id"; +ALTER TABLE "tasting" DROP COLUMN IF EXISTS "edition_id"; +DROP TABLE edition; +UPDATE "collection" SET "total_bottles" = (SELECT COUNT(*) FROM "collection_bottle" WHERE "collection_bottle"."collection_id" = "collection"."id"); diff --git a/apps/api/migrations/meta/0019_snapshot.json b/apps/api/migrations/meta/0019_snapshot.json new file mode 100644 index 00000000..ff9c4e3a --- /dev/null +++ b/apps/api/migrations/meta/0019_snapshot.json @@ -0,0 +1,1096 @@ +{ + "version": "5", + "dialect": "pg", + "id": "2ae31938-e91e-47d6-a5f9-89705babb9f8", + "prevId": "26a82438-3b08-4efb-8859-2cbdcc0b31f6", + "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 + }, + "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 + } + }, + "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 59ba7ca4..b7fd4dbc 100644 --- a/apps/api/migrations/meta/_journal.json +++ b/apps/api/migrations/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1684625241433, "tag": "0018_swift_amazoness", "breakpoints": false + }, + { + "idx": 19, + "version": "5", + "when": 1684773803115, + "tag": "0019_lucky_wild_child", + "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 468c2758..98512cd7 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -208,45 +208,14 @@ export const bottlesToDistillers = pgTable( }, ); -export const editions = pgTable( - "edition", - { - id: bigserial("id", { mode: "number" }).primaryKey(), - - // one of these should be set -- enforced at app level - name: varchar("name", { length: 255 }), - barrel: smallint("barrel"), - vintageYear: smallint("vintage_year"), - - bottleId: bigint("bottle_id", { mode: "number" }) - .references(() => bottles.id) - .notNull(), - - createdAt: timestamp("created_at").defaultNow().notNull(), - createdById: bigint("created_by_id", { mode: "number" }) - .references(() => users.id) - .notNull(), - }, - (editions) => { - return { - editionIndex: uniqueIndex("edition_unq").on( - editions.bottleId, - editions.name, - editions.barrel, - editions.vintageYear, - ), - }; - }, -); - -export type Edition = InferModel; -export type NewEdition = InferModel; - export const collections = pgTable( "collection", { id: bigserial("id", { mode: "number" }).primaryKey(), name: varchar("name", { length: 255 }).notNull(), + totalBottles: bigint("total_bottles", { mode: "number" }) + .default(0) + .notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), createdById: bigint("created_by_id", { mode: "number" }) .references(() => users.id) @@ -275,16 +244,18 @@ export const collectionBottles = pgTable( bottleId: bigint("bottle_id", { mode: "number" }) .references(() => bottles.id) .notNull(), - editionId: bigint("edition_id", { mode: "number" }).references( - () => editions.id, - ), + + vintageFingerprint: varchar("vintage_fingerprint", { length: 128 }), + series: varchar("series", { length: 255 }), + vintageYear: smallint("vintage_year"), + barrel: smallint("barrel"), }, (collectionBottles) => { return { collectionDistillerId: uniqueIndex("collection_bottle_unq").on( collectionBottles.collectionId, collectionBottles.bottleId, - collectionBottles.editionId, + collectionBottles.vintageFingerprint, ), }; }, @@ -304,16 +275,15 @@ export const tastings = pgTable( bottleId: bigint("bottle_id", { mode: "number" }) .references(() => bottles.id) .notNull(), - editionId: bigint("edition_id", { mode: "number" }).references( - () => editions.id, - ), - tags: text("tags").array(), rating: doublePrecision("rating"), imageUrl: text("image_url"), - notes: text("notes"), + series: varchar("series", { length: 255 }), + vintageYear: smallint("vintage_year"), + barrel: smallint("barrel"), + comments: integer("comments").default(0).notNull(), toasts: integer("toasts").default(0).notNull(), @@ -392,7 +362,6 @@ export type NewComment = InferModel; export type ObjectType = | "bottle" | "comment" - | "edition" | "entity" | "tasting" | "toast" @@ -401,7 +370,6 @@ export type ObjectType = export const objectTypeEnum = pgEnum("object_type", [ "bottle", "comment", - "edition", "entity", "tasting", "toast", diff --git a/apps/api/src/lib/hash.ts b/apps/api/src/lib/hash.ts new file mode 100644 index 00000000..392ef7c7 --- /dev/null +++ b/apps/api/src/lib/hash.ts @@ -0,0 +1,11 @@ +import { createHash } from "crypto"; + +import { notEmpty } from "./filter"; + +export function sha1(...value: (string | number | null | undefined)[]) { + const sum = createHash("sha1"); + for (const v of value) { + sum.update(`${notEmpty(v) ? v : ""}`); + } + return sum.digest("hex"); +} diff --git a/apps/api/src/lib/notifications.ts b/apps/api/src/lib/notifications.ts index 0adda344..069a3a73 100644 --- a/apps/api/src/lib/notifications.ts +++ b/apps/api/src/lib/notifications.ts @@ -6,7 +6,6 @@ import { Notification, bottles, comments, - editions, entities, follows, notifications, @@ -20,8 +19,6 @@ export const objectTypeFromSchema = (schema: AnyPgTable) => { return "bottle"; case comments: return "comment"; - case editions: - return "edition"; case entities: return "entity"; case follows: diff --git a/apps/api/src/lib/serializers/collection.ts b/apps/api/src/lib/serializers/collection.ts index 93e1c89e..c9e0b518 100644 --- a/apps/api/src/lib/serializers/collection.ts +++ b/apps/api/src/lib/serializers/collection.ts @@ -7,6 +7,7 @@ export const CollectionSerializer: Serializer = { return { id: item.id, name: item.name, + totalBottles: item.totalBottles, createdAt: item.createdAt, }; }, diff --git a/apps/api/src/lib/serializers/collectionBottle.ts b/apps/api/src/lib/serializers/collectionBottle.ts new file mode 100644 index 00000000..e3c2be08 --- /dev/null +++ b/apps/api/src/lib/serializers/collectionBottle.ts @@ -0,0 +1,50 @@ +import { Bottle, CollectionBottle, User } from "../../db/schema"; + +import { Serializer, serialize } from "."; +import { BottleSerializer } from "./bottle"; + +export const CollectionBottleSerializer: Serializer< + CollectionBottle & { + bottle: Bottle; + } +> = { + attrs: async ( + itemList: (CollectionBottle & { + bottle: Bottle; + })[], + currentUser?: User, + ) => { + const bottleList = itemList.map((i) => i.bottle); + const bottlesById = Object.fromEntries( + (await serialize(BottleSerializer, bottleList, currentUser)).map( + (data, index) => [bottleList[index].id, data], + ), + ); + + return Object.fromEntries( + itemList.map((item) => { + return [ + item.id, + { + bottle: bottlesById[item.bottleId], + }, + ]; + }), + ); + }, + item: ( + item: CollectionBottle & { + bottle: Bottle; + }, + attrs: Record, + currentUser?: User, + ) => { + return { + id: item.id, + series: item.series, + vintageYear: item.vintageYear, + barrel: item.barrel, + bottle: attrs.bottle, + }; + }, +}; diff --git a/apps/api/src/lib/serializers/edition.ts b/apps/api/src/lib/serializers/edition.ts deleted file mode 100644 index fab8b104..00000000 --- a/apps/api/src/lib/serializers/edition.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Edition, User } from "../../db/schema"; - -import { Serializer } from "."; - -export const EditionSerializer: Serializer = { - item: (item: Edition, attrs: Record, currentUser?: User) => { - return { - id: item.id, - name: item.name, - barrel: item.barrel, - vintageYear: item.vintageYear, - createdAt: item.createdAt, - }; - }, -}; diff --git a/apps/api/src/lib/serializers/tasting.ts b/apps/api/src/lib/serializers/tasting.ts index e6045627..688f05ab 100644 --- a/apps/api/src/lib/serializers/tasting.ts +++ b/apps/api/src/lib/serializers/tasting.ts @@ -6,14 +6,11 @@ import { Tasting, User, bottles, - editions, tastings, toasts, users, } from "../../db/schema"; -import { notEmpty } from "../filter"; import { BottleSerializer } from "./bottle"; -import { EditionSerializer } from "./edition"; import { UserSerializer } from "./user"; export const TastingSerializer: Serializer = { @@ -24,12 +21,10 @@ export const TastingSerializer: Serializer = { id: tastings.id, bottle: bottles, createdBy: users, - edition: editions, }) .from(tastings) .innerJoin(users, eq(tastings.createdById, users.id)) .innerJoin(bottles, eq(tastings.bottleId, bottles.id)) - .leftJoin(editions, eq(tastings.editionId, editions.id)) .where(inArray(tastings.id, itemIds)); const userToastsList: number[] = currentUser @@ -66,20 +61,12 @@ export const TastingSerializer: Serializer = { ).map((data, index) => [results[index].id, data]), ); - const editionList = results.map((r) => r.edition).filter(notEmpty); - const editionsById = Object.fromEntries( - (await serialize(EditionSerializer, editionList, currentUser)).map( - (data, index) => [editionList[index].id, data], - ), - ); - return Object.fromEntries( itemList.map((item) => { return [ item.id, { hasToasted: userToastsList.indexOf(item.id) !== -1, - edition: item.editionId ? editionsById[item.editionId] : null, createdBy: usersByRef[item.id] || null, bottle: bottlesByRef[item.id] || null, }, @@ -95,6 +82,10 @@ export const TastingSerializer: Serializer = { notes: item.notes, tags: item.tags || [], rating: item.rating, + series: item.series, + vintageYear: item.vintageYear, + barrel: item.barrel, + createdAt: item.createdAt, comments: item.comments, @@ -102,7 +93,6 @@ export const TastingSerializer: Serializer = { bottle: attrs.bottle, createdBy: attrs.createdBy, - edition: attrs.edition, hasToasted: attrs.hasToasted, }; }, diff --git a/apps/api/src/lib/test/fixtures.ts b/apps/api/src/lib/test/fixtures.ts index 58cefb6a..84ea80cc 100644 --- a/apps/api/src/lib/test/fixtures.ts +++ b/apps/api/src/lib/test/fixtures.ts @@ -1,8 +1,10 @@ import { faker } from "@faker-js/faker"; import { eq } from "drizzle-orm"; - import { readFile } from "fs/promises"; import path from "path"; + +import { toTitleCase } from "@peated/shared/lib/strings"; + import { db, first } from "../../db"; import { NewBottle, @@ -87,7 +89,12 @@ export const Bottle = async ({ const [bottle] = await db .insert(bottles) .values({ - name: faker.music.songName(), + name: toTitleCase( + choose([ + faker.company.bsNoun(), + `${faker.company.bsAdjective()} ${faker.company.bsNoun()}`, + ]), + ), category: choose([ "blend", "bourbon", diff --git a/apps/api/src/routes/addCollectionBottle.ts b/apps/api/src/routes/addCollectionBottle.ts index 48452ba4..ebc5d67c 100644 --- a/apps/api/src/routes/addCollectionBottle.ts +++ b/apps/api/src/routes/addCollectionBottle.ts @@ -1,6 +1,9 @@ -import { eq, inArray } from "drizzle-orm"; +import { CollectionBottleInputSchema } from "@peated/shared/schemas"; +import { 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, first } from "../db"; import { Collection, @@ -9,6 +12,7 @@ import { collections, } from "../db/schema"; import { getDefaultCollection } from "../lib/db"; +import { sha1 } from "../lib/hash"; import { requireAuth } from "../middleware/auth"; export default { @@ -22,18 +26,7 @@ export default { collectionId: { anyOf: [{ type: "number" }, { const: "default" }] }, }, }, - body: { - type: "object", - required: ["bottle"], - properties: { - bottle: { - anyOf: [ - { type: "number" }, - { type: "array", items: { type: "number" } }, - ], - }, - }, - }, + body: zodToJsonSchema(CollectionBottleInputSchema), }, preHandler: [requireAuth], handler: async (req, res) => { @@ -57,28 +50,40 @@ export default { .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 + const [bottle] = await db .select() .from(bottles) - .where(inArray(bottles.id, bottleIds)); - if (bottleList.length !== bottleIds.length) { - // could error out here + .where(eq(bottles.id, req.body.bottle)); + if (!bottle) { + return res.status(404).send({ error: "Not found" }); } + const vintageFingerprint = sha1( + req.body.series, + req.body.vintageYear, + req.body.barrel, + ); + await db.transaction(async (tx) => { - for (const bottle of bottleList) { - await tx.insert(collectionBottles).values({ + const [cb] = await tx + .insert(collectionBottles) + .values({ collectionId: collection.id, bottleId: bottle.id, - }); + vintageFingerprint, + series: req.body.series, + vintageYear: req.body.vintageYear, + barrel: req.body.barrel, + }) + .onConflictDoNothing() + .returning(); + if (cb) { + await tx + .update(collections) + .set({ + totalBottles: sql`${collections.totalBottles} + 1`, + }) + .where(eq(collections.id, collection.id)); } }); @@ -92,8 +97,6 @@ export default { Params: { collectionId: number | "default"; }; - Body: { - bottle: number | number[]; - }; + Body: z.infer; } >; diff --git a/apps/api/src/routes/addTasting.test.ts b/apps/api/src/routes/addTasting.test.ts index 5d7c8437..9d44a792 100644 --- a/apps/api/src/routes/addTasting.test.ts +++ b/apps/api/src/routes/addTasting.test.ts @@ -2,7 +2,7 @@ import { eq } from "drizzle-orm"; import { FastifyInstance } from "fastify"; import buildFastify from "../app"; import { db } from "../db"; -import { bottles, editions, entities, tastings } from "../db/schema"; +import { bottles, entities, tastings } from "../db/schema"; import * as Fixtures from "../lib/test/fixtures"; let app: FastifyInstance; @@ -109,7 +109,7 @@ test("creates a new tasting with notes", async () => { expect(tasting.notes).toEqual("hello world"); }); -test("creates a new tasting with all edition params", async () => { +test("creates a new tasting with all vintage params", async () => { const bottle = await Fixtures.Bottle(); const response = await app.inject({ method: "POST", @@ -117,7 +117,7 @@ test("creates a new tasting with all edition params", async () => { payload: { bottle: bottle.id, rating: 3.5, - edition: "Test", + series: "Test", vintageYear: 2023, barrel: 69, }, @@ -135,16 +135,9 @@ test("creates a new tasting with all edition params", async () => { expect(tasting.bottleId).toEqual(bottle.id); expect(tasting.createdById).toEqual(DefaultFixtures.user.id); - expect(tasting.editionId).toBeDefined(); - - const [edition] = await db - .select() - .from(editions) - .where(eq(editions.id, tasting.editionId as number)); - expect(edition.bottleId).toEqual(bottle.id); - expect(edition.vintageYear).toEqual(2023); - expect(edition.barrel).toEqual(69); - expect(edition.name).toEqual("Test"); + expect(tasting.vintageYear).toEqual(2023); + expect(tasting.barrel).toEqual(69); + expect(tasting.series).toEqual("Test"); }); test("creates a new tasting with empty rating", async () => { diff --git a/apps/api/src/routes/addTasting.ts b/apps/api/src/routes/addTasting.ts index 904a860a..4b60a16a 100644 --- a/apps/api/src/routes/addTasting.ts +++ b/apps/api/src/routes/addTasting.ts @@ -1,4 +1,4 @@ -import { and, eq, inArray, isNull, sql } from "drizzle-orm"; +import { eq, inArray, sql } from "drizzle-orm"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; import { z } from "zod"; @@ -12,8 +12,6 @@ import { Tasting, bottles, bottlesToDistillers, - changes, - editions, entities, tastings, } from "../db/schema"; @@ -60,77 +58,16 @@ export default { } } - const hasEdition = body.edition || body.barrel || body.vintageYear; - const tasting = await db.transaction(async (tx) => { - const getEditionId = async (): Promise => { - if (!hasEdition) return; - - const lookupParams = [eq(editions.bottleId, bottle.id)]; - if (body.edition) { - lookupParams.push(eq(editions.name, body.edition)); - } else { - lookupParams.push(isNull(editions.name)); - } - if (body.barrel) { - lookupParams.push(eq(editions.barrel, body.barrel)); - } else { - lookupParams.push(isNull(editions.barrel)); - } - if (body.vintageYear) { - lookupParams.push(eq(editions.vintageYear, body.vintageYear)); - } else { - lookupParams.push(isNull(editions.vintageYear)); - } - - const [edition] = await tx - .select() - .from(editions) - .where(and(...lookupParams)); - if (edition) return edition.id; - - const [newEdition] = await tx - .insert(editions) - .values({ - bottleId: bottle.id, - name: body.edition || null, - vintageYear: body.vintageYear || null, - barrel: body.barrel || null, - createdById: req.user.id, - }) - .onConflictDoNothing() - .returning(); - - // race for conflicts - if (newEdition) { - await tx.insert(changes).values({ - objectType: "edition", - objectId: newEdition.id, - createdById: req.user.id, - data: JSON.stringify({ - bottleId: bottle.id, - name: body.edition, - barrel: body.barrel, - vintageYear: body.vintageYear, - }), - }); - return newEdition?.id; - } - return ( - await tx - .select() - .from(editions) - .where(and(...lookupParams)) - )[0].id; - }; - let tasting: Tasting | undefined; try { [tasting] = await tx .insert(tastings) .values({ ...data, - editionId: await getEditionId(), + series: body.series, + vintageYear: body.vintageYear, + barrel: body.barrel, }) .returning(); } catch (err: any) { diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index d901fb79..0186851b 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -19,7 +19,6 @@ 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"; @@ -29,6 +28,7 @@ import listFollowers from "./listFollowers"; import listFollowing from "./listFollowing"; import listNotifications from "./listNotifications"; import listTastings from "./listTastings"; +import listUserCollectionBottles from "./listUserCollectionBottles"; import listUsers from "./listUsers"; import updateBottle from "./updateBottle"; import updateEntity from "./updateEntity"; @@ -69,8 +69,6 @@ export const router: FastifyPluginCallback = ( fastify.route(listBottleSuggestedTags); - fastify.route(listBottleEditions); - fastify.route(listEntities); fastify.route(addEntity); fastify.route(getEntity); @@ -98,6 +96,8 @@ export const router: FastifyPluginCallback = ( fastify.route(addUserFollow); fastify.route(deleteUserFollow); + fastify.route(listUserCollectionBottles); + fastify.route(listCollections); fastify.route(addCollectionBottle); fastify.route(deleteCollectionBottle); diff --git a/apps/api/src/routes/listBottleEditions.ts b/apps/api/src/routes/listBottleEditions.ts deleted file mode 100644 index 023d086c..00000000 --- a/apps/api/src/routes/listBottleEditions.ts +++ /dev/null @@ -1,90 +0,0 @@ -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 f781f754..a98efaf1 100644 --- a/apps/api/src/routes/listBottles.ts +++ b/apps/api/src/routes/listBottles.ts @@ -5,13 +5,7 @@ import { IncomingMessage, Server, ServerResponse } from "http"; import { z } from "zod"; import zodToJsonSchema from "zod-to-json-schema"; import { db } from "../db"; -import { - bottles, - bottlesToDistillers, - collectionBottles, - entities, -} from "../db/schema"; -import { getDefaultCollection } from "../lib/db"; +import { bottles, bottlesToDistillers, entities } from "../db/schema"; import { buildPageLink } from "../lib/paging"; import { serialize } from "../lib/serializers"; import { BottleSerializer } from "../lib/serializers/bottle"; @@ -29,8 +23,6 @@ export default { brand: { type: "number" }, distiller: { type: "number" }, entity: { type: "number" }, - user: { type: "number" }, - collection: { anyOf: [{ type: "number" }, { const: "default" }] }, }, }, response: { @@ -86,19 +78,6 @@ 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) { @@ -148,8 +127,6 @@ export default { brand?: number; distiller?: number; entity?: number; - collection?: number | "default"; - user?: number; sort?: "name"; }; } diff --git a/apps/api/src/routes/listUserCollectionBottles.ts b/apps/api/src/routes/listUserCollectionBottles.ts new file mode 100644 index 00000000..021ec999 --- /dev/null +++ b/apps/api/src/routes/listUserCollectionBottles.ts @@ -0,0 +1,128 @@ +import { + CollectionBottleSchema, + PaginatedSchema, +} from "@peated/shared/schemas"; +import { SQL, 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, first } from "../db"; +import { + Collection, + bottles, + collectionBottles, + collections, + entities, +} from "../db/schema"; +import { getDefaultCollection } from "../lib/db"; +import { buildPageLink } from "../lib/paging"; +import { serialize } from "../lib/serializers"; +import { CollectionBottleSerializer } from "../lib/serializers/collectionBottle"; +import { injectAuth } from "../middleware/auth"; + +export default { + method: "GET", + url: "/users/:userId/collections/:collectionId/bottles", + schema: { + querystring: { + type: "object", + properties: { + query: { type: "string" }, + page: { type: "number" }, + }, + }, + params: { + type: "object", + properties: { + userId: { anyOf: [{ type: "number" }, { const: "me" }] }, + collectionId: { anyOf: [{ type: "number" }, { const: "default" }] }, + }, + }, + response: { + 200: zodToJsonSchema( + PaginatedSchema.extend({ + results: z.array(CollectionBottleSchema), + }), + ), + }, + }, + preHandler: [injectAuth], + handler: async (req, res) => { + const userId = req.params.userId === "me" ? req.user.id : req.params.userId; + const collection = + req.params.collectionId === "default" + ? await getDefaultCollection(db, userId) + : first( + await db + .select() + .from(collections) + .where( + and( + eq(collections.createdById, userId), + eq(collections.id, req.params.collectionId), + ), + ), + ); + + if (!collection) { + return res.status(404).send({ error: "Not found" }); + } + + const page = req.query.page || 1; + + const limit = 100; + const offset = (page - 1) * limit; + + const where: (SQL | undefined)[] = [ + eq(collectionBottles.collectionId, collection.id), + ]; + + const results = await db + .select({ collectionBottles, bottles }) + .from(collectionBottles) + .where(where ? and(...where) : undefined) + .innerJoin(bottles, eq(bottles.id, collectionBottles.bottleId)) + .innerJoin(entities, eq(entities.id, bottles.brandId)) + .limit(limit + 1) + .offset(offset) + .orderBy(asc(sql`(${entities.name} || ' ' || ${bottles.name})`)); + + res.send({ + results: await serialize( + CollectionBottleSerializer, + results.slice(0, limit).map(({ collectionBottles, bottles }) => ({ + ...collectionBottles, + bottle: bottles, + })), + 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: { + userId: number | "me"; + collectionId: number | "default"; + }; + Querystring: { + query?: string; + page?: number; + }; + } +>; diff --git a/apps/web/src/components/addToCollectionModal.tsx b/apps/web/src/components/addToCollectionModal.tsx new file mode 100644 index 00000000..135d2cbc --- /dev/null +++ b/apps/web/src/components/addToCollectionModal.tsx @@ -0,0 +1,118 @@ +import { Dialog } from "@headlessui/react"; +import { ArrowDownIcon } from "@heroicons/react/20/solid"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState } from "react"; +import { SubmitHandler, useForm } from "react-hook-form"; +import { z } from "zod"; + +import { CollectionBottleInputSchema } from "@peated/shared/schemas"; + +import { ApiError } from "../lib/api"; +import { Bottle } from "../types"; +import BottleCard from "./bottleCard"; +import Button from "./button"; +import Fieldset from "./fieldset"; +import FormError from "./formError"; +import TextField from "./textField"; + +type FormSchemaType = z.infer; + +export default ({ + bottle, + open, + setOpen, + onSubmit, +}: { + bottle: Bottle; + open: boolean; + setOpen: (value: boolean) => void; + onSubmit: SubmitHandler; +}) => { + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(CollectionBottleInputSchema), + defaultValues: { + bottle: bottle.id, + }, + }); + + const [error, setError] = useState(); + + const onSubmitHandler: SubmitHandler = async (data) => { + try { + await onSubmit(data); + } catch (err) { + if (err instanceof ApiError) { + setError(err.message); + } else { + console.error(err); + setError("Internal error"); + } + } + }; + + return ( + + + +
+
+ +
+ + {error && } + +
+
+
+
+

Vintage Details

+

Is this bottle a specific vintage?

+
+ +
+
+ + + v === "" || !v ? undefined : parseInt(v, 10), + })} + error={errors.vintageYear} + type="number" + label="Year" + placeholder="e.g. 2023" + /> + + + v === "" || !v ? undefined : parseInt(v, 10), + })} + error={errors.barrel} + type="number" + label="Barrel No." + placeholder="e.g. 56" + /> +
+
+ + +
+ +
+
+ ); +}; diff --git a/apps/web/src/components/bottleForm.tsx b/apps/web/src/components/bottleForm.tsx index 868982d3..e2f67633 100644 --- a/apps/web/src/components/bottleForm.tsx +++ b/apps/web/src/components/bottleForm.tsx @@ -4,7 +4,10 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { BottleInputSchema } from "@peated/shared/schemas"; import { Controller, SubmitHandler, useForm } from "react-hook-form"; import { z } from "zod"; + +import { toTitleCase } from "@peated/shared/lib/strings"; import { PreviewBottleCard } from "../components/bottleCard"; + import EntityField from "../components/entityField"; import Fieldset from "../components/fieldset"; import FormError from "../components/formError"; @@ -14,7 +17,7 @@ import SelectField, { Option } from "../components/selectField"; import TextField from "../components/textField"; import { useRequiredAuth } from "../hooks/useAuth"; import { ApiError } from "../lib/api"; -import { formatCategoryName, toTitleCase } from "../lib/strings"; +import { formatCategoryName } from "../lib/strings"; import { Bottle, Entity } from "../types"; const categoryList = [ @@ -98,7 +101,7 @@ export default ({ /> } > -
+ {error && }
diff --git a/apps/web/src/components/bottleTable.tsx b/apps/web/src/components/bottleTable.tsx index cff793eb..a56b2d66 100644 --- a/apps/web/src/components/bottleTable.tsx +++ b/apps/web/src/components/bottleTable.tsx @@ -1,9 +1,10 @@ import { Link } from "react-router-dom"; import { formatCategoryName } from "../lib/strings"; -import { Bottle, Entity, PagingRel } from "../types"; +import { Bottle, CollectionBottle, Entity, PagingRel } from "../types"; import BottleName from "./bottleName"; import Button from "./button"; +import VintageName from "./vintageName"; type Grouper = undefined | null | Entity; @@ -13,7 +14,7 @@ export default ({ groupTo, rel, }: { - bottleList: Bottle[]; + bottleList: (Bottle | CollectionBottle)[]; groupBy?: (bottle: Bottle) => Grouper; groupTo?: (group: Entity) => string; rel?: PagingRel; @@ -47,7 +48,17 @@ export default ({ - {bottleList.map((bottle) => { + {bottleList.map((bottleOrCb) => { + const bottle = + "bottle" in bottleOrCb ? bottleOrCb.bottle : bottleOrCb; + const vintage = + "bottle" in bottleOrCb + ? { + vintageYear: bottleOrCb.vintageYear, + series: bottleOrCb.series, + barrel: bottleOrCb.barrel, + } + : null; const group = groupBy && groupBy(bottle); const showGroup = group && group.id !== lastGroup?.id; if (group) lastGroup = group; @@ -75,6 +86,11 @@ export default ({ > + {vintage && ( +
+ +
+ )} {formatCategoryName(bottle.category)} diff --git a/apps/web/src/components/entityField.tsx b/apps/web/src/components/entityField.tsx index a2d75506..7e49fc5a 100644 --- a/apps/web/src/components/entityField.tsx +++ b/apps/web/src/components/entityField.tsx @@ -4,6 +4,7 @@ import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; import Button from "./button"; import CountryField from "./countryField"; +import Fieldset from "./fieldset"; import SelectField from "./selectField"; import TextField from "./textField"; @@ -38,51 +39,53 @@ export default ({ }} className="max-w-md" > -

{createDialogHelpText}

- - ( - - )} - /> - - onFieldChange({ [e.target.name]: e.target.value }) - } - /> -
- - -
+
+

{createDialogHelpText}

+ + ( + + )} + /> + + onFieldChange({ [e.target.name]: e.target.value }) + } + /> +
+ + +
+
); }} diff --git a/apps/web/src/components/fieldset.tsx b/apps/web/src/components/fieldset.tsx index f13de353..6a5dd0a2 100644 --- a/apps/web/src/components/fieldset.tsx +++ b/apps/web/src/components/fieldset.tsx @@ -1,21 +1,5 @@ import { ReactNode } from "react"; -import Spinner from "./spinner"; -export default ({ - children, - loading, -}: { - loading?: boolean; - children: ReactNode; -}) => { - return ( -
- {loading && ( -
- -
- )} - {children} -
- ); +export default ({ children }: { children: ReactNode }) => { + return
{children}
; }; diff --git a/apps/web/src/components/selectField/createOptionDialog.tsx b/apps/web/src/components/selectField/createOptionDialog.tsx index 06ff0074..dd960eb5 100644 --- a/apps/web/src/components/selectField/createOptionDialog.tsx +++ b/apps/web/src/components/selectField/createOptionDialog.tsx @@ -1,7 +1,8 @@ import { Dialog } from "@headlessui/react"; import { useEffect, useState } from "react"; -import { toTitleCase } from "../../lib/strings"; +import { toTitleCase } from "@peated/shared/lib/strings"; + import { CreateOptionForm, Option } from "./types"; // TODO(dcramer): hitting escape doesnt do what you want here (it does nothing) diff --git a/apps/web/src/components/selectField/selectDialog.tsx b/apps/web/src/components/selectField/selectDialog.tsx index 197bd69d..1ddd1eb2 100644 --- a/apps/web/src/components/selectField/selectDialog.tsx +++ b/apps/web/src/components/selectField/selectDialog.tsx @@ -1,12 +1,12 @@ -import { useEffect, useState } from "react"; - import { Dialog } from "@headlessui/react"; import { CheckIcon, PlusIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { useEffect, useState } from "react"; + +import { toTitleCase } from "@peated/shared/lib/strings"; import config from "../../config"; import api, { debounce } from "../../lib/api"; import classNames from "../../lib/classNames"; -import { toTitleCase } from "../../lib/strings"; import Header from "../header"; import ListItem from "../listItem"; import SearchHeader from "../searchHeader"; diff --git a/apps/web/src/components/vintageName.tsx b/apps/web/src/components/vintageName.tsx new file mode 100644 index 00000000..4ab9bf62 --- /dev/null +++ b/apps/web/src/components/vintageName.tsx @@ -0,0 +1,20 @@ +export default ({ + series, + vintageYear, + barrel, +}: { + series?: string; + vintageYear?: number; + barrel?: number; +}) => { + const displayName = + series && vintageYear + ? `${series} - ${vintageYear}` + : `${series || vintageYear}`; + return ( + <> + {displayName} + {!!barrel && ` (#${barrel.toLocaleString()})`} + + ); +}; diff --git a/apps/web/src/components/vintageTable.tsx b/apps/web/src/components/vintageTable.tsx deleted file mode 100644 index 6fb113fd..00000000 --- a/apps/web/src/components/vintageTable.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { Edition, PagingRel } from "../types"; -import Button from "./button"; - -const EditionName = ({ edition }: { edition: Edition }) => { - const name = - edition.name && edition.vintageYear - ? `${edition.name} - ${edition.vintageYear}` - : `${edition.name || edition.vintageYear}`; - return ( - <> - {name} - {!!edition.barrel && ` (#${edition.barrel.toLocaleString()})`} - - ); -}; - -export default ({ values, rel }: { values: Edition[]; rel?: PagingRel }) => { - return ( - <> - - - - - - - - - - - {values.map((edition) => { - return ( - - - - ); - })} - -
- Vintage -
- -
- {rel && ( - - )} - - ); -}; diff --git a/apps/web/src/lib/log.ts b/apps/web/src/lib/log.ts new file mode 100644 index 00000000..7562d3a3 --- /dev/null +++ b/apps/web/src/lib/log.ts @@ -0,0 +1,18 @@ +import { captureException, captureMessage } from "@sentry/react"; + +export function logError(error: Error, context?: Record): void; +export function logError(message: string, context?: Record): void; +export function logError( + error: string | Error, + context?: Record, +): void { + if (error instanceof Error) + captureException(error, { + extra: context || undefined, + }); + else + captureMessage(error, { + extra: context || undefined, + }); + console.log(error); +} diff --git a/apps/web/src/lib/strings.tsx b/apps/web/src/lib/strings.tsx index 0ff80993..ea4cdba5 100644 --- a/apps/web/src/lib/strings.tsx +++ b/apps/web/src/lib/strings.tsx @@ -1,13 +1,6 @@ +import { toTitleCase } from "@peated/shared/lib/strings"; import { Category } from "../types"; -export function toTitleCase(value: string) { - const words = value.toLowerCase().split(" "); - for (let i = 0; i < words.length; i++) { - words[i] = (words[i][0] || "").toUpperCase() + words[i].slice(1); - } - return words.join(" "); -} - export function formatCategoryName( value: Category | string | undefined | null, ) { diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 96b6eb85..10967ce5 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -77,6 +77,7 @@ const queryClient = new QueryClient({ defaultOptions: { queries: { suspense: true, + retry: false, }, }, }); diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index b92c935e..f875079b 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -7,7 +7,6 @@ import AddBottle from "./routes/addBottle"; import AddTasting from "./routes/addTasting"; import BottleActivity from "./routes/bottleActivity"; import BottleDetails from "./routes/bottleDetails"; -import BottleVintages from "./routes/bottleVintages"; import BottleList from "./routes/bottles"; import EditBottle from "./routes/editBottle"; import EditEntity from "./routes/editEntity"; @@ -46,10 +45,6 @@ export default function createRoutes() { index: true, element: , }, - { - path: "vintages", - element: , - }, ], }, { diff --git a/apps/web/src/routes/addBottle.tsx b/apps/web/src/routes/addBottle.tsx index 935beff1..bfc13acf 100644 --- a/apps/web/src/routes/addBottle.tsx +++ b/apps/web/src/routes/addBottle.tsx @@ -1,11 +1,12 @@ import { useQueries } from "@tanstack/react-query"; import { useEffect, useState } from "react"; - import { useNavigate } from "react-router-dom"; + +import { toTitleCase } from "@peated/shared/lib/strings"; + import BottleForm from "../components/bottleForm"; import Spinner from "../components/spinner"; import api from "../lib/api"; -import { toTitleCase } from "../lib/strings"; import { Entity } from "../types"; export default function AddBottle() { diff --git a/apps/web/src/routes/addTasting.tsx b/apps/web/src/routes/addTasting.tsx index 9f3f5c8b..efa9768b 100644 --- a/apps/web/src/routes/addTasting.tsx +++ b/apps/web/src/routes/addTasting.tsx @@ -5,6 +5,7 @@ import { Controller, SubmitHandler, useForm } from "react-hook-form"; import { useNavigate, useParams } from "react-router-dom"; import { z } from "zod"; +import { toTitleCase } from "@peated/shared/lib/strings"; import { TastingInputSchema } from "@peated/shared/schemas"; import BottleCard from "../components/bottleCard"; @@ -20,7 +21,6 @@ import TextField from "../components/textField"; import { useSuspenseQuery } from "../hooks/useSuspenseQuery"; import api, { ApiError } from "../lib/api"; import { toBlob } from "../lib/blobs"; -import { toTitleCase } from "../lib/strings"; import type { Bottle, Paginated } from "../types"; type Tag = { @@ -184,9 +184,9 @@ export default function AddTasting() { placeholder="e.g. 2023" /> { - const { bottleId } = useParams(); +const CollectionAction = ({ bottle }: { bottle: Bottle }) => { const { data: { results: collectionList }, } = useSuspenseQuery( - ["bottles", bottleId, "collections"], + ["bottles", bottle.id, "collections"], (): Promise> => api.get(`/collections`, { query: { user: "me", - bottle: bottleId, + bottle: bottle.id, }, }), ); const [isCollected, setIsCollected] = useState(collectionList.length > 0); const [loading, setLoading] = useState(false); + const [modalIsOpen, setModalIsOpen] = useState(false); const collect = async () => { if (loading) return; - setLoading(true); if (isCollected) { - await api.delete(`/collections/default/bottles/${bottleId}`); - setIsCollected(false); + setLoading(true); + try { + await api.delete(`/collections/default/bottles/${bottle.id}`); + setIsCollected(false); + } catch (err: any) { + logError(err); + } + setLoading(false); } else { - await api.post("/collections/default/bottles", { - data: { bottle: bottleId }, - }); - setIsCollected(true); + setModalIsOpen(true); } - setLoading(false); }; return ( - + <> + + { + if (loading) return; + setLoading(true); + try { + await api.post("/collections/default/bottles", { + data, + }); + setIsCollected(true); + setModalIsOpen(false); + } catch (err: any) { + logError(err); + } + setLoading(false); + }} + /> + ); // return ( @@ -96,55 +120,57 @@ export default function BottleDetails() { return ( -
- -
-

- -

- -
+
+
+ +
+

+ +

+ +
-
-

{bottle.category && formatCategoryName(bottle.category)}

-

{bottle.statedAge ? `Aged ${bottle.statedAge} years` : null}

+
+

{bottle.category && formatCategoryName(bottle.category)}

+

{bottle.statedAge ? `Aged ${bottle.statedAge} years` : null}

+
-
-
- - } fallback={() => }> - - - - {currentUser?.mod && ( - - - - - - - Edit Bottle - - - - )} -
+
+ + } fallback={() => }> + + + + {currentUser?.mod && ( + + + + + + + Edit Bottle + + + + )} +
-
- {stats.map((stat) => ( -
-

{stat.name}

-

- {stat.value} -

-
- ))} +
+ {stats.map((stat) => ( +
+

{stat.name}

+

+ {stat.value} +

+
+ ))} +
@@ -152,13 +178,10 @@ export default function BottleDetails() { Activity - - Vintages -
- + {bottle.createdBy && ( diff --git a/apps/web/src/routes/bottleVintages.tsx b/apps/web/src/routes/bottleVintages.tsx deleted file mode 100644 index a4fdcdd7..00000000 --- a/apps/web/src/routes/bottleVintages.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useParams } from "react-router-dom"; -import EmptyActivity from "../components/emptyActivity"; -import VintageTable from "../components/vintageTable"; -import { useSuspenseQuery } from "../hooks/useSuspenseQuery"; -import api from "../lib/api"; -import { Edition, Paginated } from "../types"; - -export default function BottleVintages() { - const { bottleId } = useParams(); - if (!bottleId) return null; - const { data: editionList } = useSuspenseQuery( - ["bottle", bottleId, "editions"], - (): Promise> => api.get(`/bottles/${bottleId}/editions`), - ); - - return ( - <> - {editionList.results.length ? ( - - ) : ( - - Looks like no ones recorded any vintages for this spirit. - - )} - - ); -} diff --git a/apps/web/src/routes/editEntity.tsx b/apps/web/src/routes/editEntity.tsx index 1a22cf89..b5c6ec72 100644 --- a/apps/web/src/routes/editEntity.tsx +++ b/apps/web/src/routes/editEntity.tsx @@ -4,6 +4,7 @@ import { Controller, SubmitHandler, useForm } from "react-hook-form"; import { useNavigate, useParams } from "react-router-dom"; import { z } from "zod"; +import { toTitleCase } from "@peated/shared/lib/strings"; import { EntityInputSchema } from "@peated/shared/schemas"; import CountryField from "../components/countryField"; @@ -15,7 +16,6 @@ import SelectField from "../components/selectField"; import TextField from "../components/textField"; import { useSuspenseQuery } from "../hooks/useSuspenseQuery"; import api, { ApiError } from "../lib/api"; -import { toTitleCase } from "../lib/strings"; import { Entity } from "../types"; const entityTypes = [ diff --git a/apps/web/src/routes/profileCollections.tsx b/apps/web/src/routes/profileCollections.tsx index c7995636..4bfaf19d 100644 --- a/apps/web/src/routes/profileCollections.tsx +++ b/apps/web/src/routes/profileCollections.tsx @@ -11,12 +11,7 @@ export default function ProfileCollections() { const { data } = useQuery({ queryKey: ["collections", "user", user.id], queryFn: (): Promise> => - api.get("/bottles", { - query: { - user: user.id, - collection: "default", - }, - }), + api.get(`/users/${user.id}/collections/default/bottles`), }); return ( diff --git a/apps/web/src/routes/search.tsx b/apps/web/src/routes/search.tsx index d432caf9..a8dd9325 100644 --- a/apps/web/src/routes/search.tsx +++ b/apps/web/src/routes/search.tsx @@ -1,9 +1,11 @@ import { AtSymbolIcon, PlusIcon } from "@heroicons/react/20/solid"; import { useEffect, useState } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; +import { Link, useLocation, useNavigate } from "react-router-dom"; + +import { toTitleCase } from "@peated/shared/lib/strings"; -import { Link } from "react-router-dom"; import { ReactComponent as BottleIcon } from "../assets/bottle.svg"; +import { ReactComponent as EntityIcon } from "../assets/entity.svg"; import BottleName from "../components/bottleName"; import Chip from "../components/chip"; import Layout from "../components/layout"; @@ -11,11 +13,9 @@ import ListItem from "../components/listItem"; import SearchHeader from "../components/searchHeader"; import UserAvatar from "../components/userAvatar"; import api, { debounce } from "../lib/api"; -import { formatCategoryName, toTitleCase } from "../lib/strings"; +import { formatCategoryName } from "../lib/strings"; import { Bottle, Entity, User } from "../types"; -import { ReactComponent as EntityIcon } from "../assets/entity.svg"; - const SkeletonItem = () => { return ( diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 84f6c760..e1c45b10 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -43,16 +43,6 @@ export type Bottle = { createdBy?: User; }; -export type Edition = { - id: number; - bottle: Bottle; - name?: string; - vintageYear?: number; - barrel?: number; - createdAt: string; - createdBy?: User; -}; - export type FollowStatus = "none" | "following" | "pending"; export type FollowRequest = { @@ -93,6 +83,11 @@ export type Tasting = { notes?: string; rating: number; imageUrl?: string; + + series?: string; + vintageYear?: number; + barrel?: number; + createdBy: User; createdAt: string; hasToasted: boolean; @@ -108,13 +103,7 @@ export type Comment = { createdAt: string; }; -export type ObjectType = - | "bottle" - | "edition" - | "entity" - | "tasting" - | "toast" - | "follow"; +export type ObjectType = "bottle" | "entity" | "tasting" | "toast" | "follow"; type BaseNotification = { id: number; @@ -154,10 +143,19 @@ export type CommentNotification = BaseNotification & { export type Collection = { id: number; name: string; + totalBottles: number; createdAt?: string; createdBy?: User; }; +export type CollectionBottle = { + id: number; + bottle: Bottle; + series?: string; + vintageYear?: number; + barrel?: number; +}; + export type Notification = | FollowNotification | ToastNotification diff --git a/packages/shared/lib/strings.ts b/packages/shared/lib/strings.ts new file mode 100644 index 00000000..e7f0c53c --- /dev/null +++ b/packages/shared/lib/strings.ts @@ -0,0 +1,7 @@ +export function toTitleCase(value: string) { + const words = value.toLowerCase().split(" "); + for (let i = 0; i < words.length; i++) { + words[i] = (words[i][0] || "").toUpperCase() + words[i].slice(1); + } + return words.join(" "); +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 7c3a5afc..b6176688 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -5,6 +5,7 @@ "build": "tsc" }, "files": [ + "lib", "schemas.ts", "tsconfig.json", "tsconfig.node.json" diff --git a/packages/shared/schemas.ts b/packages/shared/schemas.ts index 0c31db35..dd510c59 100644 --- a/packages/shared/schemas.ts +++ b/packages/shared/schemas.ts @@ -74,53 +74,39 @@ export const BottleInputSchema = z.object({ category: CategoryEnum.nullable().optional(), }); -export const EditionSchema = z.object({ - id: z.number(), - - name: z.string().nullable().optional(), - vintageYear: z - .number() - .gte(1495) - .lte(new Date().getFullYear()) - .nullable() - .optional(), - barrel: z.number().nullable().optional(), - - createdAt: z.string().datetime().optional(), - createdBy: UserSchema.optional(), -}); - -export const TastingSchema = z.object({ - id: z.number(), - imageUrl: z.string().nullable(), - notes: z.string().nullable(), - bottle: BottleSchema, - rating: z.number().gte(0).lte(5).nullable(), - tags: z.array(z.string()).optional(), - - comments: z.number().gte(0), - toasts: z.number().gte(0), - hasToasted: z.boolean().optional(), - edition: EditionSchema.nullable(), - createdAt: z.string().datetime(), - createdBy: UserSchema, -}); - -export const TastingInputSchema = z.object({ - bottle: z.number(), - notes: z.string().nullable().optional(), - rating: z.number().gte(0).lte(5).nullable().optional(), - tags: z.array(z.string()).nullable().optional(), - edition: z.string().trim().nullable().optional(), - vintageYear: z - .number() - .gte(1495) - .lte(new Date().getFullYear()) - .nullable() - .optional(), - barrel: z.number().nullable().optional(), - createdAt: z.string().datetime().optional(), -}); +const VintageSchema = z.object({ + series: z.string().nullable(), + barrel: z.number().nullable(), + vintageYear: z.number().gte(1495).lte(new Date().getFullYear()).nullable(), +}); + +export const TastingSchema = z + .object({ + id: z.number(), + imageUrl: z.string().nullable(), + notes: z.string().nullable(), + bottle: BottleSchema, + rating: z.number().gte(0).lte(5).nullable(), + tags: z.array(z.string()), + + comments: z.number().gte(0), + toasts: z.number().gte(0), + hasToasted: z.boolean().optional(), + createdAt: z.string().datetime(), + createdBy: UserSchema, + }) + .merge(VintageSchema); + +export const TastingInputSchema = z + .object({ + bottle: z.number(), + notes: z.string().nullable().optional(), + rating: z.number().gte(0).lte(5).nullable().optional(), + tags: z.array(z.string()).nullable().optional(), + + createdAt: z.string().datetime().optional(), + }) + .merge(VintageSchema.partial()); export const CommentSchema = z.object({ id: z.number(), @@ -137,6 +123,7 @@ export const CommentInputSchema = z.object({ export const CollectionSchema = z.object({ id: z.number(), name: z.string().trim().min(1, "Required"), + totalBottles: z.number(), createdAt: z.string().datetime().optional(), createdBy: UserSchema.optional(), }); @@ -145,6 +132,18 @@ export const CollectionInputSchema = z.object({ name: z.string(), }); +export const CollectionBottleSchema = z + .object({ + bottle: BottleSchema, + }) + .merge(VintageSchema); + +export const CollectionBottleInputSchema = z + .object({ + bottle: z.number(), + }) + .merge(VintageSchema.partial()); + export const FollowSchema = z.object({ id: z.number(), status: FollowStatusEnum,