🧩Adding Customer Support-specific features

Now, that you have all the fundaments, it's time to create use case-specific features. With Forest Admin it only takes a few moments, as we facilitate the process thanks to smart actions.

Creating smart actions requires coding, and having an admin or a developer role on Forest Admin.

All the code snippets in this tutorial are written in TypeScript, and whenever we refer to the technical documentation, all links go to the Node.js Developer Guide.

If your application has been built in Rails, Laravel, Symfony, Flask, or Django, go the framework-specific developer guides.

In the demo project used for the purpose of this tutorial, we have created a couple of smart actions, specific to common tasks of Customer Support teams. We will now describe each of them to help you get started with your own smart action. You can find the general and comprehensive guide to smart actions in the documentation. Here we only focus on the steps needed to create the CS-specific ones.

Apply a promo code

This smart action allows CS team members to apply a promo code, link it with the existing order, and to display the amount with the discount once the coupon is applied.

In order to allow CS team members to apply promo codes, display their names and values, and link them with orders, you need to follow these instructions.

After creating a project, it's time to work on the scaffolding. We created a "customizations" folder, where the code will live every time we want to customize something. These customization files look like this:

import { CollectionCustomizer } from "@forestadmin/agent";
import { Schema } from "../typings";

export default (coupons: CollectionCustomizer<Schema, 'coupons'>) => {
  // Here, you'll write your customization on the "coupons" collection
};

Notice the CollectionCustomizer<Schema, 'coupons'>. This is what allows us to easily customize a collection.

Then, modify the index.ts (which is generated by the toolbelt) to include these customizations:

// // ...
import couponsCustomization from './customizations/coupons';
// ...
agent
  // Create the datasource - this should be available in the onboarding code
  .addDataSource(
    createSqlDataSource({
      uri: process.env.DATABASE_URL,
      schema: process.env.DATABASE_SCHEMA,
      sslMode: process.env.DATABASE_SSL_MODE as SslMode,
    }),
  )
  // Customize the collection "coupons"
  .customizeCollection('coupons', couponsCustomization);
  // ...

Then, it's time to add two new features regarding the Orders collection:

  • We want an action that links an existing coupon with an order.

  • We want to display the amount with the discount applied directly in the table view.

import { CollectionCustomizer } from "@forestadmin/agent";
import { Schema } from "../typings";

// Customize the "orders" collection
export default (orders: CollectionCustomizer<Schema, 'orders'>) => {
  orders
    // Add a new action name "Apply a coupon"
    .addAction('Apply a coupon', {
      // We're using scope: "Single" here to allow this action to be triggerable only on a single "Order"
      scope: 'Single',
      // We declare a form, to allow the user to select a coupon to apply
      form: [{
        // In this form, we will have a single field "Coupon"
        label: 'Coupon',
        // And we want to search in the existing "coupons" table
        type: 'Collection',
        collectionName: 'coupons',
      }],
      // The actual code that will be triggered when the action is ran
      execute: async (context, resultBuilder) => {
        try {
          // Retrieve the selected coupon
          const [couponId] = context.formValues['Coupon'];
          // Update the order to add the couponId
          await context.collection.update({ conditionTree: { field: 'id', operator: 'Equal', value: await context.getRecordId() } }, { coupon_id: couponId });
          // Send back a nice success message, and force refetch the record to retrieve the coupon
          return resultBuilder.success(`Successfully applied coupon`, { invalidated: ['coupon']});
        } catch (error) {
          // If any error happened, display an error toastr
          return resultBuilder.error(`Failed to apply coupon: ${error.message}.`);
        }
      }
    })
    // Then, we want to display the "real" amount of the order, taking discount into account
    // So, let's create a new field
    .addField('amount_with_discount', {
      // The amount is a number so ...
      columnType: 'Number',
      // The real amount depends on the initial amount, and discount percent & amount
      dependencies: ['coupon:discount_percent', 'coupon:discount_amount', 'initial_amount'],
      // The code related to computing the "real" amount
      getValues: (records) => {
        // For each record, apply the formulae
        // amountWithDiscount = initialAmount - (initialAmount * (discountPercent / 100)) - discountAmount;
        return records.map((record) => {
          // Since records depends on "coupon", we can retrieve all values
          // And compute the real amount synchronously
          const discountPercent = Number(record.coupon?.discount_percent || 0);
          const discountAmount = Number(record.coupon?.discount_amount || 0);
          const initialAmount = record['initial_amount'];
          const amountWithDiscount = initialAmount - (initialAmount * (discountPercent / 100)) - discountAmount;

          return Math.floor((amountWithDiscount > 0 ? amountWithDiscount : 0)*100)/100;
        });
      }
    });
};

Ticket management system

A way to automatically mark tickets as "Closed", and also have a way to re-open them. Once the smart action is created, a CS team member will be able to resolve or reopen a ticket.

In order to let your users mark a ticket as resolved and re-open it if needed, you need to follow these steps:

import { CollectionCustomizer } from "@forestadmin/agent";
import { Schema } from "../typings";

// Customize the ticket collection
export default (tickets: CollectionCustomizer<Schema, 'tickets'>) => {
  tickets
    // create a new mark tickets as resolved action
    .addAction('Mark ticket(s) as resolved', {
      // This one while be "Bulk" so we can batch mark as resolved
      scope: 'Bulk',
      execute: async (context, resultBuilder) => {
        try {
          // First, get the list of records to modify.
          // They array allow to select only the fields needed on the final object
          const records = await context.getRecords(['id', 'subject', 'is_resolved']);

          // Here, we're getting the resolved & unresolved topic to display
          // an HTML view when a record selected to be "Mark as resolved" was already resolved
          const unresolvedRecordIds = records.filter((record) => !record.is_resolved).map((record) => record.id);
          const resolvedRecordsSubject = records.filter((record) => record.is_resolved).map((record) => record.subject);

          // Then, we actually update the selected records by id, setting is_resolved to true
          await context.collection.update({ conditionTree: { field: 'id', operator: 'In', value: unresolvedRecordIds } }, { is_resolved: true });

          // And we return a success toastr, embedding HTML with the tickets that were un-affected
          return resultBuilder.success('Ticket(s) marked as resolved!', {
            html: resolvedRecordsSubject.map((record) => `<p>Ticket "${record}" is already resolved.</p>`).join(''),
          });
        } catch (error) {
          return resultBuilder.error(`Failed to mark ticket(s) as resolved ${error.message}.`);
        }
      }
    })
    // Pretty much the same, but setting is_resolved to false
    .addAction('Re-open ticket(s)', {
      scope: 'Bulk',
      execute: async (context, resultBuilder) => {
        try {
          const records = await context.getRecords(['id', 'subject', 'is_resolved']);

          const resolvedRecordIds = records.filter((record) => record.is_resolved).map((record) => record.id);
          const notResolvedRecordsSubject = records.filter((record) => !record.is_resolved).map((record) => record.subject);

          await context.collection.update({ conditionTree: { field: 'id', operator: 'In', value: resolvedRecordIds } }, { is_resolved: false });
          return resultBuilder.success('Ticket(s) reopened!', {
            html: notResolvedRecordsSubject.map((record) => `<p>Ticket "${record}" is already not resolved.</p>`).join(''),
          });
        } catch (error) {
          return resultBuilder.error(`Failed to mark ticket(s) as resolved ${error.message}.`);
        }
      }
    });
}

User Management System

Change a plan, Reset a password, Anonymize, Block a user.

Now we're going to add smart actions to the Users collection. Their aim is to allow CS team members to perform the most common actions like changing a plan, resetting passwords, and blocking (moderating) users. In addition, we will create a smart field that will display users' full names, when your database stores first and last names separately. Let's dive in.

import { CollectionCustomizer } from "@forestadmin/agent";
import { randomBytes } from 'crypto';

import { Schema } from "../typings";

export default (users: CollectionCustomizer<Schema, 'users'>) => {
  // Create a new fullname field
  users.addField('fullname', {
    // As this will be a concatenation of firstname and lastname, type is String
    columnType: 'String',
    // fullname will depend on firstname & lastname
    dependencies: ['firstname', 'lastname'],
    // Then, simply compute the value for each record
    getValues: (records) => {
      return records.map((record) => `${record.firstname} ${record.lastname}`);
    },
  })
  // We want to use the "contains" filter on the frontend, so we'll have to implement it
  .replaceFieldOperator('fullname', 'Contains', (value) => ({
    // "Contains" on fullname is equivalent to "Or Contains" on firstname & lastname
    aggregator: 'Or',
    conditions: [{
      field: 'firstname',
      operator: 'Contains',
      value
    }, {
      field: 'lastname',
      operator: 'Contains',
      value
    }],
  }))
  // We define a way to handle "writing" the fullname virtual field. So here, we're splitting the input to firstname and lastname
  .replaceFieldWriting('fullname', (value) => {
    const [firstname, lastname] = value.split(' ');
    return {
      firstname,
      lastname,
    };
  })
  // We also want to sort on fullname, so here is the equivalent sort
  .replaceFieldSorting('fullname', [
    { field: 'firstname', ascending: true },
    { field: 'lastname',  ascending: true },
  ])
  // Add an action that anonymize a specific set of user
  // Changes their name, email, picture, cellphone, password, set them as blocked and also unlink their address
  .addAction('Anonymize user', {
    scope: 'Bulk',
    execute: async (context, resultBuilder) => {
      try {
        const records = await context.getRecords(['id']);
        const userIds = records.map((record) => record.id);
        await context.collection.update({
          conditionTree: {
            field: 'id',
            operator: 'In',
            value: userIds,
          }
        }, {
          firstname: 'Anonymous',
          lastname: 'Anonymous',
          email: 'anonymous@anonymous.anonymous',
          identity_picture: null,
          cellphone: 'Unknown',
          password: '',
          is_blocked: true,
          signup_date: null,
        });

        await context.dataSource.getCollection('addresses').update({
          conditionTree: {
            field: 'user_id',
            operator: 'In',
            value: userIds,
          }
        }, {
          user_id: null,
          country: 'Unknown',
          city: 'Unknown',
          street: 'Unknown',
          number: '0',
        });

        return resultBuilder.success('User(s) anonymized!');
      } catch(error) {
        return resultBuilder.error(`Failed to anonymize user(s) ${error.message}.`);
      }
    },
  })
  // Change a user's plan by updating it's subscription
  .addAction('Change a plan', {
    scope: 'Single',
    form: [{
      label: 'plan',
      collectionName: 'plans',
      type: 'Collection',
      isRequired: true,
      defaultValue: async (context) => [(await context.getRecord(['subscription:plan_id'])).subscription?.plan_id],
    }],
    execute: async (context, resultBuilder) => {
      const [newPlanId] = context.formValues.plan;
      const record = await context.getRecord(['subscription:id']);
      const { subscription } = record;

      if (!subscription.id) return resultBuilder.error(`You can not change the plan, the user does not have subscriptions yet.`);

      try {
        await context.dataSource.getCollection('subscriptions').update({
          conditionTree: {
            field: 'id',
            operator: 'Equal',
            value: subscription.id,
          },
        }, { plan_id: newPlanId });

        return resultBuilder.success('Plan successfully updated.');
      }  catch(error) {
        return resultBuilder.error(`Failed to change plan ${error.message}.`);
      }
    },
  })
  // Fake simulate a password reset sending action
  .addAction('Reset password', {
    scope:'Single',
    execute: async (context, resultBuilder) => {
      const userId = await context.getRecordId();

      try {
        await context.dataSource.getCollection('users').update({
          conditionTree: {
            field: 'id',
            operator: 'Equal',
            value: userId,
          },
        }, { password: randomBytes(16).toString('hex') });
        // We do not encourage sending raw password as email, this is just for the example :)
        return resultBuilder.success('Password successfully updated, a mail has sended to the user with his new password.');
      }  catch(error) {
        return resultBuilder.error(`Failed to reset password ${error.message}.`);
      }
    },
  })
  // Moderate a user
  .addAction('Moderate', {
    scope:'Single',
    form: [{
      label: 'User Name',
      type: 'String',
      isReadOnly: true,
      description: 'You will block the following user',
      defaultValue: async (context) => (await context.getRecord(['fullname'])).fullname,
    }, {
      label: 'reason',
      type: 'Enum',
      enumValues: ['resignation', 'dismissal', 'long-term illness', 'other'],
      isRequired: true,
    }, {
      label: 'explanation',
      type: 'String',
      description: 'Fill in the reason',
      if: (context) => context.formValues.reason === 'other',
    }],
    execute: async (context, resultBuilder) => {
      const user = await context.getRecord(['id', 'is_blocked']);

      if (user.is_blocked) return resultBuilder.success('User already blocked.');

      try {
        await context.dataSource.getCollection('users').update({
          conditionTree: {
            field: 'id',
            operator: 'Equal',
            value: user.id,
          },
        }, { is_blocked: true });

        return resultBuilder.success('User successfully blocked.');
      }  catch(error) {
        return resultBuilder.error(`Failed block user ${error.message}.`);
      }
    },
  });
};

Impersonate a user

Customer impersonation, often called a 'login as' feature is a very common functionality used by Customer Success teams. In order to troubleshoot customers' issues safely and without compromising data privacy and security, apps often have a feature that allows their managers to take a peek into what their customers see, and be able to identify the problem more easily.

Therefore, it is a must-have feature of every tool used by the CS teams. On Forest Admin, it is possible to add it via a smart action, and it is, in fact, one of the most popular ones. This code should help you create your own.

collection.addAction('Impersonate this user', {
    // We can only impersonate one user at a time, so it'll be a Single action
    scope: 'Single',
    execute: async (context, resultBuilder) => {
      // Retrieve the user id
      const userId = await context.getRecordId();
      return resultBuilder.webhook(
        'https://my-app-url/login', // The url of the company providing the service.
        'POST', // The method you would like to use (typically a POST).
        {}, // You can add some headers if needed (you can remove it)
        {
          adminToken: 'your-admin-token', // A body to send to the url (only JSON supported).
        }
      );
    },
  });

Last updated