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..5763740d 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -9,26 +9,6 @@ import { router } from "./routes"; import { initSentry } from "./instruments"; import FastifySentry from "./sentryPlugin"; -import { - bottleSchema, - newBottleSchema, - updateBottleSchema, -} from "./schemas/bottle"; -import { collectionSchema } from "./schemas/collection"; -import { commentSchema, newCommentSchema } from "./schemas/comment"; -import { editionSchema, newEditionSchema } from "./schemas/edition"; -import { - entitySchema, - newEntitySchema, - updateEntitySchema, -} from "./schemas/entity"; -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 { updateUserSchema, userSchema } from "./schemas/user"; - initSentry({ dsn: config.SENTRY_DSN, release: config.VERSION, @@ -83,27 +63,6 @@ export default async function buildFastify(options = {}) { }); app.register(FastifyCors, { credentials: true, origin: config.CORS_HOST }); - app.addSchema(bottleSchema); - app.addSchema(newBottleSchema); - app.addSchema(updateBottleSchema); - app.addSchema(entitySchema); - app.addSchema(newEntitySchema); - app.addSchema(updateEntitySchema); - app.addSchema(followingSchema); - app.addSchema(pagingSchema); - app.addSchema(userSchema); - app.addSchema(updateUserSchema); - app.addSchema(notificationSchema); - app.addSchema(tastingSchema); - app.addSchema(collectionSchema); - app.addSchema(commentSchema); - app.addSchema(newCommentSchema); - app.addSchema(newTastingSchema); - app.addSchema(editionSchema); - app.addSchema(newEditionSchema); - - app.addSchema(error401Schema); - app.register(router); app.register(FastifySentry); 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/db.ts b/apps/api/src/lib/db.ts index fb7da58b..a17482f2 100644 --- a/apps/api/src/lib/db.ts +++ b/apps/api/src/lib/db.ts @@ -4,7 +4,12 @@ import { Entity, EntityType, NewEntity, changes, entities } from "../db/schema"; export type EntityInput = | number - | { name: string; country: string; region?: string }; + | { + name: string; + country?: string; + region?: string; + type?: ("brand" | "bottler" | "distiller")[]; + }; export type UpsertOutcome = | { @@ -53,7 +58,7 @@ export const upsertEntity = async ({ name: data.name, country: data.country || null, region: data.region || null, - type: [type], + type: Array.from(new Set([type, ...(data.type || [])])), createdById: userId, }) .onConflictDoNothing() 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/notification.ts b/apps/api/src/lib/serializers/notification.ts index 927796e5..dc0191da 100644 --- a/apps/api/src/lib/serializers/notification.ts +++ b/apps/api/src/lib/serializers/notification.ts @@ -18,9 +18,13 @@ import { UserSerializer } from "./user"; export const NotificationSerializer: Serializer = { attrs: async (itemList: Notification[], currentUser: User) => { - const fromUserIds = itemList - .filter((i) => Boolean(i.fromUserId)) - .map((i) => i.fromUserId as number); + const fromUserIds = Array.from( + new Set( + itemList + .filter((i) => Boolean(i.fromUserId)) + .map((i) => i.fromUserId as number), + ), + ); const fromUserList = fromUserIds.length ? await db.select().from(users).where(inArray(users.id, fromUserIds)) @@ -129,12 +133,13 @@ export const NotificationSerializer: Serializer = { item: (item: Notification, attrs: Record, currentUser: User) => { return { - id: `${item.id}`, + id: item.id, objectType: item.objectType, objectId: item.objectId, createdAt: item.createdAt, fromUser: attrs.fromUser, ref: attrs.ref, + read: item.read, }; }, }; diff --git a/apps/api/src/lib/serializers/tasting.ts b/apps/api/src/lib/serializers/tasting.ts index 714da64a..e6045627 100644 --- a/apps/api/src/lib/serializers/tasting.ts +++ b/apps/api/src/lib/serializers/tasting.ts @@ -67,8 +67,7 @@ export const TastingSerializer: Serializer = { ); const editionList = results.map((r) => r.edition).filter(notEmpty); - - const editionsByRef = Object.fromEntries( + const editionsById = Object.fromEntries( (await serialize(EditionSerializer, editionList, currentUser)).map( (data, index) => [editionList[index].id, data], ), @@ -80,7 +79,7 @@ export const TastingSerializer: Serializer = { item.id, { hasToasted: userToastsList.indexOf(item.id) !== -1, - edition: item.editionId ? editionsByRef[item.id] : null, + edition: item.editionId ? editionsById[item.editionId] : null, createdBy: usersByRef[item.id] || null, bottle: bottlesByRef[item.id] || null, }, @@ -91,7 +90,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/addBottle.ts b/apps/api/src/routes/addBottle.ts index 980e1411..dc0c2f59 100644 --- a/apps/api/src/routes/addBottle.ts +++ b/apps/api/src/routes/addBottle.ts @@ -1,38 +1,23 @@ +import { BottleInputSchema, BottleSchema } from "@peated/shared/schemas"; import { eq, inArray, sql } from "drizzle-orm"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; +import { z } from "zod"; +import zodToJsonSchema from "zod-to-json-schema"; import { db } from "../db"; -import { - Category, - bottles, - bottlesToDistillers, - changes, - entities, -} from "../db/schema"; -import { EntityInput, upsertEntity } from "../lib/db"; +import { bottles, bottlesToDistillers, changes, entities } from "../db/schema"; +import { upsertEntity } from "../lib/db"; import { serialize } from "../lib/serializers"; import { BottleSerializer } from "../lib/serializers/bottle"; import { requireAuth } from "../middleware/auth"; -type BottleInput = { - name: string; - category: Category; - brand: EntityInput; - distillers: EntityInput[]; - statedAge?: number; -}; - export default { method: "POST", url: "/bottles", schema: { - body: { - $ref: "/schemas/newBottle", - }, + body: zodToJsonSchema(BottleInputSchema), response: { - 201: { - $ref: "/schemas/bottle", - }, + 201: zodToJsonSchema(BottleSchema), }, }, preHandler: [requireAuth], @@ -68,16 +53,24 @@ export default { }); } - const [bottle] = await tx - .insert(bottles) - .values({ - name: body.name, - statedAge: body.statedAge || null, - category: body.category || null, - brandId: brand.id, - createdById: req.user.id, - }) - .returning(); + try { + const [bottle] = await tx + .insert(bottles) + .values({ + name: body.name, + statedAge: body.statedAge || null, + category: body.category || null, + brandId: brand.id, + createdById: req.user.id, + }) + .returning(); + } catch (err: any) { + if (err?.code === "23505" && err?.constraint === "bottle_brand_unq") { + return res + .status(409) + .send({ error: "Bottle with name already exists under brand." }); + } + } const distillerIds: number[] = []; if (body.distillers) @@ -131,6 +124,6 @@ export default { IncomingMessage, ServerResponse, { - Body: BottleInput; + Body: z.infer; } >; diff --git a/apps/api/src/routes/addEntity.ts b/apps/api/src/routes/addEntity.ts index 816a41c8..c0745d5a 100644 --- a/apps/api/src/routes/addEntity.ts +++ b/apps/api/src/routes/addEntity.ts @@ -1,6 +1,9 @@ +import { EntityInputSchema, EntitySchema } from "@peated/shared/schemas"; import { 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 { NewEntity, changes, entities } from "../db/schema"; import { serialize } from "../lib/serializers"; @@ -11,13 +14,9 @@ export default { method: "POST", url: "/entities", schema: { - body: { - $ref: "/schemas/newEntity", - }, + body: zodToJsonSchema(EntityInputSchema), response: { - 201: { - $ref: "/schemas/entity", - }, + 201: zodToJsonSchema(EntitySchema), }, }, preHandler: [requireMod], @@ -69,7 +68,7 @@ export default { }); if (!entity) { - return res.status(409).send("Unable to create entity"); + return res.status(409).send({ error: "Unable to create entity" }); } res.status(201).send(await serialize(EntitySerializer, entity, req.user)); @@ -79,6 +78,6 @@ export default { IncomingMessage, ServerResponse, { - Body: NewEntity; + Body: z.infer; } >; diff --git a/apps/api/src/routes/addTasting.ts b/apps/api/src/routes/addTasting.ts index 4ab9c280..969332b4 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 { TastingInputSchema, TastingSchema } from "@peated/shared/schemas"; + import { db } from "../db"; import { - NewTasting, bottles, bottlesToDistillers, changes, @@ -19,13 +23,9 @@ export default { method: "POST", url: "/tastings", schema: { - body: { - $ref: "/schemas/newTasting", - }, + body: zodToJsonSchema(TastingInputSchema), response: { - 201: { - $ref: "/schemas/tasting", - }, + 201: zodToJsonSchema(TastingSchema), }, }, preHandler: [requireAuth], @@ -158,11 +158,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/addTastingComment.ts b/apps/api/src/routes/addTastingComment.ts index 3554e647..13bb2487 100644 --- a/apps/api/src/routes/addTastingComment.ts +++ b/apps/api/src/routes/addTastingComment.ts @@ -1,6 +1,8 @@ +import { CommentInputSchema, CommentSchema } from "@peated/shared/schemas"; import { eq, sql } from "drizzle-orm"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; +import zodToJsonSchema from "zod-to-json-schema"; import { db, first } from "../db"; import { Comment, NewComment, comments, tastings } from "../db/schema"; import { isDistantFuture, isDistantPast } from "../lib/dates"; @@ -20,13 +22,9 @@ export default { tastingId: { type: "number" }, }, }, - body: { - $ref: "/schemas/newComment", - }, + body: zodToJsonSchema(CommentInputSchema), response: { - 201: { - $ref: "/schemas/comment", - }, + 201: zodToJsonSchema(CommentSchema), }, }, preHandler: [requireAuth], diff --git a/apps/api/src/routes/authBasic.ts b/apps/api/src/routes/authBasic.ts index 03f3d063..ebb68945 100644 --- a/apps/api/src/routes/authBasic.ts +++ b/apps/api/src/routes/authBasic.ts @@ -1,8 +1,10 @@ import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; +import { AuthSchema } from "@peated/shared/schemas"; import { compareSync } from "bcrypt"; import { eq } from "drizzle-orm"; +import zodToJsonSchema from "zod-to-json-schema"; import { db } from "../db"; import { users } from "../db/schema"; import { createAccessToken } from "../lib/auth"; @@ -22,17 +24,7 @@ export default { }, }, response: { - 200: { - type: "object", - required: ["user", "accessToken"], - properties: { - user: { $ref: "/schemas/user" }, - accessToken: { type: "string" }, - }, - }, - 401: { - $ref: "/errors/401", - }, + 200: zodToJsonSchema(AuthSchema), }, }, handler: async function (req, res) { diff --git a/apps/api/src/routes/authDetails.ts b/apps/api/src/routes/authDetails.ts index 8715752e..9aa87606 100644 --- a/apps/api/src/routes/authDetails.ts +++ b/apps/api/src/routes/authDetails.ts @@ -1,7 +1,9 @@ import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; +import { AuthSchema } from "@peated/shared/schemas"; import { eq } from "drizzle-orm"; +import zodToJsonSchema from "zod-to-json-schema"; import { db } from "../db"; import { users } from "../db/schema"; import { serialize } from "../lib/serializers"; @@ -14,16 +16,7 @@ export default { preHandler: [requireAuth], schema: { response: { - 200: { - type: "object", - required: ["user"], - properties: { - user: { $ref: "/schemas/user" }, - }, - }, - 401: { - $ref: "/errors/401", - }, + 200: zodToJsonSchema(AuthSchema), }, }, handler: async function (req, res) { diff --git a/apps/api/src/routes/authGoogle.ts b/apps/api/src/routes/authGoogle.ts index bcf3ae0f..393e86e9 100644 --- a/apps/api/src/routes/authGoogle.ts +++ b/apps/api/src/routes/authGoogle.ts @@ -1,8 +1,10 @@ import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; +import { AuthSchema } from "@peated/shared/schemas"; import { and, eq } from "drizzle-orm"; import { OAuth2Client } from "google-auth-library"; +import zodToJsonSchema from "zod-to-json-schema"; import config from "../config"; import { db } from "../db"; import { identities, users } from "../db/schema"; @@ -22,17 +24,7 @@ export default { }, }, response: { - 200: { - type: "object", - required: ["user", "accessToken"], - properties: { - user: { $ref: "/schemas/user" }, - accessToken: { type: "string" }, - }, - }, - 401: { - $ref: "/errors/401", - }, + 200: zodToJsonSchema(AuthSchema), }, }, handler: async function (req, res) { 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/getBottle.tsx b/apps/api/src/routes/getBottle.tsx index d3adeeba..c4ac86d3 100644 --- a/apps/api/src/routes/getBottle.tsx +++ b/apps/api/src/routes/getBottle.tsx @@ -1,6 +1,9 @@ +import { BottleSchema } 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 } from "../db"; import { bottles, tastings } from "../db/schema"; import { serialize } from "../lib/serializers"; @@ -18,15 +21,13 @@ export default { }, }, response: { - 200: { - type: "object", - allOf: [{ $ref: "/schemas/bottle" }], - properties: { - avgRating: { type: "number" }, - tastings: { type: "number" }, - people: { type: "number" }, - }, - }, + 200: zodToJsonSchema( + BottleSchema.extend({ + avgRating: z.number(), + tastings: z.number(), + people: z.number(), + }), + ), }, }, handler: async (req, res) => { 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/getEntity.ts b/apps/api/src/routes/getEntity.ts index 2b22b195..43cfa2c1 100644 --- a/apps/api/src/routes/getEntity.ts +++ b/apps/api/src/routes/getEntity.ts @@ -1,6 +1,8 @@ +import { EntitySchema } from "@peated/shared/schemas"; import { eq } from "drizzle-orm"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; +import zodToJsonSchema from "zod-to-json-schema"; import { db } from "../db"; import { entities } from "../db/schema"; import { serialize } from "../lib/serializers"; @@ -18,9 +20,7 @@ export default { }, }, response: { - 200: { - $ref: "/schemas/entity", - }, + 200: zodToJsonSchema(EntitySchema), }, }, handler: async (req, res) => { 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/getTasting.ts b/apps/api/src/routes/getTasting.ts index 406bef34..b607ee6b 100644 --- a/apps/api/src/routes/getTasting.ts +++ b/apps/api/src/routes/getTasting.ts @@ -1,6 +1,8 @@ +import { TastingSchema } from "@peated/shared/schemas"; import { eq } from "drizzle-orm"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; +import zodToJsonSchema from "zod-to-json-schema"; import { db } from "../db"; import { tastings } from "../db/schema"; import { serialize } from "../lib/serializers"; @@ -18,9 +20,7 @@ export default { }, }, response: { - 200: { - $ref: "/schemas/tasting", - }, + 200: zodToJsonSchema(TastingSchema), }, }, handler: async (req, res) => { 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/getUser.ts b/apps/api/src/routes/getUser.ts index 94830ded..c57cd28a 100644 --- a/apps/api/src/routes/getUser.ts +++ b/apps/api/src/routes/getUser.ts @@ -1,6 +1,9 @@ +import { UserSchema } 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 } from "../db"; import { User, changes, tastings, users } from "../db/schema"; import { serialize } from "../lib/serializers"; @@ -20,6 +23,17 @@ export default { }, }, }, + response: { + 200: zodToJsonSchema( + UserSchema.extend({ + stats: z.object({ + tastings: z.number(), + bottles: z.number(), + contributions: z.number(), + }), + }), + ), + }, }, preHandler: [requireAuth], handler: async (req, res) => { diff --git a/apps/api/src/routes/listBottleSuggestedTags.ts b/apps/api/src/routes/listBottleSuggestedTags.ts index 8781b447..d74a2b61 100644 --- a/apps/api/src/routes/listBottleSuggestedTags.ts +++ b/apps/api/src/routes/listBottleSuggestedTags.ts @@ -1,6 +1,8 @@ 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 } from "../db"; import { bottles, tastings } from "../db/schema"; import { shuffle } from "../lib/rand"; @@ -18,26 +20,16 @@ export default { }, }, response: { - 200: { - type: "object", - properties: { - results: { - type: "array", - items: { - type: "object", - required: ["name", "count"], - properties: { - name: { type: "string" }, - count: { type: "number" }, - }, - }, - }, - rel: { - type: "object", - $ref: "/schemas/paging", - }, - }, - }, + 200: zodToJsonSchema( + z.object({ + results: z.array( + z.object({ + name: z.string(), + count: z.number(), + }), + ), + }), + ), }, }, handler: async (req, res) => { 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/listBottles.ts b/apps/api/src/routes/listBottles.ts index bf6976ba..a98efaf1 100644 --- a/apps/api/src/routes/listBottles.ts +++ b/apps/api/src/routes/listBottles.ts @@ -1,6 +1,9 @@ +import { BottleSchema, PaginatedSchema } from "@peated/shared/schemas"; import { SQL, and, asc, desc, eq, ilike, or, sql } from "drizzle-orm"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; +import { z } from "zod"; +import zodToJsonSchema from "zod-to-json-schema"; import { db } from "../db"; import { bottles, bottlesToDistillers, entities } from "../db/schema"; import { buildPageLink } from "../lib/paging"; @@ -23,21 +26,11 @@ export default { }, }, response: { - 200: { - type: "object", - properties: { - results: { - type: "array", - items: { - $ref: "/schemas/bottle", - }, - }, - rel: { - type: "object", - $ref: "/schemas/paging", - }, - }, - }, + 200: zodToJsonSchema( + PaginatedSchema.extend({ + results: z.array(BottleSchema), + }), + ), }, }, handler: async (req, res) => { diff --git a/apps/api/src/routes/listCollections.ts b/apps/api/src/routes/listCollections.ts index 59f7aca0..235d5a1e 100644 --- a/apps/api/src/routes/listCollections.ts +++ b/apps/api/src/routes/listCollections.ts @@ -1,6 +1,9 @@ +import { CollectionSchema, PaginatedSchema } from "@peated/shared/schemas"; import { and, 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 { collections } from "../db/schema"; import { buildPageLink } from "../lib/paging"; @@ -20,21 +23,11 @@ export default { }, }, response: { - 200: { - type: "object", - properties: { - results: { - type: "array", - items: { - $ref: "/schemas/collection", - }, - }, - rel: { - type: "object", - $ref: "/schemas/paging", - }, - }, - }, + 200: zodToJsonSchema( + PaginatedSchema.extend({ + results: z.array(CollectionSchema), + }), + ), }, }, preHandler: [requireAuth], 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/listComments.ts b/apps/api/src/routes/listComments.ts index 58efeb5e..f646b290 100644 --- a/apps/api/src/routes/listComments.ts +++ b/apps/api/src/routes/listComments.ts @@ -1,6 +1,9 @@ +import { CommentSchema, PaginatedSchema } from "@peated/shared/schemas"; import { and, 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 { comments } from "../db/schema"; import { buildPageLink } from "../lib/paging"; @@ -21,21 +24,11 @@ export default { }, }, response: { - 200: { - type: "object", - properties: { - results: { - type: "array", - items: { - $ref: "/schemas/comment", - }, - }, - rel: { - type: "object", - $ref: "/schemas/paging", - }, - }, - }, + 200: zodToJsonSchema( + PaginatedSchema.extend({ + results: z.array(CommentSchema), + }), + ), }, }, preHandler: [requireAuth], diff --git a/apps/api/src/routes/listEntities.ts b/apps/api/src/routes/listEntities.ts index f8522e63..bdf4c8a2 100644 --- a/apps/api/src/routes/listEntities.ts +++ b/apps/api/src/routes/listEntities.ts @@ -1,6 +1,9 @@ +import { EntitySchema, PaginatedSchema } from "@peated/shared/schemas"; import { SQL, and, asc, desc, ilike, sql } from "drizzle-orm"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; +import { z } from "zod"; +import zodToJsonSchema from "zod-to-json-schema"; import { db } from "../db"; import { EntityType, entities } from "../db/schema"; import { buildPageLink } from "../lib/paging"; @@ -21,20 +24,11 @@ export default { }, }, response: { - 200: { - type: "object", - properties: { - results: { - type: "array", - items: { - $ref: "/schemas/entity", - }, - }, - rel: { - $ref: "/schemas/paging", - }, - }, - }, + 200: zodToJsonSchema( + PaginatedSchema.extend({ + results: z.array(EntitySchema), + }), + ), }, }, handler: async (req, res) => { diff --git a/apps/api/src/routes/listFollowers.ts b/apps/api/src/routes/listFollowers.ts index 00d9e7c0..ff8e31b3 100644 --- a/apps/api/src/routes/listFollowers.ts +++ b/apps/api/src/routes/listFollowers.ts @@ -1,6 +1,9 @@ +import { FollowSchema, PaginatedSchema } from "@peated/shared/schemas"; import { and, asc, desc, 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 { follows } from "../db/schema"; import { buildPageLink } from "../lib/paging"; @@ -21,21 +24,11 @@ export default { }, }, response: { - 200: { - type: "object", - properties: { - results: { - type: "array", - items: { - $ref: "/schemas/follow", - }, - }, - rel: { - type: "object", - $ref: "/schemas/paging", - }, - }, - }, + 200: zodToJsonSchema( + PaginatedSchema.extend({ + results: z.array(FollowSchema), + }), + ), }, }, preHandler: [requireAuth], 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/listFollowing.ts b/apps/api/src/routes/listFollowing.ts index 7b7a08b5..aeceaed4 100644 --- a/apps/api/src/routes/listFollowing.ts +++ b/apps/api/src/routes/listFollowing.ts @@ -1,6 +1,9 @@ +import { FollowSchema, PaginatedSchema } from "@peated/shared/schemas"; import { and, asc, desc, 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 { follows } from "../db/schema"; import { buildPageLink } from "../lib/paging"; @@ -21,20 +24,11 @@ export default { }, }, response: { - 200: { - type: "object", - properties: { - results: { - type: "array", - items: { - $ref: "/schemas/follow", - }, - }, - rel: { - $ref: "/schemas/paging", - }, - }, - }, + 200: zodToJsonSchema( + PaginatedSchema.extend({ + results: z.array(FollowSchema), + }), + ), }, }, preHandler: [requireAuth], 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/listNotifications.ts b/apps/api/src/routes/listNotifications.ts index 72836b44..6ffedae0 100644 --- a/apps/api/src/routes/listNotifications.ts +++ b/apps/api/src/routes/listNotifications.ts @@ -20,21 +20,12 @@ export default { }, }, response: { - 200: { - type: "object", - properties: { - results: { - type: "array", - items: { - $ref: "/schemas/notification", - }, - }, - rel: { - type: "object", - $ref: "/schemas/paging", - }, - }, - }, + // TODO: theres an issue w/ the ref type + // 200: zodToJsonSchema( + // PaginatedSchema.extend({ + // results: z.array(NotificationSchema), + // }), + // ), }, }, preHandler: [requireAuth], 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/listTastings.ts b/apps/api/src/routes/listTastings.ts index a1320ac0..d29ef8ac 100644 --- a/apps/api/src/routes/listTastings.ts +++ b/apps/api/src/routes/listTastings.ts @@ -1,6 +1,9 @@ +import { PaginatedSchema, TastingSchema } from "@peated/shared/schemas"; import { SQL, and, desc, eq, sql } from "drizzle-orm"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; +import { z } from "zod"; +import zodToJsonSchema from "zod-to-json-schema"; import { db } from "../db"; import { follows, tastings } from "../db/schema"; import { buildPageLink } from "../lib/paging"; @@ -21,6 +24,13 @@ export default { filter: { type: "string", enum: ["global", "friends", "local"] }, }, }, + response: { + 200: zodToJsonSchema( + PaginatedSchema.extend({ + results: z.array(TastingSchema), + }), + ), + }, }, preValidation: [injectAuth], handler: async (req, res) => { 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/routes/listUsers.ts b/apps/api/src/routes/listUsers.ts index 2c0b5220..d2e4e538 100644 --- a/apps/api/src/routes/listUsers.ts +++ b/apps/api/src/routes/listUsers.ts @@ -1,6 +1,10 @@ import { SQL, and, asc, ilike, or } from "drizzle-orm"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; +import { z } from "zod"; + +import { PaginatedSchema, UserSchema } from "@peated/shared/schemas"; +import zodToJsonSchema from "zod-to-json-schema"; import { db } from "../db"; import { users } from "../db/schema"; import { buildPageLink } from "../lib/paging"; @@ -20,21 +24,11 @@ export default { }, }, response: { - 200: { - type: "object", - properties: { - results: { - type: "array", - items: { - $ref: "/schemas/user", - }, - }, - rel: { - type: "object", - $ref: "/schemas/paging", - }, - }, - }, + 200: zodToJsonSchema( + PaginatedSchema.extend({ + results: z.array(UserSchema), + }), + ), }, }, preHandler: [requireAuth], diff --git a/apps/api/src/routes/updateBottle.ts b/apps/api/src/routes/updateBottle.ts index 5eb36688..eec8e826 100644 --- a/apps/api/src/routes/updateBottle.ts +++ b/apps/api/src/routes/updateBottle.ts @@ -1,27 +1,16 @@ +import { BottleInputSchema, BottleSchema } from "@peated/shared/schemas"; import { and, 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 { - Category, - bottles, - bottlesToDistillers, - changes, - entities, -} from "../db/schema"; -import { EntityInput, upsertEntity } from "../lib/db"; +import { bottles, bottlesToDistillers, changes, entities } from "../db/schema"; +import { upsertEntity } from "../lib/db"; import { serialize } from "../lib/serializers"; import { BottleSerializer } from "../lib/serializers/bottle"; import { requireMod } from "../middleware/auth"; -type BottleInput = { - name: string; - category?: Category | null; - brand: EntityInput; - distillers: EntityInput[]; - statedAge?: number | null; -}; - export default { method: "PUT", url: "/bottles/:bottleId", @@ -33,13 +22,9 @@ export default { bottleId: { type: "number" }, }, }, - body: { - $ref: "/schemas/updateBottle", - }, + body: zodToJsonSchema(BottleInputSchema.partial()), response: { - 200: { - $ref: "/schemas/bottle", - }, + 200: zodToJsonSchema(BottleSchema), }, }, preHandler: [requireMod], @@ -83,15 +68,23 @@ export default { .where(eq(bottlesToDistillers.bottleId, bottle.id)) ).map(({ distiller }) => distiller); const newBottle = await db.transaction(async (tx) => { - const newBottle = Object.values(bottleData).length - ? ( - await tx - .update(bottles) - .set(bottleData) - .where(eq(bottles.id, bottle.id)) - .returning() - )[0] - : bottle; + try { + const newBottle = Object.values(bottleData).length + ? ( + await tx + .update(bottles) + .set(bottleData) + .where(eq(bottles.id, bottle.id)) + .returning() + )[0] + : bottle; + } catch (err: any) { + if (err?.code === "23505" && err?.constraint === "bottle_brand_unq") { + return res + .status(409) + .send({ error: "Bottle with name already exists under brand." }); + } + } if (body.brand) { if ( @@ -197,6 +190,6 @@ export default { Params: { bottleId: number; }; - Body: Partial; + Body: Partial>; } >; diff --git a/apps/api/src/routes/updateEntity.ts b/apps/api/src/routes/updateEntity.ts index 50aab861..802ebcb9 100644 --- a/apps/api/src/routes/updateEntity.ts +++ b/apps/api/src/routes/updateEntity.ts @@ -1,17 +1,15 @@ import { 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 { EntityInputSchema, EntitySchema } from "@peated/shared/schemas"; + import { db } from "../db"; -import { EntityType, changes, entities } from "../db/schema"; +import { changes, entities } from "../db/schema"; import { requireMod } from "../middleware/auth"; -type EntityInput = { - name: string; - country: string; - region: string; - type: EntityType[]; -}; - function arraysEqual(one: T[], two: T[]) { if (one.length !== two.length) return false; for (let i = 0; i < one.length; i++) { @@ -31,13 +29,9 @@ export default { entityId: { type: "number" }, }, }, - body: { - $ref: "/schemas/updateEntity", - }, + body: zodToJsonSchema(EntityInputSchema.partial()), response: { - 200: { - $ref: "/schemas/entity", - }, + 200: zodToJsonSchema(EntitySchema), }, }, preHandler: [requireMod], @@ -100,6 +94,6 @@ export default { Params: { entityId: number; }; - Body: Partial; + Body: Partial>; } >; diff --git a/apps/api/src/routes/updateFollower.ts b/apps/api/src/routes/updateFollower.ts index d1db913f..316dff32 100644 --- a/apps/api/src/routes/updateFollower.ts +++ b/apps/api/src/routes/updateFollower.ts @@ -1,6 +1,8 @@ +import { FollowSchema } from "@peated/shared/schemas"; import { and, eq } from "drizzle-orm"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; +import zodToJsonSchema from "zod-to-json-schema"; import { db } from "../db"; import { follows } from "../db/schema"; import { serialize } from "../lib/serializers"; @@ -26,9 +28,7 @@ export default { }, }, response: { - 200: { - $ref: "/schemas/follow", - }, + 200: zodToJsonSchema(FollowSchema), }, }, preHandler: [requireAuth], diff --git a/apps/api/src/routes/updateUser.ts b/apps/api/src/routes/updateUser.ts index 319fbb25..8f19faf9 100644 --- a/apps/api/src/routes/updateUser.ts +++ b/apps/api/src/routes/updateUser.ts @@ -1,8 +1,12 @@ import { 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 { UserInputSchema, UserSchema } from "@peated/shared/schemas"; import { db } from "../db"; -import { User, users } from "../db/schema"; +import { users } from "../db/schema"; import { serialize } from "../lib/serializers"; import { UserSerializer } from "../lib/serializers/user"; import { requireAuth } from "../middleware/auth"; @@ -18,13 +22,9 @@ export default { userId: { anyOf: [{ type: "number" }, { const: "me" }] }, }, }, - body: { - $ref: "/schemas/updateUser", - }, + body: zodToJsonSchema(UserInputSchema.partial()), response: { - 200: { - $ref: "/schemas/user", - }, + 200: zodToJsonSchema(UserSchema), }, }, preHandler: [requireAuth], @@ -98,6 +98,6 @@ export default { Params: { userId: number | "me"; }; - Body: Partial>; + Body: Partial>; } >; diff --git a/apps/api/src/schemas/bottle.ts b/apps/api/src/schemas/bottle.ts deleted file mode 100644 index dcd5cc66..00000000 --- a/apps/api/src/schemas/bottle.ts +++ /dev/null @@ -1,129 +0,0 @@ -export const bottleSchema = { - $id: "/schemas/bottle", - type: "object", - required: ["id", "name", "brand", "distillers", "category", "statedAge"], - properties: { - id: { type: "string" }, - name: { type: "string" }, - brand: { - $ref: "/schemas/entity", - }, - distillers: { - type: "array", - items: { - $ref: "/schemas/entity", - }, - }, - category: { - type: "string", - nullable: true, - enum: [ - null, - "blend", - "bourbon", - "rye", - "single_grain", - "single_malt", - "spirit", - ], - }, - statedAge: { - type: "number", - nullable: true, - }, - - createdAt: { type: "string" }, - createdBy: { $ref: "/schemas/user" }, - }, -}; - -export const newBottleSchema = { - $id: "/schemas/newBottle", - type: "object", - required: ["name", "brand"], - properties: { - name: { type: "string" }, - brand: { - oneOf: [ - { type: "number" }, - { - $ref: "/schemas/newEntity", - }, - ], - }, - distillers: { - type: "array", - items: { - oneOf: [ - { type: "number" }, - { - $ref: "/schemas/newEntity", - }, - ], - }, - }, - category: { - type: "string", - nullable: true, - enum: [ - null, - "", - "blend", - "bourbon", - "rye", - "single_grain", - "single_malt", - "spirit", - ], - }, - statedAge: { - type: "number", - nullable: true, - }, - }, -}; - -export const updateBottleSchema = { - $id: "/schemas/updateBottle", - type: "object", - properties: { - name: { type: "string" }, - brand: { - oneOf: [ - { type: "number" }, - { - $ref: "/schemas/newEntity", - }, - ], - }, - distillers: { - type: "array", - items: { - oneOf: [ - { type: "number" }, - { - $ref: "/schemas/newEntity", - }, - ], - }, - }, - category: { - type: "string", - nullable: true, - enum: [ - null, - "", - "blend", - "bourbon", - "rye", - "single_grain", - "single_malt", - "spirit", - ], - }, - statedAge: { - type: "number", - nullable: true, - }, - }, -}; diff --git a/apps/api/src/schemas/collection.ts b/apps/api/src/schemas/collection.ts deleted file mode 100644 index dc385ab5..00000000 --- a/apps/api/src/schemas/collection.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const collectionSchema = { - $id: "/schemas/collection", - type: "object", - required: ["id", "name", "createdAt"], - properties: { - id: { type: "string" }, - 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 deleted file mode 100644 index 7d71bff4..00000000 --- a/apps/api/src/schemas/comment.ts +++ /dev/null @@ -1,21 +0,0 @@ -export const commentSchema = { - $id: "/schemas/comment", - type: "object", - required: ["id", "comment", "createdBy", "createdAt"], - properties: { - id: { type: "string" }, - comment: { type: "string" }, - createdBy: { $ref: "/schemas/user" }, - createdAt: { type: "string" }, - }, -}; - -export const newCommentSchema = { - $id: "/schemas/newComment", - type: "object", - required: ["comment", "createdAt"], - properties: { - comment: { type: "string" }, - createdAt: { type: "string" }, - }, -}; diff --git a/apps/api/src/schemas/edition.ts b/apps/api/src/schemas/edition.ts deleted file mode 100644 index 37f5f72b..00000000 --- a/apps/api/src/schemas/edition.ts +++ /dev/null @@ -1,23 +0,0 @@ -export const editionSchema = { - $id: "/schemas/edition", - type: "object", - required: ["id", "name", "barrel", "vintageYear"], - properties: { - id: { type: "string" }, - name: { type: "string", nullable: true }, - barrel: { type: "number", nullable: true }, - vintageYear: { type: "number", nullable: true }, - createdAt: { type: "string" }, - createdBy: { $ref: "/schemas/user" }, - }, -}; - -export const newEditionSchema = { - $id: "/schemas/newEdition", - type: "object", - properties: { - 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 deleted file mode 100644 index ee4056cf..00000000 --- a/apps/api/src/schemas/entity.ts +++ /dev/null @@ -1,65 +0,0 @@ -export const entitySchema = { - $id: "/schemas/entity", - type: "object", - required: [ - "id", - "name", - "type", - "country", - "region", - "totalTastings", - "totalBottles", - ], - properties: { - id: { type: "string" }, - name: { type: "string" }, - type: { - $ref: "#/$defs/type", - }, - country: { type: "string", nullable: true }, - region: { type: "string", nullable: true }, - - totalTastings: { type: "number" }, - totalBottles: { type: "number" }, - - createdAt: { type: "string" }, - createdBy: { $ref: "/schemas/user" }, - }, - - $defs: { - type: { - type: "array", - items: { - type: "string", - enum: ["brand", "distiller", "bottler"], - }, - }, - }, -}; - -export const newEntitySchema = { - $id: "/schemas/newEntity", - type: "object", - required: ["name"], - properties: { - name: { type: "string" }, - type: { - $ref: "/schemas/entity#/$defs/type", - }, - country: { type: "string", nullable: true }, - region: { type: "string", nullable: true }, - }, -}; - -export const updateEntitySchema = { - $id: "/schemas/updateEntity", - type: "object", - properties: { - name: { type: "string" }, - type: { - $ref: "/schemas/entity#/$defs/type", - }, - country: { type: "string", nullable: true }, - region: { type: "string", nullable: true }, - }, -}; diff --git a/apps/api/src/schemas/errors.ts b/apps/api/src/schemas/errors.ts deleted file mode 100644 index 10c6eaf5..00000000 --- a/apps/api/src/schemas/errors.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const error401Schema = { - $id: "/errors/401", - type: "object", - required: ["error"], - properties: { - error: { type: "string" }, - name: { type: "string" }, - }, -}; diff --git a/apps/api/src/schemas/follow.ts b/apps/api/src/schemas/follow.ts deleted file mode 100644 index 0665c2eb..00000000 --- a/apps/api/src/schemas/follow.ts +++ /dev/null @@ -1,18 +0,0 @@ -export const followingSchema = { - $id: "/schemas/follow", - type: "object", - required: ["id", "status", "createdAt", "user", "followsBack"], - properties: { - id: { type: "string" }, - status: { - type: "string", - enum: ["pending", "following", "none"], - }, - createdAt: { type: "string" }, - user: { $ref: "/schemas/user" }, - followsBack: { - type: "string", - enum: ["pending", "following", "none"], - }, - }, -}; diff --git a/apps/api/src/schemas/notification.ts b/apps/api/src/schemas/notification.ts deleted file mode 100644 index 020e564d..00000000 --- a/apps/api/src/schemas/notification.ts +++ /dev/null @@ -1,21 +0,0 @@ -export const notificationSchema = { - $id: "/schemas/notification", - type: "object", - required: ["id", "objectId", "objectType", "fromUser", "createdAt", "ref"], - // XXX: ref isnt working right - additionalProperties: true, - properties: { - id: { type: "string" }, - objectType: { type: "string", enum: ["follow", "toast", "comment"] }, - objectId: { type: "string" }, - createdAt: { type: "string" }, - fromUser: { $ref: "/schemas/user" }, - // ref: { - // anyOf: [ - // { type: "null" }, - // { $ref: "/schemas/tasting" }, - // { $ref: "/schemas/follow" }, - // ], - // }, - }, -}; diff --git a/apps/api/src/schemas/paging.ts b/apps/api/src/schemas/paging.ts deleted file mode 100644 index dd85926a..00000000 --- a/apps/api/src/schemas/paging.ts +++ /dev/null @@ -1,10 +0,0 @@ -export default { - $id: "/schemas/paging", - type: "object", - properties: { - next: { type: "string", nullable: true }, - nextPage: { type: "string", nullable: true }, - prev: { type: "string", nullable: true }, - prevPage: { type: "string", nullable: true }, - }, -}; diff --git a/apps/api/src/schemas/tasting.ts b/apps/api/src/schemas/tasting.ts deleted file mode 100644 index 4edd60e2..00000000 --- a/apps/api/src/schemas/tasting.ts +++ /dev/null @@ -1,48 +0,0 @@ -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", - required: [ - "id", - "imageUrl", - "notes", - "tags", - "rating", - "createdAt", - "comments", - "toasts", - "bottle", - "createdBy", - ], - properties: { - id: { type: "string" }, - imageUrl: { type: "string", nullable: true }, - notes: { type: "string", nullable: true }, - bottle: { $ref: "/schemas/bottle" }, - rating: { type: "number", minimum: 0, maximum: 5 }, - tags: { type: "array", items: { type: "string" } }, - - comments: { type: "number" }, - toasts: { type: "number" }, - hasToasted: { type: "boolean" }, - edition: { - anyOf: [{ $ref: "/schemas/edition" }, { type: "null" }], - }, - createdAt: { type: "string" }, - createdBy: { $ref: "/schemas/user" }, - }, -}; diff --git a/apps/api/src/schemas/user.ts b/apps/api/src/schemas/user.ts deleted file mode 100644 index 5badd007..00000000 --- a/apps/api/src/schemas/user.ts +++ /dev/null @@ -1,30 +0,0 @@ -export const userSchema = { - $id: "/schemas/user", - type: "object", - required: ["id", "displayName", "username", "pictureUrl"], - properties: { - id: { type: "string" }, - displayName: { type: "string" }, - email: { type: "string", format: "email" }, - username: { type: "string" }, - admin: { type: "boolean" }, - mod: { type: "boolean" }, - createdAt: { type: "string" }, - pictureUrl: { type: "string" }, - followStatus: { - type: "string", - enum: ["pending", "following", "none"], - }, - }, -}; - -export const updateUserSchema = { - $id: "/schemas/updateUser", - type: "object", - properties: { - displayName: { type: "string" }, - username: { type: "string" }, - admin: { type: "boolean" }, - mod: { type: "boolean" }, - }, -}; diff --git a/apps/api/src/test/setup-test-env.ts b/apps/api/src/test/setup-test-env.ts index 0c86df31..360958a0 100644 --- a/apps/api/src/test/setup-test-env.ts +++ b/apps/api/src/test/setup-test-env.ts @@ -87,6 +87,7 @@ const createDefaultUser = async () => { return await User({ email: "fizz.buzz@example.com", displayName: "Fizzy Buzz", + username: "fizz.buzz", }); }; 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/bottleCard.tsx b/apps/web/src/components/bottleCard.tsx index 3a44e064..6f883d28 100644 --- a/apps/web/src/components/bottleCard.tsx +++ b/apps/web/src/components/bottleCard.tsx @@ -9,10 +9,10 @@ import { Option } from "./selectField"; type BottleFormData = { name: string; - brand: Option; - distillers?: Option[] | undefined; - statedAge?: number | undefined; - category?: Option; + brand?: Option | null | undefined; + distillers?: Option[] | null | undefined; + statedAge?: number | null | undefined; + category?: string | null | undefined; }; export const PreviewBottleCard = ({ @@ -20,7 +20,7 @@ export const PreviewBottleCard = ({ }: { data: Partial; }) => { - const { distillers, brand } = data; + const { brand } = data; return (
@@ -43,7 +43,7 @@ export const PreviewBottleCard = ({
-

{data.category ? data.category.name : null}

+

{data.category ? data.category : null}

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

diff --git a/apps/web/src/components/bottleForm.tsx b/apps/web/src/components/bottleForm.tsx new file mode 100644 index 00000000..de0c446f --- /dev/null +++ b/apps/web/src/components/bottleForm.tsx @@ -0,0 +1,202 @@ +import { useState } from "react"; + +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 { PreviewBottleCard } from "../components/bottleCard"; +import EntityField from "../components/entityField"; +import Fieldset from "../components/fieldset"; +import FormError from "../components/formError"; +import FormHeader from "../components/formHeader"; +import Layout from "../components/layout"; +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"; + +const categoryList = [ + "blend", + "bourbon", + "rye", + "single_grain", + "single_malt", + "spirit", +].map((c) => ({ + id: c, + name: formatCategoryName(c), +})); + +type FormSchemaType = z.infer; + +export default ({ + onSubmit, + initialData, +}: { + onSubmit: SubmitHandler; + initialData: Record; +}) => { + const { user } = useRequiredAuth(); + + const { + control, + register, + handleSubmit, + watch, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(BottleInputSchema), + defaultValues: { + name: initialData.name, + category: initialData.category, + brand: initialData.brand?.id, + distillers: initialData.distillers + ? initialData.distillers.map((d) => d.id) + : [], + statedAge: initialData.statedAge, + }, + }); + + 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"); + } + } + }; + + const [brandValue, setBrandValue] = useState