# 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` ```javascript 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: ```javascript 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: ```javascript 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: ```bash ################################# # 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: ```javascript 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 ```javascript export const AVAILABLE_MODULES = [ 'clients', 'invoice', 'your-module', ]; ``` ### `modules/modules.pages.js` — import the config ```javascript '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) ```javascript 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) ```javascript import * as yourModuleMetadata from './your-module/metadata.js'; export const MODULE_METADATA = { // ...existing modules... 'your-module': yourModuleMetadata, }; ``` ### `modules/init.js` — register the database initializer ```javascript import { createTables as createYourModuleTables } from './your-module/db.js'; const MODULE_DB_INITIALIZERS = { // ...existing modules... 'your-module': createYourModuleTables, }; ``` --- ## Step 6 — Enable the module ```bash 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 |