Skip to main content
CMS

Notifications and integrations

The CMS records every change as an action, which your server can react to, e.g. to email a team when a page is published. It can also send strings to an external translation service and import the results.

Actions

Each action has a name like doc.publish, doc.save, release.publish or doc.comment.add, the email of the user who performed it (by, or system for scheduled jobs), a timestamp and metadata such as the docId. Actions are stored at Projects/<id>/ActionLogs in Firestore and listed in the CMS's action log.

The onAction option is called on the server with every action. It isn't awaited, so use it for quick, fire-and-forget work:

ts
// @/root.config.ts

cmsPlugin({
  // ...
  onAction: (action) => {
    if (action.action === 'doc.publish') {
      console.log(`${action.by} published ${action.metadata?.docId}`);
    }
  },
}),

Plugins and other server code can log their own actions with logAction() on RootCMSClient. A custom action is stored like any other, so it shows up in the action log and on the CMS home page, and it reaches onAction and your notification services. Name it with a prefix of your own, like inventory.sync, so that it can't collide with the CMS's actions and services can match it with a pattern like inventory.*.

The home page shows recent actions as sentences: who did it, what they did, and what they did it to. Pass label to write the middle part for your action:

ts
// @/plugins/inventory.ts

import type {RootConfig} from '@blinkk/root';
import {RootCMSClient} from '@blinkk/root-cms';

export async function logInventorySync(
  rootConfig: RootConfig,
  options: {userEmail: string; feedId: string; skuCount: number}
) {
  const {userEmail, feedId, skuCount} = options;
  const cmsClient = new RootCMSClient(rootConfig);
  await cmsClient.logAction('inventory.sync', {
    by: userEmail,
    label: 'synced inventory from',
    metadata: {feedId, skuCount},
    links: [
      {label: 'View feed', url: `https://example.com/feeds/${feedId}`},
    ],
  });
}

That row reads "Ada Lin synced inventory from warehouse-feed". The name comes from by, so write the label as a lowercase phrase that follows it, with no final period. The value at the end comes from the action's metadata: a key the CMS knows, like docId, or else the first key ending in Id alphabetically. If your metadata has no such key, the label stands alone, as in "rebuilt the search index".

Pass the user's email as by so that the row shows their name and avatar. Without it, the action is attributed to system and the row starts with "System". Any other value is shown as written, next to a clock icon.

Expanding a row lists all of a custom action's metadata, a button for each of its links, and the action's name. Metadata like docId, releaseId, datasourceId and assetId also adds a link to that item.

Without a label, the row uses the action's name, as in "Ada Lin logged inventory.sync for warehouse-feed". Labels are stored with each action, so entries logged without one stay that way. The label is on action.label in onAction handlers, while the full action log keeps showing names and raw metadata.

An email notification service with no actions list sends an email for every action, including yours, so give it a list if you log custom actions often. From code running in the CMS UI, POST the same fields (action, label, metadata and links) to /cms/api/actions.log to log an action as the signed-in user.

Notification services

For notifications, register services with the notifications option. Each service has an id, a label and an async onAction(ctx, action) handler, and each one decides for itself which actions to handle. ctx has the rootConfig, a cmsClient and the user who performed the action. For example, to post to a Slack webhook:

ts
// @/root.config.ts

cmsPlugin({
  // ...
  notifications: [
    {
      id: 'slack',
      label: 'Slack',
      onAction: async (ctx, action) => {
        if (action.action !== 'doc.publish') {
          return;
        }
        await fetch(process.env.SLACK_WEBHOOK_URL!, {
          method: 'POST',
          headers: {'Content-Type': 'application/json'},
          body: JSON.stringify({
            text: `${action.by} published ${action.metadata?.docId}`,
          }),
        });
        return {status: 'success'};
      },
    },
  ],
}),

A handler can return {status: 'success' | 'info' | 'error', message} to describe what it did. Errors are logged and don't affect the action or the other services.

Email notifications

emailNotifications() is a built-in service that emails a list of people when matching actions happen. actions takes patterns where * matches anything, and to can be a list or a function that returns the recipients for an action:

ts
// @/root.config.ts

import {
  cmsPlugin,
  commentEmailNotifications,
  emailNotifications,
} from '@blinkk/root-cms/plugin';

cmsPlugin({
  // ...
  notifications: [
    emailNotifications({
      actions: ['doc.publish', 'release.*'],
      to: ['web-team@example.com'],
      template: {
        subject: '[My Site] {action} by {by}',
        body: '{by} ran {action} on {metadata.docId}.',
      },
      emailService: 'https://my-email-service.appspot.com',
    }),
    commentEmailNotifications({
      emailService: 'https://my-email-service.appspot.com',
    }),
  ],
}),

Templates fill {placeholder} values from the action, including nested ones like {metadata.docId}, and can set subject, a plain-text body and html. Without a template, a default email describes the action. For full control, pass a function that returns the email. filter can skip actions (e.g. ones by a bot account), and transformData changes the data the template sees.

commentEmailNotifications() emails the people @mentioned in a field comment and everyone who has already commented in the thread, when a comment is added, resolved or reopened. Links in the email point to rootConfig.domain unless you set cmsUrl.

Delivering email

Emails are queued in Firestore at Projects/<id>/Emails and sent by the Root.js email service, a small App Engine app (apps/root-services in the Root.js repo) that reads the queue and sends with the App Engine Mail API. It only reads the queue of the Google Cloud project it runs in, so deploy it to the project that has your Firestore database. Set emailService to its URL so new emails are sent right away, and have a scheduled job call its /_/send_emails?projectId=<id> endpoint every few minutes to send anything that was missed.

The Mail API sends to at most 100 recipients a day, 8 a minute, and each recipient of an email counts. Emails over the limit are marked as failed and aren't retried, so send alerts to a Google Group address (which counts once) rather than to each person. For more volume, send through a provider like SendGrid with a custom notification service.

Emails are sent from noreply@<project-id>.appspotmail.com by default. A different from address must be an authorized sender for the Mail API. Set expireAfterMinutes to skip emails that couldn't be sent in time instead of sending them late.

Your own server code can send email through the same queue with cmsClient.sendEmail({to, subject, body, htmlBody}).

A coding agent can set all of this up for you. Paste this prompt in your project:

Set up email notifications for our CMS. Follow the instructions at
https://rootjs.dev/skills/root-cms-email-setup.md

The agent asks who should get which emails, then deploys the email service to App Engine, creates the Firestore index and the scheduled job it needs, and adds ready-made emails for releases, publishes, publishing locks and comments to cms/emails.ts. It asks before creating or deploying anything, and ends by sending you a test email. root-cms skill.install also installs the skill, as root-cms-email-setup.

Translation services

To connect a translation platform like Crowdin or Smartling, register a service with the translations option. It appears in the Import and Export menus of a doc's localization modal. onExport receives the doc's strings and their current translations, and onImport returns translated rows to merge into the CMS:

ts
// @/root.config.ts

cmsPlugin({
  // ...
  translations: [
    {
      id: 'my-tms',
      label: 'My TMS',
      onExport: async (ctx, rows) => {
        // rows: [{source, translations: {de: '...', fr: '...'}}, ...]
        const job = await myTms.upload(ctx.docId, rows);
        return {
          status: 'success',
          message: `Sent ${rows.length} strings.`,
          link: {url: job.url, label: 'Open in My TMS'},
        };
      },
      onImport: async (ctx) => {
        return await myTms.download(ctx.docId, ctx.translationLanguages);
      },
    },
  ],
}),

ctx has the doc's docId, the project's locales and its translationLanguages (the keys used in translations; see i18n.translationLanguages in root.config.ts). Either handler can return a title, message, link and status to show in a notification, and onImport can return one of these instead of rows when there's nothing to import.

excludeLocalesFromTranslations leaves locales out of every import and export, including CSV and Google Sheets. It takes patterns like ['ALL_*'], which is useful for pseudo-locales that are never translated.

1
2
3
4
5
6
7
8
9
10
11
12
Breakpoint: