From 383f60ceefa4cfaba6fb76447420cf64f4336bff Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 19 May 2023 11:38:46 -0700 Subject: [PATCH 1/2] feat: Add zod validation to forms This adds an initial implementation of Zod for form validation on the add tasting flow, duplicating the schema on the API for consistency. This also makes rating optional, to simplify check-in experiences if you just wanna record notes. --- apps/api/migrations/0015_cheerful_smasher.sql | 1 + apps/api/migrations/meta/0015_snapshot.json | 1159 +++++++++++++++++ apps/api/migrations/meta/_journal.json | 7 + apps/api/package.json | 6 +- apps/api/src/app.ts | 3 +- apps/api/src/db/schema.ts | 2 +- apps/api/src/lib/serializers/bottle.ts | 2 +- apps/api/src/lib/serializers/collection.ts | 2 +- apps/api/src/lib/serializers/comment.ts | 2 +- apps/api/src/lib/serializers/edition.ts | 2 +- apps/api/src/lib/serializers/entity.ts | 2 +- apps/api/src/lib/serializers/follow.ts | 4 +- apps/api/src/lib/serializers/tasting.ts | 2 +- apps/api/src/lib/serializers/user.ts | 2 +- apps/api/src/routes/addTasting.ts | 17 +- apps/api/src/routes/getBottle.test.ts | 2 +- apps/api/src/routes/getEntity.test.ts | 2 +- apps/api/src/routes/getTasting.test.ts | 2 +- apps/api/src/routes/getUser.test.ts | 8 +- apps/api/src/routes/listBottles.test.ts | 6 +- apps/api/src/routes/listComments.test.ts | 2 +- apps/api/src/routes/listFollowing.test.ts | 2 +- apps/api/src/routes/listNotifications.test.ts | 12 +- apps/api/src/routes/listTastings.test.ts | 6 +- apps/api/src/routes/listUsers.test.ts | 2 +- apps/api/src/schemas/bottle.ts | 2 +- apps/api/src/schemas/collection.ts | 2 +- apps/api/src/schemas/comment.ts | 2 +- apps/api/src/schemas/edition.ts | 2 +- apps/api/src/schemas/entity.ts | 2 +- apps/api/src/schemas/follow.ts | 2 +- apps/api/src/schemas/notification.ts | 4 +- apps/api/src/schemas/tasting.ts | 19 +- apps/api/src/schemas/user.ts | 2 +- apps/scraper/package.json | 6 +- apps/scraper/src/main.ts | 2 +- apps/web/package.json | 7 +- apps/web/src/components/bottleMetadata.tsx | 4 +- apps/web/src/components/countryField.tsx | 2 + apps/web/src/components/formField.tsx | 11 + apps/web/src/components/imageField.tsx | 215 +-- .../components/notifications/followEntry.tsx | 4 +- apps/web/src/components/rangeField.tsx | 135 +- apps/web/src/components/ratingField.tsx | 33 - apps/web/src/components/selectField/index.tsx | 5 + .../components/selectField/selectDialog.tsx | 2 +- apps/web/src/components/selectField/types.tsx | 2 +- apps/web/src/components/tastingList.tsx | 2 +- apps/web/src/components/textArea.tsx | 10 +- apps/web/src/components/textAreaField.tsx | 46 +- apps/web/src/components/textField.tsx | 46 +- apps/web/src/components/textInput.tsx | 76 +- apps/web/src/lib/setRef.ts | 50 + apps/web/src/routes/addTasting.tsx | 172 +-- apps/web/src/routes/followers.tsx | 4 +- apps/web/src/routes/following.tsx | 2 +- apps/web/src/routes/tastingDetails.tsx | 8 +- apps/web/src/styles/index.css | 16 +- apps/web/src/types.ts | 28 +- package.json | 2 +- packages/shared/package.json | 9 +- packages/shared/schemas.ts | 11 + packages/shared/tsconfig.json | 6 +- pnpm-lock.yaml | 66 +- 64 files changed, 1776 insertions(+), 500 deletions(-) create mode 100644 apps/api/migrations/0015_cheerful_smasher.sql create mode 100644 apps/api/migrations/meta/0015_snapshot.json delete mode 100644 apps/web/src/components/ratingField.tsx create mode 100644 apps/web/src/lib/setRef.ts create mode 100644 packages/shared/schemas.ts diff --git a/apps/api/migrations/0015_cheerful_smasher.sql b/apps/api/migrations/0015_cheerful_smasher.sql new file mode 100644 index 00000000..9cb97a6d --- /dev/null +++ b/apps/api/migrations/0015_cheerful_smasher.sql @@ -0,0 +1 @@ +ALTER TABLE "tasting" ALTER COLUMN "rating" DROP NOT NULL; \ No newline at end of file diff --git a/apps/api/migrations/meta/0015_snapshot.json b/apps/api/migrations/meta/0015_snapshot.json new file mode 100644 index 00000000..fb0a4522 --- /dev/null +++ b/apps/api/migrations/meta/0015_snapshot.json @@ -0,0 +1,1159 @@ +{ + "version": "5", + "dialect": "pg", + "id": "f7d987b4-332b-4285-a97e-3ef5bfad4d59", + "prevId": "089dee02-5206-4b80-b713-53d93584d58f", + "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": { + "collection_id": { + "name": "collection_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "bottle_id": { + "name": "bottle_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "edition_id": { + "name": "edition_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "collection_bottle_collection_id_collection_id_fk": { + "name": "collection_bottle_collection_id_collection_id_fk", + "tableFrom": "collection_bottle", + "tableTo": "collection", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "collection_bottle_bottle_id_bottle_id_fk": { + "name": "collection_bottle_bottle_id_bottle_id_fk", + "tableFrom": "collection_bottle", + "tableTo": "bottle", + "columnsFrom": [ + "bottle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "collection_bottle_edition_id_edition_id_fk": { + "name": "collection_bottle_edition_id_edition_id_fk", + "tableFrom": "collection_bottle", + "tableTo": "edition", + "columnsFrom": [ + "edition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "collection_bottle_collection_id_bottle_id_edition_id": { + "name": "collection_bottle_collection_id_bottle_id_edition_id", + "columns": [ + "collection_id", + "bottle_id", + "edition_id" + ] + } + } + }, + "collection": { + "name": "collection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "collection_name_unq": { + "name": "collection_name_unq", + "columns": [ + "name", + "created_by_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "collection_created_by_id_user_id_fk": { + "name": "collection_created_by_id_user_id_fk", + "tableFrom": "collection", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "tasting_id": { + "name": "tasting_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "comment_unq": { + "name": "comment_unq", + "columns": [ + "tasting_id", + "created_by_id", + "created_at" + ], + "isUnique": true + } + }, + "foreignKeys": { + "comments_tasting_id_tasting_id_fk": { + "name": "comments_tasting_id_tasting_id_fk", + "tableFrom": "comments", + "tableTo": "tasting", + "columnsFrom": [ + "tasting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "comments_created_by_id_user_id_fk": { + "name": "comments_created_by_id_user_id_fk", + "tableFrom": "comments", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "edition": { + "name": "edition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "barrel": { + "name": "barrel", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "vintage_year": { + "name": "vintage_year", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "bottle_id": { + "name": "bottle_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "edition_unq": { + "name": "edition_unq", + "columns": [ + "bottle_id", + "name", + "barrel", + "vintage_year" + ], + "isUnique": true + } + }, + "foreignKeys": { + "edition_bottle_id_bottle_id_fk": { + "name": "edition_bottle_id_bottle_id_fk", + "tableFrom": "edition", + "tableTo": "bottle", + "columnsFrom": [ + "bottle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "edition_created_by_id_user_id_fk": { + "name": "edition_created_by_id_user_id_fk", + "tableFrom": "edition", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "entity": { + "name": "entity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "entity_type[]", + "primaryKey": false, + "notNull": true + }, + "total_bottles": { + "name": "total_bottles", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tastings": { + "name": "total_tastings", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "entity_name_unq": { + "name": "entity_name_unq", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "entity_created_by_id_user_id_fk": { + "name": "entity_created_by_id_user_id_fk", + "tableFrom": "entity", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "follow": { + "name": "follow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "from_user_id": { + "name": "from_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "to_user_id": { + "name": "to_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "follow_status", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "follow_unq": { + "name": "follow_unq", + "columns": [ + "from_user_id", + "to_user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "follow_from_user_id_user_id_fk": { + "name": "follow_from_user_id_user_id_fk", + "tableFrom": "follow", + "tableTo": "user", + "columnsFrom": [ + "from_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "follow_to_user_id_user_id_fk": { + "name": "follow_to_user_id_user_id_fk", + "tableFrom": "follow", + "tableTo": "user", + "columnsFrom": [ + "to_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "identity": { + "name": "identity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "identity_provider", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "identity_unq": { + "name": "identity_unq", + "columns": [ + "provider", + "external_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "identity_user_id_user_id_fk": { + "name": "identity_user_id_user_id_fk", + "tableFrom": "identity", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "from_user_id": { + "name": "from_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "object_id": { + "name": "object_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "object_type": { + "name": "object_type", + "type": "object_type", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "notifications_unq": { + "name": "notifications_unq", + "columns": [ + "user_id", + "object_id", + "object_type", + "created_at" + ], + "isUnique": true + } + }, + "foreignKeys": { + "notifications_user_id_user_id_fk": { + "name": "notifications_user_id_user_id_fk", + "tableFrom": "notifications", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "notifications_from_user_id_user_id_fk": { + "name": "notifications_from_user_id_user_id_fk", + "tableFrom": "notifications", + "tableTo": "user", + "columnsFrom": [ + "from_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "tasting": { + "name": "tasting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "bottle_id": { + "name": "bottle_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "edition_id": { + "name": "edition_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "rating": { + "name": "rating", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "comments": { + "name": "comments", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "toasts": { + "name": "toasts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tasting_bottle_id_bottle_id_fk": { + "name": "tasting_bottle_id_bottle_id_fk", + "tableFrom": "tasting", + "tableTo": "bottle", + "columnsFrom": [ + "bottle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasting_edition_id_edition_id_fk": { + "name": "tasting_edition_id_edition_id_fk", + "tableFrom": "tasting", + "tableTo": "edition", + "columnsFrom": [ + "edition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasting_created_by_id_user_id_fk": { + "name": "tasting_created_by_id_user_id_fk", + "tableFrom": "tasting", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "toasts": { + "name": "toasts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "tasting_id": { + "name": "tasting_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "toast_unq": { + "name": "toast_unq", + "columns": [ + "tasting_id", + "created_by_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "toasts_tasting_id_tasting_id_fk": { + "name": "toasts_tasting_id_tasting_id_fk", + "tableFrom": "toasts", + "tableTo": "tasting", + "columnsFrom": [ + "tasting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "toasts_created_by_id_user_id_fk": { + "name": "toasts_created_by_id_user_id_fk", + "tableFrom": "toasts", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {} + }, + "user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "picture_url": { + "name": "picture_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "admin": { + "name": "admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "mod": { + "name": "mod", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_email_unq": { + "name": "user_email_unq", + "columns": [ + "email" + ], + "isUnique": true + }, + "user_username_unq": { + "name": "user_username_unq", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {} + } + }, + "enums": { + "category": { + "name": "category", + "values": { + "blend": "blend", + "bourbon": "bourbon", + "rye": "rye", + "single_grain": "single_grain", + "single_malt": "single_malt", + "spirit": "spirit" + } + }, + "entity_type": { + "name": "entity_type", + "values": { + "brand": "brand", + "distiller": "distiller", + "bottler": "bottler" + } + }, + "follow_status": { + "name": "follow_status", + "values": { + "none": "none", + "pending": "pending", + "following": "following" + } + }, + "identity_provider": { + "name": "identity_provider", + "values": { + "google": "google" + } + }, + "object_type": { + "name": "object_type", + "values": { + "bottle": "bottle", + "comment": "comment", + "edition": "edition", + "entity": "entity", + "tasting": "tasting", + "toast": "toast", + "follow": "follow" + } + } + }, + "schemas": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} \ No newline at end of file diff --git a/apps/api/migrations/meta/_journal.json b/apps/api/migrations/meta/_journal.json index 867fd4c6..a66efec9 100644 --- a/apps/api/migrations/meta/_journal.json +++ b/apps/api/migrations/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1684337194944, "tag": "0014_pink_white_tiger", "breakpoints": false + }, + { + "idx": 15, + "version": "5", + "when": 1684534477328, + "tag": "0015_cheerful_smasher", + "breakpoints": false } ] } \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index c0cecd37..49943de9 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -25,7 +25,7 @@ "@opentelemetry/auto-instrumentations-node": "^0.36.6", "@opentelemetry/sdk-node": "^0.38.0", "@paralleldrive/cuid2": "^2.2.0", - "@peated/shared": "file:../../packages/shared", + "@peated/shared": "workspace:*", "@sentry/node": "^7.51.0", "@sentry/opentelemetry-node": "^7.51.0", "@sentry/profiling-node": "^0.3.0", @@ -59,6 +59,8 @@ "ts-node": "^10.9.1", "typescript": "^5.0.4", "vite-tsconfig-paths": "^4.0.8", - "vitest": "^0.29.8" + "vitest": "^0.29.8", + "zod": "^3.21.4", + "zod-to-json-schema": "^3.21.1" } } diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 7cf61cd7..2cf5c4d2 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -26,7 +26,7 @@ import { error401Schema } from "./schemas/errors"; import { followingSchema } from "./schemas/follow"; import { notificationSchema } from "./schemas/notification"; import pagingSchema from "./schemas/paging"; -import { newTastingSchema, tastingSchema } from "./schemas/tasting"; +import { tastingSchema } from "./schemas/tasting"; import { updateUserSchema, userSchema } from "./schemas/user"; initSentry({ @@ -98,7 +98,6 @@ export default async function buildFastify(options = {}) { app.addSchema(collectionSchema); app.addSchema(commentSchema); app.addSchema(newCommentSchema); - app.addSchema(newTastingSchema); app.addSchema(editionSchema); app.addSchema(newEditionSchema); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 15d66a31..40694ff1 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -306,7 +306,7 @@ export const tastings = pgTable("tasting", { ), tags: text("tags").array(), - rating: doublePrecision("rating").notNull(), + rating: doublePrecision("rating"), imageUrl: text("image_url"), notes: text("notes"), diff --git a/apps/api/src/lib/serializers/bottle.ts b/apps/api/src/lib/serializers/bottle.ts index 2a713ea2..af6f2e04 100644 --- a/apps/api/src/lib/serializers/bottle.ts +++ b/apps/api/src/lib/serializers/bottle.ts @@ -54,7 +54,7 @@ export const BottleSerializer: Serializer = { item: (item: Bottle, attrs: Record, currentUser?: User) => { return { - id: `${item.id}`, + id: item.id, name: item.name, statedAge: item.statedAge, category: item.category, diff --git a/apps/api/src/lib/serializers/collection.ts b/apps/api/src/lib/serializers/collection.ts index 012908a4..93e1c89e 100644 --- a/apps/api/src/lib/serializers/collection.ts +++ b/apps/api/src/lib/serializers/collection.ts @@ -5,7 +5,7 @@ import { Serializer } from "."; export const CollectionSerializer: Serializer = { item: (item: Collection, attrs: Record, currentUser?: User) => { return { - id: `${item.id}`, + id: item.id, name: item.name, createdAt: item.createdAt, }; diff --git a/apps/api/src/lib/serializers/comment.ts b/apps/api/src/lib/serializers/comment.ts index d89f8d19..43b01a11 100644 --- a/apps/api/src/lib/serializers/comment.ts +++ b/apps/api/src/lib/serializers/comment.ts @@ -35,7 +35,7 @@ export const CommentSerializer: Serializer = { item: (item: Comment, attrs: Record, currentUser?: User) => { return { - id: `${item.id}`, + id: item.id, comment: item.comment, createdAt: item.createdAt, createdBy: attrs.createdBy, diff --git a/apps/api/src/lib/serializers/edition.ts b/apps/api/src/lib/serializers/edition.ts index b108a80b..fab8b104 100644 --- a/apps/api/src/lib/serializers/edition.ts +++ b/apps/api/src/lib/serializers/edition.ts @@ -5,7 +5,7 @@ import { Serializer } from "."; export const EditionSerializer: Serializer = { item: (item: Edition, attrs: Record, currentUser?: User) => { return { - id: `${item.id}`, + id: item.id, name: item.name, barrel: item.barrel, vintageYear: item.vintageYear, diff --git a/apps/api/src/lib/serializers/entity.ts b/apps/api/src/lib/serializers/entity.ts index 818696a6..a2a15412 100644 --- a/apps/api/src/lib/serializers/entity.ts +++ b/apps/api/src/lib/serializers/entity.ts @@ -5,7 +5,7 @@ import { Serializer } from "."; export const EntitySerializer: Serializer = { item: (item: Entity, attrs: Record, currentUser?: User) => { return { - id: `${item.id}`, + id: item.id, name: item.name, country: item.country, region: item.region, diff --git a/apps/api/src/lib/serializers/follow.ts b/apps/api/src/lib/serializers/follow.ts index bb814d2f..96857714 100644 --- a/apps/api/src/lib/serializers/follow.ts +++ b/apps/api/src/lib/serializers/follow.ts @@ -57,7 +57,7 @@ export const FollowerSerializer: Serializer = { }, item: (item: Follow, attrs: Record, currentUser?: User) => { return { - id: `${item.id}`, + id: item.id, status: item.status, createdAt: item.createdAt, user: attrs.user, @@ -116,7 +116,7 @@ export const FollowingSerializer: Serializer = { }, item: (item: Follow, attrs: Record, currentUser?: User) => { return { - id: `${item.id}`, + id: item.id, status: item.status, createdAt: item.createdAt, user: attrs.user, diff --git a/apps/api/src/lib/serializers/tasting.ts b/apps/api/src/lib/serializers/tasting.ts index 714da64a..7143547c 100644 --- a/apps/api/src/lib/serializers/tasting.ts +++ b/apps/api/src/lib/serializers/tasting.ts @@ -91,7 +91,7 @@ export const TastingSerializer: Serializer = { item: (item: Tasting, attrs: Record, currentUser?: User) => { return { - id: `${item.id}`, + id: item.id, imageUrl: item.imageUrl ? `${config.URL_PREFIX}${item.imageUrl}` : null, notes: item.notes, tags: item.tags || [], diff --git a/apps/api/src/lib/serializers/user.ts b/apps/api/src/lib/serializers/user.ts index abe5aff0..3dfa84bc 100644 --- a/apps/api/src/lib/serializers/user.ts +++ b/apps/api/src/lib/serializers/user.ts @@ -39,7 +39,7 @@ export const UserSerializer: Serializer = { }, item: (item: User, attrs: Record, currentUser?: User) => { const data = { - id: `${item.id}`, + id: item.id, displayName: item.displayName, username: item.username, pictureUrl: item.pictureUrl diff --git a/apps/api/src/routes/addTasting.ts b/apps/api/src/routes/addTasting.ts index 4ab9c280..12b31ec0 100644 --- a/apps/api/src/routes/addTasting.ts +++ b/apps/api/src/routes/addTasting.ts @@ -1,9 +1,13 @@ import { and, eq, inArray, isNull, 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 { NewTasting } from "@peated/shared/schemas"; + import { db } from "../db"; import { - NewTasting, bottles, bottlesToDistillers, changes, @@ -19,9 +23,7 @@ export default { method: "POST", url: "/tastings", schema: { - body: { - $ref: "/schemas/newTasting", - }, + body: zodToJsonSchema(NewTasting), response: { 201: { $ref: "/schemas/tasting", @@ -158,11 +160,6 @@ export default { IncomingMessage, ServerResponse, { - Body: NewTasting & { - bottle: number; - edition?: string; - barrel?: number; - vintageYear?: number; - }; + Body: z.infer; } >; diff --git a/apps/api/src/routes/getBottle.test.ts b/apps/api/src/routes/getBottle.test.ts index d2c270f0..3ce77af9 100644 --- a/apps/api/src/routes/getBottle.test.ts +++ b/apps/api/src/routes/getBottle.test.ts @@ -22,5 +22,5 @@ test("get bottle", async () => { expect(response).toRespondWith(200); const data = JSON.parse(response.payload); - expect(data.id).toEqual(`${bottle1.id}`); + expect(data.id).toEqual(bottle1.id); }); diff --git a/apps/api/src/routes/getEntity.test.ts b/apps/api/src/routes/getEntity.test.ts index f475463b..6adc42e6 100644 --- a/apps/api/src/routes/getEntity.test.ts +++ b/apps/api/src/routes/getEntity.test.ts @@ -21,5 +21,5 @@ test("get entity", async () => { expect(response).toRespondWith(200); const data = JSON.parse(response.payload); - expect(data.id).toBe(`${brand.id}`); + expect(data.id).toBe(brand.id); }); diff --git a/apps/api/src/routes/getTasting.test.ts b/apps/api/src/routes/getTasting.test.ts index 85512bb3..a52781aa 100644 --- a/apps/api/src/routes/getTasting.test.ts +++ b/apps/api/src/routes/getTasting.test.ts @@ -21,5 +21,5 @@ test("get tasting", async () => { expect(response).toRespondWith(200); const data = JSON.parse(response.payload); - expect(data.id).toBe(`${tasting.id}`); + expect(data.id).toBe(tasting.id); }); diff --git a/apps/api/src/routes/getUser.test.ts b/apps/api/src/routes/getUser.test.ts index 9c4fff7c..60ca6b5d 100644 --- a/apps/api/src/routes/getUser.test.ts +++ b/apps/api/src/routes/getUser.test.ts @@ -22,7 +22,7 @@ test("get user by id", async () => { expect(response).toRespondWith(200); const data = JSON.parse(response.payload); - expect(data.id).toEqual(`${user.id}`); + expect(data.id).toEqual(user.id); expect(data.followStatus).toBe("none"); }); @@ -35,7 +35,7 @@ test("get user:me", async () => { expect(response).toRespondWith(200); const data = JSON.parse(response.payload); - expect(data.id).toBe(`${DefaultFixtures.user.id}`); + expect(data.id).toBe(DefaultFixtures.user.id); }); test("get user by username", async () => { @@ -47,7 +47,7 @@ test("get user by username", async () => { expect(response).toRespondWith(200); const data = JSON.parse(response.payload); - expect(data.id).toBe(`${DefaultFixtures.user.id}`); + expect(data.id).toBe(DefaultFixtures.user.id); }); test("get user requires auth", async () => { @@ -74,6 +74,6 @@ test("get user w/ followStatus", async () => { expect(response).toRespondWith(200); const data = JSON.parse(response.payload); - expect(data.id).toBe(`${user.id}`); + expect(data.id).toBe(user.id); expect(data.followStatus).toBe("following"); }); diff --git a/apps/api/src/routes/listBottles.test.ts b/apps/api/src/routes/listBottles.test.ts index 8b977014..77485743 100644 --- a/apps/api/src/routes/listBottles.test.ts +++ b/apps/api/src/routes/listBottles.test.ts @@ -40,7 +40,7 @@ test("lists bottles with query", async () => { expect(response).toRespondWith(200); const { results } = JSON.parse(response.payload); expect(results.length).toBe(1); - expect(results[0].id).toBe(`${bottle1.id}`); + expect(results[0].id).toBe(bottle1.id); }); test("lists bottles with distiller", async () => { @@ -62,7 +62,7 @@ test("lists bottles with distiller", async () => { expect(response).toRespondWith(200); const { results } = JSON.parse(response.payload); expect(results.length).toBe(1); - expect(results[0].id).toBe(`${bottle1.id}`); + expect(results[0].id).toBe(bottle1.id); }); test("lists bottles with brand", async () => { @@ -84,5 +84,5 @@ test("lists bottles with brand", async () => { expect(response).toRespondWith(200); const { results } = JSON.parse(response.payload); expect(results.length).toBe(1); - expect(results[0].id).toBe(`${bottle1.id}`); + expect(results[0].id).toBe(bottle1.id); }); diff --git a/apps/api/src/routes/listComments.test.ts b/apps/api/src/routes/listComments.test.ts index 66a125da..38ba6b5d 100644 --- a/apps/api/src/routes/listComments.test.ts +++ b/apps/api/src/routes/listComments.test.ts @@ -27,5 +27,5 @@ test("lists comments", async () => { expect(response).toRespondWith(200); const { results } = JSON.parse(response.payload); expect(results.length).toBe(1); - expect(results[0].id).toBe(`${comment.id}`); + expect(results[0].id).toBe(comment.id); }); diff --git a/apps/api/src/routes/listFollowing.test.ts b/apps/api/src/routes/listFollowing.test.ts index ed16978e..1947cbf8 100644 --- a/apps/api/src/routes/listFollowing.test.ts +++ b/apps/api/src/routes/listFollowing.test.ts @@ -26,7 +26,7 @@ test("lists following", async () => { expect(response).toRespondWith(200); const { results } = JSON.parse(response.payload); expect(results.length).toBe(1); - expect(results[0].user.id).toBe(`${follow1.toUserId}`); + expect(results[0].user.id).toBe(follow1.toUserId); }); test("requires auth", async () => { diff --git a/apps/api/src/routes/listNotifications.test.ts b/apps/api/src/routes/listNotifications.test.ts index 3d17d29b..659287e5 100644 --- a/apps/api/src/routes/listNotifications.test.ts +++ b/apps/api/src/routes/listNotifications.test.ts @@ -36,10 +36,10 @@ test("lists notifications w/ toast", async () => { expect(response).toRespondWith(200); const { results } = JSON.parse(response.payload); expect(results.length).toBe(1); - expect(results[0].id).toEqual(`${notification.id}`); + expect(results[0].id).toEqual(notification.id); expect(results[0].objectType).toEqual("toast"); expect(results[0].ref).toBeDefined(); - expect(results[0].ref.id).toEqual(`${tasting.id}`); + expect(results[0].ref.id).toEqual(tasting.id); }); test("lists notifications w/ comment", async () => { @@ -64,10 +64,10 @@ test("lists notifications w/ comment", async () => { expect(response).toRespondWith(200); const { results } = JSON.parse(response.payload); expect(results.length).toBe(1); - expect(results[0].id).toEqual(`${notification.id}`); + expect(results[0].id).toEqual(notification.id); expect(results[0].objectType).toEqual("comment"); expect(results[0].ref).toBeDefined(); - expect(results[0].ref.id).toEqual(`${tasting.id}`); + expect(results[0].ref.id).toEqual(tasting.id); }); test("lists notifications w/ follow", async () => { @@ -89,8 +89,8 @@ test("lists notifications w/ follow", async () => { expect(response).toRespondWith(200); const { results } = JSON.parse(response.payload); expect(results.length).toBe(1); - expect(results[0].id).toEqual(`${notification.id}`); + expect(results[0].id).toEqual(notification.id); expect(results[0].objectType).toEqual("follow"); expect(results[0].ref).toBeDefined(); - expect(results[0].ref.id).toEqual(`${follow.id}`); + expect(results[0].ref.id).toEqual(follow.id); }); diff --git a/apps/api/src/routes/listTastings.test.ts b/apps/api/src/routes/listTastings.test.ts index 2829461d..7ae2ad09 100644 --- a/apps/api/src/routes/listTastings.test.ts +++ b/apps/api/src/routes/listTastings.test.ts @@ -41,7 +41,7 @@ test("lists tastings with bottle", async () => { expect(response).toRespondWith(200); const { results } = JSON.parse(response.payload); expect(results.length).toBe(1); - expect(results[0].id).toEqual(`${tasting.id}`); + expect(results[0].id).toEqual(tasting.id); }); test("lists tastings with user", async () => { @@ -61,7 +61,7 @@ test("lists tastings with user", async () => { expect(response).toRespondWith(200); const { results } = JSON.parse(response.payload); expect(results.length).toBe(1); - expect(results[0].id).toEqual(`${tasting.id}`); + expect(results[0].id).toEqual(tasting.id); }); test("lists tastings filter friends unauthenticated", async () => { @@ -103,5 +103,5 @@ test("lists tastings filter friends", async () => { expect(response).toRespondWith(200); const { results } = JSON.parse(response.payload); expect(results.length).toBe(1); - expect(results[0].id).toEqual(`${lastTasting.id}`); + expect(results[0].id).toEqual(lastTasting.id); }); diff --git a/apps/api/src/routes/listUsers.test.ts b/apps/api/src/routes/listUsers.test.ts index 6473c4a2..e45d7b87 100644 --- a/apps/api/src/routes/listUsers.test.ts +++ b/apps/api/src/routes/listUsers.test.ts @@ -40,7 +40,7 @@ test("lists users needs a query", async () => { expect(response).toRespondWith(200); const { results } = JSON.parse(response.payload); expect(results.length).toBe(1); - expect(results[0].id).toBe(`${user2.id}`); + expect(results[0].id).toBe(user2.id); }); test("lists users requires auth", async () => { diff --git a/apps/api/src/schemas/bottle.ts b/apps/api/src/schemas/bottle.ts index dcd5cc66..603cfbd3 100644 --- a/apps/api/src/schemas/bottle.ts +++ b/apps/api/src/schemas/bottle.ts @@ -3,7 +3,7 @@ export const bottleSchema = { type: "object", required: ["id", "name", "brand", "distillers", "category", "statedAge"], properties: { - id: { type: "string" }, + id: { type: "number" }, name: { type: "string" }, brand: { $ref: "/schemas/entity", diff --git a/apps/api/src/schemas/collection.ts b/apps/api/src/schemas/collection.ts index dc385ab5..44af11ea 100644 --- a/apps/api/src/schemas/collection.ts +++ b/apps/api/src/schemas/collection.ts @@ -3,7 +3,7 @@ export const collectionSchema = { type: "object", required: ["id", "name", "createdAt"], properties: { - id: { type: "string" }, + id: { type: "number" }, name: { type: "string" }, createdBy: { $ref: "/schemas/user" }, createdAt: { type: "string" }, diff --git a/apps/api/src/schemas/comment.ts b/apps/api/src/schemas/comment.ts index 7d71bff4..fa453eb7 100644 --- a/apps/api/src/schemas/comment.ts +++ b/apps/api/src/schemas/comment.ts @@ -3,7 +3,7 @@ export const commentSchema = { type: "object", required: ["id", "comment", "createdBy", "createdAt"], properties: { - id: { type: "string" }, + id: { type: "number" }, comment: { type: "string" }, createdBy: { $ref: "/schemas/user" }, createdAt: { type: "string" }, diff --git a/apps/api/src/schemas/edition.ts b/apps/api/src/schemas/edition.ts index 37f5f72b..c5a66d3f 100644 --- a/apps/api/src/schemas/edition.ts +++ b/apps/api/src/schemas/edition.ts @@ -3,7 +3,7 @@ export const editionSchema = { type: "object", required: ["id", "name", "barrel", "vintageYear"], properties: { - id: { type: "string" }, + id: { type: "number" }, name: { type: "string", nullable: true }, barrel: { type: "number", nullable: true }, vintageYear: { type: "number", nullable: true }, diff --git a/apps/api/src/schemas/entity.ts b/apps/api/src/schemas/entity.ts index ee4056cf..43c049e1 100644 --- a/apps/api/src/schemas/entity.ts +++ b/apps/api/src/schemas/entity.ts @@ -11,7 +11,7 @@ export const entitySchema = { "totalBottles", ], properties: { - id: { type: "string" }, + id: { type: "number" }, name: { type: "string" }, type: { $ref: "#/$defs/type", diff --git a/apps/api/src/schemas/follow.ts b/apps/api/src/schemas/follow.ts index 0665c2eb..16bab7bf 100644 --- a/apps/api/src/schemas/follow.ts +++ b/apps/api/src/schemas/follow.ts @@ -3,7 +3,7 @@ export const followingSchema = { type: "object", required: ["id", "status", "createdAt", "user", "followsBack"], properties: { - id: { type: "string" }, + id: { type: "number" }, status: { type: "string", enum: ["pending", "following", "none"], diff --git a/apps/api/src/schemas/notification.ts b/apps/api/src/schemas/notification.ts index 020e564d..4c39738a 100644 --- a/apps/api/src/schemas/notification.ts +++ b/apps/api/src/schemas/notification.ts @@ -5,9 +5,9 @@ export const notificationSchema = { // XXX: ref isnt working right additionalProperties: true, properties: { - id: { type: "string" }, + id: { type: "number" }, objectType: { type: "string", enum: ["follow", "toast", "comment"] }, - objectId: { type: "string" }, + objectId: { type: "number" }, createdAt: { type: "string" }, fromUser: { $ref: "/schemas/user" }, // ref: { diff --git a/apps/api/src/schemas/tasting.ts b/apps/api/src/schemas/tasting.ts index 4edd60e2..72973a5c 100644 --- a/apps/api/src/schemas/tasting.ts +++ b/apps/api/src/schemas/tasting.ts @@ -1,18 +1,3 @@ -export const newTastingSchema = { - $id: "/schemas/newTasting", - type: "object", - required: ["bottle", "rating"], - properties: { - bottle: { type: "number" }, - rating: { type: "number", minimum: 0, maximum: 5 }, - notes: { type: "string", nullable: true }, - tags: { type: "array", items: { type: "string" } }, - edition: { type: "string", nullable: true }, - vintageYear: { type: "number", nullable: true }, - barrel: { type: "number", nullable: true }, - }, -}; - export const tastingSchema = { $id: "/schemas/tasting", type: "object", @@ -29,11 +14,11 @@ export const tastingSchema = { "createdBy", ], properties: { - id: { type: "string" }, + id: { type: "number" }, imageUrl: { type: "string", nullable: true }, notes: { type: "string", nullable: true }, bottle: { $ref: "/schemas/bottle" }, - rating: { type: "number", minimum: 0, maximum: 5 }, + rating: { type: "number", minimum: 0, maximum: 5, nullable: true }, tags: { type: "array", items: { type: "string" } }, comments: { type: "number" }, diff --git a/apps/api/src/schemas/user.ts b/apps/api/src/schemas/user.ts index 5badd007..06be39c7 100644 --- a/apps/api/src/schemas/user.ts +++ b/apps/api/src/schemas/user.ts @@ -3,7 +3,7 @@ export const userSchema = { type: "object", required: ["id", "displayName", "username", "pictureUrl"], properties: { - id: { type: "string" }, + id: { type: "number" }, displayName: { type: "string" }, email: { type: "string", format: "email" }, username: { type: "string" }, diff --git a/apps/scraper/package.json b/apps/scraper/package.json index 80e34b18..b11a0fa9 100644 --- a/apps/scraper/package.json +++ b/apps/scraper/package.json @@ -2,13 +2,13 @@ "private": true, "name": "@peated/scraper", "scripts": { + "build": "tsc", "start": "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", - "tsc": "tsc" + "logos": "ts-node --require dotenv/config ./src/logos.ts" }, "dependencies": { - "@peated/shared": "file:../../packages/shared", + "@peated/shared": "workspace:*", "@swc/core": "^1.3.46", "@types/cheerio": "^0.22.31", "axios": "^1.3.5", diff --git a/apps/scraper/src/main.ts b/apps/scraper/src/main.ts index 0e2e0f37..154a40b5 100644 --- a/apps/scraper/src/main.ts +++ b/apps/scraper/src/main.ts @@ -126,7 +126,7 @@ function parseName(brandName: string, bottleName: string) { function parseAbv(value: string) { if (!value || value === "") return; const amt = value.split(" % ")[0]; - const abv = parseInt(amt * 10, 10) / 100; + const abv = ((parseInt(amt, 10) * 10) / 100).toFixed(2); if (!abv) { console.warn(`Unable to parse abv: ${value}`); return; diff --git a/apps/web/package.json b/apps/web/package.json index 6dd842f1..df03815c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,7 +11,8 @@ "@fontsource/raleway": "^4.5.12", "@headlessui/react": "^1.7.14", "@heroicons/react": "^2.0.17", - "@peated/shared": "file:../../packages/shared", + "@hookform/resolvers": "^3.1.0", + "@peated/shared": "workspace:*", "@react-oauth/google": "^0.11.0", "@rollup/plugin-replace": "^5.0.2", "@sentry/react": "^7.51.0", @@ -30,6 +31,7 @@ "postcss": "^8.4.23", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.43.9", "react-router-dom": "^6.11.0", "tailwindcss": "^3.3.2", "typescript": "^5.0.4", @@ -50,7 +52,8 @@ "workbox-routing": "^6.5.4", "workbox-strategies": "^6.5.4", "workbox-streams": "^6.5.4", - "workbox-window": "^6.5.4" + "workbox-window": "^6.5.4", + "zod": "^3.21.4" }, "proxy": "http://localhost:4000" } diff --git a/apps/web/src/components/bottleMetadata.tsx b/apps/web/src/components/bottleMetadata.tsx index 9da84d81..34b1d7af 100644 --- a/apps/web/src/components/bottleMetadata.tsx +++ b/apps/web/src/components/bottleMetadata.tsx @@ -5,11 +5,11 @@ import Tooltip from "./tooltip"; type Props = { data: { brand?: { - id?: string | undefined | null; + id?: string | number | undefined | null; name: string; }; distillers?: { - id?: string | undefined | null; + id?: string | number | undefined | null; name: string; }[]; }; diff --git a/apps/web/src/components/countryField.tsx b/apps/web/src/components/countryField.tsx index b9f01689..61f93136 100644 --- a/apps/web/src/components/countryField.tsx +++ b/apps/web/src/components/countryField.tsx @@ -276,6 +276,7 @@ export default ({ className, onChange, value, + error, }: Props) => { const options = countryList.map((c) => ({ id: c, @@ -290,6 +291,7 @@ export default ({ helpText={helpText} className={className} options={options} + error={error} onChange={(value) => onChange && onChange(value ? value.name : "")} /> ); diff --git a/apps/web/src/components/formField.tsx b/apps/web/src/components/formField.tsx index 0a1e7f92..a1546135 100644 --- a/apps/web/src/components/formField.tsx +++ b/apps/web/src/components/formField.tsx @@ -11,6 +11,9 @@ type Props = React.ComponentPropsWithoutRef<"div"> & { helpText?: string; required?: boolean; children?: ReactNode; + error?: { + message?: string; + }; className?: string; labelAction?: () => void; onClick?: (e: MouseEvent) => void; @@ -24,6 +27,7 @@ export default ({ labelNote, helpText, htmlFor, + error, labelAction, onClick, }: Props) => { @@ -33,9 +37,16 @@ export default ({ `relative block bg-slate-950 px-3 py-2.5 text-white focus-within:z-10 focus-within:bg-slate-900 hover:bg-slate-900 sm:px-5 sm:py-4`, className, onClick ? "cursor-pointer" : "", + error ? "border border-red-500" : "", )} onClick={onClick} > + {error?.message && ( +
+ {error.message} +
+ )} + {label && ( , "value"> & { label?: string; - buttonLabel?: string; helpText?: string; required?: boolean; children?: ReactNode; className?: string; value?: string | File | undefined; + error?: FieldError; }; const fileToDataUrl = (file: File): Promise => { @@ -31,111 +33,109 @@ const fileToDataUrl = (file: File): Promise => { }); }; -export default ({ - name, - label, - buttonLabel = "Upload Image", - helpText, - required, - className, - value, - onChange, -}: Props) => { - const fileRef = useRef(null); - const imageRef = useRef(null); - const [_isHover, setHover] = useState(false); - const [imageSrc, setImageSrc] = useState(); +export default forwardRef( + ( + { name, label, helpText, required, className, value, error, onChange }, + ref, + ) => { + const fileRef = useRef(null); + const imageRef = useRef(null); + const [_isHover, setHover] = useState(false); + const [imageSrc, setImageSrc] = useState(); - useEffect(() => { - (async () => { - if (value instanceof File) { - setImageSrc(await fileToDataUrl(value)); - } else { - setImageSrc(value || ""); - } - })(); - }, [value]); - - const updatePreview = () => { - const file = Array.from(fileRef.current?.files || []).find(() => true); - if (file) { + useEffect(() => { (async () => { - setImageSrc(await fileToDataUrl(file)); + if (value instanceof File) { + setImageSrc(await fileToDataUrl(value)); + } else { + setImageSrc(value || ""); + } })(); - } else { - if (imageRef.current) imageRef.current.src = ""; - setImageSrc(""); - } - }; + }, [value]); - return ( - { - fileRef.current?.click(); - }} - onDragOver={(e) => { - e.preventDefault(); - }} - onDragEnter={(e) => { - e.preventDefault(); - setHover(true); - }} - onDragExit={(e) => { - e.preventDefault(); - setHover(false); - }} - onMouseOver={() => { - setHover(true); - }} - onMouseOut={() => { - setHover(false); - }} - onDrop={(e) => { - e.preventDefault(); - const dt = new DataTransfer(); - Array.from(e.dataTransfer.files).forEach((f) => dt.items.add(f)); - if (fileRef.current) fileRef.current.files = dt.files; - updatePreview(); - }} - > -
-
- {imageSrc ? ( - - ) : ( - - - Tap to Upload an Image - - )} -
- { - e.stopPropagation(); - updatePreview(); - if (onChange) onChange(e); - }} - /> - {/*
+ const updatePreview = () => { + const file = Array.from(fileRef.current?.files || []).find(() => true); + if (file) { + (async () => { + setImageSrc(await fileToDataUrl(file)); + })(); + } else { + if (imageRef.current) imageRef.current.src = ""; + setImageSrc(""); + } + }; + + return ( + { + fileRef.current?.click(); + }} + onDragOver={(e) => { + e.preventDefault(); + }} + onDragEnter={(e) => { + e.preventDefault(); + setHover(true); + }} + onDragExit={(e) => { + e.preventDefault(); + setHover(false); + }} + onMouseOver={() => { + setHover(true); + }} + onMouseOut={() => { + setHover(false); + }} + onDrop={(e) => { + e.preventDefault(); + const dt = new DataTransfer(); + Array.from(e.dataTransfer.files).forEach((f) => dt.items.add(f)); + if (fileRef.current) fileRef.current.files = dt.files; + updatePreview(); + }} + > +
+
+ {imageSrc ? ( + + ) : ( + + + Tap to Upload an Image + + )} +
+ { + setRef(fileRef, node); + }} + onChange={(e) => { + e.stopPropagation(); + updatePreview(); + if (onChange) onChange(e); + }} + /> + {/*
*/} -
-
- ); -}; +
+ + ); + }, +); diff --git a/apps/web/src/components/notifications/followEntry.tsx b/apps/web/src/components/notifications/followEntry.tsx index 03515315..309f2e7b 100644 --- a/apps/web/src/components/notifications/followEntry.tsx +++ b/apps/web/src/components/notifications/followEntry.tsx @@ -17,7 +17,7 @@ export default ({ ref.followsBack, ); - const acceptRequest = async (id: string) => { + const acceptRequest = async (id: number) => { const data = await api.put(`/followers/${id}`, { data: { action: "accept" }, }); @@ -25,7 +25,7 @@ export default ({ onComplete(); }; - const followUser = async (toUserId: string, follow: boolean) => { + const followUser = async (toUserId: number, follow: boolean) => { const data = await api[follow ? "post" : "delete"]( `/users/${toUserId}/follow`, ); diff --git a/apps/web/src/components/rangeField.tsx b/apps/web/src/components/rangeField.tsx index ad931368..b0c26d4b 100644 --- a/apps/web/src/components/rangeField.tsx +++ b/apps/web/src/components/rangeField.tsx @@ -1,5 +1,5 @@ -import { FormEvent, ReactNode, useState } from "react"; - +import { ChangeEvent, FormEvent, ReactNode, forwardRef, useState } from "react"; +import { FieldError } from "react-hook-form"; import FormField from "./formField"; type Props = { @@ -10,11 +10,11 @@ type Props = { children?: ReactNode; className?: string; value?: number; - min?: number; - max?: number; - step?: number; - factor?: number; - onChange?: (value: number) => void; + min?: string | number; + max?: string | number; + step?: string | number; + error?: FieldError; + onChange?: (e: ChangeEvent) => void; }; type InputEvent = FormEvent & { @@ -23,58 +23,71 @@ type InputEvent = FormEvent & { }; }; -export default ({ - name, - helpText, - label, - required, - className, - min = 0, - max = 20, - step = 1, - factor = 4, - value: initialValue, - onChange, - ...props -}: Props) => { - const [value, setValue] = useState(initialValue || 0); - return ( - - {!value || typeof value !== "number" ? ( - "Not Rated" - ) : ( - {value.toFixed(2)} - )} -
- } - htmlFor={`f-${name}`} - required={required} - helpText={helpText} - className={className} - > - ( + ( + { + name, + helpText, + label, + required, + className, + min = "0.00", + max = "5.00", + step = "0.25", + value: initialValue, + error, + onChange, + ...props + }, + ref, + ) => { + const [value, setValue] = useState(initialValue || 0); + return ( + + {!value || typeof value !== "number" ? ( + "Not Rated" + ) : ( + {value.toFixed(2)} + )} + + } + htmlFor={`f-${name}`} required={required} - min={min} - max={max} - step={step} - onInput={(e) => { - setValue(parseInt((e as InputEvent).target.value, 10) / factor); - }} - onChange={(e) => { - const value = parseInt(e.target.value, 10) / factor; - setValue(value); - onChange && onChange(value); - }} - value={value * factor} - type="range" - className="range range-sm mb-6 block h-1 w-full cursor-pointer appearance-none rounded-lg border-0 bg-gray-200 bg-inherit p-0" - {...props} - /> - - ); -}; + helpText={helpText} + error={error} + className={className} + > + { + setValue(parseFloat((e as InputEvent).target.value)); + }} + onChange={(e) => { + setValue(parseFloat(e.target.value)); + onChange && onChange(e); + }} + /> + + + + + + +
+ ); + }, +); diff --git a/apps/web/src/components/ratingField.tsx b/apps/web/src/components/ratingField.tsx deleted file mode 100644 index f62c3f2c..00000000 --- a/apps/web/src/components/ratingField.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { ReactNode } from "react"; - -import FormField from "./formField"; -import Rating from "./rating"; - -type Props = { - label?: string; - helpText?: string; - required?: boolean; - children?: ReactNode; - className?: string; -} & React.ComponentProps; - -export default ({ - name, - helpText, - label, - required, - className, - ...props -}: Props) => { - return ( - - - - ); -}; diff --git a/apps/web/src/components/selectField/index.tsx b/apps/web/src/components/selectField/index.tsx index 854f9446..0b8f2aec 100644 --- a/apps/web/src/components/selectField/index.tsx +++ b/apps/web/src/components/selectField/index.tsx @@ -26,6 +26,9 @@ type BaseProps = { label?: string; helpText?: string; required?: boolean; + error?: { + message?: string; + }; placeholder?: string; children?: ReactNode; className?: string; @@ -79,6 +82,7 @@ export default ({ endpoint, options = [], onChange, + error, ...props }: Props) => { const initialValue = Array.isArray(props.value) @@ -143,6 +147,7 @@ export default ({ required={required} helpText={helpText} className={className} + error={error} labelAction={() => { setDialogOpen(true); }} diff --git a/apps/web/src/components/selectField/selectDialog.tsx b/apps/web/src/components/selectField/selectDialog.tsx index 04fdc8dd..4cad22d9 100644 --- a/apps/web/src/components/selectField/selectDialog.tsx +++ b/apps/web/src/components/selectField/selectDialog.tsx @@ -14,7 +14,7 @@ import CreateOptionDialog from "./createOptionDialog"; import { CreateOptionForm } from "./types"; export type Option = { - id?: string | null; + id?: string | number | null; name: string; [key: string]: any; }; diff --git a/apps/web/src/components/selectField/types.tsx b/apps/web/src/components/selectField/types.tsx index cecca6fe..e6817782 100644 --- a/apps/web/src/components/selectField/types.tsx +++ b/apps/web/src/components/selectField/types.tsx @@ -1,7 +1,7 @@ import { ReactNode } from "react"; export type Option = { - id?: string | null; + id?: string | number | null; name: string; count?: number; [key: string]: any; diff --git a/apps/web/src/components/tastingList.tsx b/apps/web/src/components/tastingList.tsx index 44161134..518272db 100644 --- a/apps/web/src/components/tastingList.tsx +++ b/apps/web/src/components/tastingList.tsx @@ -10,7 +10,7 @@ export default ({ values: Tasting[]; noBottle?: boolean; }) => { - const [deletedValues, setDeletedValues] = useState([]); + const [deletedValues, setDeletedValues] = useState([]); const onDelete = (tasting: Tasting) => { setDeletedValues((arr) => [...arr, tasting.id]); diff --git a/apps/web/src/components/textArea.tsx b/apps/web/src/components/textArea.tsx index 6772a176..ac3fc42a 100644 --- a/apps/web/src/components/textArea.tsx +++ b/apps/web/src/components/textArea.tsx @@ -1,10 +1,16 @@ -export default ({ ...props }: React.ComponentPropsWithoutRef<"textarea">) => { +import { forwardRef } from "react"; + +export default forwardRef< + HTMLTextAreaElement, + React.ComponentPropsWithoutRef<"textarea"> +>((props, ref) => { const baseStyles = "bg-inherit rounded border-0 focus:ring-0"; const inputStyles = "placeholder:text-slate-700 sm:leading-6"; return (