From dff6317778da8046e92ad599cb3d699c0722f5db Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 10 Nov 2026 19:54:08 +0100 Subject: [PATCH 01/17] feat(backend): add book reviews This adds an SQL table and GraphQL resolvers --- backend/src/db/index.ts | 21 ++++ backend/src/db/schema/books.ts | 5 + backend/src/db/schema/index.ts | 1 + backend/src/db/schema/reviews.ts | 46 ++++++++ backend/src/db/seed/index.ts | 12 ++- backend/src/schema/resolvers.generated.ts | 17 ++- backend/src/schema/reviews/bookRating.ts | 63 +++++++++++ .../resolvers/Mutation/deleteReview.ts | 33 ++++++ .../resolvers/Mutation/upsertReview.ts | 56 ++++++++++ .../reviews/resolvers/Query/bookReviews.ts | 23 ++++ .../reviews/resolvers/Query/myReview.ts | 22 ++++ .../reviews/resolvers/Query/myReviews.ts | 30 ++++++ .../src/schema/reviews/resolvers/Review.ts | 25 +++++ .../schema/reviews/resolvers/ReviewAuthor.ts | 2 + .../reviews/resolvers/ReviewSearchResult.ts | 2 + backend/src/schema/reviews/schema.graphql | 42 ++++++++ backend/src/schema/reviews/schema.mappers.ts | 10 ++ backend/src/schema/schema.generated.graphqls | 44 ++++++++ backend/src/schema/typeDefs.generated.ts | 2 +- backend/src/schema/types.generated.ts | 101 ++++++++++++++++++ 20 files changed, 552 insertions(+), 5 deletions(-) create mode 100644 backend/src/db/schema/reviews.ts create mode 100644 backend/src/schema/reviews/bookRating.ts create mode 100644 backend/src/schema/reviews/resolvers/Mutation/deleteReview.ts create mode 100644 backend/src/schema/reviews/resolvers/Mutation/upsertReview.ts create mode 100644 backend/src/schema/reviews/resolvers/Query/bookReviews.ts create mode 100644 backend/src/schema/reviews/resolvers/Query/myReview.ts create mode 100644 backend/src/schema/reviews/resolvers/Query/myReviews.ts create mode 100644 backend/src/schema/reviews/resolvers/Review.ts create mode 100644 backend/src/schema/reviews/resolvers/ReviewAuthor.ts create mode 100644 backend/src/schema/reviews/resolvers/ReviewSearchResult.ts create mode 100644 backend/src/schema/reviews/schema.graphql create mode 100644 backend/src/schema/reviews/schema.mappers.ts diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index f019bb89..879d1cc7 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 881af931..c6732e91 100644 --- a/backend/src/db/schema/books.ts +++ b/backend/src/db/schema/books.ts @@ -24,6 +24,11 @@ const books = pgTable( publisher: text("publisher"), publishDate: text("publish_date"), 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 704c9073..6833b6a8 100644 --- a/backend/src/db/seed/index.ts +++ b/backend/src/db/seed/index.ts @@ -98,6 +98,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, @@ -111,7 +121,7 @@ for await (const book of parser as AsyncIterable) { publisher: book.publisher?.trim() || null, publishDate: book.publishDate?.trim() || null, coverImgUrl: book.coverImg?.trim() || null, - ...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..2eb6cdb6 --- /dev/null +++ b/backend/src/schema/reviews/bookRating.ts @@ -0,0 +1,63 @@ +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 [book] = await tx + .select() + .from(books) + .where(eq(books.id, bookId)) + .for("update"); + + 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..2bf95b06 --- /dev/null +++ b/backend/src/schema/reviews/resolvers/Mutation/upsertReview.ts @@ -0,0 +1,56 @@ +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"; + +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 = args.comment?.trim() || null; + + 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..4c626ebb --- /dev/null +++ b/backend/src/schema/reviews/resolvers/Query/bookReviews.ts @@ -0,0 +1,23 @@ +import { eq } from "drizzle-orm"; +import type { QueryResolvers } from "./../../../types.generated"; +import { db } from "~/db"; +import { bookReviews as bookReviewsTable } from "~/db/schema"; + +export const bookReviews: NonNullable = async ( + _parent, + args, + _ctx, +) => { + 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..648f8b2d --- /dev/null +++ b/backend/src/schema/reviews/resolvers/Query/myReviews.ts @@ -0,0 +1,30 @@ +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" }, + }); + } + + 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..b44f156e --- /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 30f11856..2fb1680d 100644 --- a/backend/src/schema/schema.generated.graphqls +++ b/backend/src/schema/schema.generated.graphqls @@ -29,13 +29,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. """ @@ -48,6 +61,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 @@ -71,6 +92,29 @@ type Query { ): BookSearchResult! } +type Review { + book: Book! + comment: String + + """ISO 8601 timestamps""" + createdAt: String! + id: Int! + rating: Int! + 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 3f3eeb3b..023a3669 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":"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":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortBy"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSortField"}},"defaultValue":{"kind":"EnumValue","value":"ID"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortDirection"},"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":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"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":"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":"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":"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":"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":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortBy"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSortField"}},"defaultValue":{"kind":"EnumValue","value":"ID"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortDirection"},"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":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"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":"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":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"10"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"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":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"10"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"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 timestamps","block":false},"name":{"kind":"Name","value":"createdAt"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","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 \ No newline at end of file diff --git a/backend/src/schema/types.generated.ts b/backend/src/schema/types.generated.ts index 29d54c4d..2055dfa1 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; @@ -48,7 +49,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; }; @@ -57,14 +62,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. */ @@ -73,6 +92,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; }; @@ -82,6 +105,13 @@ export type QuerybookArgs = { }; +export type QuerybookReviewsArgs = { + bookId: Scalars['Int']['input']; + limit?: InputMaybe; + offset?: InputMaybe; +}; + + export type QueryfavoriteBooksArgs = { limit?: InputMaybe; offset?: InputMaybe; @@ -93,6 +123,17 @@ export type QueryfavoriteGenresArgs = { }; +export type QuerymyReviewArgs = { + bookId: Scalars['Int']['input']; +}; + + +export type QuerymyReviewsArgs = { + limit?: InputMaybe; + offset?: InputMaybe; +}; + + export type QuerysearchBooksArgs = { author?: InputMaybe; bookFormat?: InputMaybe; @@ -111,6 +152,31 @@ export type QuerysearchBooksArgs = { title?: InputMaybe; }; +export type Review = { + __typename?: 'Review'; + book: Book; + comment?: Maybe; + /** ISO 8601 timestamps */ + createdAt: Scalars['String']['output']; + id: Scalars['Int']['output']; + rating: Scalars['Int']['output']; + 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'; @@ -197,6 +263,9 @@ export type ResolversTypes = { Mutation: ResolverTypeWrapper>; Boolean: ResolverTypeWrapper; Query: ResolverTypeWrapper>; + Review: ResolverTypeWrapper; + ReviewAuthor: ResolverTypeWrapper; + ReviewSearchResult: ResolverTypeWrapper & { reviews: Array }>; SortDirection: ResolverTypeWrapper<'ASC' | 'DESC'>; }; @@ -210,6 +279,9 @@ export type ResolversParentTypes = { Mutation: Record; Boolean: Scalars['Boolean']['output']; Query: Record; + Review: ReviewMapper; + ReviewAuthor: ReviewAuthor; + ReviewSearchResult: Omit & { reviews: Array }; }; export type BookResolvers = { @@ -240,20 +312,46 @@ export type BookSortFieldResolvers = EnumResolverSignature<{ ID?: any, RATING?: 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 = { @@ -262,6 +360,9 @@ export type Resolvers = { BookSortField?: BookSortFieldResolvers; Mutation?: MutationResolvers; Query?: QueryResolvers; + Review?: ReviewResolvers; + ReviewAuthor?: ReviewAuthorResolvers; + ReviewSearchResult?: ReviewSearchResultResolvers; SortDirection?: SortDirectionResolvers; }; From 987272b77ab2379e0c890a3c3af6cd9430f0db70 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 10 Nov 2026 19:55:21 +0100 Subject: [PATCH 02/17] chore(db): generate migration for book reviews --- .../20261110140402_book_reviews/migration.sql | 22 + .../20261110140402_book_reviews/snapshot.json | 1520 +++++++++++++++++ 2 files changed, 1542 insertions(+) create mode 100644 backend/drizzle/20261110140402_book_reviews/migration.sql create mode 100644 backend/drizzle/20261110140402_book_reviews/snapshot.json 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 From 3c86939bff5970521a8d084d86677caaf77be724 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 10 Nov 2026 20:24:43 +0100 Subject: [PATCH 03/17] feat: add RadioGroup component from shadcn --- frontend/app/components/ui/RadioGroup.tsx | 35 +++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 frontend/app/components/ui/RadioGroup.tsx diff --git a/frontend/app/components/ui/RadioGroup.tsx b/frontend/app/components/ui/RadioGroup.tsx new file mode 100644 index 00000000..cf25e082 --- /dev/null +++ b/frontend/app/components/ui/RadioGroup.tsx @@ -0,0 +1,35 @@ +import { Radio as RadioPrimitive } from "@base-ui/react/radio" +import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group" +import { cn } from "cn" + +function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) { + return ( + + ) +} + +function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) { + return ( + + + + + + ) +} + +export { RadioGroup, RadioGroupItem } From f3da8dfb8b2cdc3e7bbc0c1e62ce98b24c53a49d Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 10 Nov 2026 20:25:09 +0100 Subject: [PATCH 04/17] feat: add RadioGroupField to useAppForm components --- .../forms/RadioGroupField/RadioGroupField.tsx | 123 ++++++++++++++++++ frontend/app/hooks/useAppForm.ts | 2 + 2 files changed, 125 insertions(+) create mode 100644 frontend/app/components/forms/RadioGroupField/RadioGroupField.tsx diff --git a/frontend/app/components/forms/RadioGroupField/RadioGroupField.tsx b/frontend/app/components/forms/RadioGroupField/RadioGroupField.tsx new file mode 100644 index 00000000..55c8f922 --- /dev/null +++ b/frontend/app/components/forms/RadioGroupField/RadioGroupField.tsx @@ -0,0 +1,123 @@ +import { Radio as RadioPrimitive } from "@base-ui/react/radio"; +import { useState } from "react"; +import { + Field, + FieldDescription, + FieldError, + FieldLabel, + FieldLegend, + FieldSet, +} from "~/components/ui/Field"; +import { RadioGroup, RadioGroupItem } from "~/components/ui/RadioGroup"; +import { useFieldContext } from "~/hooks/formContext"; +import { cn } from "~/lib/utils"; + +interface RadioOption { + value: string; + label: string; +} + +interface RadioOptionState { + checked: boolean; + selectedValue: string; + hoveredValue: string | null; +} + +type RadioGroupFieldProps = { + label: string; + description?: React.ReactNode; + options: RadioOption[]; + // Replaces the default radio button and label with custom content. + renderOption?: ( + option: RadioOption, + state: RadioOptionState, + ) => React.ReactNode; +} & Omit< + React.ComponentPropsWithoutRef, + "value" | "defaultValue" | "onValueChange" +>; + +function RadioGroupField({ + label, + description, + options, + renderOption, + className, + ...props +}: RadioGroupFieldProps) { + const field = useFieldContext(); + const [hoveredValue, setHoveredValue] = useState(null); + + const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; + + return ( +
+ + {label} + + {!!description && ( + + {description} + + )} + field.handleChange(String(value))} + onBlur={field.handleBlur} + onPointerLeave={() => setHoveredValue(null)} + aria-labelledby={`${field.name}-label`} + aria-describedby={description ? `${field.name}-description` : undefined} + aria-invalid={isInvalid} + className={cn(renderOption && "flex w-fit gap-1", className)} + {...props} + > + {options.map((option) => { + const checked = field.state.value === option.value; + + if (renderOption) { + return ( + setHoveredValue(option.value)} + className="rounded-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40" + > + {renderOption(option, { + checked, + selectedValue: field.state.value, + hoveredValue, + })} + + ); + } + + const id = `${field.name}-${option.value}`; + + // data-invalid on the Field makes the option label red when invalid + return ( + + + + {option.label} + + + ); + })} + + {isInvalid && } +
+ ); +} + +export { RadioGroupField, type RadioOption, type RadioOptionState }; diff --git a/frontend/app/hooks/useAppForm.ts b/frontend/app/hooks/useAppForm.ts index b9bba60e..76022cda 100644 --- a/frontend/app/hooks/useAppForm.ts +++ b/frontend/app/hooks/useAppForm.ts @@ -1,4 +1,5 @@ import { createFormHook } from "@tanstack/react-form"; +import { RadioGroupField } from "~/components/forms/RadioGroupField/RadioGroupField"; import { SubmitButton } from "~/components/forms/SubmitButton/SubmitButton"; import { TextareaField } from "~/components/forms/TextareaField/TextareaField"; import { TextField } from "~/components/forms/TextField/TextField"; @@ -10,6 +11,7 @@ export const { useAppForm } = createFormHook({ fieldComponents: { TextField, TextareaField, + RadioGroupField, }, formComponents: { SubmitButton, From 0fc12c4c84277f7b0e9bd28e8b39812441986b31 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 10 Nov 2026 20:25:20 +0100 Subject: [PATCH 05/17] feat: add book reviews to book and profile page --- backend/src/schema/schema.generated.graphqls | 4 +- backend/src/schema/typeDefs.generated.ts | 2 +- backend/src/schema/types.generated.ts | 3 +- frontend/app/api/types/graphql.generated.ts | 40 ++++ .../app/components/StarRating/StarRating.tsx | 60 ++++++ .../reviews/BookReviews/BookReviews.test.tsx | 170 +++++++++++++++ .../reviews/BookReviews/BookReviews.tsx | 74 +++++++ .../reviews/ReviewForm/ReviewForm.tsx | 171 +++++++++++++++ .../reviews/ReviewList/ReviewList.tsx | 81 +++++++ frontend/app/hooks/useReviews.ts | 202 ++++++++++++++++++ frontend/app/lib/constants.ts | 10 +- frontend/app/routes/book/selectedBook.tsx | 3 + frontend/app/routes/profile.tsx | 29 +++ 13 files changed, 845 insertions(+), 4 deletions(-) create mode 100644 frontend/app/components/StarRating/StarRating.tsx create mode 100644 frontend/app/components/reviews/BookReviews/BookReviews.test.tsx create mode 100644 frontend/app/components/reviews/BookReviews/BookReviews.tsx create mode 100644 frontend/app/components/reviews/ReviewForm/ReviewForm.tsx create mode 100644 frontend/app/components/reviews/ReviewList/ReviewList.tsx create mode 100644 frontend/app/hooks/useReviews.ts diff --git a/backend/src/schema/schema.generated.graphqls b/backend/src/schema/schema.generated.graphqls index 2fb1680d..86d36217 100644 --- a/backend/src/schema/schema.generated.graphqls +++ b/backend/src/schema/schema.generated.graphqls @@ -96,10 +96,12 @@ type Review { book: Book! comment: String - """ISO 8601 timestamps""" + """ISO 8601 timestamp""" createdAt: String! id: Int! rating: Int! + + """ISO 8601 timestamp""" updatedAt: String! user: ReviewAuthor! } diff --git a/backend/src/schema/typeDefs.generated.ts b/backend/src/schema/typeDefs.generated.ts index 023a3669..16d91f26 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":"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":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortBy"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSortField"}},"defaultValue":{"kind":"EnumValue","value":"ID"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortDirection"},"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":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"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":"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":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"10"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"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":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"10"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"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 timestamps","block":false},"name":{"kind":"Name","value":"createdAt"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","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 \ 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":"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":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortBy"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSortField"}},"defaultValue":{"kind":"EnumValue","value":"ID"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortDirection"},"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":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"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":"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":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"10"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"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":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"10"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"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 \ No newline at end of file diff --git a/backend/src/schema/types.generated.ts b/backend/src/schema/types.generated.ts index 2055dfa1..f60f0a7f 100644 --- a/backend/src/schema/types.generated.ts +++ b/backend/src/schema/types.generated.ts @@ -156,10 +156,11 @@ export type Review = { __typename?: 'Review'; book: Book; comment?: Maybe; - /** ISO 8601 timestamps */ + /** ISO 8601 timestamp */ createdAt: Scalars['String']['output']; id: Scalars['Int']['output']; rating: Scalars['Int']['output']; + /** ISO 8601 timestamp */ updatedAt: Scalars['String']['output']; user: ReviewAuthor; }; diff --git a/frontend/app/api/types/graphql.generated.ts b/frontend/app/api/types/graphql.generated.ts index af2915fa..9b8c381a 100644 --- a/frontend/app/api/types/graphql.generated.ts +++ b/frontend/app/api/types/graphql.generated.ts @@ -49,6 +49,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 | null | undefined; 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 ( +