Builder Book logo

Book: Builder Book

  1. Introduction
  2. Project structure. ESLint. Next.js. HOC. Material-UI. Server-side rendering. Styles.
  3. Server. Database. Session. Header and MenuDrop components.
  4. Authentication HOC. Promise. Async/await. Static method for User model. Google OAuth.
  5. Testing with Jest. Transactional emails with AWS SES API. In-app notifications.
  6. Book and Chapter data models. Internal API infrastructure, API methods and Express routes. ReadChapter page.
  7. Set up Github API infrastructure. Sync content API infrastructure. Missing UI infrastructure for Admin user. Redirects for Admin and Customer users. Testing.
  8. Table of Contents. Highlight for section. Hide Header. Mobile browser.
  9. BuyButton component. Buy book API infrastructure. Setup at Stripe dashboard and environmental variables. isPurchased and ReadChapter page. Redirect. My books API and MyBooks page. Mailchimp API.
  10. Prepare project for deployment. Environmental variables, production/development. Logger. SEO, robots.txt, sitemap.xml. Google Analytics. Compression and security. Heroku. Testing deployed project. AWS Elastic Beanstalk.

Chapter 5: Book and Chapter data models. Internal API infrastructure, API methods and Express routes. ReadChapter page.

The section below is a preview of Builder Book. To read the full text, you will need to purchase the book.

We are currently refactoring and updating the code in Builder Book. To reduce confusion, we have temporarily disabled the buy button. You can follow our progress here: https://github.com/builderbook/builderbook/issues/359

You can still buy our second book, SaaS Boilerplate: https://builderbook.org/books/saas-boilerplate/introduction-project-structure


In Chapter 5, you'll start with the codebase in the 5-begin folder of our builderbook repo and end up with the codebase in the 5-end folder. We'll cover the following topics in this chapter:

  • Book data model
    - Schema for Book data model
    - Static methods for Book data model

  • Chapter data model
    - Schema for Chapter data model
    - Static methods for Chapter data model

  • MongoDB index

  • API infrastructure and user roles
    - Pages and components for user roles
    - API methods by user roles
    - Express routes by user roles
    - Custom routing for pages

  • Read chapter API - ReadChapter page - Testing Read chapter API


In the previous chapter (Chapter 4), you learned how to test methods with Jest, integrated our project with AWS SES to send transactional emails, and created a Notifier component and notify method to show users informational messages (such as success or error messages).

In this chapter (Chapter 5), we introduce the Book and Chapter data models. You will be able to sell a book once you paywall your chapters' content and add Stripe API infrastructure in Chapter 8. In addition, we will discuss related API methods and Express routes define a new page, the ReadChapter page.

As discussed in the Introduction chapter, our project has three types of users - an Admin user who writes a book, a Customer who buys and reads a book, and a Public user (a logged-out user).

In this chapter, we will build the ReadChapter page that is for both Public and Customer users. We will make many improvements to the ReadChapter page in Chapter 7. In Chapter 6, we will build four Admin-related pages. Also in Chapter 6, we will build a MyBooks page for a Customer user. We will improve this page further in Chapter 8.

link Book data model

In this section, we introduce the Book data model. We will define this data model, schema, and a few static methods. We will eventually add more static methods and update schema for Github API (Chapter 6) and Stripe API (Chapter 8).

At this point in the book, you've already successfully created User and EmailTemplate data models. When working on the User data model, you learned how to define Mongoose's schema and data model. To summarize:

const { Schema } = mongoose;

const mongoSchema = new Schema({
    // parameters
});

const Book = mongoose.model('Book', mongoSchema);

You also learned, in Chapter 3, how to add static methods to a data model by using class and loadClass:

class BookClass { 
    // static methods
};

mongoSchema.loadClass(BookClass);

We typically call these static methods inside corresponding Express routes.

Based on what you learned, we can create the Book data model in two steps:
- Discuss and add parameters to the Book schema (parameters such as name and price). These parameters correspond to fields of the Book MongoDB document and to properties of the book JS object.
- Discuss and write some static methods, then add them to the the Book's class, BookClass (methods such as add and edit), and add them to the data model with the loadClass method.

According to Mongoose docs, we have to call mongoSchema.loadClass to create an ES6 class from which Mongoose Schema will be created:
https://mongoosejs.com/docs/api.html#schema_Schema-loadClass

Thus the line:

mongoSchema.loadClass(BookClass);

The static methods we defined are ES6 class and will be inherited by the Book Mongoose Model (data model) after we create it by calling mongoose.model.

Here's the blueprint (bare model) of the Book data model (it's similar to any other data model, like our User data model): server/models/Book.js :

const mongoose = require('mongoose');

const { Schema } = mongoose;

const mongoSchema = new Schema({
    // parameters/fields/properties
});

class BookClass {
    // static methods
}

mongoSchema.loadClass(BookClass);

const Book = mongoose.model('Book', mongoSchema);

module.exports = Book;

link Schema for Book data model

For our Book data model, we want at least four parameters: name (important for slug generation and SEO), slug (generated from name, important for URL queries and for the Book.getBySlug static method), createdAt (generally a good idea to have, you can use it to sort books by data), price (the price a Customer user will pay to access paywalled content):

const { Schema } = mongoose;

const mongoSchema = new Schema({
    name: {
        type: String,
        required: true,
    },
    slug: {
        type: String,
        required: true,
        unique: true,
    },
    createdAt: {
        type: Date,
        required: true,
    },
    price: {
        type: Number,
        required: true,
    },
});

Eventually, we will add more parameters to the User schema, when we work on Github- and Stripe-related API infrastructures.


link Static methods for Book data model

Alright, we are done with the User schema for now. Next, let's define static methods for the BookClass class.

You learned about Mongoose's class properties in Chapter 3 when we discussed UserClass and added the static methods User.publicFields (returns an array of public data) and User.signInOrSignUp (either finds an existing User MongoDB document and returns a corresponding user object or creates a new User MongoDB document and returns a corresponding user object).

We will start with four static methods for BookClass (and eventually add more in the next chapters, for example Book.syncContent for Github integration and Book.buy for Stripe integration):

  1. Book.list static method finds all Book MongoDB documents and returns an object with a books property, which is an array of corresponding book objects. We can use this method in the future to show all books to an Admin user at the Admin page - more on this in Chapter 6.
    2. Book.getBySlug static method finds one unique Book MongoDB document by its slug, then returns the corresponding book object. We'll use it to display a single book, for example, when a Public or Customer user loads the ReadChapter page ( more on this later in this chapter).
    3. Book.add creates a new Book MongoDB document. We'll call it, indirectly, from the AddBook page in Chapter 6. This method returns a corresponding book object.
    4. Book.edit finds a Book MongoDB document and updates some of its fields. This method is called by an Admin user indirectly from the EditBookPage page in Chapter 6. This method returns a corresponding book object.

To summarize what we just discussed about the four static methods, we can write:

class BookClass {
    static async list({ offset = 0, limit = 10 } = {}) {
        // some code

        // returns object with property books which is array of up to 10 members
    }

    static async getBySlug({ slug }) {
        // some code

        // returns book object
    }

    static async add({ name, price, githubRepo }) {
        // some code

        // returns newly created book object
    }

    static async edit({ id, name, price, githubRepo }) {
        // some code

        // returns updated book object
    }
}

mongoSchema.loadClass(BookClass);

Let's define each of the above four static methods.

  1. The static and async Book.list method (static async list(...)) takes two arguments: offset and limit. The method waits (await) until all books are found with the find Mongoose API method and returns an array of book objects as the books property of the object:

    return { books }

    Inside the Book.list method, we use three MongoDB methods to reorganize the array of book objects: sort, skip, limit:

    static async list({ offset = 0, limit = 10 } = {}) {
    const books = await this.find({})
     .sort({ createdAt: -1 })
     .skip(offset)
     .limit(limit);
    return { books };
    }

    .sort({ createdAt: -1 }) sorts book objects by creation date, from the most to least recently created.
    .skip(offset) with offset = 0 ensures that we do not skip any books.
    .limit(limit) and limit=10 returns no more than 10 books. If we return too many books, MongoDB's query time may be high and user-unfriendly.
    The default value for the skip method is zero, so we don't need to specify it explicitly. However, let's keep the offset argument explicit so there is an easy way to modify its value in the future if needed. For example, we can modify the value for offset if we decide to add pagination to our list of books.
    We discussed the async/await construct in detail in Chapter 3. We use async/await for practically all static methods of all data models.

  2. The static and async Book.getBySlug method (static async getBySlug(...)) takes one argument: slug. The main method waits (await) until Mongoose's API method findOne finds one book (slug is unique, meaning all Book MongoDB documents must have a unique value for the slug field):

    slug: {
     type: String,
     required: true,
     unique: true,
    },

    It's always a good habit to check for edge cases. What if a Book MongoDB document cannot be found? Then the corresponding object is not truthy, and we should throw some informative error:

    throw new Error('Book not found');

    If truthy, we take the book object returned from the database server and convert it into a plain JavaScript object by using Mongoose's toObject method:

    const book = bookDoc.toObject();

    In summary, we have the following for the Book.getBySlug method:

    static async getBySlug({ slug }) {
     const bookDoc = await this.findOne({ slug });
     if (!bookDoc) {
         throw new Error('Book not found');
     }
    
     const book = bookDoc.toObject();
    
     return book;
    }

    When any user of our web application loads the ReadChapter page, we want to display a chapter's content. We need the book.chapters property to be populated with an array of a book's chapters - chapter objects that correspond to Chapter MongoDB documents. To do so, we can call the Chapter.find Mongoose API method that returns an array of objects and uses book._id as search criteria. Then for every member of this array, we can run Mongoose's toObject() method using Javascript's array method, map:

    book.chapters = (
     await Chapter.find({ bookId: book._id }, 'title slug').sort({ order: 1 })
    ).map((chapter) => chapter.toObject());

    Now, the updated definition for Book.getBySlug is:

    static async getBySlug({ slug }) {
     const bookDoc = await this.findOne({ slug });
     if (!bookDoc) {
         throw new Error('Book not found');
     }
    
     const book = bookDoc.toObject();
    
     book.chapters = (
         await Chapter.find({ bookId: book._id }, 'title slug').sort({ order: 1 })
     ).map((chapter) => chapter.toObject());
    
     return book;
    }
  3. The static Book.add method (static async add(...)) takes three arguments: book name, price, and githubRepo. This method calls and waits for the generateSlug method to generate a unique slug from name. After we get slug, we use the Mongoose API method create to create a new Book MongoDB document in our database. The new document gets name, price, and slug among other fields. We also check for truthiness of the returned slug value and throw an informative error:

    static async add({ name, price, githubRepo }) {
     const slug = await generateSlug(this, name);
    
     if (!slug) {
         throw new Error(`Error with slug generation for name: ${name}`);
     }
    
     return this.create({
         name,
         slug,
         price,
         githubRepo,
         createdAt: new Date(),
     });
    }

    One of our readers noted that we can add a default option to the createdAt parameter in our Book schema like this:

    createdAt: {
     type: Date,
     required: true,
     default: Date.now,
    },

    If you do so, then you can omit the createdAt parameter from the above this.create, so it becomes:

    return this.create({
     name,
     slug,
     price,
     githubRepo,
    });

    Read more about Mongoose's default option in the offical docs.
    Later, in Chapter 6, when we work on Github integration, we will add more fields to the Book schema and use those parameters inside new static methods.

  4. The static and async Book.edit method (static async edit(...)) takes four parameters: id, name, price, and githubRepo. This method finds one book by its id with Mongoose's API method findById (internally, this method uses MongoDB's findOne method).
    Similar to the Book.getBySlug method, if a book is not found, we throw an error:

     throw new Error('Book is not found by id');

    All thrown errors get caught inside the corresponding Express routes. We will discuss Express routes later in this chapter. We will use try/catch syntax inside Express routes to catch potential errors thrown from static methods.
    If a book is found (found object has a truthy value), we define a modifier object like so:

     const modifier = { price, githubRepo };

    Then we check if the book's name in our database (book.name) matches a new name (name !== book.name). If it does not, we add a new name property with a value to our modifier object by extending it (modifier.name = name;). We also generate and add a slug property with a value to our modifier object:

    if (name !== book.name) {
     modifier.name = name;
     modifier.slug = await generateSlug(this, name);
    }

    At the end, we use the Mongoose API method updateOne and operator set to find a Book MongoDB document by id, update fields specified inside the modifier object, and return an updated book object:

    static async edit({ id, name, price, githubRepo }) {
     const book = await this.findById(id, 'slug name');
    
     if (!book) {
         throw new Error('Book is not found by id');
     }
    
     const modifier = { price, githubRepo };
    
     if (name !== book.name) {
         modifier.name = name;
         modifier.slug = await generateSlug(this, name);
     }
    
     return this.updateOne({ _id: id }, { $set: modifier });
    }

Done. We are ready to put it all together for our Book model.


You've reached the end of the Chapter 5 preview. To continue reading, you will need to purchase the book.

We are currently refactoring and updating the code in Builder Book. To reduce confusion, we have temporarily disabled the buy button. You can follow our progress here: https://github.com/builderbook/builderbook/issues/359

You can still buy our second book, SaaS Boilerplate: https://builderbook.org/books/saas-boilerplate/introduction-project-structure

format_list_bulleted
help_outline
lens