Builder Book logo

Book: Builder Book

  1. Introduction
  2. Set up Node.js project. VS code editor and lint. Set up Next.js project. Material-UI integration. Server-side rendering. Custom styles.
  3. HTTP. Express server. Next-Express server, nodemon. Index.getInitialProps. User data model and mongoose. MongoDB database and dotenv. Testing server-database connection. Retrieving document. Session and cookie. MenuWithAvatar and Header components.
  4. Authentication HOC. getInitialProps method. Login page and NProgress. Asynchronous execution. Promise.then. async/await. Google Oauth API infrastructure. setupGoogle, verify, passport, strategy. Express routes /auth/google, /oauth2callback, /logout. generateSlug. this. Set up at Google Cloud Platform.
  5. Testing method with Jest. Transactional email API with AWS SES service. Set up AWS SES service, security credentials. sendEmail method. Export and import syntax for server code. EmailTemplate data model. Update User.signInOrSignUp. Informational success/error messages. Notifier component. notify method.
  6. Book data model. Chapter data model. MongoDB index. API infrastructure and user roles. Read chapter API.
  7. Set up Github API infrastructure. Sync content API infrastructure. Missing UI infrastructure for Admin user. Two improvements. Testing.
  8. Table of Contents. Sections. Sidebar. Toggle TOC. Highlight for section. Active 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. Compression and security. Deploy project. Heroku. Testing deployed project. AWS Elastic Beanstalk.

Chapter 5: Book data model. Chapter data model. MongoDB index. API infrastructure and user roles. Read chapter API.

We keep our book up to date with recent libraries and packages. Latest update Nov 2023.


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


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.

Book data model link

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;

Schema for Book data model link

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.


Static methods for Book data model link

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;
    }

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

We keep our book up to date with recent libraries and packages. Latest update Nov 2023.