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:
Ryan Moon
2026-03-30 06:52:27 -05:00
parent 1f9297f533
commit e346e072b8
10 changed files with 294 additions and 13 deletions

View 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
},
}