diff --git a/src/app.integration.ts b/src/app.integration.ts index a2650dc..16ebeaf 100644 --- a/src/app.integration.ts +++ b/src/app.integration.ts @@ -23,6 +23,7 @@ import { PurchasesModule } from './modules/purchases'; import { InvoicesModule } from './modules/invoices'; import { ReportsModule } from './modules/reports'; import { DashboardModule } from './modules/dashboard'; +import { SettingsModule } from './modules/settings'; // Import entities from all modules for TypeORM import { @@ -113,6 +114,13 @@ import { PaymentAllocation, } from './modules/invoices/entities'; +import { + SystemSetting, + PlanSetting, + TenantSetting, + UserPreference, +} from './modules/settings/entities'; + /** * Get all entities for TypeORM configuration */ @@ -181,6 +189,11 @@ export function getAllEntities() { InvoiceItem, Payment, PaymentAllocation, + // Settings + SystemSetting, + PlanSetting, + TenantSetting, + UserPreference, ]; } @@ -240,6 +253,11 @@ export interface ModuleOptions { enabled: boolean; basePath?: string; }; + settings?: { + enabled: boolean; + basePath?: string; + cacheTtlMs?: number; + }; } /** @@ -259,6 +277,7 @@ const defaultModuleOptions: ModuleOptions = { invoices: { enabled: true, basePath: '/api' }, reports: { enabled: true, basePath: '/api' }, dashboard: { enabled: true, basePath: '/api' }, + settings: { enabled: true, basePath: '/api/v1' }, }; /** @@ -398,6 +417,17 @@ export function initializeModules( app.use(dashboardModule.router); console.log('✅ Dashboard module initialized'); } + + // Initialize Settings Module + if (config.settings?.enabled) { + const settingsModule = new SettingsModule({ + dataSource, + basePath: config.settings.basePath, + cacheTtlMs: config.settings.cacheTtlMs, + }); + app.use(settingsModule.router); + console.log('✅ Settings module initialized'); + } } /** @@ -477,6 +507,7 @@ export async function createApplication(dataSourceConfig: any): Promise invoices: true, reports: true, dashboard: true, + settings: true, }, }); }); diff --git a/src/config/typeorm.ts b/src/config/typeorm.ts index 9903c2f..f375916 100644 --- a/src/config/typeorm.ts +++ b/src/config/typeorm.ts @@ -86,6 +86,14 @@ import { WithholdingType, } from '../modules/fiscal/entities/index.js'; +// Import Settings Entities +import { + SystemSetting, + PlanSetting, + TenantSetting, + UserPreference, +} from '../modules/settings/entities/index.js'; + /** * TypeORM DataSource configuration * @@ -171,6 +179,11 @@ export const AppDataSource = new DataSource({ PaymentMethod, PaymentType, WithholdingType, + // Settings Entities + SystemSetting, + PlanSetting, + TenantSetting, + UserPreference, ], // Directorios de migraciones (para uso futuro) diff --git a/src/modules/settings/controllers/index.ts b/src/modules/settings/controllers/index.ts new file mode 100644 index 0000000..d5f9fa5 --- /dev/null +++ b/src/modules/settings/controllers/index.ts @@ -0,0 +1,3 @@ +export { SystemSettingsController } from './system-settings.controller'; +export { TenantSettingsController } from './tenant-settings.controller'; +export { UserPreferencesController } from './user-preferences.controller'; diff --git a/src/modules/settings/controllers/system-settings.controller.ts b/src/modules/settings/controllers/system-settings.controller.ts new file mode 100644 index 0000000..475fc90 --- /dev/null +++ b/src/modules/settings/controllers/system-settings.controller.ts @@ -0,0 +1,232 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { SystemSettingsService } from '../services'; +import { UpdateSystemSettingDto, CreateSystemSettingDto, SystemSettingsFiltersDto } from '../dto'; +import { logger } from '../../../shared/utils/logger.js'; + +/** + * SystemSettingsController + * + * REST API for managing global system settings. + * + * Endpoints: + * - GET /api/v1/settings/system - List all settings + * - GET /api/v1/settings/system/public - List public settings (no auth) + * - GET /api/v1/settings/system/categories - List categories + * - GET /api/v1/settings/system/:key - Get setting by key + * - PUT /api/v1/settings/system/:key - Update setting + * - POST /api/v1/settings/system/:key/reset - Reset to default + * - POST /api/v1/settings/system - Create new setting (admin) + */ +export class SystemSettingsController { + public router: Router; + + constructor(private readonly systemSettingsService: SystemSettingsService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Public routes (no authentication required) + this.router.get('/public', this.getPublicSettings.bind(this)); + + // Protected routes + this.router.get('/categories', this.getCategories.bind(this)); + this.router.get('/', this.getAll.bind(this)); + this.router.get('/:key', this.getByKey.bind(this)); + this.router.put('/:key', this.update.bind(this)); + this.router.post('/:key/reset', this.reset.bind(this)); + this.router.post('/', this.create.bind(this)); + } + + /** + * GET /api/v1/settings/system + * Get all system settings with optional filtering + */ + private async getAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const { category, isPublic, isEditable, search } = req.query; + + const filters: SystemSettingsFiltersDto = { + category: category as string, + isPublic: isPublic !== undefined ? isPublic === 'true' : undefined, + isEditable: isEditable !== undefined ? isEditable === 'true' : undefined, + search: search as string, + }; + + const settings = await this.systemSettingsService.getAll(filters); + + res.json({ + success: true, + data: settings, + total: settings.length, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /api/v1/settings/system/public + * Get all public system settings (no authentication required) + */ + private async getPublicSettings(_req: Request, res: Response, next: NextFunction): Promise { + try { + const settings = await this.systemSettingsService.getPublicSettings(); + + res.json({ + success: true, + data: settings, + total: settings.length, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /api/v1/settings/system/categories + * Get all unique setting categories + */ + private async getCategories(_req: Request, res: Response, next: NextFunction): Promise { + try { + const categories = await this.systemSettingsService.getCategories(); + + res.json({ + success: true, + data: categories, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /api/v1/settings/system/:key + * Get a single setting by key + */ + private async getByKey(req: Request, res: Response, next: NextFunction): Promise { + try { + const { key } = req.params; + const setting = await this.systemSettingsService.getByKey(key); + + if (!setting) { + res.status(404).json({ + success: false, + error: `Setting '${key}' not found`, + }); + return; + } + + res.json({ + success: true, + data: setting, + }); + } catch (error) { + next(error); + } + } + + /** + * PUT /api/v1/settings/system/:key + * Update a system setting + */ + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const { key } = req.params; + const userId = req.headers['x-user-id'] as string; + const dto: UpdateSystemSettingDto = req.body; + + const setting = await this.systemSettingsService.update(key, dto, userId); + + if (!setting) { + res.status(404).json({ + success: false, + error: `Setting '${key}' not found`, + }); + return; + } + + logger.info('System setting updated via API', { key, userId }); + + res.json({ + success: true, + data: setting, + }); + } catch (error) { + if ((error as Error).message.includes('not editable')) { + res.status(403).json({ + success: false, + error: (error as Error).message, + }); + return; + } + next(error); + } + } + + /** + * POST /api/v1/settings/system/:key/reset + * Reset a setting to its default value + */ + private async reset(req: Request, res: Response, next: NextFunction): Promise { + try { + const { key } = req.params; + const userId = req.headers['x-user-id'] as string; + + const setting = await this.systemSettingsService.reset(key, userId); + + if (!setting) { + res.status(404).json({ + success: false, + error: `Setting '${key}' not found`, + }); + return; + } + + logger.info('System setting reset via API', { key, userId }); + + res.json({ + success: true, + data: setting, + }); + } catch (error) { + if ((error as Error).message.includes('no default value')) { + res.status(400).json({ + success: false, + error: (error as Error).message, + }); + return; + } + next(error); + } + } + + /** + * POST /api/v1/settings/system + * Create a new system setting (admin only) + */ + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const userId = req.headers['x-user-id'] as string; + const dto: CreateSystemSettingDto = req.body; + + const setting = await this.systemSettingsService.create(dto, userId); + + logger.info('System setting created via API', { key: dto.key, userId }); + + res.status(201).json({ + success: true, + data: setting, + }); + } catch (error) { + if ((error as Error).message.includes('already exists')) { + res.status(409).json({ + success: false, + error: (error as Error).message, + }); + return; + } + next(error); + } + } +} diff --git a/src/modules/settings/controllers/tenant-settings.controller.ts b/src/modules/settings/controllers/tenant-settings.controller.ts new file mode 100644 index 0000000..fe76b73 --- /dev/null +++ b/src/modules/settings/controllers/tenant-settings.controller.ts @@ -0,0 +1,366 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { TenantSettingsService } from '../services'; +import { + UpdateTenantSettingDto, + CreateTenantSettingDto, + BulkUpdateTenantSettingsDto, + TenantSettingsFiltersDto, +} from '../dto'; +import { logger } from '../../../shared/utils/logger.js'; + +/** + * TenantSettingsController + * + * REST API for managing tenant-specific settings. + * + * Endpoints: + * - GET /api/v1/settings/tenant - List all tenant settings + * - GET /api/v1/settings/tenant/effective - Get all effective settings (with inheritance) + * - GET /api/v1/settings/tenant/:key - Get setting by key + * - GET /api/v1/settings/tenant/:key/effective - Get effective value + * - PUT /api/v1/settings/tenant/:key - Update setting + * - POST /api/v1/settings/tenant - Create new setting + * - POST /api/v1/settings/tenant/bulk - Bulk update settings + * - POST /api/v1/settings/tenant/:key/reset - Reset to inherited value + * - DELETE /api/v1/settings/tenant/:key - Delete setting override + */ +export class TenantSettingsController { + public router: Router; + + constructor(private readonly tenantSettingsService: TenantSettingsService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + this.router.get('/effective', this.getAllEffective.bind(this)); + this.router.get('/', this.getAll.bind(this)); + this.router.get('/:key/effective', this.getEffective.bind(this)); + this.router.get('/:key', this.get.bind(this)); + this.router.put('/:key', this.update.bind(this)); + this.router.post('/bulk', this.bulkUpdate.bind(this)); + this.router.post('/:key/reset', this.reset.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.delete('/:key', this.delete.bind(this)); + } + + /** + * Helper to get tenant ID from request + */ + private getTenantId(req: Request): string | null { + return (req.headers['x-tenant-id'] as string) || null; + } + + /** + * Helper to get plan ID from request (optional) + */ + private getPlanId(req: Request): string | undefined { + return req.headers['x-plan-id'] as string; + } + + /** + * GET /api/v1/settings/tenant + * Get all tenant settings with optional filtering + */ + private async getAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + if (!tenantId) { + res.status(400).json({ + success: false, + error: 'Tenant ID is required', + }); + return; + } + + const { inheritedFrom, isOverridden, search } = req.query; + + const filters: TenantSettingsFiltersDto = { + inheritedFrom: inheritedFrom as 'system' | 'plan' | 'custom', + isOverridden: isOverridden !== undefined ? isOverridden === 'true' : undefined, + search: search as string, + }; + + const settings = await this.tenantSettingsService.getAll(tenantId, filters); + + res.json({ + success: true, + data: settings, + total: settings.length, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /api/v1/settings/tenant/effective + * Get all effective settings with inheritance resolution + */ + private async getAllEffective(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + if (!tenantId) { + res.status(400).json({ + success: false, + error: 'Tenant ID is required', + }); + return; + } + + const planId = this.getPlanId(req); + const settings = await this.tenantSettingsService.getAllEffective(tenantId, planId); + + res.json({ + success: true, + data: settings, + total: settings.length, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /api/v1/settings/tenant/:key + * Get a tenant setting by key (direct, without inheritance) + */ + private async get(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + if (!tenantId) { + res.status(400).json({ + success: false, + error: 'Tenant ID is required', + }); + return; + } + + const { key } = req.params; + const setting = await this.tenantSettingsService.get(tenantId, key); + + if (!setting) { + res.status(404).json({ + success: false, + error: `Tenant setting '${key}' not found`, + }); + return; + } + + res.json({ + success: true, + data: setting, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /api/v1/settings/tenant/:key/effective + * Get the effective value for a setting with inheritance + */ + private async getEffective(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + if (!tenantId) { + res.status(400).json({ + success: false, + error: 'Tenant ID is required', + }); + return; + } + + const { key } = req.params; + const planId = this.getPlanId(req); + + const effective = await this.tenantSettingsService.getEffective(tenantId, key, planId); + + if (!effective) { + res.status(404).json({ + success: false, + error: `Setting '${key}' not found`, + }); + return; + } + + res.json({ + success: true, + data: effective, + }); + } catch (error) { + next(error); + } + } + + /** + * PUT /api/v1/settings/tenant/:key + * Update a tenant setting + */ + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + if (!tenantId) { + res.status(400).json({ + success: false, + error: 'Tenant ID is required', + }); + return; + } + + const { key } = req.params; + const dto: UpdateTenantSettingDto = req.body; + + const setting = await this.tenantSettingsService.update(tenantId, key, dto); + + logger.info('Tenant setting updated via API', { tenantId, key }); + + res.json({ + success: true, + data: setting, + }); + } catch (error) { + next(error); + } + } + + /** + * POST /api/v1/settings/tenant + * Create a new tenant setting + */ + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + if (!tenantId) { + res.status(400).json({ + success: false, + error: 'Tenant ID is required', + }); + return; + } + + const dto: CreateTenantSettingDto = req.body; + const setting = await this.tenantSettingsService.create(tenantId, dto); + + logger.info('Tenant setting created via API', { tenantId, key: dto.key }); + + res.status(201).json({ + success: true, + data: setting, + }); + } catch (error) { + if ((error as Error).message.includes('already exists')) { + res.status(409).json({ + success: false, + error: (error as Error).message, + }); + return; + } + next(error); + } + } + + /** + * POST /api/v1/settings/tenant/bulk + * Bulk update tenant settings + */ + private async bulkUpdate(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + if (!tenantId) { + res.status(400).json({ + success: false, + error: 'Tenant ID is required', + }); + return; + } + + const dto: BulkUpdateTenantSettingsDto = req.body; + const settings = await this.tenantSettingsService.bulkUpdate(tenantId, dto.settings); + + logger.info('Tenant settings bulk updated via API', { + tenantId, + count: dto.settings.length, + }); + + res.json({ + success: true, + data: settings, + total: settings.length, + }); + } catch (error) { + next(error); + } + } + + /** + * POST /api/v1/settings/tenant/:key/reset + * Reset a tenant setting to inherited value + */ + private async reset(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + if (!tenantId) { + res.status(400).json({ + success: false, + error: 'Tenant ID is required', + }); + return; + } + + const { key } = req.params; + const deleted = await this.tenantSettingsService.reset(tenantId, key); + + if (!deleted) { + res.status(404).json({ + success: false, + error: `Tenant setting '${key}' not found`, + }); + return; + } + + logger.info('Tenant setting reset via API', { tenantId, key }); + + res.json({ + success: true, + message: `Setting '${key}' reset to inherited value`, + }); + } catch (error) { + next(error); + } + } + + /** + * DELETE /api/v1/settings/tenant/:key + * Delete a tenant setting override + */ + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + if (!tenantId) { + res.status(400).json({ + success: false, + error: 'Tenant ID is required', + }); + return; + } + + const { key } = req.params; + const deleted = await this.tenantSettingsService.delete(tenantId, key); + + if (!deleted) { + res.status(404).json({ + success: false, + error: `Tenant setting '${key}' not found`, + }); + return; + } + + logger.info('Tenant setting deleted via API', { tenantId, key }); + + res.status(204).send(); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/settings/controllers/user-preferences.controller.ts b/src/modules/settings/controllers/user-preferences.controller.ts new file mode 100644 index 0000000..6ba23e5 --- /dev/null +++ b/src/modules/settings/controllers/user-preferences.controller.ts @@ -0,0 +1,331 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { UserPreferencesService } from '../services'; +import { + UpdateUserPreferenceDto, + BulkUpdateUserPreferencesDto, + SetUserPreferencesDto, + UserPreferencesFiltersDto, +} from '../dto'; +import { logger } from '../../../shared/utils/logger.js'; + +/** + * UserPreferencesController + * + * REST API for managing user-specific preferences. + * + * Endpoints: + * - GET /api/v1/settings/user - Get all user preferences + * - GET /api/v1/settings/user/grouped - Get preferences grouped by category + * - GET /api/v1/settings/user/:key - Get preference by key + * - PATCH /api/v1/settings/user/:key - Update preference + * - PATCH /api/v1/settings/user - Bulk update preferences + * - PUT /api/v1/settings/user - Set all preferences + * - POST /api/v1/settings/user/:key/reset - Reset preference to default + * - POST /api/v1/settings/user/reset - Reset all preferences + * - DELETE /api/v1/settings/user/:key - Delete preference + */ +export class UserPreferencesController { + public router: Router; + + constructor(private readonly userPreferencesService: UserPreferencesService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + this.router.get('/grouped', this.getGrouped.bind(this)); + this.router.get('/', this.getAll.bind(this)); + this.router.get('/:key', this.get.bind(this)); + this.router.patch('/:key', this.update.bind(this)); + this.router.patch('/', this.bulkUpdate.bind(this)); + this.router.put('/', this.setAll.bind(this)); + this.router.post('/reset', this.resetAll.bind(this)); + this.router.post('/:key/reset', this.reset.bind(this)); + this.router.delete('/:key', this.delete.bind(this)); + } + + /** + * Helper to get user ID from request + */ + private getUserId(req: Request): string | null { + return (req.headers['x-user-id'] as string) || null; + } + + /** + * GET /api/v1/settings/user + * Get all user preferences + */ + private async getAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const userId = this.getUserId(req); + if (!userId) { + res.status(400).json({ + success: false, + error: 'User ID is required', + }); + return; + } + + const { category, search } = req.query; + + const filters: UserPreferencesFiltersDto = { + category: category as string, + search: search as string, + }; + + const preferences = await this.userPreferencesService.getAll(userId, filters); + + res.json({ + success: true, + data: preferences, + total: preferences.length, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /api/v1/settings/user/grouped + * Get all preferences grouped by category + */ + private async getGrouped(req: Request, res: Response, next: NextFunction): Promise { + try { + const userId = this.getUserId(req); + if (!userId) { + res.status(400).json({ + success: false, + error: 'User ID is required', + }); + return; + } + + const grouped = await this.userPreferencesService.getGroupedByCategory(userId); + + res.json({ + success: true, + data: grouped, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /api/v1/settings/user/:key + * Get a single preference by key + */ + private async get(req: Request, res: Response, next: NextFunction): Promise { + try { + const userId = this.getUserId(req); + if (!userId) { + res.status(400).json({ + success: false, + error: 'User ID is required', + }); + return; + } + + const { key } = req.params; + const value = await this.userPreferencesService.getValue(userId, key); + + res.json({ + success: true, + data: { + key, + value, + }, + }); + } catch (error) { + next(error); + } + } + + /** + * PATCH /api/v1/settings/user/:key + * Update a single preference + */ + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const userId = this.getUserId(req); + if (!userId) { + res.status(400).json({ + success: false, + error: 'User ID is required', + }); + return; + } + + const { key } = req.params; + const dto: UpdateUserPreferenceDto = req.body; + + const preference = await this.userPreferencesService.update(userId, key, dto); + + logger.debug('User preference updated via API', { userId, key }); + + res.json({ + success: true, + data: preference, + }); + } catch (error) { + next(error); + } + } + + /** + * PATCH /api/v1/settings/user + * Bulk update preferences + */ + private async bulkUpdate(req: Request, res: Response, next: NextFunction): Promise { + try { + const userId = this.getUserId(req); + if (!userId) { + res.status(400).json({ + success: false, + error: 'User ID is required', + }); + return; + } + + const dto: BulkUpdateUserPreferencesDto = req.body; + const preferences = await this.userPreferencesService.bulkUpdate(userId, dto); + + logger.info('User preferences bulk updated via API', { + userId, + count: dto.preferences.length, + }); + + res.json({ + success: true, + data: preferences, + total: preferences.length, + }); + } catch (error) { + next(error); + } + } + + /** + * PUT /api/v1/settings/user + * Set all standard preferences at once + */ + private async setAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const userId = this.getUserId(req); + if (!userId) { + res.status(400).json({ + success: false, + error: 'User ID is required', + }); + return; + } + + const dto: SetUserPreferencesDto = req.body; + const preferences = await this.userPreferencesService.setPreferences(userId, dto); + + logger.info('User preferences set via API', { userId }); + + res.json({ + success: true, + data: preferences, + total: preferences.length, + }); + } catch (error) { + next(error); + } + } + + /** + * POST /api/v1/settings/user/:key/reset + * Reset a preference to default + */ + private async reset(req: Request, res: Response, next: NextFunction): Promise { + try { + const userId = this.getUserId(req); + if (!userId) { + res.status(400).json({ + success: false, + error: 'User ID is required', + }); + return; + } + + const { key } = req.params; + const preference = await this.userPreferencesService.reset(userId, key); + + logger.debug('User preference reset via API', { userId, key }); + + res.json({ + success: true, + data: preference, + message: `Preference '${key}' reset to default`, + }); + } catch (error) { + next(error); + } + } + + /** + * POST /api/v1/settings/user/reset + * Reset all preferences to defaults + */ + private async resetAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const userId = this.getUserId(req); + if (!userId) { + res.status(400).json({ + success: false, + error: 'User ID is required', + }); + return; + } + + const preferences = await this.userPreferencesService.resetAll(userId); + + logger.info('User preferences reset to defaults via API', { userId }); + + res.json({ + success: true, + data: preferences, + total: preferences.length, + message: 'All preferences reset to defaults', + }); + } catch (error) { + next(error); + } + } + + /** + * DELETE /api/v1/settings/user/:key + * Delete a preference + */ + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + const userId = this.getUserId(req); + if (!userId) { + res.status(400).json({ + success: false, + error: 'User ID is required', + }); + return; + } + + const { key } = req.params; + const deleted = await this.userPreferencesService.delete(userId, key); + + if (!deleted) { + res.status(404).json({ + success: false, + error: `Preference '${key}' not found`, + }); + return; + } + + logger.debug('User preference deleted via API', { userId, key }); + + res.status(204).send(); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/settings/dto/index.ts b/src/modules/settings/dto/index.ts new file mode 100644 index 0000000..a110e43 --- /dev/null +++ b/src/modules/settings/dto/index.ts @@ -0,0 +1,24 @@ +export type { + UpdateSystemSettingDto, + CreateSystemSettingDto, +} from './update-system-setting.dto'; + +export type { + UpdateTenantSettingDto, + CreateTenantSettingDto, + BulkUpdateTenantSettingsDto, +} from './update-tenant-setting.dto'; + +export type { + UpdateUserPreferenceDto, + PreferenceItem, + BulkUpdateUserPreferencesDto, + SetUserPreferencesDto, +} from './update-user-preference.dto'; + +export type { + SystemSettingsFiltersDto, + TenantSettingsFiltersDto, + UserPreferencesFiltersDto, + SettingsPaginationDto, +} from './settings-filters.dto'; diff --git a/src/modules/settings/dto/settings-filters.dto.ts b/src/modules/settings/dto/settings-filters.dto.ts new file mode 100644 index 0000000..3f67a51 --- /dev/null +++ b/src/modules/settings/dto/settings-filters.dto.ts @@ -0,0 +1,37 @@ +/** + * Filters for querying system settings. + */ +export interface SystemSettingsFiltersDto { + category?: string; + isPublic?: boolean; + isEditable?: boolean; + search?: string; +} + +/** + * Filters for querying tenant settings. + */ +export interface TenantSettingsFiltersDto { + category?: string; + inheritedFrom?: 'system' | 'plan' | 'custom'; + isOverridden?: boolean; + search?: string; +} + +/** + * Filters for querying user preferences. + */ +export interface UserPreferencesFiltersDto { + category?: string; + search?: string; +} + +/** + * Pagination parameters for settings queries. + */ +export interface SettingsPaginationDto { + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; +} diff --git a/src/modules/settings/dto/update-system-setting.dto.ts b/src/modules/settings/dto/update-system-setting.dto.ts new file mode 100644 index 0000000..bef6268 --- /dev/null +++ b/src/modules/settings/dto/update-system-setting.dto.ts @@ -0,0 +1,26 @@ +/** + * DTO for updating a system setting. + * Only allows updating the value and editable fields that admins should modify. + */ +export interface UpdateSystemSettingDto { + value?: any; + description?: string; + isPublic?: boolean; + isEditable?: boolean; +} + +/** + * DTO for creating a system setting. + * Used for initial setup or adding new system-wide settings. + */ +export interface CreateSystemSettingDto { + key: string; + value: any; + dataType?: 'string' | 'number' | 'boolean' | 'json' | 'array' | 'secret'; + category: string; + description?: string; + isPublic?: boolean; + isEditable?: boolean; + defaultValue?: any; + validationRules?: Record; +} diff --git a/src/modules/settings/dto/update-tenant-setting.dto.ts b/src/modules/settings/dto/update-tenant-setting.dto.ts new file mode 100644 index 0000000..774b16c --- /dev/null +++ b/src/modules/settings/dto/update-tenant-setting.dto.ts @@ -0,0 +1,31 @@ +/** + * DTO for updating a tenant setting. + * Allows tenant admins to customize settings for their organization. + */ +export interface UpdateTenantSettingDto { + value: any; + isOverridden?: boolean; +} + +/** + * DTO for creating/upserting a tenant setting. + * If setting exists, it updates; otherwise creates new. + */ +export interface CreateTenantSettingDto { + key: string; + value: any; + inheritedFrom?: 'system' | 'plan' | 'custom'; + isOverridden?: boolean; +} + +/** + * DTO for bulk updating tenant settings. + * Useful for settings pages that submit multiple changes at once. + */ +export interface BulkUpdateTenantSettingsDto { + settings: Array<{ + key: string; + value: any; + isOverridden?: boolean; + }>; +} diff --git a/src/modules/settings/dto/update-user-preference.dto.ts b/src/modules/settings/dto/update-user-preference.dto.ts new file mode 100644 index 0000000..2a50a66 --- /dev/null +++ b/src/modules/settings/dto/update-user-preference.dto.ts @@ -0,0 +1,36 @@ +/** + * DTO for updating a single user preference. + */ +export interface UpdateUserPreferenceDto { + value: any; +} + +/** + * Single preference item for bulk operations. + */ +export interface PreferenceItem { + key: string; + value: any; +} + +/** + * DTO for bulk updating user preferences. + * Supports updating multiple preferences in a single request. + */ +export interface BulkUpdateUserPreferencesDto { + preferences: PreferenceItem[]; +} + +/** + * DTO for setting all user preferences at once. + * Typically used for initial preference setup or reset. + */ +export interface SetUserPreferencesDto { + theme?: 'light' | 'dark' | 'system'; + language?: string; + dateFormat?: string; + numberFormat?: string; + notificationsEmail?: boolean; + notificationsPush?: boolean; + notificationsInApp?: boolean; +} diff --git a/src/modules/settings/entities/index.ts b/src/modules/settings/entities/index.ts new file mode 100644 index 0000000..daaaefc --- /dev/null +++ b/src/modules/settings/entities/index.ts @@ -0,0 +1,4 @@ +export { SystemSetting } from './system-setting.entity'; +export { PlanSetting } from './plan-setting.entity'; +export { TenantSetting } from './tenant-setting.entity'; +export { UserPreference } from './user-preference.entity'; diff --git a/src/modules/settings/entities/plan-setting.entity.ts b/src/modules/settings/entities/plan-setting.entity.ts new file mode 100644 index 0000000..a8c42ef --- /dev/null +++ b/src/modules/settings/entities/plan-setting.entity.ts @@ -0,0 +1,41 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +/** + * Plan Settings Entity (schema: core_settings.plan_settings) + * + * Default configuration settings per subscription plan. + * These settings override system defaults for tenants on a specific plan. + * + * Hierarchy: system_settings < plan_settings < tenant_settings + */ +@Entity({ name: 'plan_settings', schema: 'core_settings' }) +@Unique(['planId', 'key']) +export class PlanSetting { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'plan_id', type: 'uuid' }) + planId: string; + + @Index() + @Column({ name: 'key', type: 'varchar', length: 100 }) + key: string; + + @Column({ name: 'value', type: 'jsonb' }) + value: any; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/settings/entities/system-setting.entity.ts b/src/modules/settings/entities/system-setting.entity.ts new file mode 100644 index 0000000..251fca3 --- /dev/null +++ b/src/modules/settings/entities/system-setting.entity.ts @@ -0,0 +1,73 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +/** + * System Settings Entity (schema: core_settings.system_settings) + * + * Global system configuration settings that apply across all tenants. + * These settings define default behaviors and system-wide parameters. + * + * Data types supported: + * - string: Text values + * - number: Numeric values + * - boolean: True/false values + * - json: Complex JSON objects + * - array: JSON arrays + * - secret: Encrypted sensitive values (displayed as masked) + */ +@Entity({ name: 'system_settings', schema: 'core_settings' }) +@Unique(['key']) +export class SystemSetting { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'key', type: 'varchar', length: 100 }) + key: string; + + @Column({ name: 'value', type: 'jsonb' }) + value: any; + + @Column({ + name: 'data_type', + type: 'varchar', + length: 20, + default: 'string', + }) + dataType: 'string' | 'number' | 'boolean' | 'json' | 'array' | 'secret'; + + @Index() + @Column({ name: 'category', type: 'varchar', length: 50 }) + category: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string | null; + + @Column({ name: 'is_public', type: 'boolean', default: false }) + isPublic: boolean; + + @Column({ name: 'is_editable', type: 'boolean', default: true }) + isEditable: boolean; + + @Column({ name: 'default_value', type: 'jsonb', nullable: true }) + defaultValue: any; + + @Column({ name: 'validation_rules', type: 'jsonb', default: '{}' }) + validationRules: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string | null; +} diff --git a/src/modules/settings/entities/tenant-setting.entity.ts b/src/modules/settings/entities/tenant-setting.entity.ts new file mode 100644 index 0000000..2792e86 --- /dev/null +++ b/src/modules/settings/entities/tenant-setting.entity.ts @@ -0,0 +1,54 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +/** + * Tenant Settings Entity (schema: core_settings.tenant_settings) + * + * Custom configuration settings per tenant. + * These settings override both system and plan defaults. + * + * Hierarchy: system_settings < plan_settings < tenant_settings + * + * RLS Policy: tenant_id = current_setting('app.current_tenant_id') + */ +@Entity({ name: 'tenant_settings', schema: 'core_settings' }) +@Unique(['tenantId', 'key']) +export class TenantSetting { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'key', type: 'varchar', length: 100 }) + key: string; + + @Column({ name: 'value', type: 'jsonb' }) + value: any; + + @Column({ + name: 'inherited_from', + type: 'varchar', + length: 20, + default: 'custom', + }) + inheritedFrom: 'system' | 'plan' | 'custom'; + + @Column({ name: 'is_overridden', type: 'boolean', default: true }) + isOverridden: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/settings/entities/user-preference.entity.ts b/src/modules/settings/entities/user-preference.entity.ts new file mode 100644 index 0000000..4525948 --- /dev/null +++ b/src/modules/settings/entities/user-preference.entity.ts @@ -0,0 +1,51 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +/** + * User Preferences Entity (schema: core_settings.user_preferences) + * + * Personal preferences for individual users. + * These are user-specific settings like theme, language, notification preferences. + * + * Common keys: + * - ui.theme: 'light' | 'dark' | 'system' + * - ui.language: ISO language code + * - ui.dateFormat: Date format string + * - ui.numberFormat: Number format locale + * - notifications.email: boolean + * - notifications.push: boolean + * - notifications.inApp: boolean + */ +@Entity({ name: 'user_preferences', schema: 'core_settings' }) +@Unique(['userId', 'key']) +export class UserPreference { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'key', type: 'varchar', length: 100 }) + key: string; + + @Column({ name: 'value', type: 'jsonb' }) + value: any; + + @Column({ name: 'synced_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + syncedAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/settings/index.ts b/src/modules/settings/index.ts new file mode 100644 index 0000000..faf1690 --- /dev/null +++ b/src/modules/settings/index.ts @@ -0,0 +1,15 @@ +// Module exports +export { SettingsModule, SettingsModuleOptions } from './settings.module'; +export { createSettingsRoutes } from './settings.routes'; + +// Entity exports +export * from './entities'; + +// Service exports +export * from './services'; + +// Controller exports +export * from './controllers'; + +// DTO exports +export * from './dto'; diff --git a/src/modules/settings/services/index.ts b/src/modules/settings/services/index.ts new file mode 100644 index 0000000..186dcdc --- /dev/null +++ b/src/modules/settings/services/index.ts @@ -0,0 +1,4 @@ +export { SystemSettingsService } from './system-settings.service'; +export { TenantSettingsService, EffectiveSetting } from './tenant-settings.service'; +export { UserPreferencesService } from './user-preferences.service'; +export { SettingsService, UserSettingsContext } from './settings.service'; diff --git a/src/modules/settings/services/settings.service.ts b/src/modules/settings/services/settings.service.ts new file mode 100644 index 0000000..1b0e82f --- /dev/null +++ b/src/modules/settings/services/settings.service.ts @@ -0,0 +1,215 @@ +import { SystemSettingsService } from './system-settings.service'; +import { TenantSettingsService, EffectiveSetting } from './tenant-settings.service'; +import { UserPreferencesService } from './user-preferences.service'; +import { SystemSetting, TenantSetting, UserPreference } from '../entities'; +import { logger } from '../../../shared/utils/logger.js'; + +/** + * Combined settings response for a user context + */ +export interface UserSettingsContext { + system: SystemSetting[]; + tenant: EffectiveSetting[]; + user: Record; +} + +/** + * SettingsService + * + * Orchestrator service that combines system, tenant, and user preferences + * to provide a unified settings interface. + * + * Features: + * - Get all settings for a user context + * - Resolve effective settings with proper inheritance + * - Validate settings against rules + */ +export class SettingsService { + constructor( + private readonly systemSettingsService: SystemSettingsService, + private readonly tenantSettingsService: TenantSettingsService, + private readonly userPreferencesService: UserPreferencesService + ) {} + + /** + * Get all settings for a user context + * Combines system, tenant effective, and user preferences + */ + async getSettingsContext( + userId: string, + tenantId: string, + planId?: string + ): Promise { + const [system, tenant, user] = await Promise.all([ + this.systemSettingsService.getPublicSettings(), + this.tenantSettingsService.getAllEffective(tenantId, planId), + this.userPreferencesService.getAllAsMap(userId), + ]); + + logger.debug('Settings context loaded', { + userId, + tenantId, + systemCount: system.length, + tenantCount: tenant.length, + userCount: Object.keys(user).length, + }); + + return { system, tenant, user }; + } + + /** + * Get a specific setting value with full inheritance resolution + */ + async getSetting( + key: string, + userId: string, + tenantId: string, + planId?: string + ): Promise { + // User preferences have highest priority for user-specific keys + if (key.startsWith('ui.') || key.startsWith('notifications.')) { + const userValue = await this.userPreferencesService.getValue(userId, key); + if (userValue !== null) { + return userValue; + } + } + + // Tenant settings next + const tenantSetting = await this.tenantSettingsService.getEffective( + tenantId, + key, + planId + ); + if (tenantSetting) { + return tenantSetting.value; + } + + // Fall back to system setting + const systemValue = await this.systemSettingsService.getValue(key); + return systemValue; + } + + /** + * Get multiple settings at once + */ + async getSettings( + keys: string[], + userId: string, + tenantId: string, + planId?: string + ): Promise> { + const results: Record = {}; + + await Promise.all( + keys.map(async (key) => { + results[key] = await this.getSetting(key, userId, tenantId, planId); + }) + ); + + return results; + } + + /** + * Validate a setting value against its validation rules + */ + async validateSetting(key: string, value: any): Promise<{ valid: boolean; errors: string[] }> { + const setting = await this.systemSettingsService.getByKey(key); + + if (!setting) { + return { valid: true, errors: [] }; + } + + const errors: string[] = []; + const rules = setting.validationRules || {}; + + // Type validation + if (setting.dataType === 'number' && typeof value !== 'number') { + errors.push(`Value must be a number`); + } + if (setting.dataType === 'boolean' && typeof value !== 'boolean') { + errors.push(`Value must be a boolean`); + } + if (setting.dataType === 'string' && typeof value !== 'string') { + errors.push(`Value must be a string`); + } + if (setting.dataType === 'array' && !Array.isArray(value)) { + errors.push(`Value must be an array`); + } + + // Range validation + if (rules.min !== undefined && typeof value === 'number' && value < rules.min) { + errors.push(`Value must be at least ${rules.min}`); + } + if (rules.max !== undefined && typeof value === 'number' && value > rules.max) { + errors.push(`Value must be at most ${rules.max}`); + } + + // Length validation + if (rules.minLength !== undefined && typeof value === 'string' && value.length < rules.minLength) { + errors.push(`Value must be at least ${rules.minLength} characters`); + } + if (rules.maxLength !== undefined && typeof value === 'string' && value.length > rules.maxLength) { + errors.push(`Value must be at most ${rules.maxLength} characters`); + } + + // Enum validation + if (rules.enum && !rules.enum.includes(value)) { + errors.push(`Value must be one of: ${rules.enum.join(', ')}`); + } + + // Pattern validation + if (rules.pattern && typeof value === 'string') { + const regex = new RegExp(rules.pattern); + if (!regex.test(value)) { + errors.push(`Value does not match required pattern`); + } + } + + return { + valid: errors.length === 0, + errors, + }; + } + + /** + * Clear all caches + */ + clearCache(): void { + this.systemSettingsService.clearCache(); + logger.info('Settings cache cleared'); + } + + /** + * Initialize settings for a new user + */ + async initializeUser(userId: string): Promise { + await this.userPreferencesService.initializeDefaults(userId); + logger.info('User settings initialized', { userId }); + } + + /** + * Get settings by category across all levels + */ + async getByCategory( + category: string, + userId: string, + tenantId: string, + planId?: string + ): Promise<{ + system: SystemSetting[]; + tenant: EffectiveSetting[]; + user: Record; + }> { + const [systemAll, tenantAll, userAll] = await Promise.all([ + this.systemSettingsService.getByCategory(category), + this.tenantSettingsService.getAllEffective(tenantId, planId), + this.userPreferencesService.getGroupedByCategory(userId), + ]); + + return { + system: systemAll, + tenant: tenantAll.filter((s) => s.category === category), + user: userAll[category] || {}, + }; + } +} diff --git a/src/modules/settings/services/system-settings.service.ts b/src/modules/settings/services/system-settings.service.ts new file mode 100644 index 0000000..866ea61 --- /dev/null +++ b/src/modules/settings/services/system-settings.service.ts @@ -0,0 +1,294 @@ +import { Repository, ILike, FindOptionsWhere } from 'typeorm'; +import { SystemSetting } from '../entities'; +import { UpdateSystemSettingDto, CreateSystemSettingDto, SystemSettingsFiltersDto } from '../dto'; +import { logger } from '../../../shared/utils/logger.js'; + +/** + * Cache entry for system settings + */ +interface CacheEntry { + value: any; + expiresAt: number; +} + +/** + * SystemSettingsService + * + * Manages global system settings that apply across all tenants. + * Implements in-memory caching for performance optimization. + * + * Features: + * - CRUD operations for system settings + * - In-memory cache with TTL + * - Category-based filtering + * - Reset to default values + */ +export class SystemSettingsService { + private cache: Map = new Map(); + private cacheTtlMs: number = 5 * 60 * 1000; // 5 minutes default + + constructor(private readonly settingRepository: Repository) {} + + /** + * Sets the cache TTL in milliseconds + */ + setCacheTtl(ttlMs: number): void { + this.cacheTtlMs = ttlMs; + } + + /** + * Clears the entire cache + */ + clearCache(): void { + this.cache.clear(); + logger.debug('System settings cache cleared'); + } + + /** + * Clears a specific key from cache + */ + clearCacheKey(key: string): void { + this.cache.delete(key); + this.cache.delete(`all:${key}`); + logger.debug('System settings cache key cleared', { key }); + } + + /** + * Get all system settings with optional filtering + */ + async getAll(filters: SystemSettingsFiltersDto = {}): Promise { + const { category, isPublic, isEditable, search } = filters; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = {}; + + if (category) { + baseWhere.category = category; + } + + if (isPublic !== undefined) { + baseWhere.isPublic = isPublic; + } + + if (isEditable !== undefined) { + baseWhere.isEditable = isEditable; + } + + if (search) { + where.push( + { ...baseWhere, key: ILike(`%${search}%`) }, + { ...baseWhere, description: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const settings = await this.settingRepository.find({ + where: where.length > 0 ? where : undefined, + order: { category: 'ASC', key: 'ASC' }, + }); + + return settings; + } + + /** + * Get all public system settings (for unauthenticated access) + */ + async getPublicSettings(): Promise { + const cacheKey = 'public:all'; + const cached = this.getFromCache(cacheKey); + if (cached !== undefined) { + return cached; + } + + const settings = await this.settingRepository.find({ + where: { isPublic: true }, + order: { category: 'ASC', key: 'ASC' }, + }); + + this.setCache(cacheKey, settings); + return settings; + } + + /** + * Get settings by category + */ + async getByCategory(category: string): Promise { + const cacheKey = `category:${category}`; + const cached = this.getFromCache(cacheKey); + if (cached !== undefined) { + return cached; + } + + const settings = await this.settingRepository.find({ + where: { category }, + order: { key: 'ASC' }, + }); + + this.setCache(cacheKey, settings); + return settings; + } + + /** + * Get a single setting by key + */ + async getByKey(key: string): Promise { + const cacheKey = `key:${key}`; + const cached = this.getFromCache(cacheKey); + if (cached !== undefined) { + return cached; + } + + const setting = await this.settingRepository.findOne({ + where: { key }, + }); + + if (setting) { + this.setCache(cacheKey, setting); + } + + return setting; + } + + /** + * Get the value of a setting by key + */ + async getValue(key: string): Promise { + const setting = await this.getByKey(key); + return setting?.value ?? null; + } + + /** + * Update a system setting by key + */ + async update( + key: string, + dto: UpdateSystemSettingDto, + updatedBy?: string + ): Promise { + const setting = await this.settingRepository.findOne({ where: { key } }); + + if (!setting) { + logger.warn('System setting not found for update', { key }); + return null; + } + + if (!setting.isEditable) { + logger.warn('Attempted to update non-editable setting', { key }); + throw new Error(`Setting '${key}' is not editable`); + } + + Object.assign(setting, { + ...dto, + updatedBy, + }); + + const updated = await this.settingRepository.save(setting); + + this.clearCacheKey(key); + this.cache.delete('public:all'); + this.cache.delete(`category:${setting.category}`); + + logger.info('System setting updated', { key, updatedBy }); + + return updated; + } + + /** + * Reset a setting to its default value + */ + async reset(key: string, updatedBy?: string): Promise { + const setting = await this.settingRepository.findOne({ where: { key } }); + + if (!setting) { + logger.warn('System setting not found for reset', { key }); + return null; + } + + if (setting.defaultValue === null || setting.defaultValue === undefined) { + logger.warn('Setting has no default value', { key }); + throw new Error(`Setting '${key}' has no default value`); + } + + setting.value = setting.defaultValue; + setting.updatedBy = updatedBy ?? null; + + const updated = await this.settingRepository.save(setting); + + this.clearCacheKey(key); + this.cache.delete('public:all'); + this.cache.delete(`category:${setting.category}`); + + logger.info('System setting reset to default', { key, updatedBy }); + + return updated; + } + + /** + * Create a new system setting (admin only) + */ + async create(dto: CreateSystemSettingDto, createdBy?: string): Promise { + const existing = await this.settingRepository.findOne({ where: { key: dto.key } }); + if (existing) { + throw new Error(`Setting with key '${dto.key}' already exists`); + } + + const setting = this.settingRepository.create({ + ...dto, + updatedBy: createdBy, + }); + + const created = await this.settingRepository.save(setting); + + this.clearCache(); + logger.info('System setting created', { key: dto.key, createdBy }); + + return created; + } + + /** + * Get all unique categories + */ + async getCategories(): Promise { + const cacheKey = 'categories:all'; + const cached = this.getFromCache(cacheKey); + if (cached !== undefined) { + return cached; + } + + const result = await this.settingRepository + .createQueryBuilder('setting') + .select('DISTINCT setting.category', 'category') + .orderBy('setting.category', 'ASC') + .getRawMany(); + + const categories = result.map((r: { category: string }) => r.category); + this.setCache(cacheKey, categories); + + return categories; + } + + /** + * Cache helper: get from cache if not expired + */ + private getFromCache(key: string): any { + const entry = this.cache.get(key); + if (entry && entry.expiresAt > Date.now()) { + return entry.value; + } + if (entry) { + this.cache.delete(key); + } + return undefined; + } + + /** + * Cache helper: set value in cache with TTL + */ + private setCache(key: string, value: any): void { + this.cache.set(key, { + value, + expiresAt: Date.now() + this.cacheTtlMs, + }); + } +} diff --git a/src/modules/settings/services/tenant-settings.service.ts b/src/modules/settings/services/tenant-settings.service.ts new file mode 100644 index 0000000..1d92624 --- /dev/null +++ b/src/modules/settings/services/tenant-settings.service.ts @@ -0,0 +1,309 @@ +import { Repository, FindOptionsWhere, ILike } from 'typeorm'; +import { TenantSetting, SystemSetting, PlanSetting } from '../entities'; +import { UpdateTenantSettingDto, CreateTenantSettingDto, TenantSettingsFiltersDto } from '../dto'; +import { logger } from '../../../shared/utils/logger.js'; + +/** + * Effective setting result combining value with source information + */ +export interface EffectiveSetting { + key: string; + value: any; + source: 'system' | 'plan' | 'tenant'; + isOverridden: boolean; + category?: string; + description?: string; +} + +/** + * TenantSettingsService + * + * Manages tenant-specific settings with inheritance from system and plan defaults. + * + * Inheritance hierarchy: + * 1. Tenant Settings (highest priority) + * 2. Plan Settings (subscription plan defaults) + * 3. System Settings (lowest priority, global defaults) + * + * Features: + * - Get effective settings with inheritance resolution + * - Override system/plan settings at tenant level + * - Reset to inherited values + */ +export class TenantSettingsService { + constructor( + private readonly tenantSettingRepository: Repository, + private readonly systemSettingRepository: Repository, + private readonly planSettingRepository: Repository + ) {} + + /** + * Get the effective value for a setting key, applying inheritance + */ + async getEffective(tenantId: string, key: string, planId?: string): Promise { + // 1. Check tenant override first + const tenantSetting = await this.tenantSettingRepository.findOne({ + where: { tenantId, key, isOverridden: true }, + }); + + if (tenantSetting) { + return { + key, + value: tenantSetting.value, + source: 'tenant', + isOverridden: true, + }; + } + + // 2. Check plan settings if planId provided + if (planId) { + const planSetting = await this.planSettingRepository.findOne({ + where: { planId, key }, + }); + + if (planSetting) { + return { + key, + value: planSetting.value, + source: 'plan', + isOverridden: false, + }; + } + } + + // 3. Fall back to system setting + const systemSetting = await this.systemSettingRepository.findOne({ + where: { key }, + }); + + if (systemSetting) { + return { + key, + value: systemSetting.value ?? systemSetting.defaultValue, + source: 'system', + isOverridden: false, + category: systemSetting.category, + description: systemSetting.description ?? undefined, + }; + } + + return null; + } + + /** + * Get all effective settings for a tenant, merging all sources + */ + async getAllEffective(tenantId: string, planId?: string): Promise { + const effectiveSettings: Map = new Map(); + + // 1. Start with system settings (public ones) + const systemSettings = await this.systemSettingRepository.find({ + where: { isPublic: true }, + order: { category: 'ASC', key: 'ASC' }, + }); + + for (const setting of systemSettings) { + effectiveSettings.set(setting.key, { + key: setting.key, + value: setting.value ?? setting.defaultValue, + source: 'system', + isOverridden: false, + category: setting.category, + description: setting.description ?? undefined, + }); + } + + // 2. Apply plan settings (if planId provided) + if (planId) { + const planSettings = await this.planSettingRepository.find({ + where: { planId }, + }); + + for (const setting of planSettings) { + const existing = effectiveSettings.get(setting.key); + effectiveSettings.set(setting.key, { + key: setting.key, + value: setting.value, + source: 'plan', + isOverridden: false, + category: existing?.category, + description: existing?.description, + }); + } + } + + // 3. Apply tenant overrides + const tenantSettings = await this.tenantSettingRepository.find({ + where: { tenantId, isOverridden: true }, + }); + + for (const setting of tenantSettings) { + const existing = effectiveSettings.get(setting.key); + effectiveSettings.set(setting.key, { + key: setting.key, + value: setting.value, + source: 'tenant', + isOverridden: true, + category: existing?.category, + description: existing?.description, + }); + } + + return Array.from(effectiveSettings.values()).sort((a, b) => { + const catCompare = (a.category || '').localeCompare(b.category || ''); + if (catCompare !== 0) return catCompare; + return a.key.localeCompare(b.key); + }); + } + + /** + * Get tenant setting by key (direct, without inheritance) + */ + async get(tenantId: string, key: string): Promise { + return this.tenantSettingRepository.findOne({ + where: { tenantId, key }, + }); + } + + /** + * Get all tenant settings with optional filtering + */ + async getAll( + tenantId: string, + filters: TenantSettingsFiltersDto = {} + ): Promise { + const { inheritedFrom, isOverridden, search } = filters; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { tenantId }; + + if (inheritedFrom) { + baseWhere.inheritedFrom = inheritedFrom; + } + + if (isOverridden !== undefined) { + baseWhere.isOverridden = isOverridden; + } + + if (search) { + where.push({ ...baseWhere, key: ILike(`%${search}%`) }); + } else { + where.push(baseWhere); + } + + return this.tenantSettingRepository.find({ + where, + order: { key: 'ASC' }, + }); + } + + /** + * Update or create a tenant setting (upsert) + */ + async update( + tenantId: string, + key: string, + dto: UpdateTenantSettingDto + ): Promise { + let setting = await this.tenantSettingRepository.findOne({ + where: { tenantId, key }, + }); + + if (setting) { + Object.assign(setting, dto); + const updated = await this.tenantSettingRepository.save(setting); + logger.info('Tenant setting updated', { tenantId, key }); + return updated; + } + + // Create new setting + setting = this.tenantSettingRepository.create({ + tenantId, + key, + value: dto.value, + inheritedFrom: 'custom', + isOverridden: dto.isOverridden ?? true, + }); + + const created = await this.tenantSettingRepository.save(setting); + logger.info('Tenant setting created', { tenantId, key }); + return created; + } + + /** + * Create a new tenant setting + */ + async create(tenantId: string, dto: CreateTenantSettingDto): Promise { + const existing = await this.tenantSettingRepository.findOne({ + where: { tenantId, key: dto.key }, + }); + + if (existing) { + throw new Error(`Setting '${dto.key}' already exists for this tenant`); + } + + const setting = this.tenantSettingRepository.create({ + ...dto, + tenantId, + inheritedFrom: dto.inheritedFrom ?? 'custom', + isOverridden: dto.isOverridden ?? true, + }); + + const created = await this.tenantSettingRepository.save(setting); + logger.info('Tenant setting created', { tenantId, key: dto.key }); + return created; + } + + /** + * Reset a tenant setting to inherited value + */ + async reset(tenantId: string, key: string): Promise { + const result = await this.tenantSettingRepository.delete({ tenantId, key }); + const deleted = (result.affected ?? 0) > 0; + + if (deleted) { + logger.info('Tenant setting reset to inherited', { tenantId, key }); + } + + return deleted; + } + + /** + * Bulk update tenant settings + */ + async bulkUpdate( + tenantId: string, + settings: Array<{ key: string; value: any; isOverridden?: boolean }> + ): Promise { + const results: TenantSetting[] = []; + + for (const { key, value, isOverridden } of settings) { + const updated = await this.update(tenantId, key, { + value, + isOverridden: isOverridden ?? true, + }); + results.push(updated); + } + + logger.info('Tenant settings bulk updated', { + tenantId, + count: settings.length, + }); + + return results; + } + + /** + * Delete a tenant setting override + */ + async delete(tenantId: string, key: string): Promise { + const result = await this.tenantSettingRepository.delete({ tenantId, key }); + const deleted = (result.affected ?? 0) > 0; + + if (deleted) { + logger.info('Tenant setting deleted', { tenantId, key }); + } + + return deleted; + } +} diff --git a/src/modules/settings/services/user-preferences.service.ts b/src/modules/settings/services/user-preferences.service.ts new file mode 100644 index 0000000..3094506 --- /dev/null +++ b/src/modules/settings/services/user-preferences.service.ts @@ -0,0 +1,305 @@ +import { Repository, FindOptionsWhere, ILike } from 'typeorm'; +import { UserPreference } from '../entities'; +import { + UpdateUserPreferenceDto, + BulkUpdateUserPreferencesDto, + SetUserPreferencesDto, + UserPreferencesFiltersDto, +} from '../dto'; +import { logger } from '../../../shared/utils/logger.js'; + +/** + * Default user preferences + */ +const DEFAULT_PREFERENCES: Record = { + 'ui.theme': 'system', + 'ui.language': 'es-MX', + 'ui.dateFormat': 'DD/MM/YYYY', + 'ui.numberFormat': 'es-MX', + 'notifications.email': true, + 'notifications.push': true, + 'notifications.inApp': true, +}; + +/** + * UserPreferencesService + * + * Manages personal preferences for individual users. + * + * Features: + * - CRUD operations for user preferences + * - Bulk update support + * - Default values for new users + * - Sync timestamp tracking + */ +export class UserPreferencesService { + constructor(private readonly preferenceRepository: Repository) {} + + /** + * Get all preferences for a user + */ + async getAll( + userId: string, + filters: UserPreferencesFiltersDto = {} + ): Promise { + const { category, search } = filters; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { userId }; + + if (search) { + where.push({ ...baseWhere, key: ILike(`%${search}%`) }); + } else { + where.push(baseWhere); + } + + const preferences = await this.preferenceRepository.find({ + where, + order: { key: 'ASC' }, + }); + + // If filtering by category, filter in memory (keys start with category.) + if (category) { + const prefix = `${category}.`; + return preferences.filter((p) => p.key.startsWith(prefix)); + } + + return preferences; + } + + /** + * Get all preferences as a key-value map + */ + async getAllAsMap(userId: string): Promise> { + const preferences = await this.preferenceRepository.find({ + where: { userId }, + }); + + const map: Record = {}; + for (const pref of preferences) { + map[pref.key] = pref.value; + } + + // Fill in defaults for missing keys + for (const [key, defaultValue] of Object.entries(DEFAULT_PREFERENCES)) { + if (map[key] === undefined) { + map[key] = defaultValue; + } + } + + return map; + } + + /** + * Get a single preference by key + */ + async get(userId: string, key: string): Promise { + return this.preferenceRepository.findOne({ + where: { userId, key }, + }); + } + + /** + * Get preference value with default fallback + */ + async getValue(userId: string, key: string): Promise { + const preference = await this.get(userId, key); + if (preference) { + return preference.value; + } + return DEFAULT_PREFERENCES[key] ?? null; + } + + /** + * Update or create a single preference + */ + async update( + userId: string, + key: string, + dto: UpdateUserPreferenceDto + ): Promise { + let preference = await this.preferenceRepository.findOne({ + where: { userId, key }, + }); + + if (preference) { + preference.value = dto.value; + preference.syncedAt = new Date(); + const updated = await this.preferenceRepository.save(preference); + logger.debug('User preference updated', { userId, key }); + return updated; + } + + // Create new preference + preference = this.preferenceRepository.create({ + userId, + key, + value: dto.value, + syncedAt: new Date(), + }); + + const created = await this.preferenceRepository.save(preference); + logger.debug('User preference created', { userId, key }); + return created; + } + + /** + * Bulk update multiple preferences + */ + async bulkUpdate( + userId: string, + dto: BulkUpdateUserPreferencesDto + ): Promise { + const results: UserPreference[] = []; + + for (const { key, value } of dto.preferences) { + const updated = await this.update(userId, key, { value }); + results.push(updated); + } + + logger.info('User preferences bulk updated', { + userId, + count: dto.preferences.length, + }); + + return results; + } + + /** + * Set all standard preferences at once + */ + async setPreferences( + userId: string, + dto: SetUserPreferencesDto + ): Promise { + const preferences: Array<{ key: string; value: any }> = []; + + if (dto.theme !== undefined) { + preferences.push({ key: 'ui.theme', value: dto.theme }); + } + if (dto.language !== undefined) { + preferences.push({ key: 'ui.language', value: dto.language }); + } + if (dto.dateFormat !== undefined) { + preferences.push({ key: 'ui.dateFormat', value: dto.dateFormat }); + } + if (dto.numberFormat !== undefined) { + preferences.push({ key: 'ui.numberFormat', value: dto.numberFormat }); + } + if (dto.notificationsEmail !== undefined) { + preferences.push({ key: 'notifications.email', value: dto.notificationsEmail }); + } + if (dto.notificationsPush !== undefined) { + preferences.push({ key: 'notifications.push', value: dto.notificationsPush }); + } + if (dto.notificationsInApp !== undefined) { + preferences.push({ key: 'notifications.inApp', value: dto.notificationsInApp }); + } + + return this.bulkUpdate(userId, { preferences }); + } + + /** + * Reset a preference to default value + */ + async reset(userId: string, key: string): Promise { + const defaultValue = DEFAULT_PREFERENCES[key]; + + if (defaultValue === undefined) { + // No default, delete the preference + const result = await this.preferenceRepository.delete({ userId, key }); + if ((result.affected ?? 0) > 0) { + logger.info('User preference deleted (no default)', { userId, key }); + } + return null; + } + + // Reset to default value + return this.update(userId, key, { value: defaultValue }); + } + + /** + * Reset all preferences to defaults + */ + async resetAll(userId: string): Promise { + // Delete all existing preferences + await this.preferenceRepository.delete({ userId }); + + // Create with defaults + const results: UserPreference[] = []; + for (const [key, value] of Object.entries(DEFAULT_PREFERENCES)) { + const preference = this.preferenceRepository.create({ + userId, + key, + value, + syncedAt: new Date(), + }); + results.push(await this.preferenceRepository.save(preference)); + } + + logger.info('User preferences reset to defaults', { userId }); + return results; + } + + /** + * Delete a specific preference + */ + async delete(userId: string, key: string): Promise { + const result = await this.preferenceRepository.delete({ userId, key }); + const deleted = (result.affected ?? 0) > 0; + + if (deleted) { + logger.debug('User preference deleted', { userId, key }); + } + + return deleted; + } + + /** + * Initialize default preferences for a new user + */ + async initializeDefaults(userId: string): Promise { + const existing = await this.preferenceRepository.find({ + where: { userId }, + }); + + if (existing.length > 0) { + logger.debug('User already has preferences', { userId, count: existing.length }); + return existing; + } + + const results: UserPreference[] = []; + for (const [key, value] of Object.entries(DEFAULT_PREFERENCES)) { + const preference = this.preferenceRepository.create({ + userId, + key, + value, + syncedAt: new Date(), + }); + results.push(await this.preferenceRepository.save(preference)); + } + + logger.info('User preferences initialized with defaults', { userId }); + return results; + } + + /** + * Get preferences grouped by category + */ + async getGroupedByCategory(userId: string): Promise>> { + const preferences = await this.getAllAsMap(userId); + const grouped: Record> = {}; + + for (const [key, value] of Object.entries(preferences)) { + const [category, ...rest] = key.split('.'); + const subKey = rest.join('.'); + + if (!grouped[category]) { + grouped[category] = {}; + } + grouped[category][subKey] = value; + } + + return grouped; + } +} diff --git a/src/modules/settings/settings.module.ts b/src/modules/settings/settings.module.ts new file mode 100644 index 0000000..068f300 --- /dev/null +++ b/src/modules/settings/settings.module.ts @@ -0,0 +1,130 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { + SystemSettingsService, + TenantSettingsService, + UserPreferencesService, + SettingsService, +} from './services'; +import { + SystemSettingsController, + TenantSettingsController, + UserPreferencesController, +} from './controllers'; +import { + SystemSetting, + PlanSetting, + TenantSetting, + UserPreference, +} from './entities'; + +/** + * Options for configuring the Settings module + */ +export interface SettingsModuleOptions { + dataSource: DataSource; + basePath?: string; + cacheTtlMs?: number; +} + +/** + * SettingsModule + * + * Complete settings management module for multi-tenant applications. + * + * Features: + * - System settings (global configuration) + * - Plan settings (subscription-based defaults) + * - Tenant settings (organization overrides) + * - User preferences (personal settings) + * + * Settings inheritance: system -> plan -> tenant -> user + * + * Usage: + * ```typescript + * const settingsModule = new SettingsModule({ + * dataSource: AppDataSource, + * basePath: '/api/v1', + * }); + * + * app.use(settingsModule.router); + * ``` + */ +export class SettingsModule { + public router: Router; + public systemSettingsService: SystemSettingsService; + public tenantSettingsService: TenantSettingsService; + public userPreferencesService: UserPreferencesService; + public settingsService: SettingsService; + + private dataSource: DataSource; + private basePath: string; + private cacheTtlMs: number; + + constructor(options: SettingsModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.cacheTtlMs = options.cacheTtlMs || 5 * 60 * 1000; // 5 minutes default + + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + /** + * Initialize all services with their repositories + */ + private initializeServices(): void { + const systemSettingRepository = this.dataSource.getRepository(SystemSetting); + const planSettingRepository = this.dataSource.getRepository(PlanSetting); + const tenantSettingRepository = this.dataSource.getRepository(TenantSetting); + const userPreferenceRepository = this.dataSource.getRepository(UserPreference); + + // Create services + this.systemSettingsService = new SystemSettingsService(systemSettingRepository); + this.systemSettingsService.setCacheTtl(this.cacheTtlMs); + + this.tenantSettingsService = new TenantSettingsService( + tenantSettingRepository, + systemSettingRepository, + planSettingRepository + ); + + this.userPreferencesService = new UserPreferencesService(userPreferenceRepository); + + // Create orchestrator service + this.settingsService = new SettingsService( + this.systemSettingsService, + this.tenantSettingsService, + this.userPreferencesService + ); + } + + /** + * Initialize routes with controllers + */ + private initializeRoutes(): void { + const systemSettingsController = new SystemSettingsController(this.systemSettingsService); + const tenantSettingsController = new TenantSettingsController(this.tenantSettingsService); + const userPreferencesController = new UserPreferencesController(this.userPreferencesService); + + // Mount routes + this.router.use(`${this.basePath}/settings/system`, systemSettingsController.router); + this.router.use(`${this.basePath}/settings/tenant`, tenantSettingsController.router); + this.router.use(`${this.basePath}/settings/user`, userPreferencesController.router); + } + + /** + * Get all entities managed by this module + */ + static getEntities(): Function[] { + return [SystemSetting, PlanSetting, TenantSetting, UserPreference]; + } + + /** + * Clear all caches in the module + */ + clearCache(): void { + this.systemSettingsService.clearCache(); + } +} diff --git a/src/modules/settings/settings.routes.ts b/src/modules/settings/settings.routes.ts new file mode 100644 index 0000000..c25aaa7 --- /dev/null +++ b/src/modules/settings/settings.routes.ts @@ -0,0 +1,62 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { + SystemSetting, + PlanSetting, + TenantSetting, + UserPreference, +} from './entities'; +import { + SystemSettingsService, + TenantSettingsService, + UserPreferencesService, +} from './services'; +import { + SystemSettingsController, + TenantSettingsController, + UserPreferencesController, +} from './controllers'; + +/** + * Creates and configures the settings routes + * + * Routes: + * - /api/v1/settings/system/* - System settings (global configuration) + * - /api/v1/settings/tenant/* - Tenant settings (organization overrides) + * - /api/v1/settings/user/* - User preferences (personal settings) + * + * @param dataSource - TypeORM DataSource instance + * @returns Configured Express Router + */ +export function createSettingsRoutes(dataSource: DataSource): Router { + const router = Router(); + + // Initialize repositories + const systemSettingRepository = dataSource.getRepository(SystemSetting); + const planSettingRepository = dataSource.getRepository(PlanSetting); + const tenantSettingRepository = dataSource.getRepository(TenantSetting); + const userPreferenceRepository = dataSource.getRepository(UserPreference); + + // Initialize services + const systemSettingsService = new SystemSettingsService(systemSettingRepository); + const tenantSettingsService = new TenantSettingsService( + tenantSettingRepository, + systemSettingRepository, + planSettingRepository + ); + const userPreferencesService = new UserPreferencesService(userPreferenceRepository); + + // Initialize controllers + const systemSettingsController = new SystemSettingsController(systemSettingsService); + const tenantSettingsController = new TenantSettingsController(tenantSettingsService); + const userPreferencesController = new UserPreferencesController(userPreferencesService); + + // Mount routes + router.use('/system', systemSettingsController.router); + router.use('/tenant', tenantSettingsController.router); + router.use('/user', userPreferencesController.router); + + return router; +} + +export default createSettingsRoutes;