Plugin Development Guide

A feature plugin is the other kind of EduNest 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, learner pages, public pages, API endpoints, navigation, scheduled hooks, and its own database tables. Certificates, Gamification, Forums, Exams, Assignments, 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 Certificates (src/product/addons/certificates/) as the worked example. The plugin contract is defined in src/core/plugins/types.ts.

Anatomy of a Feature Plugin

Code
src/product/addons/certificates/
  addon.json            manifest (feature: true, premium, templates…)
  index.ts              exports `plugin` (the FeaturePlugin object)
  schema.ts             ensureSchema — idempotent CREATE TABLE
  hooks.ts              core-event handlers (issue on course.completed)
  api.ts                API handlers (templates CRUD, mine, verify)
  admin.tsx             admin page component
  learner.tsx           learner page component
  public.tsx            public verification page
  installation-guide.md docs shown in admin

The manifest

JSON
{
  "id": "certificates",
  "name": "Certificates",
  "type": "code",
  "feature": true,
  "products": ["edunest-lms"],
  "version": "1.1.0",
  "premium": true,
  "vendor": "creative-cape.com",
  "category": "lms",
  "templates": [
    { "slug": "certificate_issued", "channel": "EMAIL",
      "name": "Certificate Issued",
      "subject": "Your certificate for {{course}} 🎓",
      "body": "Hi {{name}}, congratulations on completing {{course}}! …" }
  ]
}

"feature": true marks it a plugin. Any templates are seeded into the notification system on activation (idempotently), so the plugin's emails/SMS/WhatsApp 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";

export const plugin: FeaturePlugin = {
  id: "certificates",
  name: "Certificates",
  ensureSchema: ensureCertificatesSchema,
  nav: [
    { area: "admin",   label: "Certificates", icon: "Award", slug: "", permission: "certificates" },
    { area: "learner", label: "Certificates", icon: "Award", slug: "" },
  ],
  adminPages:   { "": CertificatesAdmin },
  learnerPages: { "": LearnerCertificates },
  publicPages:  { "verify/:code": VerifyCertificate },
  api: {
    "GET templates":     listTemplates,
    "POST templates":    createTemplate,
    "GET templates/:id": getTemplate,
    "PUT templates/:id": updateTemplate,
    "DELETE templates/:id": deleteTemplate,
    "GET mine":   mine,
    "GET verify/:code": verify,
  },
  hooks: { "course.completed": issueCertificate },
};

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

Field Purpose
id, name Identity (id matches the folder and license key)
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 by the learner catch-all
publicPages? pattern → component (e.g. "verify/:code"); rendered by the site catch-all
api? "METHOD pattern" → handler (e.g. "GET verify/:code")
hooks? Handlers for core domain events
slots? Components injected into named core slots (e.g. learner.dashboard.cards)
settings? Per-plugin settings fields (text/secret/select/switch) edited on its admin page
loginButtons? Buttons rendered on login/register (e.g. SSO)

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 ensureCertificatesSchema(): Promise<void> {
  await sql`CREATE TABLE IF NOT EXISTS certificate_templates ( … )`;
  await sql`CREATE TABLE IF NOT EXISTS certificates (
    id SERIAL PRIMARY KEY,
    customer_id INT NOT NULL,
    course_id   INT NOT NULL,
    certificate_no VARCHAR(64) UNIQUE,
    UNIQUE (customer_id, course_id)
  )`;
}

Hooks on core events

Core emits domain events (src/core/plugins/types.ts → CoreEvent): course.completed, lesson.completed, enrollment.created, order.paid, order.refunded, user.registered, user.login, certificate.issued, admin.action. A plugin subscribes by adding a handler. Certificates issues a certificate on completion and notifies the learner across configured channels:

TS
export async function issueCertificate(payload: unknown): Promise<void> {
  const p = payload as { courseId?: number; customerId?: number };
  if (!p?.courseId || !p?.customerId) return;
  const no = `EN-${p.customerId}-${p.courseId}-${Date.now().toString(36).toUpperCase()}`;
  const [ins] = await sql`INSERT INTO certificates (customer_id, course_id, certificate_no)
    VALUES (${p.customerId}, ${p.courseId}, ${no})
    ON CONFLICT (customer_id, course_id) DO NOTHING RETURNING id`;
  if (!ins) return;  // already issued
  await notifyLearner(p.customerId, "certificate_issued", { course: courseTitle });
}

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 learner endpoints:

TS
export const listTemplates: PluginApiHandler = async () => {
  const denied = await requirePermission("read", "certificates");
  if (denied) return denied;
  const rows = await sql`SELECT * FROM certificate_templates 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. Three 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>
Learner pages src/app/learner/x/[...slug]/page.tsx /learner/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…>

The API dispatcher looks up the active plugin, walks its api map, matches the "METHOD pattern" key against the request path, and calls the handler:

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 a Certificates verification call hits /api/v1/ext/certificates/verify/ABC123 and matches "GET verify/:code".

Activation & Licensing

A plugin is usable only when it is installed (present in the generated FEATURES list, built-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