diff --git a/backend/drizzle/20261110140402_book_reviews/migration.sql b/backend/drizzle/20261110140402_book_reviews/migration.sql new file mode 100644 index 00000000..a0dd7dc4 --- /dev/null +++ b/backend/drizzle/20261110140402_book_reviews/migration.sql @@ -0,0 +1,22 @@ +CREATE TABLE "book_reviews" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "book_reviews_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "user_id" text NOT NULL, + "book_id" integer NOT NULL, + "rating" smallint NOT NULL, + "comment" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "book_reviews_user_id_book_id_unique" UNIQUE("user_id","book_id"), + CONSTRAINT "book_reviews_rating_check" CHECK ("rating" between 1 and 5) +); +--> statement-breakpoint +ALTER TABLE "books" ADD COLUMN "initial_rating" real;--> statement-breakpoint +ALTER TABLE "books" ADD COLUMN "initial_num_ratings" integer;--> statement-breakpoint +CREATE INDEX "book_reviews_book_id_created_at_idx" ON "book_reviews" ("book_id","created_at");--> statement-breakpoint +CREATE INDEX "book_reviews_user_id_created_at_idx" ON "book_reviews" ("user_id","created_at");--> statement-breakpoint +ALTER TABLE "book_reviews" ADD CONSTRAINT "book_reviews_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "book_reviews" ADD CONSTRAINT "book_reviews_book_id_books_id_fkey" FOREIGN KEY ("book_id") REFERENCES "books"("id") ON DELETE CASCADE;--> statement-breakpoint + +-- Custom SQL: the existing rating columns hold the dataset values, so copy them to the new initial columns. +-- There are no user reviews yet, so rating and num_ratings already hold the correct values. +UPDATE "books" SET "initial_rating" = "rating", "initial_num_ratings" = "num_ratings"; diff --git a/backend/drizzle/20261110140402_book_reviews/snapshot.json b/backend/drizzle/20261110140402_book_reviews/snapshot.json new file mode 100644 index 00000000..20ed1968 --- /dev/null +++ b/backend/drizzle/20261110140402_book_reviews/snapshot.json @@ -0,0 +1,1520 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "87533c3f-dad4-44eb-a1ec-3ba8cdab509d", + "prevIds": [ + "edf6a9ab-7ae7-4b5b-a04a-1f5e3204758b" + ], + "ddl": [ + { + "isRlsEnabled": false, + "name": "account", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "session", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "user", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "verification", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "book_genres", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "book_to_genres", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "books", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "favorite_books", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "book_reviews", + "entityType": "tables", + "schema": "public" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_id", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider_id", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refresh_token", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id_token", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token_expires_at", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refresh_token_expires_at", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scope", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ip_address", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_agent", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "email_verified", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "image", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bio", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "verification" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "identifier", + "entityType": "columns", + "schema": "public", + "table": "verification" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "verification" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "verification" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "verification" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "verification" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "always", + "name": "book_genres_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "book_genres" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "genre", + "entityType": "columns", + "schema": "public", + "table": "book_genres" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "book_id", + "entityType": "columns", + "schema": "public", + "table": "book_to_genres" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "genre_id", + "entityType": "columns", + "schema": "public", + "table": "book_to_genres" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "always", + "name": "books_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "varchar(13)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "isbn", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "series", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "book_format", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "edition", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pages", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publisher", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publish_date", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cover_img_url", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "real", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "initial_rating", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "initial_num_ratings", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "real", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rating", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "num_ratings", + "entityType": "columns", + "schema": "public", + "table": "books" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "favorite_books" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "book_id", + "entityType": "columns", + "schema": "public", + "table": "favorite_books" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "favorite_books" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "always", + "name": "book_reviews_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "book_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "book_reviews" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "book_id", + "entityType": "columns", + "schema": "public", + "table": "book_reviews" + }, + { + "type": "smallint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rating", + "entityType": "columns", + "schema": "public", + "table": "book_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "comment", + "entityType": "columns", + "schema": "public", + "table": "book_reviews" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "book_reviews" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "book_reviews" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "account_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "account" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "session_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "session" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "identifier", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "verification_identifier_idx", + "entityType": "indexes", + "schema": "public", + "table": "verification" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "genre_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "book_to_genres_genre_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "book_to_genres" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "books_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "books" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "book_format", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "books_book_format_idx", + "entityType": "indexes", + "schema": "public", + "table": "books" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "rating", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "num_ratings", + "isExpression": false, + "asc": false, + "nullsFirst": true, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "books_rating_num_ratings_idx", + "entityType": "indexes", + "schema": "public", + "table": "books" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "title", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": { + "name": "gin_trgm_ops", + "default": false + } + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "books_title_trgm_idx", + "entityType": "indexes", + "schema": "public", + "table": "books" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "favorite_books_user_id_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "favorite_books" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "book_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "favorite_books_book_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "favorite_books" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "book_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "book_reviews_book_id_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "book_reviews" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "book_reviews_user_id_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "book_reviews" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "account_user_id_user_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "account" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "session_user_id_user_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "session" + }, + { + "nameExplicit": false, + "columns": [ + "book_id" + ], + "schemaTo": "public", + "tableTo": "books", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "book_to_genres_book_id_books_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "book_to_genres" + }, + { + "nameExplicit": false, + "columns": [ + "genre_id" + ], + "schemaTo": "public", + "tableTo": "book_genres", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "book_to_genres_genre_id_book_genres_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "book_to_genres" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "favorite_books_user_id_user_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "favorite_books" + }, + { + "nameExplicit": false, + "columns": [ + "book_id" + ], + "schemaTo": "public", + "tableTo": "books", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "favorite_books_book_id_books_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "favorite_books" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "book_reviews_user_id_user_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "book_reviews" + }, + { + "nameExplicit": false, + "columns": [ + "book_id" + ], + "schemaTo": "public", + "tableTo": "books", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "book_reviews_book_id_books_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "book_reviews" + }, + { + "columns": [ + "book_id", + "genre_id" + ], + "nameExplicit": false, + "name": "book_to_genres_pkey", + "entityType": "pks", + "schema": "public", + "table": "book_to_genres" + }, + { + "columns": [ + "user_id", + "book_id" + ], + "nameExplicit": false, + "name": "favorite_books_pkey", + "entityType": "pks", + "schema": "public", + "table": "favorite_books" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pkey", + "schema": "public", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pkey", + "schema": "public", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "user_pkey", + "schema": "public", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "verification_pkey", + "schema": "public", + "table": "verification", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "book_genres_pkey", + "schema": "public", + "table": "book_genres", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "books_pkey", + "schema": "public", + "table": "books", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "book_reviews_pkey", + "schema": "public", + "table": "book_reviews", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": [ + "user_id", + "book_id" + ], + "nullsNotDistinct": false, + "name": "book_reviews_user_id_book_id_unique", + "entityType": "uniques", + "schema": "public", + "table": "book_reviews" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "session_token_key", + "schema": "public", + "table": "session", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "user_email_key", + "schema": "public", + "table": "user", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "genre" + ], + "nullsNotDistinct": false, + "name": "book_genres_genre_key", + "schema": "public", + "table": "book_genres", + "entityType": "uniques" + }, + { + "value": "\"rating\" between 1 and 5", + "name": "book_reviews_rating_check", + "entityType": "checks", + "schema": "public", + "table": "book_reviews" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index 862951df..7a143db2 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -5,6 +5,7 @@ import { account, bookGenres, books, + bookReviews, bookToGenres, favoriteBooks, session, @@ -23,6 +24,7 @@ const relations = defineRelations( books, bookGenres, bookToGenres, + bookReviews, }, (r) => ({ // Auth @@ -39,6 +41,10 @@ const relations = defineRelations( from: r.user.id, to: r.favoriteBooks.userId, }), + bookReviews: r.many.bookReviews({ + from: r.user.id, + to: r.bookReviews.userId, + }), }, session: { user: r.one.user({ @@ -58,6 +64,10 @@ const relations = defineRelations( from: r.books.id.through(r.bookToGenres.bookId), to: r.bookGenres.id.through(r.bookToGenres.genreId), }), + reviews: r.many.bookReviews({ + from: r.books.id, + to: r.bookReviews.bookId, + }), }, bookGenres: { books: r.many.books(), @@ -73,6 +83,17 @@ const relations = defineRelations( to: r.books.id, }), }, + // Reviews + bookReviews: { + user: r.one.user({ + from: r.bookReviews.userId, + to: r.user.id, + }), + book: r.one.books({ + from: r.bookReviews.bookId, + to: r.books.id, + }), + }, }), ); diff --git a/backend/src/db/schema/books.ts b/backend/src/db/schema/books.ts index a4d5c523..1c36b26a 100644 --- a/backend/src/db/schema/books.ts +++ b/backend/src/db/schema/books.ts @@ -26,6 +26,11 @@ const books = pgTable( // Parsed from publishDate (once), which is free text, so books can be sorted by it publishYear: integer("publish_year"), coverImgUrl: text("cover_img_url"), + // Average rating and number of ratings from the book dataset. + initialRating: real("initial_rating"), + initialNumRatings: integer("initial_num_ratings"), + // Combined rating of the dataset ratings and our own reviews. + // Stored on the book so sorting by rating can use the index below rating: real("rating"), numRatings: integer("num_ratings"), }, diff --git a/backend/src/db/schema/index.ts b/backend/src/db/schema/index.ts index 341a8155..462dfb97 100644 --- a/backend/src/db/schema/index.ts +++ b/backend/src/db/schema/index.ts @@ -1,3 +1,4 @@ export * from "./auth"; export * from "./books"; export * from "./favorites"; +export * from "./reviews"; diff --git a/backend/src/db/schema/reviews.ts b/backend/src/db/schema/reviews.ts new file mode 100644 index 00000000..f93eac31 --- /dev/null +++ b/backend/src/db/schema/reviews.ts @@ -0,0 +1,46 @@ +import { sql } from "drizzle-orm"; +import { + check, + index, + integer, + pgTable, + smallint, + text, + timestamp, + unique, +} from "drizzle-orm/pg-core"; +import { user } from "./auth"; +import { books } from "./books"; + +const bookReviews = pgTable( + "book_reviews", + { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + bookId: integer("book_id") + .notNull() + .references(() => books.id, { onDelete: "cascade" }), + rating: smallint("rating").notNull(), + comment: text("comment"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + }, + (t) => [ + // A user can only have one review per book + unique("book_reviews_user_id_book_id_unique").on(t.userId, t.bookId), + check("book_reviews_rating_check", sql`${t.rating} BETWEEN 1 AND 5`), + // Matches the sorting in bookReviews and myReviews (newest first) + index("book_reviews_book_id_created_at_idx").on(t.bookId, t.createdAt), + index("book_reviews_user_id_created_at_idx").on(t.userId, t.createdAt), + ], +); + +type InsertBookReview = typeof bookReviews.$inferInsert; +type SelectBookReview = typeof bookReviews.$inferSelect; + +export { bookReviews, type InsertBookReview, type SelectBookReview }; diff --git a/backend/src/db/seed/index.ts b/backend/src/db/seed/index.ts index c28479ee..533a3463 100644 --- a/backend/src/db/seed/index.ts +++ b/backend/src/db/seed/index.ts @@ -11,6 +11,7 @@ import { bookToGenres, type InsertBook, } from "~/db/schema/books"; +import { emptyToNull } from "~/utils/emptyToNull"; interface BookRow { bookId: string; @@ -55,13 +56,6 @@ let booksToInsert: InsertBook[] = []; let genresToInsert: string[][] = []; const genreIds = new Map(); -// Empty or missing text in the dataset is stored as null. -function emptyToNull(value: string | null | undefined) { - const trimmed = value?.trim(); - if (!trimmed) return null; - return trimmed; -} - async function insertBatch(batch: InsertBook[], batchGenres: string[][]) { const insertedBooks = await db .insert(books) @@ -106,6 +100,16 @@ for await (const book of parser as AsyncIterable) { } } + const { rating, numRatings } = parseRating(book); + + // There are no user reviews yet, so the combined rating starts as the dataset rating + const ratings = { + initialRating: rating, + initialNumRatings: numRatings, + rating, + numRatings, + }; + booksToInsert.push({ isbn: book.isbn, title: book.title, @@ -120,7 +124,7 @@ for await (const book of parser as AsyncIterable) { publishDate: emptyToNull(book.publishDate), publishYear: parsePublishYear(emptyToNull(book.publishDate)), coverImgUrl: emptyToNull(book.coverImg), - ...parseRating(book), + ...ratings, }); if (book.genres == "[]") { diff --git a/backend/src/schema/resolvers.generated.ts b/backend/src/schema/resolvers.generated.ts index 9439ab81..f0397cc5 100644 --- a/backend/src/schema/resolvers.generated.ts +++ b/backend/src/schema/resolvers.generated.ts @@ -2,20 +2,31 @@ import type { Resolvers } from './types.generated'; import { book as Query_book } from './books/resolvers/Query/book'; import { bookFormats as Query_bookFormats } from './books/resolvers/Query/bookFormats'; +import { bookReviews as Query_bookReviews } from './reviews/resolvers/Query/bookReviews'; import { favoriteBookIds as Query_favoriteBookIds } from './favorites/resolvers/Query/favoriteBookIds'; import { favoriteBooks as Query_favoriteBooks } from './favorites/resolvers/Query/favoriteBooks'; import { favoriteGenres as Query_favoriteGenres } from './favorites/resolvers/Query/favoriteGenres'; import { genres as Query_genres } from './books/resolvers/Query/genres'; import { languages as Query_languages } from './books/resolvers/Query/languages'; +import { myReview as Query_myReview } from './reviews/resolvers/Query/myReview'; +import { myReviews as Query_myReviews } from './reviews/resolvers/Query/myReviews'; import { searchBooks as Query_searchBooks } from './books/resolvers/Query/searchBooks'; import { addFavoriteBook as Mutation_addFavoriteBook } from './favorites/resolvers/Mutation/addFavoriteBook'; +import { deleteReview as Mutation_deleteReview } from './reviews/resolvers/Mutation/deleteReview'; import { removeFavoriteBook as Mutation_removeFavoriteBook } from './favorites/resolvers/Mutation/removeFavoriteBook'; +import { upsertReview as Mutation_upsertReview } from './reviews/resolvers/Mutation/upsertReview'; import { Book } from './books/resolvers/Book'; import { BookSearchResult } from './books/resolvers/BookSearchResult'; +import { Review } from './reviews/resolvers/Review'; +import { ReviewAuthor } from './reviews/resolvers/ReviewAuthor'; +import { ReviewSearchResult } from './reviews/resolvers/ReviewSearchResult'; export const resolvers: Resolvers = { - Query: { book: Query_book,bookFormats: Query_bookFormats,favoriteBookIds: Query_favoriteBookIds,favoriteBooks: Query_favoriteBooks,favoriteGenres: Query_favoriteGenres,genres: Query_genres,languages: Query_languages,searchBooks: Query_searchBooks }, - Mutation: { addFavoriteBook: Mutation_addFavoriteBook,removeFavoriteBook: Mutation_removeFavoriteBook }, + Query: { book: Query_book,bookFormats: Query_bookFormats,bookReviews: Query_bookReviews,favoriteBookIds: Query_favoriteBookIds,favoriteBooks: Query_favoriteBooks,favoriteGenres: Query_favoriteGenres,genres: Query_genres,languages: Query_languages,myReview: Query_myReview,myReviews: Query_myReviews,searchBooks: Query_searchBooks }, + Mutation: { addFavoriteBook: Mutation_addFavoriteBook,deleteReview: Mutation_deleteReview,removeFavoriteBook: Mutation_removeFavoriteBook,upsertReview: Mutation_upsertReview }, Book: Book, -BookSearchResult: BookSearchResult +BookSearchResult: BookSearchResult, +Review: Review, +ReviewAuthor: ReviewAuthor, +ReviewSearchResult: ReviewSearchResult } \ No newline at end of file diff --git a/backend/src/schema/reviews/bookRating.ts b/backend/src/schema/reviews/bookRating.ts new file mode 100644 index 00000000..c2996161 --- /dev/null +++ b/backend/src/schema/reviews/bookRating.ts @@ -0,0 +1,64 @@ +import { count, eq, sum } from "drizzle-orm"; +import { GraphQLError } from "graphql/error"; +import { db } from "~/db"; +import { bookReviews, books, type SelectBook } from "~/db/schema"; + +type Transaction = Parameters[0]>[0]; + +/** + * Locks the book's row until the transaction ends, so concurrent reviews of + * the same book are handled one at a time and the combined rating stays correct. + */ +async function fetchBookAndLock(tx: Transaction, bookId: number) { + const rows = await tx + .select() + .from(books) + .where(eq(books.id, bookId)) + .for("update"); + const book = rows.at(0); + + if (!book) { + throw new GraphQLError("Book not found.", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + return book; +} + +/** + * Recalculates the combined rating from all reviews of the book. + * We recalculate from scratch instead of adding or subtracting a single + * review, so the stored rating is always up to date + */ +async function updateBookRating(tx: Transaction, book: SelectBook) { + const [userReviews] = await tx + .select({ count: count(), sum: sum(bookReviews.rating).mapWith(Number) }) + .from(bookReviews) + .where(eq(bookReviews.bookId, book.id)); + + let rating: number | null = book.initialRating; + + // Update the rating if there are some user reviews + if (userReviews.count > 0 && userReviews.sum !== null) { + const initRatingCount = book.initialNumRatings ?? 0; + const initRatingSum = (book.initialRating ?? 0) * initRatingCount; + + const totalRatings = initRatingCount + userReviews.count; + + rating = (initRatingSum + userReviews.sum) / totalRatings; + } + + const [updatedBook] = await tx + .update(books) + .set({ + rating, + numRatings: (book.initialNumRatings ?? 0) + userReviews.count, + }) + .where(eq(books.id, book.id)) + .returning(); + + return updatedBook; +} + +export { fetchBookAndLock, updateBookRating }; diff --git a/backend/src/schema/reviews/resolvers/Mutation/deleteReview.ts b/backend/src/schema/reviews/resolvers/Mutation/deleteReview.ts new file mode 100644 index 00000000..fc0ae661 --- /dev/null +++ b/backend/src/schema/reviews/resolvers/Mutation/deleteReview.ts @@ -0,0 +1,33 @@ +import { GraphQLError } from "graphql/error"; +import { and, eq } from "drizzle-orm"; +import type { MutationResolvers } from "./../../../types.generated"; +import { db } from "~/db"; +import { bookReviews } from "~/db/schema"; +import { + fetchBookAndLock, + updateBookRating, +} from "~/schema/reviews/bookRating"; + +export const deleteReview: NonNullable< + MutationResolvers["deleteReview"] +> = async (_parent, args, ctx) => { + if (!ctx.session) { + throw new GraphQLError("You must be logged in to delete a review.", { + extensions: { code: "UNAUTHENTICATED" }, + }); + } + + const userId = ctx.session.user.id; + + return db.transaction(async (tx) => { + const book = await fetchBookAndLock(tx, args.bookId); + + await tx + .delete(bookReviews) + .where( + and(eq(bookReviews.userId, userId), eq(bookReviews.bookId, book.id)), + ); + + return updateBookRating(tx, book); + }); +}; diff --git a/backend/src/schema/reviews/resolvers/Mutation/upsertReview.ts b/backend/src/schema/reviews/resolvers/Mutation/upsertReview.ts new file mode 100644 index 00000000..78472c2b --- /dev/null +++ b/backend/src/schema/reviews/resolvers/Mutation/upsertReview.ts @@ -0,0 +1,57 @@ +import { GraphQLError } from "graphql/error"; +import type { MutationResolvers } from "./../../../types.generated"; +import { db } from "~/db"; +import { bookReviews } from "~/db/schema"; +import { + fetchBookAndLock, + updateBookRating, +} from "~/schema/reviews/bookRating"; +import { emptyToNull } from "~/utils/emptyToNull"; + +const MAX_COMMENT_LENGTH = 2000; + +export const upsertReview: NonNullable< + MutationResolvers["upsertReview"] +> = async (_parent, args, ctx) => { + if (!ctx.session) { + throw new GraphQLError("You must be logged in to review a book.", { + extensions: { code: "UNAUTHENTICATED" }, + }); + } + + if (!Number.isInteger(args.rating) || args.rating < 1 || args.rating > 5) { + throw new GraphQLError("Rating must be a whole number from 1 to 5.", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + // An empty comment should be stored as NULL, so avoid empty strings + const comment = emptyToNull(args.comment); + + if (comment && comment.length > MAX_COMMENT_LENGTH) { + throw new GraphQLError( + `Comment must be at most ${MAX_COMMENT_LENGTH} characters.`, + { extensions: { code: "BAD_USER_INPUT" } }, + ); + } + + const userId = ctx.session.user.id; + + return db.transaction(async (tx) => { + const book = await fetchBookAndLock(tx, args.bookId); + + const [review] = await tx + .insert(bookReviews) + .values({ userId, bookId: book.id, rating: args.rating, comment }) + .onConflictDoUpdate({ + target: [bookReviews.userId, bookReviews.bookId], + set: { rating: args.rating, comment, updatedAt: new Date() }, + }) + .returning(); + + const updatedBook = await updateBookRating(tx, book); + + // Return the updated book so the client gets the new combined rating + return { ...review, book: updatedBook }; + }); +}; diff --git a/backend/src/schema/reviews/resolvers/Query/bookReviews.ts b/backend/src/schema/reviews/resolvers/Query/bookReviews.ts new file mode 100644 index 00000000..da7cd341 --- /dev/null +++ b/backend/src/schema/reviews/resolvers/Query/bookReviews.ts @@ -0,0 +1,29 @@ +import { eq } from "drizzle-orm"; +import { GraphQLError } from "graphql/error"; +import type { QueryResolvers } from "./../../../types.generated"; +import { db } from "~/db"; +import { bookReviews as bookReviewsTable } from "~/db/schema"; + +export const bookReviews: NonNullable = async ( + _parent, + args, +) => { + if (args.offset < 0) { + throw new GraphQLError("Offset must not be negative.", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + const [reviews, totalCount] = await Promise.all([ + db.query.bookReviews.findMany({ + where: { bookId: args.bookId }, + with: { user: true }, + orderBy: { createdAt: "desc", id: "desc" }, + limit: args.limit, + offset: args.offset, + }), + db.$count(bookReviewsTable, eq(bookReviewsTable.bookId, args.bookId)), + ]); + + return { reviews, totalCount }; +}; diff --git a/backend/src/schema/reviews/resolvers/Query/myReview.ts b/backend/src/schema/reviews/resolvers/Query/myReview.ts new file mode 100644 index 00000000..745ec28b --- /dev/null +++ b/backend/src/schema/reviews/resolvers/Query/myReview.ts @@ -0,0 +1,22 @@ +import { GraphQLError } from "graphql/error"; +import type { QueryResolvers } from "./../../../types.generated"; +import { db } from "~/db"; + +export const myReview: NonNullable = async ( + _parent, + args, + ctx, +) => { + if (!ctx.session) { + throw new GraphQLError("You must be logged in to view your review.", { + extensions: { code: "UNAUTHENTICATED" }, + }); + } + + const review = await db.query.bookReviews.findFirst({ + where: { userId: ctx.session.user.id, bookId: args.bookId }, + with: { user: true }, + }); + + return review ?? null; +}; diff --git a/backend/src/schema/reviews/resolvers/Query/myReviews.ts b/backend/src/schema/reviews/resolvers/Query/myReviews.ts new file mode 100644 index 00000000..ffcf30a9 --- /dev/null +++ b/backend/src/schema/reviews/resolvers/Query/myReviews.ts @@ -0,0 +1,36 @@ +import { GraphQLError } from "graphql/error"; +import { eq } from "drizzle-orm"; +import type { QueryResolvers } from "./../../../types.generated"; +import { db } from "~/db"; +import { bookReviews } from "~/db/schema"; + +export const myReviews: NonNullable = async ( + _parent, + args, + ctx, +) => { + if (!ctx.session) { + throw new GraphQLError("You must be logged in to view your reviews.", { + extensions: { code: "UNAUTHENTICATED" }, + }); + } + + if (args.offset < 0) { + throw new GraphQLError("Offset must not be negative.", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + const [reviews, totalCount] = await Promise.all([ + db.query.bookReviews.findMany({ + where: { userId: ctx.session.user.id }, + with: { user: true, book: true }, + orderBy: { createdAt: "desc", id: "desc" }, + limit: args.limit, + offset: args.offset, + }), + db.$count(bookReviews, eq(bookReviews.userId, ctx.session.user.id)), + ]); + + return { reviews, totalCount }; +}; diff --git a/backend/src/schema/reviews/resolvers/Review.ts b/backend/src/schema/reviews/resolvers/Review.ts new file mode 100644 index 00000000..93072dd0 --- /dev/null +++ b/backend/src/schema/reviews/resolvers/Review.ts @@ -0,0 +1,25 @@ +import type { ReviewResolvers } from "./../../types.generated"; +import { db } from "~/db"; + +export const Review: ReviewResolvers = { + // Some queries load these already. + // If the query doesn't, but the client asks for them, we load them here + async user(parent) { + const user = + parent.user ?? + (await db.query.user.findFirst({ where: { id: parent.userId } })); + + if (!user) throw new Error(`User ${parent.userId} not found.`); + return user; + }, + async book(parent) { + const book = + parent.book ?? + (await db.query.books.findFirst({ where: { id: parent.bookId } })); + + if (!book) throw new Error(`Book ${parent.bookId} not found.`); + return book; + }, + createdAt: (parent) => parent.createdAt.toISOString(), + updatedAt: (parent) => parent.updatedAt.toISOString(), +}; diff --git a/backend/src/schema/reviews/resolvers/ReviewAuthor.ts b/backend/src/schema/reviews/resolvers/ReviewAuthor.ts new file mode 100644 index 00000000..ecdc0311 --- /dev/null +++ b/backend/src/schema/reviews/resolvers/ReviewAuthor.ts @@ -0,0 +1,2 @@ +import type { ReviewAuthorResolvers } from "./../../types.generated"; +export const ReviewAuthor: ReviewAuthorResolvers = {}; diff --git a/backend/src/schema/reviews/resolvers/ReviewSearchResult.ts b/backend/src/schema/reviews/resolvers/ReviewSearchResult.ts new file mode 100644 index 00000000..d60a4a86 --- /dev/null +++ b/backend/src/schema/reviews/resolvers/ReviewSearchResult.ts @@ -0,0 +1,2 @@ +import type { ReviewSearchResultResolvers } from "./../../types.generated"; +export const ReviewSearchResult: ReviewSearchResultResolvers = {}; diff --git a/backend/src/schema/reviews/schema.graphql b/backend/src/schema/reviews/schema.graphql new file mode 100644 index 00000000..d8d25152 --- /dev/null +++ b/backend/src/schema/reviews/schema.graphql @@ -0,0 +1,42 @@ +type Review { + id: Int! + rating: Int! + comment: String + "ISO 8601 timestamp" + createdAt: String! + "ISO 8601 timestamp" + updatedAt: String! + user: ReviewAuthor! + book: Book! +} + +type ReviewAuthor { + id: String! + name: String! + image: String +} + +type ReviewSearchResult { + reviews: [Review!]! + totalCount: Int! +} + +extend type Query { + "Reviews of a book, newest first." + bookReviews( + bookId: Int! + limit: Int! = 10 + offset: Int! = 0 + ): ReviewSearchResult! + "The logged-in user's review of a book, or null if they have not reviewed it." + myReview(bookId: Int!): Review + "Reviews written by the logged-in user, newest first." + myReviews(limit: Int! = 10, offset: Int! = 0): ReviewSearchResult! +} + +extend type Mutation { + "Create the logged-in user's review of a book, or update it if it already exists." + upsertReview(bookId: Int!, rating: Int!, comment: String): Review! + "Delete the logged-in user's review of a book. Returns the book with its updated rating." + deleteReview(bookId: Int!): Book! +} diff --git a/backend/src/schema/reviews/schema.mappers.ts b/backend/src/schema/reviews/schema.mappers.ts new file mode 100644 index 00000000..c3d98f91 --- /dev/null +++ b/backend/src/schema/reviews/schema.mappers.ts @@ -0,0 +1,10 @@ +import type { SelectUser } from "~/db/schema/auth"; +import type { SelectBook } from "~/db/schema/books"; +import type { SelectBookReview } from "~/db/schema/reviews"; + +// Resolvers return the DB row for a review. The user and book can be +// loaded together with the review, otherwise the Review field resolvers fetch them. +export type ReviewMapper = SelectBookReview & { + user?: SelectUser | null; + book?: SelectBook | null; +}; diff --git a/backend/src/schema/schema.generated.graphqls b/backend/src/schema/schema.generated.graphqls index c0bb4ff0..8c11c0b2 100644 --- a/backend/src/schema/schema.generated.graphqls +++ b/backend/src/schema/schema.generated.graphqls @@ -35,13 +35,26 @@ enum BookSortField { type Mutation { addFavoriteBook(bookId: Int!): Book! + + """ + Delete the logged-in user's review of a book. Returns the book with its updated rating. + """ + deleteReview(bookId: Int!): Book! removeFavoriteBook(bookId: Int!): Boolean! + + """ + Create the logged-in user's review of a book, or update it if it already exists. + """ + upsertReview(bookId: Int!, comment: String, rating: Int!): Review! } type Query { book(id: Int!): Book bookFormats: [String!]! + """Reviews of a book, newest first.""" + bookReviews(bookId: Int!, limit: Int! = 10, offset: Int! = 0): ReviewSearchResult! + """ ID of every book the logged-in user that has been added to their favorites. """ @@ -54,6 +67,14 @@ type Query { favoriteGenres(limit: Int! = 3): [String!]! genres: [String!]! languages: [String!]! + + """ + The logged-in user's review of a book, or null if they have not reviewed it. + """ + myReview(bookId: Int!): Review + + """Reviews written by the logged-in user, newest first.""" + myReviews(limit: Int! = 10, offset: Int! = 0): ReviewSearchResult! searchBooks( author: String bookFormat: String @@ -77,6 +98,31 @@ type Query { ): BookSearchResult! } +type Review { + book: Book! + comment: String + + """ISO 8601 timestamp""" + createdAt: String! + id: Int! + rating: Int! + + """ISO 8601 timestamp""" + updatedAt: String! + user: ReviewAuthor! +} + +type ReviewAuthor { + id: String! + image: String + name: String! +} + +type ReviewSearchResult { + reviews: [Review!]! + totalCount: Int! +} + enum SortDirection { ASC DESC diff --git a/backend/src/schema/typeDefs.generated.ts b/backend/src/schema/typeDefs.generated.ts index 2d428e30..5aecaf09 100644 --- a/backend/src/schema/typeDefs.generated.ts +++ b/backend/src/schema/typeDefs.generated.ts @@ -1,2 +1,2 @@ import type { DocumentNode } from 'graphql'; - export const typeDefs = {"kind":"Document","definitions":[{"name":{"kind":"Name","value":"Query"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"book"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"languages"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormats"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"searchBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","description":{"kind":"StringValue","value":"Match titles that are similar to the title, for example with typos, ordered by similarity","block":false},"name":{"kind":"Name","value":"fuzzy"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}},"defaultValue":{"kind":"BooleanValue","value":false}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"maxPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"0"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortBy"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSortField"}}},"defaultValue":{"kind":"EnumValue","value":"RELEVANCE"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortDirection"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SortDirection"}}},"defaultValue":{"kind":"EnumValue","value":"ASC"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"Favorite books of the logged-in user, newest first.","block":false},"name":{"kind":"Name","value":"favoriteBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"0"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"ID of every book the logged-in user that has been added to their favorites.","block":false},"name":{"kind":"Name","value":"favoriteBookIds"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"The logged-in user's most common genres among their favorite books.","block":false},"name":{"kind":"Name","value":"favoriteGenres"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"3"}}],"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"directives":[],"interfaces":[]},{"name":{"kind":"Name","value":"Mutation"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"addFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"removeFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"directives":[],"interfaces":[]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"Book"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"isbn"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"description"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"edition"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"pages"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publishDate"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publishYear"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"coverImgUrl"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"rating"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"numRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"BookSortField"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"RELEVANCE"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ID"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"RATING"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"TITLE"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"NUM_RATINGS"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"PAGES"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"PUBLISH_YEAR"}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"SortDirection"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ASC"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"DESC"}}]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"BookSearchResult"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"books"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"totalCount"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}]},{"kind":"SchemaDefinition","operationTypes":[{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Query"}},"operation":"query"},{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Mutation"}},"operation":"mutation"}]}]} as unknown as DocumentNode \ No newline at end of file + export const typeDefs = {"kind":"Document","definitions":[{"name":{"kind":"Name","value":"Query"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"book"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"languages"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormats"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"searchBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","description":{"kind":"StringValue","value":"Match titles that are similar to the title, for example with typos, ordered by similarity","block":false},"name":{"kind":"Name","value":"fuzzy"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}},"defaultValue":{"kind":"BooleanValue","value":false}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"maxPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"0"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortBy"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSortField"}}},"defaultValue":{"kind":"EnumValue","value":"ID"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortDirection"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SortDirection"}}},"defaultValue":{"kind":"EnumValue","value":"ASC"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"Favorite books of the logged-in user, newest first.","block":false},"name":{"kind":"Name","value":"favoriteBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"0"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"ID of every book the logged-in user that has been added to their favorites.","block":false},"name":{"kind":"Name","value":"favoriteBookIds"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"The logged-in user's most common genres among their favorite books.","block":false},"name":{"kind":"Name","value":"favoriteGenres"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"3"}}],"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"Reviews of a book, newest first.","block":false},"name":{"kind":"Name","value":"bookReviews"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"10"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"0"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ReviewSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"The logged-in user's review of a book, or null if they have not reviewed it.","block":false},"name":{"kind":"Name","value":"myReview"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NamedType","name":{"kind":"Name","value":"Review"}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"Reviews written by the logged-in user, newest first.","block":false},"name":{"kind":"Name","value":"myReviews"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"10"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"0"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ReviewSearchResult"}}}}],"directives":[],"interfaces":[]},{"name":{"kind":"Name","value":"Mutation"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"addFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"removeFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"Create the logged-in user's review of a book, or update it if it already exists.","block":false},"name":{"kind":"Name","value":"upsertReview"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"rating"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"comment"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Review"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"Delete the logged-in user's review of a book. Returns the book with its updated rating.","block":false},"name":{"kind":"Name","value":"deleteReview"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}}],"directives":[],"interfaces":[]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"Book"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"isbn"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"description"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"edition"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"pages"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publishDate"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"coverImgUrl"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"rating"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"numRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"BookSortField"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ID"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"RATING"}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"SortDirection"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ASC"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"DESC"}}]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"BookSearchResult"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"books"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"totalCount"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"Review"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"rating"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"comment"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"ISO 8601 timestamp","block":false},"name":{"kind":"Name","value":"createdAt"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"ISO 8601 timestamp","block":false},"name":{"kind":"Name","value":"updatedAt"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"user"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ReviewAuthor"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"book"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}}]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"ReviewAuthor"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"name"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"image"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"ReviewSearchResult"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"reviews"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Review"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"totalCount"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}]},{"kind":"SchemaDefinition","operationTypes":[{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Query"}},"operation":"query"},{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Mutation"}},"operation":"mutation"}]}]} as unknown as DocumentNode diff --git a/backend/src/schema/types.generated.ts b/backend/src/schema/types.generated.ts index 49bd158b..a64c8507 100644 --- a/backend/src/schema/types.generated.ts +++ b/backend/src/schema/types.generated.ts @@ -1,5 +1,6 @@ import type { GraphQLResolveInfo } from 'graphql'; import type { BookMapper } from './books/schema.mappers'; +import type { ReviewMapper } from './reviews/schema.mappers'; import type { ApolloContext } from '~/index'; export type Maybe = T | null | undefined; export type InputMaybe = T | null | undefined; @@ -54,7 +55,11 @@ export type BookSortField = export type Mutation = { __typename?: 'Mutation'; addFavoriteBook: Book; + /** Delete the logged-in user's review of a book. Returns the book with its updated rating. */ + deleteReview: Book; removeFavoriteBook: Scalars['Boolean']['output']; + /** Create the logged-in user's review of a book, or update it if it already exists. */ + upsertReview: Review; }; @@ -63,14 +68,28 @@ export type MutationaddFavoriteBookArgs = { }; +export type MutationdeleteReviewArgs = { + bookId: Scalars['Int']['input']; +}; + + export type MutationremoveFavoriteBookArgs = { bookId: Scalars['Int']['input']; }; + +export type MutationupsertReviewArgs = { + bookId: Scalars['Int']['input']; + comment?: InputMaybe; + rating: Scalars['Int']['input']; +}; + export type Query = { __typename?: 'Query'; book?: Maybe; bookFormats: Array; + /** Reviews of a book, newest first. */ + bookReviews: ReviewSearchResult; /** ID of every book the logged-in user that has been added to their favorites. */ favoriteBookIds: Array; /** Favorite books of the logged-in user, newest first. */ @@ -79,6 +98,10 @@ export type Query = { favoriteGenres: Array; genres: Array; languages: Array; + /** The logged-in user's review of a book, or null if they have not reviewed it. */ + myReview?: Maybe; + /** Reviews written by the logged-in user, newest first. */ + myReviews: ReviewSearchResult; searchBooks: BookSearchResult; }; @@ -88,6 +111,13 @@ export type QuerybookArgs = { }; +export type QuerybookReviewsArgs = { + bookId: Scalars['Int']['input']; + limit?: Scalars['Int']['input']; + offset?: Scalars['Int']['input']; +}; + + export type QueryfavoriteBooksArgs = { limit?: Scalars['Int']['input']; offset?: Scalars['Int']['input']; @@ -99,6 +129,17 @@ export type QueryfavoriteGenresArgs = { }; +export type QuerymyReviewArgs = { + bookId: Scalars['Int']['input']; +}; + + +export type QuerymyReviewsArgs = { + limit?: Scalars['Int']['input']; + offset?: Scalars['Int']['input']; +}; + + export type QuerysearchBooksArgs = { author?: InputMaybe; bookFormat?: InputMaybe; @@ -117,6 +158,32 @@ export type QuerysearchBooksArgs = { title?: InputMaybe; }; +export type Review = { + __typename?: 'Review'; + book: Book; + comment?: Maybe; + /** ISO 8601 timestamp */ + createdAt: Scalars['String']['output']; + id: Scalars['Int']['output']; + rating: Scalars['Int']['output']; + /** ISO 8601 timestamp */ + updatedAt: Scalars['String']['output']; + user: ReviewAuthor; +}; + +export type ReviewAuthor = { + __typename?: 'ReviewAuthor'; + id: Scalars['String']['output']; + image?: Maybe; + name: Scalars['String']['output']; +}; + +export type ReviewSearchResult = { + __typename?: 'ReviewSearchResult'; + reviews: Array; + totalCount: Scalars['Int']['output']; +}; + export type SortDirection = | 'ASC' | 'DESC'; @@ -203,6 +270,9 @@ export type ResolversTypes = { Mutation: ResolverTypeWrapper>; Boolean: ResolverTypeWrapper; Query: ResolverTypeWrapper>; + Review: ResolverTypeWrapper; + ReviewAuthor: ResolverTypeWrapper; + ReviewSearchResult: ResolverTypeWrapper & { reviews: Array }>; SortDirection: ResolverTypeWrapper<'ASC' | 'DESC'>; }; @@ -216,6 +286,9 @@ export type ResolversParentTypes = { Mutation: Record; Boolean: Scalars['Boolean']['output']; Query: Record; + Review: ReviewMapper; + ReviewAuthor: ReviewAuthor; + ReviewSearchResult: Omit & { reviews: Array }; }; export type BookResolvers = { @@ -247,20 +320,46 @@ export type BookSortFieldResolvers = EnumResolverSignature<{ ID?: any, NUM_RATIN export type MutationResolvers = { addFavoriteBook?: Resolver>; + deleteReview?: Resolver>; removeFavoriteBook?: Resolver>; + upsertReview?: Resolver>; }; export type QueryResolvers = { book?: Resolver, ParentType, ContextType, RequireFields>; bookFormats?: Resolver, ParentType, ContextType>; + bookReviews?: Resolver>; favoriteBookIds?: Resolver, ParentType, ContextType>; favoriteBooks?: Resolver>; favoriteGenres?: Resolver, ParentType, ContextType, RequireFields>; genres?: Resolver, ParentType, ContextType>; languages?: Resolver, ParentType, ContextType>; + myReview?: Resolver, ParentType, ContextType, RequireFields>; + myReviews?: Resolver>; searchBooks?: Resolver>; }; +export type ReviewResolvers = { + book?: Resolver; + comment?: Resolver, ParentType, ContextType>; + createdAt?: Resolver; + id?: Resolver; + rating?: Resolver; + updatedAt?: Resolver; + user?: Resolver; +}; + +export type ReviewAuthorResolvers = { + id?: Resolver; + image?: Resolver, ParentType, ContextType>; + name?: Resolver; +}; + +export type ReviewSearchResultResolvers = { + reviews?: Resolver, ParentType, ContextType>; + totalCount?: Resolver; +}; + export type SortDirectionResolvers = EnumResolverSignature<{ ASC?: any, DESC?: any }, ResolversTypes['SortDirection']>; export type Resolvers = { @@ -269,6 +368,9 @@ export type Resolvers = { BookSortField?: BookSortFieldResolvers; Mutation?: MutationResolvers; Query?: QueryResolvers; + Review?: ReviewResolvers; + ReviewAuthor?: ReviewAuthorResolvers; + ReviewSearchResult?: ReviewSearchResultResolvers; SortDirection?: SortDirectionResolvers; }; diff --git a/backend/src/utils/emptyToNull.ts b/backend/src/utils/emptyToNull.ts new file mode 100644 index 00000000..4b632801 --- /dev/null +++ b/backend/src/utils/emptyToNull.ts @@ -0,0 +1,8 @@ +// Empty or whitespace-only text is stored as null, otherwise it's trimmed. +function emptyToNull(value: string | null | undefined) { + const trimmed = value?.trim(); + if (!trimmed) return null; + return trimmed; +} + +export { emptyToNull }; diff --git a/frontend/app/api/types/graphql.generated.ts b/frontend/app/api/types/graphql.generated.ts index 299d7d5a..8aae4209 100644 --- a/frontend/app/api/types/graphql.generated.ts +++ b/frontend/app/api/types/graphql.generated.ts @@ -54,6 +54,46 @@ export type RemoveFavoriteBookMutationVariables = Exact<{ export type RemoveFavoriteBookMutation = { removeFavoriteBook: boolean }; +export type BookReviewsQueryVariables = Exact<{ + bookId: number; + limit?: number | null | undefined; + offset?: number | null | undefined; +}>; + + +export type BookReviewsQuery = { bookReviews: { __typename: 'ReviewSearchResult', totalCount: number, reviews: Array<{ __typename: 'Review', id: number, rating: number, comment: string | null, createdAt: string, user: { __typename: 'ReviewAuthor', id: string, name: string } }> } }; + +export type MyReviewQueryVariables = Exact<{ + bookId: number; +}>; + + +export type MyReviewQuery = { myReview: { __typename: 'Review', id: number, rating: number, comment: string | null, updatedAt: string } | null }; + +export type MyReviewsQueryVariables = Exact<{ + limit?: number | null | undefined; + offset?: number | null | undefined; +}>; + + +export type MyReviewsQuery = { myReviews: { __typename: 'ReviewSearchResult', totalCount: number, reviews: Array<{ __typename: 'Review', id: number, rating: number, comment: string | null, createdAt: string, book: { __typename: 'Book', id: number, title: string } }> } }; + +export type UpsertReviewMutationVariables = Exact<{ + bookId: number; + rating: number; + comment?: string | null | undefined; +}>; + + +export type UpsertReviewMutation = { upsertReview: { __typename: 'Review', id: number, book: { __typename: 'Book', id: number, rating: number | null, numRatings: number | null } } }; + +export type DeleteReviewMutationVariables = Exact<{ + bookId: number; +}>; + + +export type DeleteReviewMutation = { deleteReview: { __typename: 'Book', id: number, rating: number | null, numRatings: number | null } }; + export type SearchBooksQueryVariables = Exact<{ title?: string | null | undefined; fuzzy?: boolean; diff --git a/frontend/app/components/BookPagination/BookPagination.test.tsx b/frontend/app/components/BookPagination/BookPagination.test.tsx index 8f323ee0..0ef80d02 100644 --- a/frontend/app/components/BookPagination/BookPagination.test.tsx +++ b/frontend/app/components/BookPagination/BookPagination.test.tsx @@ -12,9 +12,7 @@ const Stub = createRoutesStub([ }, ]); -const currentPage = () => - screen.getByRole("link", { current: "page" }).textContent; - +// jsdom has no matchMedia, which is used to check for reduced motion function prefersReducedMotion(reduce: boolean) { vi.stubGlobal( "matchMedia", @@ -22,6 +20,9 @@ function prefersReducedMotion(reduce: boolean) { ); } +const currentPage = () => + screen.getByRole("link", { current: "page" }).textContent; + describe("BookPagination", () => { afterEach(() => { vi.unstubAllGlobals(); @@ -50,6 +51,7 @@ describe("BookPagination", () => { prefersReducedMotion(false); // jsdom cannot scroll; replace window.scrollTo so it does not warn. const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(vi.fn()); + prefersReducedMotion(false); const user = userEvent.setup(); render(); @@ -63,9 +65,9 @@ describe("BookPagination", () => { expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: "smooth" }); }); - it("scrolls to the top without animation when the user prefers reduced motion", async () => { - prefersReducedMotion(true); + it("scrolls without animation when the user prefers reduced motion", async () => { const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(vi.fn()); + prefersReducedMotion(true); const user = userEvent.setup(); render(); @@ -93,4 +95,21 @@ describe("BookPagination", () => { ).toHaveAttribute("aria-disabled", "true"); }, ); + + it("can use another search param, so a page can have more than one list", () => { + const ReviewsStub = createRoutesStub([ + { + path: "/", + Component: () => ( + + ), + }, + ]); + render(); + + expect(currentPage()).toBe("2"); + expect( + screen.getByRole("link", { name: "Go to next page" }), + ).toHaveAttribute("href", "/?page=4&reviewPage=3"); + }); }); diff --git a/frontend/app/components/BookPagination/BookPagination.tsx b/frontend/app/components/BookPagination/BookPagination.tsx index 5e629624..2f552347 100644 --- a/frontend/app/components/BookPagination/BookPagination.tsx +++ b/frontend/app/components/BookPagination/BookPagination.tsx @@ -12,9 +12,20 @@ import { parsePageNum } from "~/hooks/useBookSearchParams"; const DISABLED_STYLE = "pointer-events-none opacity-50"; -function BookPagination({ totalPages }: { totalPages: number }) { +interface BookPaginationProps { + totalPages: number; + searchParam?: string; + // id of the element to scroll to when changing page. Scrolls to the top of the page by default + scrollTargetId?: string; +} + +function BookPagination({ + totalPages, + searchParam = "page", + scrollTargetId, +}: BookPaginationProps) { const [searchParams] = useSearchParams(); - const currentPage = parsePageNum(searchParams.get("page")); + const currentPage = parsePageNum(searchParams.get(searchParam)); const isFirstPage = currentPage === 1; const isLastPage = currentPage === totalPages; @@ -41,7 +52,7 @@ function BookPagination({ totalPages }: { totalPages: number }) { // Copy the current search params and update only the page parameter const getPageLink = (page: number) => { const params = new URLSearchParams(searchParams); - params.set("page", getValidPage(page).toString()); + params.set(searchParam, getValidPage(page).toString()); return { search: params.toString() }; }; @@ -50,11 +61,15 @@ function BookPagination({ totalPages }: { totalPages: number }) { window.scrollTo({ top: 0, behavior: reduceMotion ? "instant" : "smooth" }); }; + // Stop the router from scrolling to the top, so we can scroll to the target instead + const preventScrollReset = scrollTargetId !== undefined; + const renderPageLink = (page: number) => ( @@ -70,6 +85,7 @@ function BookPagination({ totalPages }: { totalPages: number }) { +
    + {reviews.map((review) => ( +
  • +
    +
    + {review.book && ( + + {review.book.title} + + )} + {review.user && ( + {review.user.name} + )} + + +
    + {review.comment && ( +

    {review.comment}

    + )} +
    +
  • + ))} +
+ {totalPages > 1 && ( + + )} + + ); +} + +export { ReviewList }; diff --git a/frontend/app/components/StarRating/StarRating.tsx b/frontend/app/components/StarRating/StarRating.tsx new file mode 100644 index 00000000..9bc3e476 --- /dev/null +++ b/frontend/app/components/StarRating/StarRating.tsx @@ -0,0 +1,60 @@ +import { StarIcon } from "lucide-react"; +import { cn } from "~/lib/utils"; + +const MAX_STARS = 5; + +function Star({ filled, className }: { filled: boolean; className?: string }) { + return ( +