From e346e072b81dfd62c1ca4923240f08798b4cceda Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Mon, 30 Mar 2026 06:52:27 -0500 Subject: [PATCH] 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 --- packages/admin/src/api/modules.ts | 28 +++++++++ packages/admin/src/routes/_authenticated.tsx | 22 ++++++- .../src/routes/_authenticated/settings.tsx | 63 ++++++++++++++++++- packages/backend/api-tests/run.ts | 16 +++++ .../src/db/migrations/0026_modules.sql | 24 +++++++ .../src/db/migrations/meta/_journal.json | 7 +++ packages/backend/src/db/schema/modules.ts | 14 +++++ packages/backend/src/main.ts | 56 ++++++++++++++--- packages/backend/src/routes/v1/modules.ts | 26 ++++++++ .../backend/src/services/module.service.ts | 51 +++++++++++++++ 10 files changed, 294 insertions(+), 13 deletions(-) create mode 100644 packages/admin/src/api/modules.ts create mode 100644 packages/backend/src/db/migrations/0026_modules.sql create mode 100644 packages/backend/src/db/schema/modules.ts create mode 100644 packages/backend/src/routes/v1/modules.ts create mode 100644 packages/backend/src/services/module.service.ts diff --git a/packages/admin/src/api/modules.ts b/packages/admin/src/api/modules.ts new file mode 100644 index 0000000..7f3792b --- /dev/null +++ b/packages/admin/src/api/modules.ts @@ -0,0 +1,28 @@ +import { queryOptions } from '@tanstack/react-query' +import { api } from '@/lib/api-client' + +export interface ModuleConfig { + id: string + slug: string + name: string + description: string | null + licensed: boolean + enabled: boolean + createdAt: string + updatedAt: string +} + +export const moduleKeys = { + list: ['modules'] as const, +} + +export function moduleListOptions() { + return queryOptions({ + queryKey: moduleKeys.list, + queryFn: () => api.get<{ data: ModuleConfig[] }>('/v1/modules'), + }) +} + +export const moduleMutations = { + toggle: (slug: string, enabled: boolean) => api.patch(`/v1/modules/${slug}`, { enabled }), +} diff --git a/packages/admin/src/routes/_authenticated.tsx b/packages/admin/src/routes/_authenticated.tsx index 063bed8..8f4f00d 100644 --- a/packages/admin/src/routes/_authenticated.tsx +++ b/packages/admin/src/routes/_authenticated.tsx @@ -5,6 +5,7 @@ import { useEffect, useState } from 'react' import { api } from '@/lib/api-client' import { useAuthStore } from '@/stores/auth.store' import { myPermissionsOptions } from '@/api/rbac' +import { moduleListOptions } from '@/api/modules' import { Avatar } from '@/components/shared/avatar-upload' import { Button } from '@/components/ui/button' import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package, ClipboardList, FolderOpen, KeyRound, Settings } from 'lucide-react' @@ -90,6 +91,17 @@ function AuthenticatedLayout() { enabled: !!useAuthStore.getState().token, }) + // Fetch enabled modules + const { data: modulesData } = useQuery({ + ...moduleListOptions(), + enabled: !!useAuthStore.getState().token, + }) + + const enabledModules = new Set( + (modulesData?.data ?? []).filter((m) => m.enabled && m.licensed).map((m) => m.slug), + ) + const isModuleEnabled = (slug: string) => enabledModules.has(slug) + useEffect(() => { if (permData?.permissions) { setPermissions(permData.permissions) @@ -124,7 +136,7 @@ function AuthenticatedLayout() { } label="Members" /> )} - {canViewRepairs && ( + {isModuleEnabled('repairs') && canViewRepairs && ( <> } label="Repairs" /> } label="Repair Batches" /> @@ -133,8 +145,12 @@ function AuthenticatedLayout() { )} )} - } label="Files" /> - } label="Vault" /> + {isModuleEnabled('files') && ( + } label="Files" /> + )} + {isModuleEnabled('vault') && ( + } label="Vault" /> + )} {canViewUsers && (
Admin diff --git a/packages/admin/src/routes/_authenticated/settings.tsx b/packages/admin/src/routes/_authenticated/settings.tsx index 76c4d58..330cdc9 100644 --- a/packages/admin/src/routes/_authenticated/settings.tsx +++ b/packages/admin/src/routes/_authenticated/settings.tsx @@ -13,7 +13,9 @@ import { Skeleton } from '@/components/ui/skeleton' import { Badge } from '@/components/ui/badge' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { AvatarUpload } from '@/components/shared/avatar-upload' -import { Save, Plus, Trash2, MapPin, Building, ImageIcon } from 'lucide-react' +import { Switch } from '@/components/ui/switch' +import { moduleListOptions, moduleMutations, moduleKeys, type ModuleConfig } from '@/api/modules' +import { Save, Plus, Trash2, MapPin, Building, ImageIcon, Blocks, Lock } from 'lucide-react' import { toast } from 'sonner' interface StoreSettings { @@ -232,10 +234,69 @@ function SettingsPage() { )} + + {/* Modules */} +
) } +function ModulesCard() { + const queryClient = useQueryClient() + const hasPermission = useAuthStore((s) => s.hasPermission) + const canEdit = hasPermission('settings.edit') + + const { data: modulesData, isLoading } = useQuery(moduleListOptions()) + const modules = modulesData?.data ?? [] + + const toggleMutation = useMutation({ + mutationFn: ({ slug, enabled }: { slug: string; enabled: boolean }) => moduleMutations.toggle(slug, enabled), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: moduleKeys.list }) + toast.success('Module updated') + }, + onError: (err) => toast.error(err.message), + }) + + return ( + + + + Modules + + + + {isLoading ? ( +
{Array.from({ length: 4 }).map((_, i) => )}
+ ) : ( +
+ {modules.map((mod) => ( +
+
+
+ {mod.name} + {!mod.licensed && ( + + Not Licensed + + )} +
+ {mod.description &&

{mod.description}

} +
+ toggleMutation.mutate({ slug: mod.slug, enabled: checked })} + disabled={!canEdit || !mod.licensed || toggleMutation.isPending} + /> +
+ ))} +
+ )} +
+
+ ) +} + function LocationCard({ location }: { location: Location }) { const queryClient = useQueryClient() const [editing, setEditing] = useState(false) diff --git a/packages/backend/api-tests/run.ts b/packages/backend/api-tests/run.ts index e319269..0df2e3c 100644 --- a/packages/backend/api-tests/run.ts +++ b/packages/backend/api-tests/run.ts @@ -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') } diff --git a/packages/backend/src/db/migrations/0026_modules.sql b/packages/backend/src/db/migrations/0026_modules.sql new file mode 100644 index 0000000..99cae1f --- /dev/null +++ b/packages/backend/src/db/migrations/0026_modules.sql @@ -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); diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index 26076de..a915e46 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -183,6 +183,13 @@ "when": 1774850000000, "tag": "0025_vault", "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1774860000000, + "tag": "0026_modules", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/modules.ts b/packages/backend/src/db/schema/modules.ts new file mode 100644 index 0000000..273b52d --- /dev/null +++ b/packages/backend/src/db/schema/modules.ts @@ -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 diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 9be003c..ae25969 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -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 diff --git a/packages/backend/src/routes/v1/modules.ts b/packages/backend/src/routes/v1/modules.ts new file mode 100644 index 0000000..e5d7119 --- /dev/null +++ b/packages/backend/src/routes/v1/modules.ts @@ -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 } }) + } + }) +} diff --git a/packages/backend/src/services/module.service.ts b/packages/backend/src/services/module.service.ts new file mode 100644 index 0000000..95454f2 --- /dev/null +++ b/packages/backend/src/services/module.service.ts @@ -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 | null = null + +export const ModuleService = { + async listAll(db: PostgresJsDatabase) { + return db.select().from(moduleConfig).orderBy(moduleConfig.name) + }, + + async isEnabled(db: PostgresJsDatabase, slug: string): Promise { + if (!enabledCache) await this.refreshCache(db) + return enabledCache!.has(slug) + }, + + async getEnabledSlugs(db: PostgresJsDatabase): Promise> { + if (!enabledCache) await this.refreshCache(db) + return new Set(enabledCache!) + }, + + async setEnabled(db: PostgresJsDatabase, slug: string, enabled: boolean): Promise { + // 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) { + 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 + }, +}