diff --git a/server/.env.example b/server/.env.example index 8901ac6..18e83ce 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1 +1,2 @@ -MONGODB_URI=mongodb://127.0.0.1:27017/project2 \ No newline at end of file +MONGODB_URI=mongodb://127.0.0.1:27017/project2 +PORT=3001 diff --git a/server/API.md b/server/API.md new file mode 100644 index 0000000..03ba0a8 --- /dev/null +++ b/server/API.md @@ -0,0 +1,107 @@ +# GraphQL API + +The server is an [Apollo Server](https://www.apollographql.com/docs/apollo-server) that answers the +schema in [`docs/decisions-and-contracts.md`](../docs/decisions-and-contracts.md). The schema lives in +`src/graphql/typeDefs.ts`. + +## Running it + +1. MongoDB must be running (see `MONGODB_INSTALL.md`), and `server/.env` must exist (copy + `.env.example`). +2. From `server/`: + +```bash +npm install +npm run import:mock # fill the database with the mock books +npm run dev # starts on http://localhost:3001/ and restarts on changes +``` + +For production: `npm run build`, then `npm start`. + +| Variable | Needed | Default | What it is | +| ------------- | ------ | ------- | ---------------------------------- | +| `MONGODB_URI` | yes | | MongoDB connection string | +| `PORT` | no | `3001` | Port the GraphQL server listens on | + +## Trying it out + +Open http://localhost:3001/ in a browser. In development this opens Apollo Sandbox, where you can +write queries with autocomplete and read the schema docs. To act as a user, add the header +`x-user-id` (any 8-64 letters, digits, `-` or `_`) under **Headers**. + +```graphql +query { + books(search: "tolkien", first: 5) { + items { + id + title + authors + publishedYear + myEntry { + rating + note + } + } + nextCursor + totalCount + } +} +``` + +## How it is built + +```text +src/ +├── index.ts # connects to MongoDB, starts Apollo on PORT +├── graphql/ +│ ├── typeDefs.ts # the schema (the contract) +│ ├── resolvers.ts # checks arguments and calls the services +│ └── context.ts # reads the x-user-id header for each request +├── services/ +│ ├── books.ts # search, filter, sort, paging and cursors +│ └── shelf.ts # shelf entries: read, add, remove, rate, note +└── models/ + ├── Book.ts + └── ShelfEntry.ts # one per user and book, unique on (userId, bookId) +``` + +We use Apollo's standalone server (no Express) because we only serve GraphQL. It allows requests from +other origins (CORS), so the Vite dev server can call it. + +## Choices made in the API + +- **`Book.id` is the Google Books id** (`googleBooksId`), not Mongo's `_id`. Mongo makes up a new + `_id` whenever a book is inserted, so the same book has a different `_id` in each of our local + databases and on the VM. The Google Books id is the same everywhere, so a link like `/book/`, a + test or a bug report points to the same book on every machine. It is also what the client's mock + data already uses, and it already has a unique index. +- **The user's own data is a `myEntry` field on `Book`.** Every query and mutation that returns a book + includes it, so the client's Apollo cache updates every place a book is shown after a mutation. For + a list, all entries are fetched in one query, not one per book. +- **User ID:** the client sends its anonymous ID in the `x-user-id` header (decision 4). Without a + valid ID, `myEntry` is always `null`, and `myShelf` and the mutations return the error + `USER_ID_REQUIRED`. +- **Search** ignores case and matches title and authors. With several words, every word must match + somewhere, so `tolkien hobbit` finds _The Hobbit_. Special characters are escaped, so they are + searched for literally. +- **Paging:** `first` defaults to 12 and is capped at 50. The cursor is opaque. For now it holds an + offset, which is stable because the catalogue is seeded once and never changes while the app runs. Every sort ends with the + book id, so pages never overlap or skip (decision 6). +- **Sorting:** `YEAR_DESC` puts books with an unknown year last. `RATING_DESC` on the shelf puts + unrated books last. +- **Shelf rules** (decision 3): rating or writing a note adds the book to the shelf. Clearing a + rating or note (`null`) does not, and leaves the book on the shelf. `setShelved(false)` deletes the + entry, including its rating and note. Shelving a book that is already shelved changes nothing. +- **`RECENT`** sorts by `updatedAt`, which changes when a book is added and when its rating or note + changes. +- **Notes** are trimmed. An empty note deletes the note. + +## Errors + +Invalid input gives a GraphQL error with a code in `error.extensions.code`: + +| Code | When | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | +| `BAD_USER_INPUT` | `first` < 1, invalid `after` cursor, `yearFrom` > `yearTo`, search over 200 characters, rating outside 1-5, note over 1000 characters | +| `USER_ID_REQUIRED` | `myShelf` or a mutation without a valid `x-user-id` header | +| `NOT_FOUND` | A mutation on a book id that does not exist (`book(id)` returns `null` instead) | diff --git a/server/DATABASE_TEMPLATE.md b/server/DATABASE_TEMPLATE.md index 1e12161..d9b7121 100644 --- a/server/DATABASE_TEMPLATE.md +++ b/server/DATABASE_TEMPLATE.md @@ -87,7 +87,7 @@ A successful connection prints: ```text Connected to MongoDB -Server initialized +GraphQL server ready at http://localhost:3001/ ``` ## Importing the mock data diff --git a/server/src/graphql/context.ts b/server/src/graphql/context.ts new file mode 100644 index 0000000..8535edc --- /dev/null +++ b/server/src/graphql/context.ts @@ -0,0 +1,26 @@ +import type { StandaloneServerContextFunctionArgument } from '@apollo/server/standalone'; + +/** What every resolver gets as its third argument. */ +export type Context = { + /** The anonymous user's ID, or null if the request did not send a valid one. */ + userId: string | null; +}; + +/** The header the client sends its anonymous ID in (decision 4). */ +export const USER_ID_HEADER = 'x-user-id'; + +// crypto.randomUUID() gives 36 characters of hex digits and dashes. We accept a +// little more than that, but nothing that could be confused with a query operator. +const USER_ID_PATTERN = /^[A-Za-z0-9_-]{8,64}$/; + +/** + * Builds the context for one request. Runs before any resolver. + * The ID is not a password: anyone who knows it can see and change that shelf. + */ +export async function createContext({ + req, +}: StandaloneServerContextFunctionArgument): Promise { + const header = req.headers[USER_ID_HEADER]; + const userId = typeof header === 'string' && USER_ID_PATTERN.test(header) ? header : null; + return { userId }; +} diff --git a/server/src/graphql/resolvers.ts b/server/src/graphql/resolvers.ts new file mode 100644 index 0000000..a1acdef --- /dev/null +++ b/server/src/graphql/resolvers.ts @@ -0,0 +1,174 @@ +import { GraphQLError } from 'graphql'; +import { ApolloServerErrorCode } from '@apollo/server/errors'; +import { + decodeCursor, + encodeCursor, + findBookById, + findBooks, + listGenres, + type BookRecord, + type BookSort, +} from '../services/books.js'; +import { + addToShelf, + getShelf, + removeFromShelf, + updateEntry, + withEntry, + withMyEntries, + type ShelfSort, +} from '../services/shelf.js'; +import { USER_ID_HEADER, type Context } from './context.js'; + +/* + * Resolvers turn GraphQL requests into calls to the services. This file + * checks the arguments (the API's job). The database work lives in + * services/, so it can be tested and changed on its own. + */ + +const DEFAULT_PAGE_SIZE = 12; +const MAX_PAGE_SIZE = 50; +const MAX_SEARCH_LENGTH = 200; +const MAX_NOTE_LENGTH = 1000; + +// Error codes the client can check in error.extensions.code. +// BAD_USER_INPUT is Apollo's standard code for invalid arguments. +const BAD_USER_INPUT = ApolloServerErrorCode.BAD_USER_INPUT; +const NOT_FOUND = 'NOT_FOUND'; +const USER_ID_REQUIRED = 'USER_ID_REQUIRED'; + +function badInput(message: string): GraphQLError { + return new GraphQLError(message, { extensions: { code: BAD_USER_INPUT } }); +} + +/** Shelf data belongs to a user, so these operations need the user ID header. */ +function requireUserId(context: Context): string { + if (!context.userId) { + throw new GraphQLError(`Send the anonymous user ID in the ${USER_ID_HEADER} header.`, { + extensions: { code: USER_ID_REQUIRED }, + }); + } + return context.userId; +} + +async function requireBook(bookId: string): Promise { + const book = await findBookById(bookId); + if (!book) { + throw new GraphQLError(`No book with id "${bookId}".`, { extensions: { code: NOT_FOUND } }); + } + return book; +} + +// Argument types. GraphQL has already checked the basic types (Int, String, enums) +// before a resolver runs. Optional arguments can still be null if the client sends null. +type BooksArgs = { + search?: string | null; + genres?: string[] | null; + yearFrom?: number | null; + yearTo?: number | null; + sort?: BookSort | null; + first?: number | null; + after?: string | null; +}; +type BookArgs = { id: string }; +type MyShelfArgs = { sort?: ShelfSort | null }; +type SetShelvedArgs = { bookId: string; shelved: boolean }; +type SetRatingArgs = { bookId: string; rating?: number | null }; +type SetNoteArgs = { bookId: string; note?: string | null }; + +export const resolvers = { + Query: { + async books(_parent: unknown, args: BooksArgs, context: Context) { + const first = args.first ?? DEFAULT_PAGE_SIZE; + if (first < 1) throw badInput('"first" must be at least 1.'); + const limit = Math.min(first, MAX_PAGE_SIZE); + + let offset = 0; + if (args.after) { + const decoded = decodeCursor(args.after); + if (decoded === null) throw badInput('"after" is not a valid cursor.'); + offset = decoded; + } + + const search = args.search?.trim() || undefined; + if (search && search.length > MAX_SEARCH_LENGTH) { + throw badInput(`"search" can be at most ${MAX_SEARCH_LENGTH} characters.`); + } + + const yearFrom = args.yearFrom ?? undefined; + const yearTo = args.yearTo ?? undefined; + if (yearFrom !== undefined && yearTo !== undefined && yearFrom > yearTo) { + throw badInput('"yearFrom" cannot be after "yearTo".'); + } + + const { items, totalCount } = await findBooks( + { search, genres: args.genres ?? undefined, yearFrom, yearTo }, + args.sort ?? 'TITLE_ASC', + offset, + limit, + ); + + const end = offset + items.length; + return { + items: await withMyEntries(items, context.userId), + nextCursor: end < totalCount ? encodeCursor(end) : null, + totalCount, + }; + }, + + async book(_parent: unknown, { id }: BookArgs, context: Context) { + const book = await findBookById(id); + if (!book) return null; + const [withMine] = await withMyEntries([book], context.userId); + return withMine; + }, + + myShelf(_parent: unknown, { sort }: MyShelfArgs, context: Context) { + return getShelf(requireUserId(context), sort ?? 'RECENT'); + }, + + genres() { + return listGenres(); + }, + }, + + Mutation: { + async setShelved(_parent: unknown, { bookId, shelved }: SetShelvedArgs, context: Context) { + const userId = requireUserId(context); + const book = await requireBook(bookId); + + if (!shelved) { + await removeFromShelf(userId, bookId); + return withEntry(book, null); + } + return withEntry(book, await addToShelf(userId, bookId)); + }, + + async setRating(_parent: unknown, args: SetRatingArgs, context: Context) { + const userId = requireUserId(context); + const rating = args.rating ?? null; + if (rating !== null && (rating < 1 || rating > 5)) { + throw badInput('"rating" must be between 1 and 5, or null to clear it.'); + } + const book = await requireBook(args.bookId); + return withEntry(book, await updateEntry(userId, args.bookId, { rating })); + }, + + async setNote(_parent: unknown, args: SetNoteArgs, context: Context) { + const userId = requireUserId(context); + // Leading and trailing whitespace is dropped. An empty note deletes the note. + const note = args.note?.trim() || null; + if (note !== null && note.length > MAX_NOTE_LENGTH) { + throw badInput(`"note" can be at most ${MAX_NOTE_LENGTH} characters.`); + } + const book = await requireBook(args.bookId); + return withEntry(book, await updateEntry(userId, args.bookId, { note })); + }, + }, + + Book: { + // The API's Book.id is the Google Books id, which is the same in every + // database (each local one and the VM's). Mongo's _id is never exposed. + id: (book: BookRecord) => book.googleBooksId, + }, +}; diff --git a/server/src/graphql/typeDefs.ts b/server/src/graphql/typeDefs.ts new file mode 100644 index 0000000..35a08d0 --- /dev/null +++ b/server/src/graphql/typeDefs.ts @@ -0,0 +1,104 @@ +/** + * The GraphQL schema: the contract between backend and frontend. + * Keep it in sync with docs/decisions-and-contracts.md (decision 11). + * + * The """text""" blocks are descriptions. They show up in Apollo Sandbox + * (open the server URL in a browser) and in the client's editor tooling. + */ +export const typeDefs = `#graphql + """ + A book from the catalogue. Books are read-only and filled in by the seed script. + """ + type Book { + "The Google Books id, the same in every database. Used in URLs and as the shelf key." + id: ID! + title: String! + "Empty string when there is none." + subtitle: String! + "Empty list when unknown." + authors: [String!]! + "Null when unknown." + publishedYear: Int + genres: [String!]! + "Plain text, never HTML. Empty string when there is none." + description: String! + "Empty string when there is no cover. The client then shows a generated cover." + coverURL: String! + "Null when unknown." + pageCount: Int + "Empty string when unknown." + language: String! + "The current user's shelf entry for this book. Null when the book is not on their shelf." + myEntry: ShelfEntry + } + + """ + The current user's own data for one book. If it exists, the book is on their shelf. + """ + type ShelfEntry { + "1-5, or null when not rated." + rating: Int + "Plain text, max 1000 characters, or null when there is no note." + note: String + "When the book was added or the entry was last changed (ISO 8601)." + updatedAt: String! + } + + "One page of search results." + type BookPage { + items: [Book!]! + "Pass this back as 'after' to get the next page. Null when there are no more books." + nextCursor: String + "How many books match the search and filters in total (not just this page)." + totalCount: Int! + } + + enum BookSort { + TITLE_ASC + YEAR_DESC + } + + enum ShelfSort { + RECENT + TITLE_ASC + RATING_DESC + } + + type Query { + """ + Search, filter and sort the whole catalogue, one page at a time. + 'search' matches title and author, ignoring case; every word must match. + A book matches 'genres' if it has any of them. 'first' is capped at 50. + """ + books( + search: String + genres: [String!] + yearFrom: Int + yearTo: Int + sort: BookSort = TITLE_ASC + first: Int = 12 + after: String + ): BookPage! + + "One book, or null if no book has this id." + book(id: ID!): Book + + "The books on the current user's shelf. Needs the x-user-id header." + myShelf(sort: ShelfSort = RECENT): [Book!]! + + "Every genre used in the catalogue, sorted A-Z." + genres: [String!]! + } + + "All mutations need the x-user-id header and return the updated book with its myEntry." + type Mutation { + "Add the book to the shelf, or remove it (this also deletes its rating and note)." + setShelved(bookId: ID!, shelved: Boolean!): Book! + + "Rate the book 1-5 (adds it to the shelf if needed). Null clears the rating." + setRating(bookId: ID!, rating: Int): Book! + + "Save a note, max 1000 characters (adds the book to the shelf if needed). Null or empty deletes it." + setNote(bookId: ID!, note: String): Book! + } +`; diff --git a/server/src/index.ts b/server/src/index.ts index 7d0e4ff..e43c457 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,9 +1,28 @@ -import { connectDatabase } from "./database/database.js"; +import { ApolloServer } from '@apollo/server'; +import { startStandaloneServer } from '@apollo/server/standalone'; +import { connectDatabase } from './database/database.js'; +import { createContext, type Context } from './graphql/context.js'; +import { resolvers } from './graphql/resolvers.js'; +import { typeDefs } from './graphql/typeDefs.js'; + +const DEFAULT_PORT = 3001; async function startServer() { await connectDatabase(); - console.log("Server initialized"); + const server = new ApolloServer({ typeDefs, resolvers }); + + // The standalone server is Apollo's built-in HTTP server. It allows requests + // from other origins (CORS), so the Vite dev server can reach it. + const { url } = await startStandaloneServer(server, { + listen: { port: Number(process.env.PORT) || DEFAULT_PORT }, + context: createContext, + }); + + console.log(`GraphQL server ready at ${url}`); } -startServer(); \ No newline at end of file +startServer().catch((error) => { + console.error('Failed to start the server:', error); + process.exit(1); +}); diff --git a/server/src/models/ShelfEntry.ts b/server/src/models/ShelfEntry.ts new file mode 100644 index 0000000..bfcbae1 --- /dev/null +++ b/server/src/models/ShelfEntry.ts @@ -0,0 +1,47 @@ +import mongoose from 'mongoose'; + +/** + * One shelf entry per (anonymous user, book). + * + * An entry existing means the book is on that user's shelf (decision 3). + * Rating or writing a note creates the entry if it is missing, and removing + * the book from the shelf deletes it, including the rating and the note. + */ +const shelfEntrySchema = new mongoose.Schema( + { + // The random ID the browser creates once and sends in the x-user-id header. + userId: { + type: String, + required: true, + }, + + // The book's googleBooksId, which is also the `id` the API exposes on Book + // (API.md explains why it is not Mongo's _id). + bookId: { + type: String, + required: true, + }, + + // 1-5, or null when the user has not rated the book. + rating: { + type: Number, + min: 1, + max: 5, + default: null, + }, + + // Plain text, at most 1000 characters (decision 8), or null when there is no note. + note: { + type: String, + maxlength: 1000, + default: null, + }, + }, + // Adds createdAt and updatedAt. updatedAt drives the RECENT sort on My shelf. + { timestamps: true }, +); + +// One entry per user and book. The index also makes "all entries for this user" fast. +shelfEntrySchema.index({ userId: 1, bookId: 1 }, { unique: true }); + +export const ShelfEntry = mongoose.model('ShelfEntry', shelfEntrySchema); diff --git a/server/src/services/books.ts b/server/src/services/books.ts new file mode 100644 index 0000000..e2c2cbe --- /dev/null +++ b/server/src/services/books.ts @@ -0,0 +1,119 @@ +import type { InferSchemaType, QueryFilter } from 'mongoose'; +import { Book } from '../models/Book.js'; + +/** A book as stored in MongoDB (a plain object, not a Mongoose document). */ +export type BookRecord = InferSchemaType; + +export type BookSort = 'TITLE_ASC' | 'YEAR_DESC'; + +export type BookFilters = { + search?: string; + genres?: string[]; + yearFrom?: number; + yearTo?: number; +}; + +// Every sort ends with googleBooksId as a tie-breaker, so books with equal values +// always come in the same order and "Load more" never repeats or skips a book +// (decision 6). MongoDB sorts null lowest, so books without a year come last in YEAR_DESC. +const SORT_ORDER = { + TITLE_ASC: { title: 1, googleBooksId: 1 }, + YEAR_DESC: { publishedYear: -1, googleBooksId: 1 }, +} as const; + +/** Makes user input safe to use inside a regular expression. */ +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Turns the search and filters into one MongoDB filter. + * - search: every word must appear in the title or in one of the authors, ignoring case. + * - genres: the book must have at least one of them. + * - yearFrom / yearTo: inclusive. Books with an unknown year are left out when either is set. + */ +export function buildBookFilter({ + search, + genres, + yearFrom, + yearTo, +}: BookFilters): QueryFilter { + const conditions: QueryFilter[] = []; + + const words = search?.trim().split(/\s+/).filter(Boolean) ?? []; + for (const word of words) { + const pattern = new RegExp(escapeRegExp(word), 'i'); + conditions.push({ $or: [{ title: pattern }, { authors: pattern }] }); + } + + if (genres && genres.length > 0) { + conditions.push({ genres: { $in: genres } }); + } + + if (yearFrom !== undefined) { + conditions.push({ publishedYear: { $gte: yearFrom } }); + } + if (yearTo !== undefined) { + conditions.push({ publishedYear: { $lte: yearTo } }); + } + + return conditions.length > 0 ? { $and: conditions } : {}; +} + +/** + * One page of books matching the filters, plus how many books match in total. + * This is the one function the API uses to search the catalogue. + */ +export async function findBooks( + filters: BookFilters, + sort: BookSort, + offset: number, + limit: number, +): Promise<{ items: BookRecord[]; totalCount: number }> { + const filter = buildBookFilter(filters); + + const [items, totalCount] = await Promise.all([ + Book.find(filter).sort(SORT_ORDER[sort]).skip(offset).limit(limit).lean(), + // Same filter, so the count is for the filtered set, not the whole catalogue. + Book.countDocuments(filter), + ]); + + return { items, totalCount }; +} + +/** One book by its id (googleBooksId), or null if there is none. */ +export async function findBookById(id: string): Promise { + return Book.findOne({ googleBooksId: id }).lean(); +} + +/** Several books by id, in no particular order. Ids with no book are skipped. */ +export async function findBooksByIds(ids: string[]): Promise { + if (ids.length === 0) return []; + return Book.find({ googleBooksId: { $in: ids } }).lean(); +} + +/** Every genre used by at least one book, sorted A-Z. */ +export async function listGenres(): Promise { + const genres: string[] = await Book.distinct('genres'); + return genres.filter((genre) => genre.trim() !== '').sort((a, b) => a.localeCompare(b)); +} + +/* + * Cursors are opaque to the client (decision 6): it only passes nextCursor back + * as `after`. For now a cursor holds the offset of the next book. The catalogue + * is seeded once and never changes while the app runs, so an offset is stable, + * and the format can change later (for example to "last title + id") without + * touching the client. + */ +const CURSOR_PREFIX = 'offset:'; + +export function encodeCursor(offset: number): string { + return Buffer.from(`${CURSOR_PREFIX}${offset}`).toString('base64url'); +} + +/** Returns the offset stored in the cursor, or null if the cursor is not one of ours. */ +export function decodeCursor(cursor: string): number | null { + const text = Buffer.from(cursor, 'base64url').toString('utf8'); + const match = new RegExp(`^${CURSOR_PREFIX}(\\d{1,9})$`).exec(text); + return match ? Number(match[1]) : null; +} diff --git a/server/src/services/shelf.ts b/server/src/services/shelf.ts new file mode 100644 index 0000000..e636e27 --- /dev/null +++ b/server/src/services/shelf.ts @@ -0,0 +1,127 @@ +import type { InferSchemaType } from 'mongoose'; +import { ShelfEntry } from '../models/ShelfEntry.js'; +import { findBooksByIds, type BookRecord } from './books.js'; + +type ShelfEntryRecord = InferSchemaType; + +export type ShelfSort = 'RECENT' | 'TITLE_ASC' | 'RATING_DESC'; + +/** A shelf entry the way the API returns it. */ +export type ShelfEntryResult = { + rating: number | null; + note: string | null; + updatedAt: string; +}; + +/** A book together with the current user's entry for it (null when not on their shelf). */ +export type BookWithEntry = BookRecord & { myEntry: ShelfEntryResult | null }; + +function toEntryResult(entry: ShelfEntryRecord | null | undefined): ShelfEntryResult | null { + if (!entry) return null; + return { + rating: entry.rating ?? null, + note: entry.note ?? null, + updatedAt: entry.updatedAt.toISOString(), + }; +} + +/** Attaches an already loaded entry (or null) to a book. */ +export function withEntry(book: BookRecord, entry: ShelfEntryRecord | null): BookWithEntry { + return { ...book, myEntry: toEntryResult(entry) }; +} + +/** + * Adds the user's shelf entry to each book, using one database query for the + * whole list instead of one per book. Without a user, every myEntry is null. + */ +export async function withMyEntries( + books: BookRecord[], + userId: string | null, +): Promise { + if (!userId || books.length === 0) { + return books.map((book) => ({ ...book, myEntry: null })); + } + + const entries = await ShelfEntry.find({ + userId, + bookId: { $in: books.map((book) => book.googleBooksId) }, + }).lean(); + + const entryByBookId = new Map(entries.map((entry) => [entry.bookId, entry])); + return books.map((book) => withEntry(book, entryByBookId.get(book.googleBooksId) ?? null)); +} + +/** Plain character-by-character order, the same order MongoDB uses for the ids. */ +function compareStrings(a: string, b: string): number { + if (a < b) return -1; + return a > b ? 1 : 0; +} + +/** All books on the user's shelf, sorted on the server. */ +export async function getShelf(userId: string, sort: ShelfSort): Promise { + const entries = await ShelfEntry.find({ userId }).lean(); + const books = await findBooksByIds(entries.map((entry) => entry.bookId)); + const bookById = new Map(books.map((book) => [book.googleBooksId, book])); + + // Entries whose book has been removed from the catalogue are skipped. + const rows = entries.flatMap((entry) => { + const book = bookById.get(entry.bookId); + return book ? [{ book, entry }] : []; + }); + + type Row = (typeof rows)[number]; + // Like the catalogue sorts, every shelf sort ends with the book id as a tie-breaker. + const byId = (a: Row, b: Row) => compareStrings(a.book.googleBooksId, b.book.googleBooksId); + const byTitle = (a: Row, b: Row) => a.book.title.localeCompare(b.book.title) || byId(a, b); + const compare = { + // Most recently added or changed first. + RECENT: (a: Row, b: Row) => + b.entry.updatedAt.getTime() - a.entry.updatedAt.getTime() || byId(a, b), + TITLE_ASC: byTitle, + // Highest rating first, unrated books last. + RATING_DESC: (a: Row, b: Row) => (b.entry.rating ?? 0) - (a.entry.rating ?? 0) || byTitle(a, b), + }[sort]; + + return rows.sort(compare).map(({ book, entry }) => withEntry(book, entry)); +} + +/** + * Puts the book on the shelf. Does nothing if it is already there, so its + * updatedAt (and its place in the RECENT sort) does not change. + */ +export async function addToShelf(userId: string, bookId: string): Promise { + const now = new Date(); + const entry = await ShelfEntry.findOneAndUpdate( + { userId, bookId }, + // Only written when the entry is created. One atomic operation, so two + // clicks at once cannot create two entries. + { $setOnInsert: { rating: null, note: null, createdAt: now, updatedAt: now } }, + { upsert: true, returnDocument: 'after', timestamps: false }, + ).lean(); + return entry!; +} + +/** Takes the book off the shelf, deleting its rating and note. */ +export async function removeFromShelf(userId: string, bookId: string): Promise { + await ShelfEntry.deleteOne({ userId, bookId }); +} + +/** + * Sets the rating or the note. + * - A value: saved, and the book is added to the shelf if it is not there yet. + * - null: cleared. The book stays on the shelf. If it was not on the shelf, + * nothing happens (clearing should not add a book to the shelf). + * Returns the entry after the change, or null if there is none. + */ +export async function updateEntry( + userId: string, + bookId: string, + change: { rating: number | null } | { note: string | null }, +): Promise { + const value = 'rating' in change ? change.rating : change.note; + return ShelfEntry.findOneAndUpdate( + { userId, bookId }, + { $set: change }, + { upsert: value !== null, returnDocument: 'after', runValidators: true }, + ).lean(); +}