Structure

|-- server
    |-- .eslintrc.json          # eslint configuration
    |-- .gitignore
    |-- index.js                # Server entry file
    |-- package-lock.json
    |-- package.json
    |-- settings-dev.js         # Global dev app settings
    |-- settings.js             # Global production app settings
    |-- api                     # All the routes are placed in this folder
    |   |-- UserRoute.js        # Example route for the /user
    |-- charts                  # Chart configurations
    |   |-- BarChart.js
    |   |-- LineChart.js
    |   |-- PieChart.js
    |-- controllers             # Controllers that interact directly with the models
    |   |-- UserController.js
    |-- models                  # All database-related files
    |   |-- config              # DB configration
    |   |   |-- config.js
    |   |-- models
    |   |   |-- User.js         # Example User model
    |   |-- migrations          # DB migration files
    |   |-- seeders             # If any data needs to be placed in the database
    |-- modules                 # Misc modules (AKA Services, Middlewares)

How it works

Basically, a Model needs a Controller and if any data needs to be exposed through the API, then a Route is needed as well. The next part of the documentation will show a practical example on how to create a new model, controller, route and middleware.

Models

Check out the Sequelize documentation to find out all the possible options for a model.

To create a new model run the following command in server/models:

npx sequelize-cli model:generate --name Brew --attributes name:string

…where Brew is the model name and name is a string attribute.

Important! make sure that the generated migration contains all the fields created in the model.

Check the other models to learn how to create associations.

Code style used by the models:

// any fields that define a relation will use snake_case
user_id: { // snake_case here
  type: Sequelize.INTEGER,
  allowNull: false,
  reference: {
    model: "Team",
    key: "id",
    onDelete: "cascade",
  },
},

// any other fields, camelCase
name: { // camelCase here
  type: Sequelize.STRING,
},

Now let’s see how a new model can be integrated with the app. In the example below we will create a Brew model.

module.exports = (sequelize, DataTypes) => {
  const Brew = sequelize.define("Brew", {
    id: {
      type: DataTypes.INTEGER,
      primaryKey: true,
      autoIncrement: true,
    },
    user_id: {
      type: DataTypes.INTEGER,
      allowNull: false,
      reference: {
        model: "Team",
        key: "id",
        onDelete: "cascade",
      },
    },
    name: {
      type: DataTypes.STRING,
    },
    flavour: {
      type: DataTypes.String,
      defaultValue: "Charty",
    },
  }, {
    freezeTableName: true, // ! important to set this !
  });

  Brew.associate = (models) => {
    // example association when a brew has multiple ingredients
    // the 'Ingredient' model has a foreign key names 'brew_id'
    models.Brew.hasMany(models.Ingredients, { foreignKey: "brew_id" });
  };

  return Brew;
};

Controllers

The controllers hold all the functions that the app needs to manipulate the data with Sequelize (or any other functionality that uses data from the database). If the functions are not using any data from the database, consider using Middleware or Modules.

Controllers code-style and Brew example below:

const db = require("../models/models");

class BrewController { // The name of the controllers should always be <Model>Controller
  findById(id) { // standard function name when retrieving one item
    return db.Brew.findByPk(id) // always using promises (no callbacks, async/await acceptable)
      .then((foundBrew) => {
        if (!foundBrew) {
          return new Promise((resolve, reject) => reject(new Error(404)));
        }

        return foundBrew;
      })
      .catch((error) => {
        return new Promise((resolve, reject) => reject(error.message || error));
      });
  }
}

Routes

Like the models, all the routes need to be registered in api/index.js file in order for the application to see them.

Below is an example of a brew route that uses the controller created above with some explanations about the code style guide.

const BrewController = require("../controllers/BrewController");

module.exports = (app) => {
  const brewController = new BrewController(); // initialise the controller in a camelCase variable

  /*
  ** This is a mandatory explanation of the route.
  ** This route will return a single brew by ID
  */
  app.get("/brew/:id", (req, res) => {
    return brewController.findById(req.params.id) // promises (desirable) or async/await
      .then((foundBrew) => {
        return res.status(200).send(foundBrew);
      })
      .catch((error) => { // needs a better error management strategy, but this is how it's done atm
        if (error.message === "404") {
          return res.status(404).send({ error: "not found" });
        }

        return res.status(400).send({ error });
      });
  });
  // ------------------------------------------

  // this modules needs to return a middleware
  return (req, res, next) => {
    next();
  };
};

The next step is to register the new route with the index file:

const brew = require("../BrewRoute");
// ---

module.exports = {
  brew,
  // ---
};

Authentication

Chartbrew uses jwt token authentication.

To make authenticated requests, the Authorization header must be set to include a valid token

Authorization: Bearer <token>

Making a POST to /user/login with a valid email and password will return the token in the response.

In order to add authorization checks to the routes, the verifyToken middleware can be used in the routes like so:

const verifyToken = require("../modules/verifyToken");
// -------

app.get("/brew/:id", verifyToken, (req, res) => {
  // ---
});
// ---

Permissions & Roles

Chartbrew implements permissions and roles as well, but in not-so-ideal way. A future update will try a remedy this in a way to make it easier to make changes to these.

All the permissions and roles are registered in modules/accessControl.js. It is important to note that most of these roles are from the team perspective. So for example if a chart "read:any" permission is given to a user, this user can read any charts from the team that user is in only.

Below you can see an example on how to protect resources based on permissions and roles.

// create a chart example
app.post("/project/:project_id/chart", verifyToken, (req, res) => {
  return projectController.findById(req.params.project_id)
    .then((project) => {
      // get the team role for the user that is making the request
      // "req.user" is populated by the "verifyToken" middleware
      return teamController.getTeamRole(project.team_id, req.user.id);
    })
    .then((teamRole) => {
      // if the user can update any charts in the team, proceed with the request
      const permission = accessControl.can(teamRole.role).updateAny("chart");
      if (!permission.granted) {
        throw new Error(401);
      }
    }
    // ---
  });
  // ---
});

Middleware

The middleware can be used in the all the routes in the api folder. Have a look at the ExpressJS documentation on Middleware for more details.

Modules

This folder contains various functionality that usually doesn’t use local database data. The middleware will be moved from here in due course.