Learn FeathersJS by Building a Simple CRM App

We have previously seen a simple todo app using FeathersJS(and NeDB).

Feathers makes it really easy to develop an API or a real-time app by providing a super-powered baseline that can be extended quickly to create a useful app. However, the example provided earlier does not provide a set of features anywhere near the real-world experience.

So, here we are talking about all the additional things that Feather could do - only if you let it. This is a FeatherJS + ObjectionJS + Feather-Vuex tutorial through building a totally real-world, simple CRM application called ‘FeatherLight CRM’. We name it thus since all awesome apps deserve their own name and identity.

Our Chosen Ecosystem

First, let us get our basic ecosystem right.

Feathers groups together various components and provides more than one component options to do the same job. So, it is that with flutter in my heart, I choose -

  1. Postgres - possibly the greatest database of our times
  2. Objection ORM - Benefits of an ORM, and knex for DB migration + operations + low-level query builder
  3. Local authentication

Next.. since we love our SPAs and scoff at anything more simple, we choose -

  1. Vue + Vuex
  2. Feathers-Vuex (this is the main reason for choosing Vue - makes it a cool developer experience. Feathers and cool - did you get it?)

Dealing with all this complexity can spur anxiety, so let us divide the tasks into those required for server and client.

This post primarily deals with server-related tasks, i.e, all things FeathersJS. We will see more of Vue in a subsequent post.

Server: Get Started

Getting started on Feathers is a breeze and is similar to our earlier project.

Install CLI if you don’t have feathers on your computer.

npm i -global @feathersjs/cli

Create the project folder and a client and server folders to store your code.

mkdir featherlight-crm
cd featherlight-crm
mkdir client
mkdir server
cd server
feathers g app

Choose the configuration values for FeathersJS server that talked about five seconds back. Also, it will be great if you can make the database available at this time - the generator also tries to check for the database.

Open the project folder in your favourite editor - which should be VSCode by now.

Once the app generation is complete, open ./config/default.json and change the database section to tell knex where migrations need to be stored - both the directories for seed/migrations, and the table in database. This will come in handy in a bit.

"postgres": {
    "client": "pg",
    "connection": "postgres://postgres:@localhost:5432/t_2",
    "migrations": {
      "directory": "../database/migrations",
      "tableName": "knex_migrations"
    },
    "seeds": {
      "directory": "../database/seeds"
    }
  }

Run your server -

npm run dev

This will not only start the server, but also migrate users (created by our generator) to the database. Note that we did not create migrations for users - ideally we should have done that, but we are crazy enough to want a practical demo of what is possible with Feathers out of the box vs. how our changes impact the way we work.

Login to your DB with your favourite client (which should be HeidiSQL) to see this table in all its glory.

You can check whether server is running by hitting a few sample end-points in your favourite API client - which should be Insomnia.

API Result
GET: http://localhost:3030 200 OK: Sample feathers HTML
GET: http://localhost:3030/users 401 Unauthorized: Not authenticated yet
POST: http://localhost:3030/users ; input JSON below 200 OK: User created, response JSON with user id
POST: http://localhost:3030/authentication ; input JSON below 200 OK: User logged in, response JSON with JWT

Input messages -

POST: New User

{
  "email": "a1@a.com",
  "password": "a1"
}

POST: Login

{
  "email": "a1@a.com",
  "password": "a1",
  "strategy": "local"
}

That is it - you have your baseline beautiful project ready.

Server: Folder Structure

Before we proceed, we will have “more words” on the setup.

FeathersJS is straightforward and does not believe in magic. The folder structure reflects that. Some of the key folders along with their functions are outlined below.

Folder Name What it contains? Function
config Configuration files for prod & non-prod environments Provides config values and parameters including server address/port, DB connection strings, auth mechanism, etc.
public Any files available to the external world Typical server implementations use some kind of reverse proxy to expose public files to the outside world
src All source code Key files: app, authentication, knex, objection
src/models All models DB models ready to be migrated
src/services Services are at the heart of Feathers Individual folders for users and other services. Consists of services and hooks. Hooks also specify routes amongst other things
test Tests for your app Automated testing!

We have to pay attention to services in some detail.

Services will have folders created for each new service (well, there are exceptions and weird logic - but this is typical ). Services have a couple of things going for them -

  1. Feathers maps REST services to the services defined here - you will be able to make out the similarities between the methods supplied by default and the REST service types
  2. Services can also be called on websockets and the same service runs happily
  3. Hooks provide a before/after way of plugging in custom logic for the services

We will see much more of this with a practical example below.

Server: Create new functionality

Let us create a string of entities and functions around the entity to enable our super CRM app.

Create contact service

It may be a good idea to stop your feathers service at this time. Now, use feathers generate command to create a new service.

feathers g service

Answer the questions to create a new service called contacts. You can select the same Objection as data source as that chosen while creating the app.

This does some useful things -

  • create 3 files under src/services/contacts/ - service, hooks and class
  • create a sample test for contacts
  • create contacts.model in ./src/models/
  • update services/index with the new contacts service

If you had the feathers server running, contacts table will get created with the default columns specified in contacts.model. We stopped the service since we don’t want that.

As you can see -

  • the new structure for contacts is straight-forward and similar to our users example above.
  • By just running the CLI, you now have the entire contacts service + model setup.

The Art of Models and Migration

Open src/models/contacts.model.js. Add your own fields, and remove logic to migrate tables.

// See https://vincit.github.io/objection.js/#models
// for more of what you can do here.
const { Model } = require("objection");

class contacts extends Model {
  static get tableName() {
    return "contacts";
  }

  static get jsonSchema() {
    return {
      type: "object",
      required: ["last_name"],

      properties: {
        first_name: { type: "string", maxLength: 50 },
        last_name: { type: "string", maxLength: 50 },
        title: { type: "string", maxLength: 25 },
        email: { type: "string", maxLength: 100 },
        phone: { type: "string", maxLength: 25 },
        status_cd: {
          type: "string",
          enum: ["active", "disabled"],
          default: "active",
        },
        address: {
          type: "object",
          properties: {
            street: { type: "string" },
            city: { type: "string" },
            zipCode: { type: "string" },
          },
        },
      },
    };
  }

  $beforeInsert() {
    this.created_at = this.updated_at = new Date().toISOString();
  }

  $beforeUpdate() {
    this.updated_at = new Date().toISOString();
  }
}

module.exports = function (app) {
  // remove the knex table creation code from here

  return contacts;
};

While the default contact.model.js is good for initial migration, it becomes tricky to manage future changes. So, we will take the migration statements away from this file, group it elsewhere, and at the same time - streamline migrations.

First, create a file knexfile.js in project root -

const app = require("./src/app");
module.exports = app.get("postgres");

Next, install knex command line utility globally. This is useful to manage migrations in this project, and in hundreds of other projects that we will created in the next year.

npm i -g knex

You would have already noticed the config folder in the project root. Make modifications to default.json..

// lot of params/code
"postgres": {
    "client": "pg",
    "connection": "postgres://postgres:@localhost:5432/t_2",
    "migrations": {
      "tableName": "knex_migrations"
    }
  }
// ...

The above configuration will set the stage and indicate that a place to store migrations in a DB table called knex_migrations (not created yet).

We are all set. Run knex to create migration files -

knex migrate:make init

This will create a template migration file in ./database/migrations/ folder - something like 20200422152442_init.js.

Cut/paste create table statements from the contacts.models.js to this migration file - with a few changes.

// ./database/migrations/20200422152442_init.js
exports.up = function (knex) {
  return knex.schema.createTable("contacts", (table) => {
    table.increments("id");
    table.timestamp("created_at");
    table.timestamp("updated_at");
    table.string("first_name", 50);
    table.string("last_name", 50);
    table.string("title", 25);
    table.string("email", 100);
    table.string("phone", 25);
    table.string("status_c").defaultTo("active");
  });
};

exports.down = function (knex) {
  return knex.schema.dropTable("contacts");
};

Now, you can take a deep breath and migrate the table to database.

knex migrate:latest

Check in your database to find contacts table.

But then, while still holding on to the breath, you realise a mistake. There has been one of the hardest typo mistakes that can ever be - status_cd is wrongly input as status_c.

No problem, let us change that.

knex migrate:rollback

.. and ta da - the contacts table is gone from the DB.

Re-run migration -

knex migrate:latest

Did you start breathing again? Well, good for you - that was just in time to realise another miss. You never added address - tis’ a good thing we are still learning here and not doing a multi-million dollar project. We can always do this later.

Congratulations on completing the database tasks.

Back to the Server

Start your server again ..

npm run dev

You can start doing your REST calls without writing any code to handle contacts transactions -

1. POST http://localhost:3030/contacts

Input -

{
  "first_name": "abc",
  "last_name": "1"
}

Don’t forget to provide token from the authentication/login call done earlier as a bearer token.

Output -

{
  "id": 1,
  "created_at": "2020-03-22T10:59:03.265Z",
  "updated_at": "2020-03-22T10:59:03.265Z",
  "first_name": "abc",
  "last_name": "1",
  "title": null,
  "email": null,
  "phone": null,
  "status_cd": "active"
}

A new contact called abc 1 is created.

2. GET http://localhost:3030/contacts

Input -

< no input >

Output -

{
  "total": 1,
  "limit": 10,
  "skip": 0,
  "data": [
    {
      "id": 1,
      "created_at": "2020-03-22T10:59:03.265Z",
      "updated_at": "2020-03-22T10:59:03.265Z",
      "first_name": "abc",
      "last_name": "1",
      "title": null,
      "email": null,
      "phone": null,
      "status_cd": "active"
    }
  ]
}

This is the right time to let out a joyous cry - huzzah!

More migrations

You have already seen that feathers generate has already created the contacts model. But, what if you want to change the model?

Changing model will imply changes to -

  • model
  • underlying table

Since feathers is just the coordinator here, we will just use the full power of underlying ObjectionJS/knex and migrations as seen previously.

In our case, we demonstrated our careful planning abilities by adding address in our model (yes, this is a mistake. And yes, this is one of the ways of covering up mistakes). So, the model doesn’t need to change.

But, we do have to add address to database. We already have two tables created and are in no mood to rollback a second time. Instead, we add incremental changes to the same table.

Let’s create another migration file -

knex migrate:make contact-address

Change the migration file -

// ./database/migrations/20200422173242_contact-address.js
exports.up = function (knex) {
  return knex.schema.table("contacts", (table) => {
    table.json("address");
  });
};

exports.down = function (knex) {
  knex.schema.table("contacts", function (table) {
    table.dropColumn("address");
  });
};

Run migration -

knex migrate:latest

You will now start seeing an address column against contacts table.

Also, you may have already noticed a table called knex_migrations in your database. That table should have two migration records corresponding to the initial migration and the subsequent incremental migration after doing the above tasks for address.

Knex migrations started shining really bright, didn’t they?

More Entities and Functions

Now that we have walked through an entity and service, and also know how to change them - we can create dozens more with just clicks and copy/paste magic.

Change Users

Previously when you saw how easily we could manage database changes - you were left thinking on how users was not included into this fold. Feathers migrated that table for us, and we don’t quite have any way to control that migration.

The good news - you can start anytime without a care for the past deeds. You just have to create another migration and repeat the above tasks to create additional columns and manually change the users.models.js file. It does not matter whether users was created initially by feathers or created by hand in the backend.

So, let’s do that!

Create migration file -

knex migrate:make user-enhancements

Change the migration file -

exports.up = function (knex) {
  return knex.schema.table("users", (table) => {
    table.string("role_cd", 50);
    table.string("status_cd", 50);
    table.date("start_date");
    table.date("end_date");
  });
};

exports.down = function (knex) {
  return knex.schema.table("users", function (table) {
    table.dropColumn("role_cd");
    table.dropColumn("status_cd");
    table.dropColumn("start_date");
    table.dropColumn("end_date");
  });
};

Run migration -

knex migrate:latest

Add Activities Model/Service

It is time to upgrade our app - a CRM app cannot be as simple as having users and contacts. Everyone knows that it is the activities that complete CRM.

By now, you will probably be scoffing and guffawing at adding a new service.

feathers g service

Name this new service activities, and select all the right options.

Change activities.model.js -

// See https://vincit.github.io/objection.js/#models
// for more of what you can do here.
const { Model } = require("objection");

class activities extends Model {
  static get tableName() {
    return "activities";
  }

  static get jsonSchema() {
    return {
      type: "object",
      required: ["type_cd", "status_cd"],

      properties: {
        text: { type: "string" },
        description: { type: "string", maxLength: 255 },

        type_cd: {
          type: "string",
          enum: ["Task", "Call", "Meeting"],
          default: "Task",
        },
        status_cd: {
          type: "string",
          enum: ["Created", "Planned", "In Progress", "Closed", "Cancelled"],
          default: "Created",
        },
        start_date: { type: "timestamp" },
        end_date: { type: "timestamp" },
        due_date: { type: "timestamp" },
        duration: { type: "integer" },
        contact_id: { type: "integer" },
        user_id: { type: "integer" },
        remarks: { type: "text" },
      },
    };
  }

  $beforeInsert() {
    this.created_at = this.updated_at = new Date().toISOString();
  }

  $beforeUpdate() {
    this.updated_at = new Date().toISOString();
  }
}

module.exports = function () {
  return activities;
};

Create a migration template for activities -

knex migrate:make activities

Change 20200422192555_activities.js (the comparative migration file created for you in database/migrations folder) -

exports.up = function (knex) {
  return knex.schema.createTable("activities", (table) => {
    table.increments("id");
    table.timestamp("created_at");
    table.timestamp("updated_at");
    table.integer("contact_id");
    table.integer("user_id");
    table.string("type_cd", 100);
    table.string("status_cd", 100);
    table.string("description", 255);
    table.timestamp("start_date");
    table.timestamp("end_date");
    table.timestamp("due_date");
    table.integer("duration");
    table.text("remarks");
  });
};

exports.down = function (knex) {
  return knex.schema.dropTable("activities");
};

You perform activities and tasks for a contact. So, one contact can have many activities. Similarly an activity is done by a user. Hence, we can smartly conclude that activities are related to both users and contacts.

We have played things well and already created contact_id, user_id columns in our activities table. We can also establish the relationship at the model level.

Insert the below code block before $beforeInsert() in activities.model.js.


//.. other code

static get relationMappings() {
    const User = require("./users.model");
    const Contact = require("./contacts.model");

    return {
      owner: {
        relation: Model.BelongsToOneRelation,
        modelClass: User,
        join: {
          from: "activities.user_id",
          to: "users.id",
        },
      },
      contact: {
        relation: Model.BelongsToOneRelation,
        modelClass: Contact,
        join: {
          from: "activities.contact_id",
          to: "contacts.id",
        },
      },
    };
  }

  //.. more code

You can see a pattern emerging here, don’t you? Objection and Knex make it quite easy to establish and manage relationships. (In this case, however, note that we did not tell the DB that contact_id or user_id are foreign key fields - that can be easily fixed in the migration files).

You can start performing activity requests.

  • POST http://localhost:3030/activities

    < input and output are the same :) >

    {
      "type_cd": "Meeting",
      "status_cd": "Cancelled",
      "contact_id": 1,
      "user_id": 1
    }
    
  • GET http://localhost:3030/activities

    < no input data >

    {
      "total": 1,
      "limit": 10,
      "skip": 0,
      "data": [
        {
          "type_cd": "Meeting",
          "status_cd": "Cancelled",
          "start_date": null,
          "end_date": null,
          "due_date": null,
          "description": null,
          "created_at": "2020-03-23T04:39:09.182Z",
          "updated_at": "2020-03-23T04:39:09.182Z",
          "id": 1
        }
      ]
    }
    

Since you already established relationship and storing contact you may also want contact to be fetched along with activity details. We established the relationship by changing activity.model.js but did not tell feathers to get contact details.

That’s quite easy to do and requires two steps.

Step 1: Change activities.service.js

We tell feathers to allow “eager” loading of contact details. Change options value in activity service.

const options = {
  Model: createModel(app),
  paginate: app.get("paginate"),
  whitelist: ["$eager"],
  allowedEager: "[contact]",
};

Step 2: Change activities.hooks.js

We will do more than just query for the related contacts -

  1. Use find hook to $select only some fields to include in the result
  2. Allow feathers to eager load contact - $eager: "contact"
  3. Sort results by created_at (desc) - $sort: { created_at: -1 }
  4. Filter activities by owner if the logged in user is not an admin - if (!isAdmin(context)) query["user_id"] = context.params.user.id;

These changes are more or less self-explanatory - you can just see the super powers of feathers tumbling out one after the other.

const { authenticate } = require("@feathersjs/authentication").hooks;

const isAdmin = (context) => {
  return context.app.settings.appconf.admin_roles.includes(
    context.params.user.role_cd
  );
};

module.exports = {
  before: {
    all: [authenticate("jwt")],

    find: [
      async (context) => {
        const query = {
          $select: [
            "type_cd",
            "status_cd",
            "start_date",
            "end_date",
            "due_date",
            "description",
            "created_at",
            "updated_at",
          ],
          $eager: "contact",
          $sort: { created_at: -1 },
        };

        if (!isAdmin(context)) query["user_id"] = context.params.user.id;

        context.params.query = { ...context.params.query, ...query };
        return context;
      },
    ],
    get: [],
    create: [
      async (context) => {
        // populate user as logged in user by default
        if (!isAdmin(context)) context.data.user_id = context.params.user.id;

        return context;
      },
    ],
    update: [],
    patch: [],
    remove: [],
  },

  after: {
    all: [],
    find: [],
    get: [],
    create: [],
    update: [],
    patch: [],
    remove: [],
  },

  error: {
    all: [],
    find: [],
    get: [],
    create: [],
    update: [],
    patch: [],
    remove: [],
  },
};

If you don’t want to pass context everywhere (e.g. isAdmin(context)), you should totally checkout hooks-common.

After these changes, it is time to get a different response when querying activities -

GET http://localhost:3030/activities

{
  "total": 1,
  "limit": 10,
  "skip": 0,
  "data": [
    {
      "type_cd": "Meeting",
      "status_cd": "Cancelled",
      "start_date": null,
      "end_date": null,
      "due_date": null,
      "description": null,
      "created_at": "2020-03-23T04:39:09.182Z",
      "updated_at": "2020-03-23T04:39:09.182Z",
      "id": 1,
      "contact": {
        "id": 1,
        "created_at": "2020-03-22T10:57:37.957Z",
        "updated_at": "2020-03-22T10:57:37.957Z",
        "first_name": "abc",
        "last_name": "1",
        "title": null,
        "email": null,
        "phone": null,
        "status_cd": "active",
        "address": null
      }
    }
  ]
}

You can create more than one record to test sort order and filter conditions.

Finis

That is it!

We now have the server-side of a totally real-world CRM application ready -

  1. Store users, contacts and activities
  2. Ability to register and login
  3. Secured data access by filtering activities by owner

If you are stuck anywhere, you could always check your code against the featherlight-crm repository.

Excited about this CRM application and can’t wait to start using it? Onwards to the next phase and developing a FeathersJS client application on Vue!

comments powered by Disqus