Files
core/src/modules/README.md
T
2026-04-12 12:50:14 -04:00

7.9 KiB

Module System

Modules are self-contained features that can be enabled/disabled via environment variables.

File Structure

src/modules/your-module/
├── module.config.js        # Required — navigation, pages, widgets
├── db.js                   # Database schema (createTables / dropTables)
├── crud.js                 # CRUD operations
├── actions.js              # Server actions (for public pages)
├── metadata.js             # SEO metadata generators
├── api.js                  # API route handlers
├── cron.config.js          # Scheduled tasks
├── index.js                # Public API re-exports
├── .env.example            # Environment variable documentation
├── admin/                  # Admin pages (lazy-loaded)
│   └── index.js            # Re-exports admin components
├── pages/                  # Public pages (lazy-loaded)
│   └── index.js
├── dashboard/              # Dashboard widgets
│   ├── statsActions.js
│   └── Widget.js
└── sub-feature/            # Optional sub-modules (e.g. items/, categories/)
    ├── db.js
    ├── crud.js
    └── admin/

Not all files are required. Only create what the module actually needs.


Step 1 — Create module.config.js

import { lazy } from 'react';

export default {
  // Module identity
  name: 'your-module',
  displayName: 'Your Module',
  version: '1.0.0',
  description: 'Description of your module',

  // Other modules this one depends on (must be enabled too)
  dependencies: ['clients'],

  // Environment variables this module uses (documentation only)
  envVars: [
    'YOUR_MODULE_API_KEY',
  ],

  // Admin navigation — single section object or array of section objects
  navigation: {
    id: 'your-module',
    title: 'Your Module',
    icon: 'SomeIcon',       // String icon name from shared/Icons.js
    items: [
      { name: 'Items', href: '/admin/your-module/items', icon: 'PackageIcon' },
      { name: 'New',   href: '/admin/your-module/items/new', icon: 'PlusSignIcon' },
    ],
  },

  // Admin pages — path → lazy component
  adminPages: {
    '/admin/your-module/items':      lazy(() => import('./admin/ItemsListPage.js')),
    '/admin/your-module/items/new':  lazy(() => import('./admin/ItemCreatePage.js')),
    '/admin/your-module/items/edit': lazy(() => import('./admin/ItemEditPage.js')),
  },

  // (Optional) Custom resolver for dynamic paths not known at build time.
  // Called before the adminPages map. Return the lazy component or null.
  pageResolver(path) {
    const parts = path.split('/').filter(Boolean);
    // example: /admin/your-module/{type}/list
    if (parts[2] === 'list') return lazy(() => import('./admin/ItemsListPage.js'));
    return null;
  },

  // Public pages — keyed by 'default' (one component handles all public routes)
  publicPages: {
    default: lazy(() => import('./pages/YourModulePublicPages.js')),
  },

  // Public route patterns for SEO/route matching (relative to /zen/your-module/)
  publicRoutes: [
    { pattern: ':id',      description: 'View item' },
    { pattern: ':id/pdf',  description: 'PDF viewer' },
  ],

  // Dashboard widgets (lazy-loaded, rendered on the admin dashboard)
  dashboardWidgets: [
    lazy(() => import('./dashboard/Widget.js')),
  ],
};

Navigation as multiple sections

When a module provides several distinct sections (like the posts module with one section per post type), set navigation to an array:

navigation: [
  { id: 'your-module-foo', title: 'Foo', icon: 'Book02Icon', items: [...] },
  { id: 'your-module-bar', title: 'Bar', icon: 'Layers01Icon', items: [...] },
],

Step 2 — Create db.js

Every module that uses a database must expose a createTables function:

import { query } from '@hykocx/zen/database';

export async function createTables() {
  const created = [];
  const skipped = [];

  const exists = await query(`
    SELECT EXISTS (
      SELECT FROM information_schema.tables
      WHERE table_schema = 'public' AND table_name = $1
    )`, ['zen_your_module']);
  
  if (!exists.rows[0].exists) {
    await query(`
      CREATE TABLE zen_your_module (
        id         SERIAL PRIMARY KEY,
        name       VARCHAR(255) NOT NULL,
        created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
        updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
      )
    `);
    created.push('zen_your_module');
  } else {
    skipped.push('zen_your_module');
  }

  return { created, skipped };
}

export async function dropTables() {
  await query(`DROP TABLE IF EXISTS zen_your_module CASCADE`);
}

Never create migrations. Instead, provide the SQL to the user so they can run it manually.


Step 3 — Create .env.example

Document every environment variable the module reads:

#################################
# MODULE YOUR-MODULE
ZEN_MODULE_YOUR_MODULE=false

ZEN_MODULE_YOUR_MODULE_API_KEY=
ZEN_MODULE_YOUR_MODULE_SOME_OPTION=default_value
#################################

Step 4 — Create cron.config.js (optional)

Only needed if the module requires scheduled tasks:

import { doSomething } from './reminders.js';

export default {
  jobs: [
    {
      name: 'your-module-task',
      description: 'Description of what this job does',
      schedule: '*/5 * * * *',           // cron expression
      handler: doSomething,
      timezone: process.env.ZEN_TIMEZONE || 'America/Toronto',
    },
  ],
};

Step 5 — Register the module in 5 files

modules/modules.registry.js — add the module name

export const AVAILABLE_MODULES = [
  'clients',
  'invoice',
  'your-module',
];

modules/modules.pages.js — import the config

'use client';

import yourModuleConfig from './your-module/module.config.js';

const MODULE_CONFIGS = {
  // ...existing modules...
  'your-module': yourModuleConfig,
};

modules/modules.actions.js — import server actions (if public pages or dashboard widgets)

import { yourPublicAction } from './your-module/actions.js';
import { getYourModuleDashboardStats } from './your-module/dashboard/statsActions.js';

export const MODULE_ACTIONS = {
  // ...existing modules...
  'your-module': { yourPublicAction },
};

export const MODULE_DASHBOARD_ACTIONS = {
  // ...existing modules...
  'your-module': getYourModuleDashboardStats,
};

modules/modules.metadata.js — import metadata generators (if SEO needed)

import * as yourModuleMetadata from './your-module/metadata.js';

export const MODULE_METADATA = {
  // ...existing modules...
  'your-module': yourModuleMetadata,
};

modules/init.js — register the database initializer

import { createTables as createYourModuleTables } from './your-module/db.js';

const MODULE_DB_INITIALIZERS = {
  // ...existing modules...
  'your-module': createYourModuleTables,
};

Step 6 — Enable the module

ZEN_MODULE_YOUR_MODULE=true

The environment variable is derived from the module name: ZEN_MODULE_ + module name uppercased (hyphens become underscores).


Sub-modules

For complex modules, group related features into sub-directories. Each sub-module follows the same pattern (db.js, crud.js, admin/). The parent module.config.js registers all sub-module admin pages, and the parent index.js re-exports everything publicly.

See src/modules/invoice/ for a complete example with items/, categories/, transactions/, and recurrences/ sub-modules.


Reference implementations

Module Features demonstrated
src/modules/invoice/ Sub-modules, public pages, cron jobs, Stripe, email, PDF, dashboard widgets, metadata
src/modules/posts/ Dynamic config from env vars, pageResolver, multiple navigation sections
src/modules/clients/ Simple module, dependencies, no public pages