Add module management system for enabling/disabling features
Stores can enable/disable feature modules from Settings. When disabled, nav links are hidden and API routes return 403. Designed as the foundation for future license-based gating (licensed + enabled flags). Core modules (Accounts, Members, Users, Roles, Settings) are always on. - module_config table with slug, name, description, licensed, enabled - In-memory cache for fast per-request module checks - requireModule middleware wraps route groups in main.ts - Settings page Modules card with toggle switches - Sidebar hides nav links for disabled modules - Default modules seeded: inventory, pos, repairs, rentals, lessons, files, vault, email, reports
This commit is contained in:
@@ -91,6 +91,22 @@ async function setupDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
// Seed default modules
|
||||
const DEFAULT_MODULES = [
|
||||
{ slug: 'inventory', name: 'Inventory', description: 'Product catalog, stock tracking, and unit management', enabled: true },
|
||||
{ slug: 'pos', name: 'Point of Sale', description: 'Sales transactions, cash drawer, and receipts', enabled: true },
|
||||
{ slug: 'repairs', name: 'Repairs', description: 'Repair ticket management, batches, and service templates', enabled: true },
|
||||
{ slug: 'rentals', name: 'Rentals', description: 'Instrument rental agreements and billing', enabled: false },
|
||||
{ slug: 'lessons', name: 'Lessons', description: 'Lesson scheduling, instructor management, and billing', enabled: false },
|
||||
{ slug: 'files', name: 'Files', description: 'Shared file storage with folder organization', enabled: true },
|
||||
{ slug: 'vault', name: 'Vault', description: 'Encrypted password and secret manager', enabled: true },
|
||||
{ slug: 'email', name: 'Email', description: 'Email campaigns, templates, and sending', enabled: false },
|
||||
{ slug: 'reports', name: 'Reports', description: 'Business reports and data export', enabled: true },
|
||||
]
|
||||
for (const m of DEFAULT_MODULES) {
|
||||
await testSql`INSERT INTO module_config (slug, name, description, licensed, enabled) VALUES (${m.slug}, ${m.name}, ${m.description}, true, ${m.enabled}) ON CONFLICT (slug) DO NOTHING`
|
||||
}
|
||||
|
||||
await testSql.end()
|
||||
console.log(' Database ready')
|
||||
}
|
||||
|
||||
24
packages/backend/src/db/migrations/0026_modules.sql
Normal file
24
packages/backend/src/db/migrations/0026_modules.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- Module configuration table for enabling/disabling feature modules
|
||||
|
||||
CREATE TABLE module_config (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug VARCHAR(50) NOT NULL UNIQUE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
licensed BOOLEAN NOT NULL DEFAULT true,
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Seed default modules
|
||||
INSERT INTO module_config (slug, name, description, licensed, enabled) VALUES
|
||||
('inventory', 'Inventory', 'Product catalog, stock tracking, and unit management', true, true),
|
||||
('pos', 'Point of Sale', 'Sales transactions, cash drawer, and receipts', true, true),
|
||||
('repairs', 'Repairs', 'Repair ticket management, batches, and service templates', true, true),
|
||||
('rentals', 'Rentals', 'Instrument rental agreements and billing', true, false),
|
||||
('lessons', 'Lessons', 'Lesson scheduling, instructor management, and billing', true, false),
|
||||
('files', 'Files', 'Shared file storage with folder organization', true, true),
|
||||
('vault', 'Vault', 'Encrypted password and secret manager', true, true),
|
||||
('email', 'Email', 'Email campaigns, templates, and sending', true, false),
|
||||
('reports', 'Reports', 'Business reports and data export', true, true);
|
||||
@@ -183,6 +183,13 @@
|
||||
"when": 1774850000000,
|
||||
"tag": "0025_vault",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "7",
|
||||
"when": 1774860000000,
|
||||
"tag": "0026_modules",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
14
packages/backend/src/db/schema/modules.ts
Normal file
14
packages/backend/src/db/schema/modules.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { pgTable, uuid, varchar, text, timestamp, boolean } from 'drizzle-orm/pg-core'
|
||||
|
||||
export const moduleConfig = pgTable('module_config', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
slug: varchar('slug', { length: 50 }).notNull().unique(),
|
||||
name: varchar('name', { length: 100 }).notNull(),
|
||||
description: text('description'),
|
||||
licensed: boolean('licensed').notNull().default(true),
|
||||
enabled: boolean('enabled').notNull().default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export type ModuleConfig = typeof moduleConfig.$inferSelect
|
||||
@@ -20,7 +20,9 @@ import { storageRoutes } from './routes/v1/storage.js'
|
||||
import { storeRoutes } from './routes/v1/store.js'
|
||||
import { vaultRoutes } from './routes/v1/vault.js'
|
||||
import { webdavRoutes } from './routes/webdav/index.js'
|
||||
import { moduleRoutes } from './routes/v1/modules.js'
|
||||
import { RbacService } from './services/rbac.service.js'
|
||||
import { ModuleService } from './services/module.service.js'
|
||||
|
||||
export async function buildApp() {
|
||||
const app = Fastify({
|
||||
@@ -61,19 +63,48 @@ export async function buildApp() {
|
||||
throw new Error('JWT_SECRET is required in production')
|
||||
}
|
||||
|
||||
// Routes
|
||||
// Module gate middleware — returns 403 if module is disabled
|
||||
function requireModule(slug: string) {
|
||||
return async (_request: any, reply: any) => {
|
||||
const enabled = await ModuleService.isEnabled(app.db, slug)
|
||||
if (!enabled) {
|
||||
reply.status(403).send({ error: { message: `Module '${slug}' is not enabled`, statusCode: 403, module: slug } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: wrap a route plugin so every route in it gets a module check.
|
||||
// Uses fastify-plugin (fp) pattern to inherit parent decorators while adding the hook.
|
||||
function withModule(slug: string, plugin: any) {
|
||||
const wrapped = async (instance: any, opts: any) => {
|
||||
instance.addHook('onRequest', requireModule(slug))
|
||||
await plugin(instance, opts)
|
||||
}
|
||||
// Copy the plugin's symbol to maintain encapsulation behavior
|
||||
if (plugin[Symbol.for('skip-override')]) {
|
||||
wrapped[Symbol.for('skip-override')] = true
|
||||
}
|
||||
// Copy plugin name for debugging
|
||||
Object.defineProperty(wrapped, 'name', { value: `${plugin.name || 'anonymous'}[${slug}]` })
|
||||
return wrapped
|
||||
}
|
||||
|
||||
// Core routes — always available
|
||||
await app.register(healthRoutes, { prefix: '/v1' })
|
||||
await app.register(authRoutes, { prefix: '/v1' })
|
||||
await app.register(accountRoutes, { prefix: '/v1' })
|
||||
await app.register(inventoryRoutes, { prefix: '/v1' })
|
||||
await app.register(productRoutes, { prefix: '/v1' })
|
||||
await app.register(lookupRoutes, { prefix: '/v1' })
|
||||
await app.register(fileRoutes, { prefix: '/v1' })
|
||||
await app.register(rbacRoutes, { prefix: '/v1' })
|
||||
await app.register(repairRoutes, { prefix: '/v1' })
|
||||
await app.register(storageRoutes, { prefix: '/v1' })
|
||||
await app.register(storeRoutes, { prefix: '/v1' })
|
||||
await app.register(vaultRoutes, { prefix: '/v1' })
|
||||
await app.register(moduleRoutes, { prefix: '/v1' })
|
||||
await app.register(lookupRoutes, { prefix: '/v1' })
|
||||
|
||||
// Module-gated routes
|
||||
await app.register(withModule('inventory', inventoryRoutes), { prefix: '/v1' })
|
||||
await app.register(withModule('inventory', productRoutes), { prefix: '/v1' })
|
||||
await app.register(withModule('files', fileRoutes), { prefix: '/v1' })
|
||||
await app.register(withModule('files', storageRoutes), { prefix: '/v1' })
|
||||
await app.register(withModule('repairs', repairRoutes), { prefix: '/v1' })
|
||||
await app.register(withModule('vault', vaultRoutes), { prefix: '/v1' })
|
||||
// Register WebDAV custom HTTP methods before routes
|
||||
app.addHttpMethod('PROPFIND', { hasBody: true })
|
||||
app.addHttpMethod('PROPPATCH', { hasBody: true })
|
||||
@@ -84,7 +115,7 @@ export async function buildApp() {
|
||||
app.addHttpMethod('UNLOCK')
|
||||
await app.register(webdavRoutes, { prefix: '/webdav' })
|
||||
|
||||
// Auto-seed system permissions on startup
|
||||
// Auto-seed system permissions and warm module cache on startup
|
||||
app.addHook('onReady', async () => {
|
||||
try {
|
||||
await RbacService.seedPermissions(app.db)
|
||||
@@ -92,6 +123,13 @@ export async function buildApp() {
|
||||
} catch (err) {
|
||||
app.log.error({ err }, 'Failed to seed permissions')
|
||||
}
|
||||
try {
|
||||
await ModuleService.refreshCache(app.db)
|
||||
const enabled = await ModuleService.getEnabledSlugs(app.db)
|
||||
app.log.info({ modules: [...enabled] }, 'Module cache loaded')
|
||||
} catch (err) {
|
||||
app.log.error({ err }, 'Failed to load module cache')
|
||||
}
|
||||
})
|
||||
|
||||
return app
|
||||
|
||||
26
packages/backend/src/routes/v1/modules.ts
Normal file
26
packages/backend/src/routes/v1/modules.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { ModuleService } from '../../services/module.service.js'
|
||||
|
||||
export const moduleRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/modules', { preHandler: [app.authenticate, app.requirePermission('settings.view')] }, async (_request, reply) => {
|
||||
const modules = await ModuleService.listAll(app.db)
|
||||
return reply.send({ data: modules })
|
||||
})
|
||||
|
||||
app.patch('/modules/:slug', { preHandler: [app.authenticate, app.requirePermission('settings.edit')] }, async (request, reply) => {
|
||||
const { slug } = request.params as { slug: string }
|
||||
const { enabled } = request.body as { enabled?: boolean }
|
||||
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return reply.status(400).send({ error: { message: 'enabled (boolean) is required', statusCode: 400 } })
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await ModuleService.setEnabled(app.db, slug, enabled)
|
||||
if (!updated) return reply.status(404).send({ error: { message: 'Module not found', statusCode: 404 } })
|
||||
return reply.send(updated)
|
||||
} catch (err: any) {
|
||||
return reply.status(400).send({ error: { message: err.message, statusCode: 400 } })
|
||||
}
|
||||
})
|
||||
}
|
||||
51
packages/backend/src/services/module.service.ts
Normal file
51
packages/backend/src/services/module.service.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||
import { moduleConfig } from '../db/schema/modules.js'
|
||||
|
||||
// In-memory cache of enabled module slugs — avoids DB query per request
|
||||
let enabledCache: Set<string> | null = null
|
||||
|
||||
export const ModuleService = {
|
||||
async listAll(db: PostgresJsDatabase<any>) {
|
||||
return db.select().from(moduleConfig).orderBy(moduleConfig.name)
|
||||
},
|
||||
|
||||
async isEnabled(db: PostgresJsDatabase<any>, slug: string): Promise<boolean> {
|
||||
if (!enabledCache) await this.refreshCache(db)
|
||||
return enabledCache!.has(slug)
|
||||
},
|
||||
|
||||
async getEnabledSlugs(db: PostgresJsDatabase<any>): Promise<Set<string>> {
|
||||
if (!enabledCache) await this.refreshCache(db)
|
||||
return new Set(enabledCache!)
|
||||
},
|
||||
|
||||
async setEnabled(db: PostgresJsDatabase<any>, slug: string, enabled: boolean): Promise<typeof moduleConfig.$inferSelect | null> {
|
||||
// Check that module exists and is licensed
|
||||
const [mod] = await db.select().from(moduleConfig).where(eq(moduleConfig.slug, slug)).limit(1)
|
||||
if (!mod) return null
|
||||
if (enabled && !mod.licensed) {
|
||||
throw new Error('Module is not licensed')
|
||||
}
|
||||
|
||||
const [updated] = await db.update(moduleConfig)
|
||||
.set({ enabled, updatedAt: new Date() })
|
||||
.where(eq(moduleConfig.slug, slug))
|
||||
.returning()
|
||||
|
||||
// Invalidate cache
|
||||
enabledCache = null
|
||||
|
||||
return updated ?? null
|
||||
},
|
||||
|
||||
async refreshCache(db: PostgresJsDatabase<any>) {
|
||||
const modules = await db.select({ slug: moduleConfig.slug, enabled: moduleConfig.enabled, licensed: moduleConfig.licensed })
|
||||
.from(moduleConfig)
|
||||
enabledCache = new Set(modules.filter((m) => m.enabled && m.licensed).map((m) => m.slug))
|
||||
},
|
||||
|
||||
invalidateCache() {
|
||||
enabledCache = null
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user