Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
60a510d
feat: update db seeding script to insert book genres
martakam Oct 20, 2026
6eba723
fix: force book genres to be unique
martakam Oct 20, 2026
1b0b3cd
feat: add GraphQL endpoint for genres
martakam Oct 20, 2026
bea8b4b
feat: allow filtering books based on genre
martakam Oct 20, 2026
052de9c
fix: import better-auth util from correct package
martakam Oct 20, 2026
0a5927d
fix: book genres we're searching for should be OR, not AND
martakam Oct 20, 2026
05bb2b9
chore: generate GraphQL types
martakam Oct 20, 2026
2cdf893
feat: add genre filtering to frontend
martakam Oct 20, 2026
1d3ecfa
chore: generate GraphQL types
martakam Oct 20, 2026
41b862a
feat: sort genres by popularity
martakam Oct 20, 2026
c0ecc59
test: add unit tests for genre filtering
martakam Oct 20, 2026
610864a
feat: add languages to GraphQL schema
martakam Oct 20, 2026
6458c3a
feat: add language filtering to FilterBooksSection
martakam Oct 20, 2026
51362e6
test: check language filter usage and URL synchronization
martakam Oct 20, 2026
f511662
feat: add Silder component from Shadcn
martakam Oct 20, 2026
8ea7baa
feat: add filtering based on page count
martakam Oct 20, 2026
455ef84
test: check pages filter counts as one filter
martakam Oct 20, 2026
885386b
test: pages count filter and URL synchronization
martakam Oct 20, 2026
74516ec
fix: do not allow empty strings as values in book data
martakam Oct 21, 2026
85e1e2f
chore: run Prettier after GraphQL codegen
martakam Oct 21, 2026
bda4670
feat: add book formats query to backend
martakam Oct 21, 2026
f7941ec
feat: add book format filtering to book filters
martakam Oct 21, 2026
7e8bd88
Merge branch 'main' into feat/add-book-filters
martakam Oct 22, 2026
34a0402
feat: add rating and numRatings to backend
johahor Oct 23, 2026
01a98d0
chore: generate GraphQL types
johahor Oct 23, 2026
7238d6a
feat: sort books by rating in searchBooks
johahor Oct 23, 2026
8c068a9
feat: add book sort options to frontend
johahor Oct 23, 2026
ea892ed
feat: add sort to useBookSearchParams
johahor Oct 23, 2026
d148a70
feat: send sorting to searchBooks query
johahor Oct 23, 2026
7e4bb1f
chore: generate GraphQL types
johahor Oct 23, 2026
0e1cc03
feat: add sort select to search bar
johahor Oct 23, 2026
491e5f0
refactor: rename pages filter to book length
martakam Oct 23, 2026
9d2f92b
fix: reset page search param when changing filters
martakam Oct 23, 2026
394d55f
Merge branch 'main' into feat/add-book-filters
martakam Oct 24, 2026
47ded9b
feat: add rating and numRatings to backend
johahor Oct 23, 2026
fe7364a
chore: generate GraphQL types
johahor Oct 23, 2026
ca69070
feat: sort books by rating in searchBooks
johahor Oct 23, 2026
aa2e5a4
feat: add book sort options to frontend
johahor Oct 23, 2026
e41e2e2
feat: add sort to useBookSearchParams
johahor Oct 23, 2026
ff98e0d
feat: send sorting to searchBooks query
johahor Oct 23, 2026
4e4afec
chore: generate GraphQL types
johahor Oct 23, 2026
10f334e
feat: add sort select to search bar
johahor Oct 23, 2026
21788de
Merge branch 'feat/sort-by-rating' of https://git.ntnu.no/IT2810-H26/…
martakam Oct 24, 2026
ffd979f
Update backend/src/schema/books/resolvers/Query/searchBooks.ts
johahor Oct 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const config: CodegenConfig = {
},
}),
},
hooks: { afterAllFileWrite: ["prettier --write"] },
};

export default config;
7 changes: 5 additions & 2 deletions backend/src/db/schema/books.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
varchar,
text,
primaryKey,
real,
} from "drizzle-orm/pg-core";

const books = pgTable("books", {
Expand All @@ -14,17 +15,19 @@ const books = pgTable("books", {
author: text("author").notNull(),
description: text("description"),
language: text("language"),
bookFormat: text("book_format").notNull(),
bookFormat: text("book_format"),
edition: text("edition"),
pages: integer("pages").notNull(),
publisher: text("publisher"),
publishDate: text("publish_date"),
coverImgUrl: text("cover_img_url"),
rating: real("rating"),
numRatings: integer("num_ratings"),
});

const bookGenres = pgTable("book_genres", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
genre: text("genre").notNull(),
genre: text("genre").unique().notNull(),
});

const bookToGenres = pgTable(
Expand Down
77 changes: 64 additions & 13 deletions backend/src/db/seed/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { parse } from "csv-parse";
import { db } from "~/db";
import { books, type InsertBook } from "~/db/schema/books";
import { parseRating } from "~/db/seed/parseRatings";
import {
bookGenres,
books,
bookToGenres,
type InsertBook,
} from "~/db/schema/books";

type BookRow = {
bookId: string;
Expand All @@ -14,9 +20,9 @@ type BookRow = {
description: string | null;
language: string | null;
isbn: string;
genres: string[];
genres: string;
characters: string[];
bookFormat: string;
bookFormat: string | null;
edition: string | null;
pages: number | string;
publisher: string | null;
Expand Down Expand Up @@ -45,6 +51,43 @@ const parser = createReadStream(resolve(__dirname, "book-data.csv")).pipe(

let inserted = 0;
let booksToInsert: InsertBook[] = [];
let genresToInsert: string[][] = [];
const genreIds = new Map<string, number>();

async function insertBatch(batch: InsertBook[], batchGenres: string[][]) {
const insertedBooks = await db
.insert(books)
.values(batch)
.returning({ id: books.id });

const newGenres = [...new Set(batchGenres.flat())].filter(
(genre) => !genreIds.has(genre),
);

if (newGenres.length > 0) {
const insertedGenres = await db
.insert(bookGenres)
.values(newGenres.map((genre) => ({ genre })))
.returning();

insertedGenres.forEach(({ id, genre }) => genreIds.set(genre, id));
}

const bookToGenreRows = insertedBooks.flatMap(({ id: bookId }, i) =>
[...new Set(batchGenres[i])].map((genre) => {
const genreId = genreIds.get(genre);

if (genreId === undefined) {
throw new Error(`Genre ID not found for genre "${genre}"`);
}
return { bookId, genreId };
}),
);

if (bookToGenreRows.length > 0) {
await db.insert(bookToGenres).values(bookToGenreRows);
}
}

for await (const book of parser as AsyncIterable<BookRow>) {
if (typeof book.pages === "string") {
Expand All @@ -58,22 +101,30 @@ for await (const book of parser as AsyncIterable<BookRow>) {
booksToInsert.push({
isbn: book.isbn,
title: book.title,
series: book.series || null,
series: book.series?.trim() || null,
author: book.author,
description: book.description || null,
language: book.language || null,
bookFormat: book.bookFormat,
edition: book.edition || null,
description: book.description?.trim() || null,
language: book.language?.trim() || null,
bookFormat: book.bookFormat?.trim() || null,
edition: book.edition?.trim() || null,
pages: book.pages,
publisher: book.publisher || null,
publishDate: book.publishDate || null,
coverImgUrl: book.coverImg || null,
publisher: book.publisher?.trim() || null,
publishDate: book.publishDate?.trim() || null,
coverImgUrl: book.coverImg?.trim() || null,
...parseRating(book),
});

if (book.genres == "[]") {
genresToInsert.push([]);
} else {
genresToInsert.push(book.genres.slice(2, -2).split("', '"));
}

try {
if (booksToInsert.length >= BATCH_SIZE) {
await db.insert(books).values(booksToInsert);
await insertBatch(booksToInsert, genresToInsert);
booksToInsert = [];
genresToInsert = [];
inserted += BATCH_SIZE;
}
} catch (error) {
Expand All @@ -91,7 +142,7 @@ for await (const book of parser as AsyncIterable<BookRow>) {
}

// Insert last batch if there are any remaining books to insert
await db.insert(books).values(booksToInsert);
await insertBatch(booksToInsert, genresToInsert);

console.clear();
console.log("Seeding complete!");
Expand Down
21 changes: 21 additions & 0 deletions backend/src/db/seed/parseRatings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
function parseRating({
rating,
numRatings,
}: {
rating: string | number;
numRatings: string | number;
}) {
const count = Number.parseInt(String(numRatings), 10);
const average = Number.parseFloat(String(rating));

const hasRatings =
Number.isFinite(count) && count > 0 && Number.isFinite(average);

// The dataset uses 0.0 for books without ratings. We store NULL instead, so unrated books are not mistaken for books with the worst rating.
return {
rating: hasRatings && average > 0 ? average : null,
numRatings: Number.isFinite(count) ? count : null,
};
}

export { parseRating };
20 changes: 20 additions & 0 deletions backend/src/schema/books/resolvers/Query/bookFormats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { asc, count, desc, isNotNull } from "drizzle-orm";
import type { QueryResolvers } from "~/schema/types.generated";
import { db } from "~/db";
import { books } from "~/db/schema";

export const bookFormats: NonNullable<QueryResolvers["bookFormats"]> = async (
_parent,
_args,
_ctx,
) => {
// Order by each format's book count, and then alphabetically
const rows = await db
.select({ bookFormat: books.bookFormat })
.from(books)
.where(isNotNull(books.bookFormat))
.groupBy(books.bookFormat)
.orderBy(desc(count()), asc(books.bookFormat));

return rows.flatMap((row) => (row.bookFormat ? [row.bookFormat] : []));
};
20 changes: 20 additions & 0 deletions backend/src/schema/books/resolvers/Query/genres.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { asc, count, desc, eq } from "drizzle-orm";
import type { QueryResolvers } from "~/schema/types.generated";
import { db } from "~/db";
import { bookGenres, bookToGenres } from "~/db/schema";

export const genres: NonNullable<QueryResolvers["genres"]> = async (
_parent,
_args,
_ctx,
) => {
// Order by each genre's book count, and then alphabetically
const rows = await db
.select({ genre: bookGenres.genre })
.from(bookGenres)
.leftJoin(bookToGenres, eq(bookToGenres.genreId, bookGenres.id))
.groupBy(bookGenres.id)
.orderBy(desc(count(bookToGenres.bookId)), asc(bookGenres.genre));

return rows.map((row) => row.genre);
};
20 changes: 20 additions & 0 deletions backend/src/schema/books/resolvers/Query/languages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { asc, count, desc, isNotNull } from "drizzle-orm";
import type { QueryResolvers } from "~/schema/types.generated";
import { db } from "~/db";
import { books } from "~/db/schema";

export const languages: NonNullable<QueryResolvers["languages"]> = async (
_parent,
_args,
_ctx,
) => {
// Order by each language's book count, and then alphabetically
const rows = await db
.select({ language: books.language })
.from(books)
.where(isNotNull(books.language))
.groupBy(books.language)
.orderBy(desc(count()), asc(books.language));

return rows.flatMap((row) => (row.language ? [row.language] : []));
};
60 changes: 53 additions & 7 deletions backend/src/schema/books/resolvers/Query/searchBooks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
import { and, asc, ilike, type SQL } from "drizzle-orm";
import {
and,
asc,
desc,
eq,
exists,
gte,
ilike,
isNull,
lte,
or,
type SQL,
} from "drizzle-orm";
import { db } from "~/db";
import { books } from "~/db/schema/books";
import { bookGenres, books, bookToGenres } from "~/db/schema";
import type { QueryResolvers } from "~/schema/types.generated";

export const searchBooks: NonNullable<QueryResolvers["searchBooks"]> = async (
Expand All @@ -13,23 +25,57 @@ export const searchBooks: NonNullable<QueryResolvers["searchBooks"]> = async (
if (args.title) conditions.push(ilike(books.title, `%${args.title}%`));
if (args.series) conditions.push(ilike(books.series, `%${args.series}%`));
if (args.author) conditions.push(ilike(books.author, `%${args.author}%`));
if (args.language)
conditions.push(ilike(books.language, `%${args.language}%`));
if (args.bookFormat)
conditions.push(ilike(books.bookFormat, `%${args.bookFormat}%`));
if (args.language) conditions.push(eq(books.language, args.language));
if (args.bookFormat) conditions.push(eq(books.bookFormat, args.bookFormat));
if (args.publisher)
conditions.push(ilike(books.publisher, `%${args.publisher}%`));
if (args.minPages != null) conditions.push(gte(books.pages, args.minPages));
if (args.maxPages != null) conditions.push(lte(books.pages, args.maxPages));

const genreConditions: SQL[] = [];

for (const genre of args.genres ?? []) {
genreConditions.push(
exists(
db
.select()
.from(bookToGenres)
.innerJoin(bookGenres, eq(bookGenres.id, bookToGenres.genreId))
.where(
and(eq(bookToGenres.bookId, books.id), eq(bookGenres.genre, genre)),
),
),
);
}

if (genreConditions.length > 0) {
conditions.push(or(...genreConditions) as SQL);
}

// Here we build the filter once with Drizzle's SQL builder, so the same filter
// can be used both for fetching one page and for counting all matches.
const filter = and(...conditions);

const orderBy =
args.sortBy === "RATING"
? [
// If the book has no rating (isNull returns true), we want it to be sorted last.
// Otherwise, the DESC order would put books with no rating first
asc(isNull(books.rating)),
args.sortDirection === "DESC"
? desc(books.rating)
: asc(books.rating),
desc(books.numRatings),
asc(books.id),
]
: [asc(books.id)];

const [foundBooks, totalCount] = await Promise.all([
db
.select()
.from(books)
.where(filter)
.orderBy(asc(books.id))
.orderBy(...orderBy)
.limit(args.limit)
.offset(args.offset),
db.$count(books, filter),
Expand Down
25 changes: 23 additions & 2 deletions backend/src/schema/books/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,48 @@ type Book {
author: String!
description: String
language: String
bookFormat: String!
bookFormat: String
edition: String
pages: Int!
publisher: String
publishDate: String
coverImgUrl: String
genres: [String!]!
rating: Float
numRatings: Int
}

enum BookSortField {
ID
RATING
}

enum SortDirection {
ASC
DESC
}

extend type Query {
book(id: Int!): Book
genres: [String!]!
languages: [String!]!
bookFormats: [String!]!

searchBooks(
title: String
series: String
author: String
language: String
bookFormat: String
publisher: String
genres: [String!]
minPages: Int
maxPages: Int
limit: Int = 12
offset: Int = 0
sortBy: BookSortField = ID
sortDirection: SortDirection = ASC
): BookSearchResult!
book(id: Int!): Book
}

type BookSearchResult {
Expand Down
5 changes: 4 additions & 1 deletion backend/src/schema/resolvers.generated.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
/* This file was automatically generated. DO NOT UPDATE MANUALLY. */
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 { 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 { searchBooks as Query_searchBooks } from './books/resolvers/Query/searchBooks';
import { addFavoriteBook as Mutation_addFavoriteBook } from './favorites/resolvers/Mutation/addFavoriteBook';
import { removeFavoriteBook as Mutation_removeFavoriteBook } from './favorites/resolvers/Mutation/removeFavoriteBook';
import { Book } from './books/resolvers/Book';
import { BookSearchResult } from './books/resolvers/BookSearchResult';
export const resolvers: Resolvers = {
Query: { book: Query_book,favoriteBookIds: Query_favoriteBookIds,favoriteBooks: Query_favoriteBooks,favoriteGenres: Query_favoriteGenres,searchBooks: Query_searchBooks },
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 },

Book: Book,
Expand Down
Loading