feat(modules): add external module registration and defineModule support
- Add `./modules/define` export path pointing to `defineModule.js` - Implement `registerExternalModules()` to handle modules passed via `zen.config.js`, with env var gating (`ZEN_MODULE_<NAME>=true`) - Extract `buildAdminConfig()` helper to consolidate admin navigation/page config building - Refactor `loadModuleConfig()` to use `buildAdminConfig()` and simplify public routes check - Improve `initializeModuleTables()` to gracefully skip modules without `db.js` instead of erroring - Update module discovery JSDoc to reflect external module registration support
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
# Créer un module externe
|
||||
|
||||
Un module externe est un package npm indépendant qui s'intègre dans une app qui utilise `@hykocx/zen`. Il n'a pas besoin de modifier le code source du CMS.
|
||||
|
||||
---
|
||||
|
||||
## Convention de nommage
|
||||
|
||||
```
|
||||
@scope/zen-nom-du-module
|
||||
```
|
||||
|
||||
Exemples : `@hykocx/zen-invoice`, `@hykocx/zen-nuage`.
|
||||
|
||||
---
|
||||
|
||||
## Structure du package
|
||||
|
||||
```
|
||||
zen-invoice/
|
||||
├── index.js # Point d'entrée — exporte defineModule()
|
||||
├── admin/ # Composants React pour l'admin
|
||||
│ ├── InvoiceListPage.js
|
||||
│ ├── InvoiceCreatePage.js
|
||||
│ └── InvoiceEditPage.js
|
||||
├── db.js # createTables() et dropTables()
|
||||
├── actions.js # Server actions pour pages publiques
|
||||
├── metadata.js # Générateurs de métadonnées SEO
|
||||
├── package.json
|
||||
└── .env.example
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## index.js
|
||||
|
||||
On utilise `defineModule()` importé depuis `@hykocx/zen/modules/define`.
|
||||
|
||||
```js
|
||||
import { lazy } from 'react';
|
||||
import { defineModule } from '@hykocx/zen/modules/define';
|
||||
|
||||
import { createTables, dropTables } from './db.js';
|
||||
import { getInvoiceByToken } from './actions.js';
|
||||
import { generatePaymentMetadata } from './metadata.js';
|
||||
|
||||
const InvoiceListPage = lazy(() => import('./admin/InvoiceListPage.js'));
|
||||
const InvoiceCreatePage = lazy(() => import('./admin/InvoiceCreatePage.js'));
|
||||
const InvoiceEditPage = lazy(() => import('./admin/InvoiceEditPage.js'));
|
||||
|
||||
export default defineModule({
|
||||
name: 'invoice',
|
||||
displayName: 'Facturation',
|
||||
version: '1.0.0',
|
||||
description: 'Gestion des factures et paiements.',
|
||||
|
||||
dependencies: [],
|
||||
envVars: ['STRIPE_SECRET_KEY', 'ZEN_INVOICE_TAX_RATE'],
|
||||
|
||||
// Navigation dans le panneau admin
|
||||
navigation: [
|
||||
{
|
||||
id: 'invoice',
|
||||
title: 'Facturation',
|
||||
icon: 'Invoice03Icon',
|
||||
items: [
|
||||
{ name: 'Factures', href: '/admin/invoice/list', icon: 'Invoice03Icon' },
|
||||
{ name: 'Nouvelle', href: '/admin/invoice/new', icon: 'Add01Icon' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
// Pages admin avec leurs composants lazy
|
||||
adminPages: {
|
||||
'/admin/invoice/list': InvoiceListPage,
|
||||
'/admin/invoice/new': InvoiceCreatePage,
|
||||
'/admin/invoice/edit': InvoiceEditPage,
|
||||
},
|
||||
|
||||
// Résolveur pour les routes dynamiques (ex: /admin/invoice/edit/123)
|
||||
pageResolver(path) {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
if (parts[0] !== 'admin' || parts[1] !== 'invoice') return null;
|
||||
if (parts[2] === 'list') return InvoiceListPage;
|
||||
if (parts[2] === 'new') return InvoiceCreatePage;
|
||||
if (parts[2] === 'edit') return InvoiceEditPage;
|
||||
return null;
|
||||
},
|
||||
|
||||
publicPages: {},
|
||||
publicRoutes: [
|
||||
{ pattern: ':token', description: 'Page de paiement' },
|
||||
{ pattern: ':token/pdf', description: 'Télécharger la facture PDF' },
|
||||
],
|
||||
|
||||
dashboardWidgets: [],
|
||||
|
||||
// Base de données
|
||||
db: { createTables, dropTables },
|
||||
|
||||
// Server actions pour les pages publiques (/zen/invoice/...)
|
||||
actions: { getInvoiceByToken },
|
||||
|
||||
// Générateurs de métadonnées SEO
|
||||
metadata: {
|
||||
payment: generatePaymentMetadata,
|
||||
},
|
||||
|
||||
// Appelé une fois au démarrage du serveur
|
||||
// ctx donne accès aux services du CMS
|
||||
async setup(ctx) {
|
||||
const stripe = await ctx.payments.then(p => p.stripe);
|
||||
// Initialiser des webhooks, vérifier la config, etc.
|
||||
console.log('[invoice] Stripe prêt :', !!stripe);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## L'objet ctx dans setup()
|
||||
|
||||
`setup(ctx)` reçoit un objet qui donne accès aux services du CMS. Chaque propriété retourne une promesse vers le module correspondant.
|
||||
|
||||
```js
|
||||
async setup(ctx) {
|
||||
// Base de données PostgreSQL
|
||||
const { query, queryOne, queryAll } = await ctx.db;
|
||||
const rows = await query('SELECT * FROM zen_auth_users LIMIT 1');
|
||||
|
||||
// Envoi de courriels (Resend)
|
||||
const { sendEmail } = await ctx.email;
|
||||
await sendEmail({ to: 'test@example.com', subject: 'Test', html: '<p>ok</p>' });
|
||||
|
||||
// Stockage de fichiers (Cloudflare R2)
|
||||
const { uploadFile, deleteFile } = await ctx.storage;
|
||||
|
||||
// Stripe
|
||||
const { stripe } = await ctx.payments;
|
||||
|
||||
// Variables d'environnement
|
||||
const apiKey = ctx.config.get('STRIPE_SECRET_KEY');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## package.json du module externe
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@hykocx/zen-invoice",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "./index.js",
|
||||
"exports": {
|
||||
".": "./index.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@hykocx/zen": ">=1.0.0",
|
||||
"react": ">=19.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Intégration dans l'app consommatrice
|
||||
|
||||
### 1. Installer le package
|
||||
|
||||
```bash
|
||||
npm install @hykocx/zen-invoice
|
||||
```
|
||||
|
||||
### 2. Créer zen.config.js à la racine de l'app
|
||||
|
||||
```js
|
||||
// zen.config.js
|
||||
import invoiceModule from '@hykocx/zen-invoice';
|
||||
|
||||
export default {
|
||||
modules: [invoiceModule],
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Passer la config à initializeZen()
|
||||
|
||||
```js
|
||||
// instrumentation.js
|
||||
import zenConfig from './zen.config.js';
|
||||
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
const { initializeZen } = await import('@hykocx/zen');
|
||||
await initializeZen(zenConfig);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Passer les modules à ZenProvider
|
||||
|
||||
```jsx
|
||||
// app/layout.js
|
||||
import zenConfig from './zen.config.js';
|
||||
import { ZenProvider } from '@hykocx/zen/provider';
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<ZenProvider modules={zenConfig.modules}>
|
||||
{children}
|
||||
</ZenProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Activer le module via variable d'environnement
|
||||
|
||||
```bash
|
||||
# .env.local
|
||||
ZEN_MODULE_INVOICE=true
|
||||
```
|
||||
|
||||
La convention est la même que pour les modules internes : tirets en underscores, tout en majuscules.
|
||||
|
||||
---
|
||||
|
||||
## Initialiser la base de données
|
||||
|
||||
Le module déclare ses tables dans `db.createTables`. Deux façons de les créer.
|
||||
|
||||
**Au démarrage du serveur** (si `skipDb: false`) :
|
||||
|
||||
```js
|
||||
await initializeZen({ modules: zenConfig.modules, skipDb: false });
|
||||
```
|
||||
|
||||
**Via la CLI** :
|
||||
|
||||
```bash
|
||||
npx zen-db init
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Vérification rapide
|
||||
|
||||
1. Démarrer avec `ZEN_MODULE_INVOICE=true`.
|
||||
2. Ouvrir `/admin`. La section "Facturation" doit apparaître dans la navigation.
|
||||
3. Naviguer vers `/admin/invoice/list`. La page du module doit se charger.
|
||||
4. Appeler `getModuleActions('invoice')` côté serveur. Les actions du module doivent être retournées.
|
||||
@@ -0,0 +1,221 @@
|
||||
# Créer un module interne
|
||||
|
||||
Un module interne vit dans `src/modules/` et fait partie du package `@hykocx/zen`. Il a accès direct aux services du CMS sans passer par un contexte injecté.
|
||||
|
||||
---
|
||||
|
||||
## Structure d'un module
|
||||
|
||||
```
|
||||
src/modules/mon-module/
|
||||
├── module.config.js # Obligatoire — navigation, pages, routes, cron, etc.
|
||||
├── db.js # Optionnel — createTables() et dropTables()
|
||||
├── api.js # Optionnel — routes REST
|
||||
├── cron.config.js # Optionnel — tâches planifiées
|
||||
├── crud.js # Optionnel — accès aux données
|
||||
├── admin/ # Composants React pour l'admin
|
||||
└── .env.example # Variables d'environnement requises
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## module.config.js
|
||||
|
||||
C'est la source de vérité du module. On utilise `defineModule()` pour déclarer la configuration.
|
||||
|
||||
```js
|
||||
import { lazy } from 'react';
|
||||
import { defineModule } from '../../core/modules/defineModule.js';
|
||||
|
||||
const ListPage = lazy(() => import('./admin/ListPage.js'));
|
||||
const CreatePage = lazy(() => import('./admin/CreatePage.js'));
|
||||
const EditPage = lazy(() => import('./admin/EditPage.js'));
|
||||
|
||||
export default defineModule({
|
||||
name: 'mon-module',
|
||||
displayName: 'Mon module',
|
||||
version: '1.0.0',
|
||||
description: 'Description courte.',
|
||||
|
||||
// Modules dont celui-ci dépend (vérification au démarrage)
|
||||
dependencies: [],
|
||||
|
||||
// Variables d'environnement que ce module lit
|
||||
envVars: ['ZEN_MON_MODULE_OPTION'],
|
||||
|
||||
// Navigation admin — un ou plusieurs objets de section
|
||||
navigation: [
|
||||
{
|
||||
id: 'mon-module',
|
||||
title: 'Mon module',
|
||||
icon: 'SomeIcon',
|
||||
items: [
|
||||
{ name: 'Liste', href: '/admin/mon-module/list', icon: 'SomeIcon' },
|
||||
{ name: 'Nouveau', href: '/admin/mon-module/new', icon: 'AddIcon' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
// Pages admin — chemin exact vers composant lazy
|
||||
adminPages: {
|
||||
'/admin/mon-module/list': ListPage,
|
||||
'/admin/mon-module/new': CreatePage,
|
||||
'/admin/mon-module/edit': EditPage,
|
||||
},
|
||||
|
||||
// Résolveur pour les routes dynamiques (non connues à la compilation)
|
||||
// Retourner null si le chemin ne correspond pas
|
||||
pageResolver(path) {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
if (parts[0] !== 'admin' || parts[1] !== 'mon-module') return null;
|
||||
if (parts[2] === 'list') return ListPage;
|
||||
if (parts[2] === 'new') return CreatePage;
|
||||
if (parts[2] === 'edit') return EditPage;
|
||||
return null;
|
||||
},
|
||||
|
||||
// Pages publiques accessibles sans authentification (/zen/mon-module/...)
|
||||
publicPages: {},
|
||||
publicRoutes: [],
|
||||
|
||||
// Widgets affichés sur le dashboard admin
|
||||
dashboardWidgets: [],
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## db.js
|
||||
|
||||
Si le module crée des tables, on exporte `createTables` et `dropTables`.
|
||||
|
||||
```js
|
||||
import { query } from '../../core/database/index.js';
|
||||
import { tableExists } from '../../core/database/helpers.js';
|
||||
|
||||
export async function createTables() {
|
||||
const created = [];
|
||||
const skipped = [];
|
||||
|
||||
if (!(await tableExists('zen_mon_module'))) {
|
||||
await query(`
|
||||
CREATE TABLE zen_mon_module (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
titre TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
created.push('zen_mon_module');
|
||||
} else {
|
||||
skipped.push('zen_mon_module');
|
||||
}
|
||||
|
||||
return { created, skipped };
|
||||
}
|
||||
|
||||
export async function dropTables() {
|
||||
await query('DROP TABLE IF EXISTS zen_mon_module CASCADE');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## api.js
|
||||
|
||||
Les routes API sont montées automatiquement sous `/api/zen/mon-module/...`.
|
||||
|
||||
```js
|
||||
async function handleList(request) {
|
||||
// ...
|
||||
return Response.json({ items });
|
||||
}
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
path: 'mon-module/list',
|
||||
method: 'GET',
|
||||
handler: handleList,
|
||||
requireAuth: true,
|
||||
requireAdmin: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## cron.config.js
|
||||
|
||||
```js
|
||||
import { maFonction } from './crud.js';
|
||||
|
||||
export default {
|
||||
jobs: [
|
||||
{
|
||||
name: 'mon-module:nettoyage',
|
||||
schedule: '0 3 * * *', // Tous les jours à 3h
|
||||
handler: maFonction,
|
||||
timezone: 'America/Toronto',
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Enregistrement — 2 étapes
|
||||
|
||||
Après avoir créé les fichiers, on enregistre le module dans deux endroits.
|
||||
|
||||
### 1. `src/modules/modules.registry.js`
|
||||
|
||||
Ajouter le nom du module à `AVAILABLE_MODULES` :
|
||||
|
||||
```js
|
||||
export const AVAILABLE_MODULES = [
|
||||
'posts',
|
||||
'mon-module', // ajout
|
||||
];
|
||||
```
|
||||
|
||||
### 2. `src/modules/modules.pages.js`
|
||||
|
||||
Importer la config et l'ajouter à `MODULE_CONFIGS` :
|
||||
|
||||
```js
|
||||
import postsConfig from './posts/module.config.js';
|
||||
import monModuleConfig from './mon-module/module.config.js'; // ajout
|
||||
|
||||
const MODULE_CONFIGS = {
|
||||
posts: postsConfig,
|
||||
'mon-module': monModuleConfig, // ajout
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Activation
|
||||
|
||||
Le module est ignoré au démarrage tant que sa variable d'environnement n'est pas définie :
|
||||
|
||||
```bash
|
||||
# .env.local
|
||||
ZEN_MODULE_MON_MODULE=true
|
||||
```
|
||||
|
||||
La règle de conversion : tirets et espaces deviennent des underscores, tout en majuscules.
|
||||
|
||||
```
|
||||
mon-module → ZEN_MODULE_MON_MODULE
|
||||
post-types → ZEN_MODULE_POST_TYPES
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Vérification rapide
|
||||
|
||||
1. Démarrer le serveur avec `ZEN_MODULE_MON_MODULE=true`.
|
||||
2. Ouvrir `/admin`. La section de navigation du module doit apparaître.
|
||||
3. Naviguer vers `/admin/mon-module/list`. La page doit se charger.
|
||||
4. Lancer `npx zen-db init`. La table `zen_mon_module` doit être créée.
|
||||
@@ -135,6 +135,9 @@
|
||||
"./modules": {
|
||||
"import": "./dist/modules/index.js"
|
||||
},
|
||||
"./modules/define": {
|
||||
"import": "./dist/core/modules/defineModule.js"
|
||||
},
|
||||
"./modules/pages": {
|
||||
"import": "./dist/modules/pages.js"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* defineModule — helper to declare a ZEN module.
|
||||
*
|
||||
* Used for both internal modules (src/modules/) and external npm packages.
|
||||
*
|
||||
* @param {Object} config - Module configuration
|
||||
* @returns {Object} Normalized module configuration
|
||||
*/
|
||||
export function defineModule(config) {
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new Error('[defineModule] Config must be an object.');
|
||||
}
|
||||
|
||||
if (!config.name || typeof config.name !== 'string') {
|
||||
throw new Error('[defineModule] Field "name" is required (e.g. "invoice").');
|
||||
}
|
||||
|
||||
return {
|
||||
// Identity
|
||||
version: '1.0.0',
|
||||
displayName: config.name.charAt(0).toUpperCase() + config.name.slice(1),
|
||||
description: '',
|
||||
|
||||
// Dependencies and environment variables
|
||||
dependencies: [],
|
||||
envVars: [],
|
||||
|
||||
// Admin UI
|
||||
navigation: null,
|
||||
adminPages: {},
|
||||
pageResolver: null,
|
||||
|
||||
// Public pages
|
||||
publicPages: {},
|
||||
publicRoutes: [],
|
||||
dashboardWidgets: [],
|
||||
|
||||
// Server actions for public pages
|
||||
actions: {},
|
||||
|
||||
// SEO metadata generators
|
||||
metadata: {},
|
||||
|
||||
// Database (optional) — { createTables, dropTables }
|
||||
db: null,
|
||||
|
||||
// Initialization callback (optional) — setup(ctx)
|
||||
setup: null,
|
||||
|
||||
// Spread last so all fields above can be overridden
|
||||
...config,
|
||||
|
||||
// Internal marker — do not override
|
||||
__isZenModule: true,
|
||||
};
|
||||
}
|
||||
+134
-28
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Module Discovery System
|
||||
* Auto-discovers and registers modules from the modules directory
|
||||
* Auto-discovers and registers modules from the modules directory.
|
||||
* Also handles registration of external modules passed via zen.config.js.
|
||||
*/
|
||||
|
||||
import { registerModule, clearRegistry } from './registry.js';
|
||||
@@ -95,27 +96,7 @@ async function loadModuleConfig(moduleName) {
|
||||
try {
|
||||
const config = await import(`../../modules/${moduleName}/module.config.js`);
|
||||
const moduleConfig = config.default || config;
|
||||
|
||||
// Build admin config with navigation and pages
|
||||
let adminConfig = undefined;
|
||||
if (moduleConfig.navigation || moduleConfig.adminPages) {
|
||||
adminConfig = {};
|
||||
if (moduleConfig.navigation) {
|
||||
adminConfig.navigation = moduleConfig.navigation;
|
||||
}
|
||||
// Extract admin page paths (keys only, not the lazy components)
|
||||
// This allows getAdminPage() to know which paths belong to this module
|
||||
if (moduleConfig.adminPages) {
|
||||
adminConfig.pages = {};
|
||||
for (const path of Object.keys(moduleConfig.adminPages)) {
|
||||
// Store true as a marker that this path exists
|
||||
// The actual component is loaded client-side via modules.pages.js
|
||||
adminConfig.pages[path] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract server-side relevant data
|
||||
|
||||
return {
|
||||
name: moduleConfig.name || moduleName,
|
||||
displayName: moduleConfig.displayName || moduleName.charAt(0).toUpperCase() + moduleName.slice(1),
|
||||
@@ -123,12 +104,12 @@ async function loadModuleConfig(moduleName) {
|
||||
description: moduleConfig.description || `${moduleName} module`,
|
||||
dependencies: moduleConfig.dependencies || [],
|
||||
envVars: moduleConfig.envVars || [],
|
||||
// Admin configuration (navigation + page paths)
|
||||
admin: adminConfig,
|
||||
// Public routes metadata (not components)
|
||||
public: moduleConfig.publicRoutes ? {
|
||||
routes: moduleConfig.publicRoutes
|
||||
} : undefined,
|
||||
// Admin config: navigation + page path markers (components loaded client-side)
|
||||
admin: buildAdminConfig(moduleConfig),
|
||||
// Public routes metadata (components loaded client-side)
|
||||
public: moduleConfig.publicRoutes?.length
|
||||
? { routes: moduleConfig.publicRoutes }
|
||||
: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
console.log(`[Module Discovery] No module.config.js for ${moduleName}, using defaults`);
|
||||
@@ -182,6 +163,131 @@ async function loadModuleComponents(moduleName) {
|
||||
return components;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register external modules provided via zen.config.js.
|
||||
* Skips any module whose ZEN_MODULE_<NAME>=true env var is not set.
|
||||
*
|
||||
* @param {Array} modules - Array of module configs created with defineModule()
|
||||
* @returns {Promise<Object>} { registered, skipped, errors }
|
||||
*/
|
||||
export async function registerExternalModules(modules = []) {
|
||||
const registered = [];
|
||||
const skipped = [];
|
||||
const errors = [];
|
||||
|
||||
for (const moduleConfig of modules) {
|
||||
const moduleName = moduleConfig?.name;
|
||||
|
||||
if (!moduleName) {
|
||||
errors.push({ module: '(unknown)', error: 'Missing "name" field.' });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isModuleEnabledInEnv(moduleName)) {
|
||||
skipped.push(moduleName);
|
||||
console.log(`[External Modules] Skipped ${moduleName} (not enabled)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build registry entry from the external module config
|
||||
const adminConfig = buildAdminConfig(moduleConfig);
|
||||
|
||||
const moduleData = {
|
||||
name: moduleName,
|
||||
displayName: moduleConfig.displayName || moduleName,
|
||||
version: moduleConfig.version || '1.0.0',
|
||||
description: moduleConfig.description || '',
|
||||
dependencies: moduleConfig.dependencies || [],
|
||||
envVars: moduleConfig.envVars || [],
|
||||
admin: adminConfig,
|
||||
public: moduleConfig.publicRoutes?.length
|
||||
? { routes: moduleConfig.publicRoutes }
|
||||
: undefined,
|
||||
actions: moduleConfig.actions || {},
|
||||
metadata: moduleConfig.metadata || {},
|
||||
db: moduleConfig.db
|
||||
? { init: moduleConfig.db.createTables, drop: moduleConfig.db.dropTables }
|
||||
: undefined,
|
||||
cron: moduleConfig.cron || undefined,
|
||||
api: moduleConfig.api || undefined,
|
||||
enabled: true,
|
||||
external: true,
|
||||
};
|
||||
|
||||
registerModule(moduleName, moduleData);
|
||||
|
||||
// Call setup(ctx) if provided
|
||||
if (typeof moduleConfig.setup === 'function') {
|
||||
const ctx = await buildModuleContext();
|
||||
await moduleConfig.setup(ctx);
|
||||
}
|
||||
|
||||
registered.push(moduleName);
|
||||
console.log(`[External Modules] Registered ${moduleName}`);
|
||||
} catch (error) {
|
||||
errors.push({ module: moduleName, error: error.message });
|
||||
console.error(`[External Modules] Error registering ${moduleName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (registered.length > 0 || skipped.length > 0) {
|
||||
console.log(
|
||||
`[External Modules] Done. Registered: ${registered.length}, Skipped: ${skipped.length}, Errors: ${errors.length}`
|
||||
);
|
||||
}
|
||||
|
||||
return { registered, skipped, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build admin config object from a module config (shared with loadModuleConfig).
|
||||
* @param {Object} moduleConfig
|
||||
* @returns {Object|undefined}
|
||||
*/
|
||||
function buildAdminConfig(moduleConfig) {
|
||||
if (!moduleConfig.navigation && !moduleConfig.adminPages) return undefined;
|
||||
|
||||
const adminConfig = {};
|
||||
|
||||
if (moduleConfig.navigation) {
|
||||
adminConfig.navigation = moduleConfig.navigation;
|
||||
}
|
||||
|
||||
if (moduleConfig.adminPages) {
|
||||
adminConfig.pages = {};
|
||||
for (const path of Object.keys(moduleConfig.adminPages)) {
|
||||
adminConfig.pages[path] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return adminConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the context object injected into module setup() callbacks.
|
||||
* All services are lazy-initialized internally — importing them is safe.
|
||||
* @returns {Promise<Object>} ctx
|
||||
*/
|
||||
async function buildModuleContext() {
|
||||
const [db, email, storage, payments] = await Promise.all([
|
||||
import('../../core/database/index.js'),
|
||||
import('../../core/email/index.js'),
|
||||
import('../../core/storage/index.js'),
|
||||
import('../../core/payments/index.js'),
|
||||
]);
|
||||
|
||||
return {
|
||||
db,
|
||||
email,
|
||||
storage,
|
||||
payments,
|
||||
config: {
|
||||
get: (key) => process.env[key],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset module discovery (useful for testing)
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// Discovery
|
||||
export {
|
||||
discoverModules,
|
||||
registerExternalModules,
|
||||
isModuleEnabledInEnv,
|
||||
resetDiscovery
|
||||
} from './discovery.js';
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ToastProvider, ToastContainer } from '@hykocx/zen/toast';
|
||||
import { registerExternalModulePages } from '../../modules/modules.pages.js';
|
||||
|
||||
export function ZenProvider({ children }) {
|
||||
return (
|
||||
<ToastProvider>
|
||||
{children}
|
||||
<ToastContainer />
|
||||
</ToastProvider>
|
||||
);
|
||||
/**
|
||||
* ZenProvider — root client provider for the ZEN CMS.
|
||||
*
|
||||
* Pass external module configs via the `modules` prop so their
|
||||
* admin pages and public pages are available to the client router.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Array} props.modules - External module configs from zen.config.js
|
||||
* @param {ReactNode} props.children
|
||||
*/
|
||||
export function ZenProvider({ modules = [], children }) {
|
||||
// Register external module pages once, synchronously, before first render.
|
||||
// useState initializer runs exactly once and does not cause a re-render.
|
||||
useState(() => {
|
||||
if (modules.length > 0) {
|
||||
registerExternalModulePages(modules);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{children}
|
||||
<ToastContainer />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
export {
|
||||
// Discovery & initialization
|
||||
discoverModules,
|
||||
registerExternalModules,
|
||||
initializeModules,
|
||||
initializeModuleDatabases,
|
||||
startModuleCronJobs,
|
||||
@@ -45,5 +46,8 @@ export {
|
||||
isModuleRegistered,
|
||||
} from '../core/modules/index.js';
|
||||
|
||||
// Client-side module pages registry
|
||||
export { registerExternalModulePages } from './modules.pages.js';
|
||||
|
||||
// Public pages system
|
||||
export * from './pages.js';
|
||||
|
||||
+27
-37
@@ -1,32 +1,20 @@
|
||||
/**
|
||||
* Module Database Initialization
|
||||
* Initializes enabled module database tables
|
||||
*
|
||||
* IMPORTANT: When creating a new module, add its createTables import below
|
||||
* and add it to MODULE_DB_INITIALIZERS.
|
||||
*/
|
||||
|
||||
// Import createTables functions from each module
|
||||
// These are bundled together so they're available at runtime
|
||||
import { createTables as createPostsTables } from './posts/db.js';
|
||||
|
||||
/**
|
||||
* Module database initializers
|
||||
* Maps module names to their createTables functions
|
||||
* Module Database Initialization (CLI)
|
||||
*
|
||||
* Add new modules here:
|
||||
* Initializes DB tables for each enabled module.
|
||||
* Modules are auto-discovered from AVAILABLE_MODULES —
|
||||
* no manual registration needed when adding a new module.
|
||||
*/
|
||||
const MODULE_DB_INITIALIZERS = {
|
||||
posts: createPostsTables,
|
||||
};
|
||||
|
||||
import { AVAILABLE_MODULES } from './modules.registry.js';
|
||||
|
||||
/**
|
||||
* Check if a module is enabled in the environment
|
||||
* Check if a module is enabled via environment variable
|
||||
* @param {string} moduleName - Module name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isModuleEnabled(moduleName) {
|
||||
const envKey = `ZEN_MODULE_${moduleName.toUpperCase()}`;
|
||||
const envKey = `ZEN_MODULE_${moduleName.toUpperCase().replace(/-/g, '_')}`;
|
||||
return process.env[envKey] === 'true';
|
||||
}
|
||||
|
||||
@@ -37,36 +25,38 @@ function isModuleEnabled(moduleName) {
|
||||
export async function initModules() {
|
||||
const created = [];
|
||||
const skipped = [];
|
||||
|
||||
|
||||
console.log('\nInitializing module databases...');
|
||||
|
||||
for (const [moduleName, createTables] of Object.entries(MODULE_DB_INITIALIZERS)) {
|
||||
|
||||
for (const moduleName of AVAILABLE_MODULES) {
|
||||
if (!isModuleEnabled(moduleName)) {
|
||||
console.log(`- Skipped ${moduleName} (not enabled)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
if (typeof createTables === 'function') {
|
||||
console.log(`\nInitializing ${moduleName} module tables...`);
|
||||
const result = await createTables();
|
||||
|
||||
if (result?.created) {
|
||||
created.push(...result.created);
|
||||
}
|
||||
if (result?.skipped) {
|
||||
skipped.push(...result.skipped);
|
||||
}
|
||||
|
||||
console.log(`\nInitializing ${moduleName} module tables...`);
|
||||
const db = await import(`./${moduleName}/db.js`);
|
||||
|
||||
if (typeof db.createTables === 'function') {
|
||||
const result = await db.createTables();
|
||||
|
||||
if (result?.created) created.push(...result.created);
|
||||
if (result?.skipped) skipped.push(...result.skipped);
|
||||
|
||||
console.log(`✓ ${moduleName} module initialized`);
|
||||
} else {
|
||||
console.log(`- ${moduleName} has no createTables function`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error initializing ${moduleName}:`, error.message);
|
||||
if (error.code === 'ERR_MODULE_NOT_FOUND' || error.message?.includes('Cannot find module')) {
|
||||
console.log(`- ${moduleName} has no db.js (skipped)`);
|
||||
} else {
|
||||
console.error(`Error initializing ${moduleName}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return { created, skipped };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +1,36 @@
|
||||
/**
|
||||
* Module Actions Registry (Server-Side)
|
||||
*
|
||||
* Import server actions for public pages (/zen/*) and dashboard.
|
||||
* Admin pages import actions directly from modules.
|
||||
* See modules.registry.js for full module creation guide.
|
||||
*
|
||||
* Usage in consuming Next.js app:
|
||||
* ```
|
||||
* import { MODULE_ACTIONS, MODULE_DASHBOARD_ACTIONS } from '@hykocx/zen/modules/actions';
|
||||
*
|
||||
* // Access module actions
|
||||
* const { getInvoiceByTokenAction } = MODULE_ACTIONS.invoice;
|
||||
*
|
||||
* // Get dashboard stats
|
||||
* const stats = await MODULE_DASHBOARD_ACTIONS.invoice();
|
||||
* ```
|
||||
*
|
||||
* Static registry for internal module server actions.
|
||||
* External modules registered via zen.config.js are resolved through the runtime registry.
|
||||
*
|
||||
* Usage:
|
||||
* import { getModuleActions } from '@hykocx/zen/modules/actions';
|
||||
* const { getInvoiceByToken } = getModuleActions('invoice');
|
||||
*/
|
||||
|
||||
// Register module actions (for public pages)
|
||||
import { getModule } from '../core/modules/registry.js';
|
||||
|
||||
// Static actions for internal modules (add entries here for new internal modules)
|
||||
export const MODULE_ACTIONS = {
|
||||
posts: {},
|
||||
};
|
||||
|
||||
// Register dashboard stats actions (for admin dashboard)
|
||||
// Static dashboard stats actions for internal modules
|
||||
export const MODULE_DASHBOARD_ACTIONS = {};
|
||||
|
||||
/**
|
||||
* Get actions for a specific module
|
||||
* Get actions for a specific module.
|
||||
* Checks the static registry first, then the runtime registry for external modules.
|
||||
*
|
||||
* @param {string} moduleName - Module name
|
||||
* @returns {Object} Module actions object or empty object
|
||||
*/
|
||||
export function getModuleActions(moduleName) {
|
||||
return MODULE_ACTIONS[moduleName] || {};
|
||||
if (MODULE_ACTIONS[moduleName]) return MODULE_ACTIONS[moduleName];
|
||||
|
||||
// External modules declare their actions in their defineModule() config
|
||||
return getModule(moduleName)?.actions ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,51 +1,57 @@
|
||||
/**
|
||||
* Module Metadata Registry (Server-Side)
|
||||
*
|
||||
* Import metadata generators from modules for SEO/dynamic metadata.
|
||||
* See modules.registry.js for full module creation guide.
|
||||
*
|
||||
* Usage in Next.js page.js:
|
||||
* ```
|
||||
* import { MODULE_METADATA } from '@hykocx/zen/modules/metadata';
|
||||
*
|
||||
* export async function generateMetadata({ params }) {
|
||||
* return await MODULE_METADATA.invoice.generateInvoicePaymentMetadata(params.token);
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Static registry for internal module SEO metadata generators.
|
||||
* External modules registered via zen.config.js are resolved through the runtime registry.
|
||||
*
|
||||
* Usage:
|
||||
* import { getMetadataGenerator } from '@hykocx/zen/modules/metadata';
|
||||
* const fn = getMetadataGenerator('invoice', 'payment');
|
||||
* const meta = await fn(params.token);
|
||||
*/
|
||||
|
||||
// Register module metadata
|
||||
import { getModule } from '../core/modules/registry.js';
|
||||
|
||||
// Static metadata for internal modules (add entries here for new internal modules)
|
||||
export const MODULE_METADATA = {};
|
||||
|
||||
/**
|
||||
* Get metadata generators for a specific module
|
||||
* Get metadata generators for a specific module.
|
||||
* Checks the static registry first, then the runtime registry for external modules.
|
||||
*
|
||||
* @param {string} moduleName - Module name
|
||||
* @returns {Object|null} Module metadata object or null
|
||||
*/
|
||||
export function getModuleMetadata(moduleName) {
|
||||
return MODULE_METADATA[moduleName] || null;
|
||||
return MODULE_METADATA[moduleName] || getModule(moduleName)?.metadata || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific metadata generator function
|
||||
* Get a specific metadata generator function.
|
||||
* Checks the static registry first, then the runtime registry for external modules.
|
||||
*
|
||||
* @param {string} moduleName - Module name
|
||||
* @param {string} generatorName - Name of the metadata generator function (e.g., 'payment', 'pdf', 'receipt')
|
||||
* @param {string} generatorName - Metadata generator key (e.g., 'payment', 'pdf')
|
||||
* @returns {Function|null} Metadata generator function or null
|
||||
*/
|
||||
export function getMetadataGenerator(moduleName, generatorName) {
|
||||
const metadata = MODULE_METADATA[moduleName];
|
||||
if (!metadata) return null;
|
||||
|
||||
// Check the default export first (where the route type mapping is)
|
||||
if (metadata.default && typeof metadata.default[generatorName] === 'function') {
|
||||
return metadata.default[generatorName];
|
||||
|
||||
if (metadata) {
|
||||
if (metadata.default && typeof metadata.default[generatorName] === 'function') {
|
||||
return metadata.default[generatorName];
|
||||
}
|
||||
if (typeof metadata[generatorName] === 'function') {
|
||||
return metadata[generatorName];
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to direct named export
|
||||
if (typeof metadata[generatorName] === 'function') {
|
||||
return metadata[generatorName];
|
||||
|
||||
// External modules declare metadata in their defineModule() config
|
||||
const externalMetadata = getModule(moduleName)?.metadata;
|
||||
if (externalMetadata && typeof externalMetadata[generatorName] === 'function') {
|
||||
return externalMetadata[generatorName];
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,19 +2,41 @@
|
||||
|
||||
/**
|
||||
* Module Pages Registry (Client-Side)
|
||||
*
|
||||
* Import module configs and register them here.
|
||||
* See modules.registry.js for full module creation guide.
|
||||
*
|
||||
* Static registry for internal modules (imported explicitly for proper code splitting).
|
||||
* External modules are registered at runtime via registerExternalModulePages().
|
||||
*
|
||||
* To add an internal module:
|
||||
* 1. Import its config below
|
||||
* 2. Add it to MODULE_CONFIGS
|
||||
*/
|
||||
|
||||
// Import module configs
|
||||
// Import module configs — add new internal modules here
|
||||
import postsConfig from './posts/module.config.js';
|
||||
|
||||
// Register module configs
|
||||
// Internal module configs — add new modules here
|
||||
const MODULE_CONFIGS = {
|
||||
posts: postsConfig,
|
||||
};
|
||||
|
||||
// Runtime registry for external modules (populated by ZenProvider via registerExternalModulePages)
|
||||
const EXTERNAL_MODULE_CONFIGS = new Map();
|
||||
|
||||
/**
|
||||
* Register external module configs at runtime.
|
||||
* Called by ZenProvider when the app starts.
|
||||
* Idempotent — safe to call multiple times with the same modules.
|
||||
*
|
||||
* @param {Array} modules - Array of module configs created with defineModule()
|
||||
*/
|
||||
export function registerExternalModulePages(modules = []) {
|
||||
for (const mod of modules) {
|
||||
if (mod?.name) {
|
||||
EXTERNAL_MODULE_CONFIGS.set(mod.name, mod);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build admin pages from all module configs
|
||||
*/
|
||||
@@ -46,13 +68,27 @@ export const MODULE_DASHBOARD_WIDGETS = Object.entries(MODULE_CONFIGS).reduce((a
|
||||
}, {});
|
||||
|
||||
/**
|
||||
* Get admin page loader for a specific module and path
|
||||
* Get admin page loader for a specific module and path.
|
||||
* Checks external modules first, then internal modules.
|
||||
*
|
||||
* @param {string} moduleName - Module name
|
||||
* @param {string} path - Admin path
|
||||
* @returns {React.LazyExoticComponent|null} Lazy component or null
|
||||
*/
|
||||
export function getModulePageLoader(moduleName, path) {
|
||||
// Use custom resolver first (for dynamic paths not known at build time)
|
||||
// Check external modules first
|
||||
const externalConfig = EXTERNAL_MODULE_CONFIGS.get(moduleName);
|
||||
if (externalConfig) {
|
||||
if (typeof externalConfig.pageResolver === 'function') {
|
||||
const resolved = externalConfig.pageResolver(path);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
if (externalConfig.adminPages?.[path]) {
|
||||
return externalConfig.adminPages[path];
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to internal modules
|
||||
const config = MODULE_CONFIGS[moduleName];
|
||||
if (config?.pageResolver) {
|
||||
const resolved = config.pageResolver(path);
|
||||
@@ -60,31 +96,45 @@ export function getModulePageLoader(moduleName, path) {
|
||||
}
|
||||
|
||||
const modulePages = MODULE_ADMIN_PAGES[moduleName];
|
||||
if (modulePages && modulePages[path]) {
|
||||
if (modulePages?.[path]) {
|
||||
return modulePages[path];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public page loader for a specific module
|
||||
* Get public page loader for a specific module.
|
||||
* Checks external modules first, then internal modules.
|
||||
*
|
||||
* @param {string} moduleName - Module name
|
||||
* @returns {React.LazyExoticComponent|null} Lazy component or null
|
||||
*/
|
||||
export function getModulePublicPageLoader(moduleName) {
|
||||
const externalConfig = EXTERNAL_MODULE_CONFIGS.get(moduleName);
|
||||
if (externalConfig?.publicPages?.default) {
|
||||
return externalConfig.publicPages.default;
|
||||
}
|
||||
|
||||
const modulePages = MODULE_PUBLIC_PAGES[moduleName];
|
||||
if (modulePages?.default) {
|
||||
return modulePages.default;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get module navigation config
|
||||
* Get module navigation config.
|
||||
* Checks external modules first, then internal modules.
|
||||
*
|
||||
* @param {string} moduleName - Module name
|
||||
* @returns {Object|null} Navigation config or null
|
||||
*/
|
||||
export function getModuleNavigation(moduleName) {
|
||||
const externalConfig = EXTERNAL_MODULE_CONFIGS.get(moduleName);
|
||||
if (externalConfig?.navigation) return externalConfig.navigation;
|
||||
|
||||
const config = MODULE_CONFIGS[moduleName];
|
||||
return config?.navigation || null;
|
||||
}
|
||||
|
||||
+32
-18
@@ -3,7 +3,7 @@
|
||||
* Initialize all ZEN services and modules using dynamic module discovery
|
||||
*/
|
||||
|
||||
import { discoverModules, startModuleCronJobs, stopModuleCronJobs } from '../../core/modules/index.js';
|
||||
import { discoverModules, registerExternalModules, startModuleCronJobs, stopModuleCronJobs } from '../../core/modules/index.js';
|
||||
|
||||
// Use globalThis to persist initialization flag across module reloads
|
||||
const ZEN_INIT_KEY = Symbol.for('__ZEN_INITIALIZED__');
|
||||
@@ -16,26 +16,32 @@ const ZEN_INIT_KEY = Symbol.for('__ZEN_INITIALIZED__');
|
||||
* Alternative: Call this function manually in your root layout
|
||||
*
|
||||
* @example
|
||||
* // instrumentation.js (Recommended)
|
||||
* // instrumentation.js (Recommended) — internal modules only
|
||||
* export async function register() {
|
||||
* if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
* const { initializeZen } = await import('@hykocx/zen');
|
||||
* await initializeZen();
|
||||
* }
|
||||
* }
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* // app/layout.js (Alternative)
|
||||
* import { initializeZen } from '@hykocx/zen';
|
||||
* initializeZen();
|
||||
*
|
||||
* @param {Object} options - Initialization options
|
||||
* @param {boolean} options.skipCron - Skip cron job initialization
|
||||
* @param {boolean} options.skipDb - Skip database initialization
|
||||
* // instrumentation.js — with external modules from zen.config.js
|
||||
* import zenConfig from './zen.config.js';
|
||||
* export async function register() {
|
||||
* if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
* const { initializeZen } = await import('@hykocx/zen');
|
||||
* await initializeZen(zenConfig);
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @param {Object} config - Configuration object
|
||||
* @param {Array} config.modules - External module configs (from zen.config.js)
|
||||
* @param {boolean} config.skipCron - Skip cron job initialization
|
||||
* @param {boolean} config.skipDb - Skip database initialization
|
||||
* @returns {Promise<Object>} Initialization result
|
||||
*/
|
||||
export async function initializeZen(options = {}) {
|
||||
const { skipCron = false, skipDb = true } = options;
|
||||
export async function initializeZen(config = {}) {
|
||||
const { modules: externalModules = [], skipCron = false, skipDb = true } = config;
|
||||
|
||||
// Only run on server-side
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -57,21 +63,29 @@ export async function initializeZen(options = {}) {
|
||||
};
|
||||
|
||||
try {
|
||||
// Step 1: Discover and register all enabled modules
|
||||
// This reads from modules.registry.js and loads each module's config files
|
||||
// Step 1: Discover and register internal modules (from modules.registry.js)
|
||||
result.discovery = await discoverModules();
|
||||
|
||||
|
||||
const enabledCount = result.discovery.enabled?.length || 0;
|
||||
const skippedCount = result.discovery.skipped?.length || 0;
|
||||
|
||||
|
||||
if (enabledCount > 0) {
|
||||
console.log(`✓ ZEN: Discovered ${enabledCount} enabled module(s): ${result.discovery.enabled.join(', ')}`);
|
||||
console.log(`✓ ZEN: Discovered ${enabledCount} internal module(s): ${result.discovery.enabled.join(', ')}`);
|
||||
}
|
||||
if (skippedCount > 0) {
|
||||
console.log(`⚠ ZEN: Skipped ${skippedCount} disabled module(s): ${result.discovery.skipped.join(', ')}`);
|
||||
}
|
||||
|
||||
// Step 2: Register external modules from zen.config.js (if any)
|
||||
if (externalModules.length > 0) {
|
||||
result.external = await registerExternalModules(externalModules);
|
||||
|
||||
if (result.external.registered.length > 0) {
|
||||
console.log(`✓ ZEN: Registered ${result.external.registered.length} external module(s): ${result.external.registered.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Start cron jobs for all enabled modules
|
||||
// Step 3: Start cron jobs for all enabled modules (internal + external)
|
||||
if (!skipCron) {
|
||||
result.cron = await startModuleCronJobs();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user