Case studies

Novitee Web

Installation

First, you can get the project source code at the Novitee Website GitHub or run the git clone command like this:

git clone git@github.com:GlifeRnD/novitee_next.git

Then you can follow the Installation Guide to install all the dependencies and start the app.

Article Forms

Novitee Website is basically a static website with just one dynamic part, a blog system called Essential, so Article Form is where most CRUDS operations will happen.

Once the app started, you can log into Admin Page and open the Article Form by going to Articles Management and click the New Article button.

create article button

Validations

Although the Admin Page is for internal uses only, Back-end(BE) and Front-end(FE) should be separated completely and BE should never trust in the FE, this will make you a good behavior for more complicated projects in the future when your BE might be received requests from multiple FE and even from third-parties.

First, to store Article information in the database, you will need a Model to map and store the Article to the MongoDB Documents. In the Novitee Website project, Article is mapped to Post model, you can go and check out the Post model located at /backend/models/Post.js

const Post = mongodb.createModel(
  "posts",
  {
    slug: "[String]|Required",
    title: "String|Required",
    photoURL: "String|Required",
    description: "String|Required",
    summary: "String",
    status: "String",
    publishedAt: "Date",
    categoryId: "ID",
    subcategoryId: "ID",
    creatorId: "ID",
    relatedIds: "[ID]",
    meta: "Object|Required",
  },
  {
    includes: {
      ...
    },
    indexes: [
      {
        key: { slug: 1 },
        unique: true,
      },
    ],
  }
)

Some fields are required when an admin creates a new article like title, description, photoURL or meta, so you can see these fields will have a Required annotation. And slug is generated from BE, but still, you can also mark it as Required field in case your co-developers forget to generate one.

In our case, the slug must be unique because FE is using the slug to fetch the right posts, so in the indexes options, you can see the slug is indexed as ascending single field with unique option set to true. This will make sure when someone creates or updates a document with an existing slug, an error will be thrown and no changes will be made.

One to Many relationships

In this project, an Article belongs to a category, a subcategory and is created by a creator. So how can we define those One to Many relationships?

First, you need to create keys inside Post model which refer to the related models, so that in the future when we want to get the related documents, we can use these keys as the references. We created 3 keys categoryId, subcategoryId and creatorId

const Post = mongodb.createModel(
  "posts",
  {
    ... 
    categoryId: "ID",
    subcategoryId: "ID",
    creatorId: "ID",
  },
  ...
},

The relationship keys should have the data type ID since their value will be the id from other related documents.

Now to resolve and include the related items to the Article, we create includes rules inside the Post model. For example, creator will be resolved using data from users model, whose id is the same as creatorId key

const Post = mongodb.createModel(
  "posts",
  {
    ... 
    categoryId: "ID",
    subcategoryId: "ID",
    creatorId: "ID",
  },
  includes: {
    category: { ref: "categories", key: "categoryId" },
    subcategory: { ref: "subcategories", key: "subcategoryId" },
    creator: { ref: "users", key: "creatorId" },
  }
},

Many to Many relationships

In Post model, there's another relationship called relatedPosts which indicates articles that are related to the current article. An article can be related to multiple articles, so in this case, the relatedIds should be an Array of ID type. And also, for resolving, the include should be using keys instead of key. The returned data after resolving will be an array of matched data. So, the relatedPosts will be resolved using data from posts model, whose id is in the relatedIds list.

const Post = mongodb.createModel(
  "posts",
  {
    ... 
    relatedIds: "[ID]",
  },
  includes: {
    ...
    relatedPosts: { ref: "posts", keys: "relatedIds" }
  }
},

Now we go to the Subcategory model, since a Post can belong to a Subcategory, then you might be wondering how can I get all the Posts related to the current Subcategory. Because the Post model already had the reference key to the Subcategory model, we don't have to add another key here. We can use the defined key from Post model to resolve the relationship by using it as the foreignKey in the include definition like this

const Subcategory = mongodb.createModel(
  "subcategories",
  {
    ...
  },
  includes: {
    posts: { ref: "posts", foreignKey: "subcategoryId" }
  }
)

So the posts will be resolved using data from posts model whose subcategoryId is the same as the current subcategory's id


CRUDS

To implement the CRUD operations for Post model, a posts service (controller) is created and extends from the CRUDService and passed in Post to the super() method inside constructor to indicate that we are creating CRUD handlers for model Post. Check out the /backend/services/posts.js

Create

Server Side

For Post, we need to inject the creatorId when creating, and it should be the one who's calling the request, so in posts service, instead of using the default create method from CRUDService we need to override it. Here, we check if the user is logged in or not, and if yes, we add a creatorId directly to the data object inside req.body. So that when we call super.create() the data object will be carried on to the default method.

export default class Posts extends CRUDService {
  constructor() {
    super("Post")
  }

  async create(req, res) {
    const { data } = req.body || {}
    if (!req.user) {
      return res.status(401).json({
        success: false,
        error: "Unauthorized action",
      })
    }
    // injecting to data object
    data.creatorId = req.user._id
    // then calling default method to carry on handling
    super.create(req, res)
  }

  ...
}

Client Side

On the client side, we use the useMutation() hooks to create a function that will send a POST request to the /api/v1/posts endpoint, and the passed-in parameters will be used as the body of the request. The POST request will be mapped and handled by the create method above.

// /src/components/dashboard/Article/Form.js

function ArticleForm(...) {
  const [createArticleMutation] = useMutation({
    method: "POST",
    url: "/api/v1/posts"
  })

  async function handleSave(values) {
    let saveArticle = createArticleMutation
    let variables = { data: ... }

    try {
      const { error } = await saveArticle(variables)
      if (error) {
        throw new Error(error)
      }
      ...
    } catch (error) {
      displayMessage("error", error.message)
    }
  }
}

Read

Server Side

Usually, we don't have to override the read method, but Post read method has a special case when end users reading an essential article, we need to fetch the post using the slug property. So again in the posts service, we will override the default read method, add in checking for slug case and call the super.read to carry on handling the request.

export default class Posts extends CRUDService {
  // slug and id share the same endpoint /api/v1/posts/{id|slug}
  async read(req, res) {
    const { params } = req.query
    const [, id] = params || []
    if (id) {
      let objectId, data
      try {
        objectId = ObjectId(id)
      } catch (error) {
        objectId = null
      }
      // if id cases
      if (objectId) {
        return await super.read(req, res)
      } else {
      // if slug cases
        data = await this.model.findOne({ slug: { $in: [id] } })
      }
      return res.status(200).json({
        success: true,
        data,
      })
    } else {
      await super.read(req, res)
    }
  }
}

Client Side

For the client side, we have 2 places where the posts endpoints are called, one from the admin article listing page, and one from the main website on the essentials page. You can check the following files /src/components/dashboard/Article/index.js and /src/components/website/Blog/CollectionBlog/index.js

First, let's take a look at the admin page. To send a GET request to the server and get the post list, we use the useQuery hook and pass in with the endpoint and queries. The passed-in arguments should be an array so that when there're any changes in the array, a refetch will be triggered.

// /src/components/dashboard/Article/index.js
const variables = {
  first: pageSize,
  skip: pageSkip * pageSize,
  filter: articleFilters,
}

const {
  data,
  isValidating: isFetching,
  mutate: refetch,
} = useQuery(["/api/v1/posts", variables])

const posts = data.data

From the main website essential page, we used a different approach to fetch the data by using useSWRInfinite hook, but basically, it shares the same idea as the useQuery hook. The first argument is the endpoint and queries but generated by a function, the second argument is the fetcher (Axios in this case) to send the request, and the last one is the hook options, you can see SWR Infinite Documents for more information

// /src/components/website/Blog/CollectionBlog/index.js
const {
  data = [],
  size,
  setSize,
  isValidating,
} = useSWRInfinite(
  (pageIndex, previousPageData) => {
    if (previousPageData && !previousPageData?.data?.length) {
      return null
    }
    const variables = {
      first: pageSize,
      skip: pageIndex * pageSize,
      filter: filter,
      orderBy,
    }
    return ["/api/v1/posts", variables]
  },
  fetcher,
  { initialSize: 2 }
)

Update, Delete

Basically, Update and Delete are the same as Create method, you just need to remember to pass in the correct request method for corresponding operations, PUT or PATCH for Update and DELETE for Delete method.

// src/components/dashboard/Article/Listing.js

const [updateArticleMutation] = useMutation({
  method: "PATCH",
  url: "/api/v1/posts/:id",
})

const [removeArticleMutation] = useMutation({
  method: "DELETE",
  url: "/api/v1/posts/:id",
})

Subscription

On the server side, once you create a model, the subscription is already ready to use. To use it on the client use the useSubscription hook and pass in the collection name and the subscribed actions. For example, on the admin Article management page, we call a useSubscription hook right after the useQuery hook

// /src/components/dashboard/Article/index.js

const {
  data,
  isValidating: isFetching,
  mutate: refetch,
} = useQuery(["/api/v1/posts", variables])

useSubscription({
  refetch,
  collection: "posts",
  tags: ["create", "update", "delete"]
})

Here, we subscribed for changes from posts collection and will be notified if there are any create, update or delete actions were made. If there are, the refetch function will be called to fetch the new data.

Previous
Setup Husky