Plugin Development Guide

A feature plugin is the other kind of Tutor add-on (the first being channel integrations — see the Add-on Development Guide). Where a channel add-on connects an external provider, a feature plugin adds a whole capability — admin pages, member pages, public pages, API endpoints, navigation, event hooks, and its own database tables. Announcements, Scheduled Digest Reports, Audit Logs, and dozens more ship this way.

Feature plugins live alongside channel add-ons under src/product/addons/<id>/, are declared with "feature": true in their manifest, and are activated by license entitlement + an admin toggle (no files to upload). This guide uses Announcements (src/product/addons/announcements/) as the worked example. The plugin contract is defined in src/core/plugins/types.ts.

Anatomy of a Feature Plugin

Code
src/product/addons/announcements/
  addon.json            manifest (feature: true, premium, category…)
  index.ts              exports `plugin` (the FeaturePlugin object)
  schema.ts             ensureSchema — idempotent CREATE TABLE
  api.ts                API handlers (mine, mark-read, admin list/save/delete)
  admin.tsx             admin page component
  learner.tsx           member-facing page component
  installation-guide.md docs shown in admin

The manifest

JSON
{
  "id": "announcements",
  "name": "Announcements",
  "type": "code",
  "feature": true,
  "products": ["*"],
  "version": "1.0.0",
  "premium": true,
  "category": "announcements",
  "vendor": "creative-cape.com"
}

"feature": true marks it a plugin. A plugin may also declare templates (email / SMS / WhatsApp messages) in its manifest; those are seeded into the notification system on activation (idempotently), so the plugin's messages exist as soon as it's switched on.

The FeaturePlugin object

index.ts exports a plugin matching the FeaturePlugin interface:

TS
import type { FeaturePlugin } from "@/core/plugins/types";
import { ensureSchema } from "./schema";
import { mine, markRead, markAllRead, adminList, save, remove } from "./api";
import AnnouncementsLearner from "./learner";
import AnnouncementsAdmin from "./admin";

export const plugin: FeaturePlugin = {
  id: "announcements",
  name: "Announcements",
  category: "Engagement",
  ensureSchema,
  nav: [
    { area: "admin", label: "Announcements", icon: "Megaphone", slug: "", permission: "settings" },
    { area: "learner", label: "Announcements", icon: "Megaphone", slug: "" },
  ],
  adminPages: { "": AnnouncementsAdmin },
  learnerPages: { "": AnnouncementsLearner },
  api: {
    "GET mine": mine,
    "POST :id/read": markRead,
    "POST read-all": markAllRead,
    "GET admin/list": adminList,
    "POST admin/save": save,
    "DELETE admin/:id": remove,
  },
};

The full shape (src/core/plugins/types.ts):

Field Purpose
id, name Identity (id matches the folder and license key)
category? Grouping label on the admin Integrations page (defaults to "Features")
ensureSchema? Idempotent CREATE TABLE …, run on activation
nav? Sidebar entries (area: admin / learner / instructor; slug; optional permission)
adminPages? sub-slug → component ("" is the plugin root); rendered by the admin catch-all
learnerPages? sub-slug → component; rendered for the member-facing sidebar
publicPages? pattern → component (e.g. "verify/:code"); rendered by the site catch-all
api? "METHOD pattern" → handler (e.g. "GET admin/list")
hooks? Handlers for core domain events
slots? Components injected into named core slots
settings? Per-plugin settings fields (text/secret/select/switch) edited on its admin page
loginButtons? Buttons rendered on login/register (e.g. SSO)

The area values in the type are admin / learner / instructor. In the marketplace these map to the three signed-in sidebars — admin, the customer area, and the host area respectively.

Schema

ensureSchema follows the project-wide ensure-schema pattern — a guarded, idempotent function that creates the plugin's tables. It runs automatically when the plugin is activated:

TS
export async function ensureSchema(): Promise<void> {
  await sql`CREATE TABLE IF NOT EXISTS announcement ( … )`;
  await sql`CREATE TABLE IF NOT EXISTS announcement_read (
    id SERIAL PRIMARY KEY,
    announcement_id INT NOT NULL,
    customer_id     INT NOT NULL,
    read_at         TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE (announcement_id, customer_id)
  )`;
}

Hooks on core events

Core emits domain events (CoreEvent in src/core/plugins/types.ts); a plugin subscribes by adding a handler under hooks. The events relevant to the marketplace are order.paid (a booking's payment cleared), order.refunded, user.registered, user.login, and admin.action. Announcements is a pure plugin (no hooks), but any plugin can react to a paid booking — for example, to notify a member after checkout:

TS
export const plugin: FeaturePlugin = {
  id: "thank-you",
  name: "Thank-you Messages",
  hooks: {
    "order.paid": async (payload) => {
      const p = payload as { customerId?: number; bookingId?: number };
      if (!p?.customerId) return;
      await notifyCustomer(p.customerId, "booking_thanks", { bookingId: p.bookingId });
    },
  },
};

API handlers

Each API entry maps "METHOD pattern" to a PluginApiHandler(req, ctx) where ctx.params carries pattern matches and ctx.query is the search params. Handlers reuse the same auth helpers as core — requirePermission() for admin endpoints, getCustomerId() for member endpoints:

TS
export const adminList: PluginApiHandler = async () => {
  const denied = await requirePermission("read", "settings");
  if (denied) return denied;
  const rows = await sql`SELECT * FROM announcement ORDER BY created_at DESC`;
  return Response.json(rows);
};

How Plugins Are Rendered (catch-all routes)

Plugins don't define their own Next.js route files. Catch-all routes plus one API dispatcher resolve active plugins at runtime:

Surface Route file URL
Admin pages src/app/admin/(panel)/x/[...slug]/page.tsx /admin/x/<id>/<slug>
Public pages src/app/(site)/x/[...slug]/page.tsx /x/<id>/<slug>
API src/app/api/v1/ext/[addon]/[...path]/route.ts /api/v1/ext/<id>/<path…>

Each renderer resolves the active plugin via getActiveFeatures() and matches the request. The admin catch-all also enforces the nav item's permission before rendering; the public catch-all matches the plugin's publicPages patterns. The API dispatcher walks the api map, matching the "METHOD pattern" key against the request path:

TS
const feature = (await getActiveFeatures()).find((f) => f.id === addon);
for (const [key, handler] of Object.entries(feature.api)) {
  const [m, pattern = ""] = key.trim().split(/\s+/);
  if (m.toUpperCase() !== method) continue;
  const params = matchPattern(pattern, parts);
  if (params) return handler(req, { params, query });
}

So an Announcements read call hits POST /api/v1/ext/announcements/42/read and matches "POST :id/read".

Activation & Licensing

A plugin is usable only when it is installed (present in the generated FEATURES list, built at bundle time) and active (a row in the plugin_state table). Activation is handled by setFeatureActive() (src/core/plugins/state.ts):

TS
export async function setFeatureActive(id: string, active: boolean): Promise<void> {
  const f = getFeature(id);
  if (!f) throw new Error("Unknown feature plugin.");
  if (active && f.ensureSchema) await f.ensureSchema();           // create tables
  if (active && ADDON_TEMPLATES[id]) await seedAddonTemplates(…); // seed templates
  await sql`INSERT INTO plugin_state (id, active, activated_at) VALUES (${id}, ${active}, NOW())
            ON CONFLICT (id) DO UPDATE SET active = ${active}`;
}

On activation it runs ensureSchema, seeds declared templates, and flips the plugin_state row. getActiveFeatures() is the single resolver every catch-all and the event bus use, so deactivating a plugin instantly hides its nav, pages, and hooks (its data is kept). Settings are stored per-plugin in the same plugin_state.settings JSON column via saveFeatureSettings().

License gate

Premium plugins are gated by featureBlock(headers, featureId) (src/lib/premium.ts), mirroring the channel logic. A premium plugin activates only when the license entitlements carry the feature id (or "*"/"all"), or a per-add-on purchase code has been activated for the domain. The free/premium split is in src/core/channels/tiers.ts (FREE_FEATURES is empty by default — every feature is premium); development/localhost hosts unlock everything.

Admins manage activation from Settings → Integrations — toggling a feature on (which calls setFeatureActive) and, if needed, entering a purchase code to satisfy the entitlement.


© CreativeCape Solutions · creative-cape.com · support@creative-cape.com