From 22b8e93d5586d0e629b6051e80a76d52d4562904 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Sat, 31 Jan 2026 01:44:26 -0600 Subject: [PATCH] feat(settings): Add services and controllers for settings module - SystemSettingService: Global system-wide settings with CRUD, get/set by key, validation rules, and bulk operations - PlanSettingService: Plan-level defaults with inheritance support and copy between plans functionality - TenantSettingService: Tenant-specific settings with hierarchy (system < plan < tenant), effective value resolution, and override management - UserPreferenceService: Personal preferences with defaults, sync support, and cross-user operations - SettingsController: Unified REST endpoints for all settings types with proper authentication and authorization Implements hierarchical inheritance: system_settings < plan_settings < tenant_settings < user_preferences. All services follow ServiceContext pattern with tenantId for multi-tenant support. Co-Authored-By: Claude Opus 4.5 --- src/modules/settings/controllers/index.ts | 7 + .../controllers/settings.controller.ts | 942 ++++++++++++++++++ src/modules/settings/index.ts | 17 + src/modules/settings/services/index.ts | 37 + .../settings/services/plan-setting.service.ts | 264 +++++ .../services/system-setting.service.ts | 369 +++++++ .../services/tenant-setting.service.ts | 377 +++++++ .../services/user-preference.service.ts | 392 ++++++++ 8 files changed, 2405 insertions(+) create mode 100644 src/modules/settings/controllers/index.ts create mode 100644 src/modules/settings/controllers/settings.controller.ts create mode 100644 src/modules/settings/index.ts create mode 100644 src/modules/settings/services/index.ts create mode 100644 src/modules/settings/services/plan-setting.service.ts create mode 100644 src/modules/settings/services/system-setting.service.ts create mode 100644 src/modules/settings/services/tenant-setting.service.ts create mode 100644 src/modules/settings/services/user-preference.service.ts diff --git a/src/modules/settings/controllers/index.ts b/src/modules/settings/controllers/index.ts new file mode 100644 index 0000000..def6509 --- /dev/null +++ b/src/modules/settings/controllers/index.ts @@ -0,0 +1,7 @@ +/** + * Settings Controllers - Export + * + * @module Settings + */ + +export { createSettingsController, default as SettingsController } from './settings.controller'; diff --git a/src/modules/settings/controllers/settings.controller.ts b/src/modules/settings/controllers/settings.controller.ts new file mode 100644 index 0000000..e5a383c --- /dev/null +++ b/src/modules/settings/controllers/settings.controller.ts @@ -0,0 +1,942 @@ +/** + * SettingsController - Unified Settings Management Controller + * + * REST endpoints for managing settings with hierarchical inheritance. + * Handles system, plan, tenant settings and user preferences. + * + * @module Settings + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { + SystemSettingService, + PlanSettingService, + TenantSettingService, + UserPreferenceService, + SystemSettingFilters, + TenantSettingFilters, +} from '../services'; +import { + SystemSetting, + PlanSetting, + TenantSetting, + UserPreference, +} from '../entities'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +export function createSettingsController(dataSource: DataSource): Router { + const router = Router(); + + // Repositories + const systemSettingRepository = dataSource.getRepository(SystemSetting); + const planSettingRepository = dataSource.getRepository(PlanSetting); + const tenantSettingRepository = dataSource.getRepository(TenantSetting); + const userPreferenceRepository = dataSource.getRepository(UserPreference); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Services + const systemSettingService = new SystemSettingService(systemSettingRepository); + const planSettingService = new PlanSettingService(planSettingRepository); + const tenantSettingService = new TenantSettingService( + tenantSettingRepository, + systemSettingRepository, + planSettingRepository + ); + const userPreferenceService = new UserPreferenceService(userPreferenceRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper to create service context + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + // ==================== EFFECTIVE SETTINGS ==================== + + /** + * GET /settings/effective + * Get all effective settings with inheritance applied + */ + router.get('/effective', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const planId = req.query.planId as string | undefined; + + const settings = await tenantSettingService.getEffectiveSettings(ctx, planId); + + res.status(200).json({ + success: true, + data: settings, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /settings/effective/:key + * Get effective value for a specific key + */ + router.get('/effective/:key', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const planId = req.query.planId as string | undefined; + + const value = await tenantSettingService.getEffectiveValue(ctx, req.params.key, planId); + + if (value === null) { + res.status(404).json({ error: 'Not Found', message: 'Setting not found' }); + return; + } + + res.status(200).json({ + success: true, + data: { key: req.params.key, value }, + }); + } catch (error) { + next(error); + } + }); + + // ==================== SYSTEM SETTINGS ==================== + + /** + * GET /settings/system + * Get system settings (admin only) + */ + router.get('/system', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); + + const filters: SystemSettingFilters = {}; + if (req.query.category) filters.category = req.query.category as string; + if (req.query.isPublic !== undefined) filters.isPublic = req.query.isPublic === 'true'; + if (req.query.search) filters.search = req.query.search as string; + + const result = await systemSettingService.findWithFilters(filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, + }, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /settings/system/public + * Get public system settings (no auth required) + */ + router.get('/system/public', async (_req: Request, res: Response, next: NextFunction): Promise => { + try { + const settings = await systemSettingService.getPublicSettings(); + res.status(200).json({ success: true, data: settings }); + } catch (error) { + next(error); + } + }); + + /** + * GET /settings/system/categories + * Get all system setting categories + */ + router.get('/system/categories', authMiddleware.authenticate, async (_req: Request, res: Response, next: NextFunction): Promise => { + try { + const categories = await systemSettingService.getCategories(); + res.status(200).json({ success: true, data: categories }); + } catch (error) { + next(error); + } + }); + + /** + * GET /settings/system/category/:category + * Get system settings by category + */ + router.get('/system/category/:category', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const settings = await systemSettingService.findByCategory(req.params.category); + res.status(200).json({ success: true, data: settings }); + } catch (error) { + next(error); + } + }); + + /** + * GET /settings/system/key/:key + * Get system setting by key + */ + router.get('/system/key/:key', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const setting = await systemSettingService.findByKey(req.params.key); + + if (!setting) { + res.status(404).json({ error: 'Not Found', message: 'System setting not found' }); + return; + } + + res.status(200).json({ success: true, data: setting }); + } catch (error) { + next(error); + } + }); + + /** + * POST /settings/system + * Create system setting (admin only) + */ + router.post('/system', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const { key, value, dataType, category, description, isPublic, isEditable, defaultValue, validationRules } = req.body; + + if (!key || !category) { + res.status(400).json({ error: 'Bad Request', message: 'key and category are required' }); + return; + } + + const setting = await systemSettingService.create(ctx, { + key, + value, + dataType, + category, + description, + isPublic, + isEditable, + defaultValue, + validationRules, + }); + + res.status(201).json({ success: true, data: setting }); + } catch (error) { + if (error instanceof Error && error.message.includes('already exists')) { + res.status(409).json({ error: 'Conflict', message: error.message }); + return; + } + next(error); + } + }); + + /** + * PUT /settings/system/:id + * Update system setting + */ + router.put('/system/:id', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const setting = await systemSettingService.update(ctx, req.params.id, req.body); + + if (!setting) { + res.status(404).json({ error: 'Not Found', message: 'System setting not found' }); + return; + } + + res.status(200).json({ success: true, data: setting }); + } catch (error) { + if (error instanceof Error && error.message.includes('not editable')) { + res.status(403).json({ error: 'Forbidden', message: error.message }); + return; + } + next(error); + } + }); + + /** + * PUT /settings/system/key/:key/value + * Update system setting value by key + */ + router.put('/system/key/:key/value', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const { value } = req.body; + + if (value === undefined) { + res.status(400).json({ error: 'Bad Request', message: 'value is required' }); + return; + } + + const setting = await systemSettingService.updateValueByKey(ctx, req.params.key, value); + + if (!setting) { + res.status(404).json({ error: 'Not Found', message: 'System setting not found' }); + return; + } + + res.status(200).json({ success: true, data: setting }); + } catch (error) { + if (error instanceof Error && (error.message.includes('not editable') || error.message.includes('must be'))) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /settings/system/key/:key/reset + * Reset system setting to default value + */ + router.post('/system/key/:key/reset', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const setting = await systemSettingService.resetToDefault(ctx, req.params.key); + + if (!setting) { + res.status(404).json({ error: 'Not Found', message: 'System setting not found or no default value' }); + return; + } + + res.status(200).json({ success: true, data: setting }); + } catch (error) { + next(error); + } + }); + + /** + * PUT /settings/system/bulk + * Bulk update system settings + */ + router.put('/system/bulk', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const { settings } = req.body; + + if (!Array.isArray(settings)) { + res.status(400).json({ error: 'Bad Request', message: 'settings array is required' }); + return; + } + + const result = await systemSettingService.updateMultiple(ctx, settings); + + res.status(200).json({ + success: true, + data: result, + message: `Updated ${result.updated} settings`, + }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /settings/system/:id + * Delete system setting + */ + router.delete('/system/:id', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const deleted = await systemSettingService.hardDelete(req.params.id); + + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'System setting not found' }); + return; + } + + res.status(200).json({ success: true, message: 'System setting deleted' }); + } catch (error) { + if (error instanceof Error && error.message.includes('cannot be deleted')) { + res.status(403).json({ error: 'Forbidden', message: error.message }); + return; + } + next(error); + } + }); + + // ==================== PLAN SETTINGS ==================== + + /** + * GET /settings/plan/:planId + * Get all settings for a plan + */ + router.get('/plan/:planId', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const settings = await planSettingService.findByPlan(req.params.planId); + res.status(200).json({ success: true, data: settings }); + } catch (error) { + next(error); + } + }); + + /** + * GET /settings/plan/:planId/key/:key + * Get plan setting by plan ID and key + */ + router.get('/plan/:planId/key/:key', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const setting = await planSettingService.findByPlanAndKey(req.params.planId, req.params.key); + + if (!setting) { + res.status(404).json({ error: 'Not Found', message: 'Plan setting not found' }); + return; + } + + res.status(200).json({ success: true, data: setting }); + } catch (error) { + next(error); + } + }); + + /** + * POST /settings/plan + * Create plan setting + */ + router.post('/plan', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const { planId, key, value } = req.body; + + if (!planId || !key) { + res.status(400).json({ error: 'Bad Request', message: 'planId and key are required' }); + return; + } + + const setting = await planSettingService.create(ctx, { planId, key, value }); + res.status(201).json({ success: true, data: setting }); + } catch (error) { + if (error instanceof Error && error.message.includes('already exists')) { + res.status(409).json({ error: 'Conflict', message: error.message }); + return; + } + next(error); + } + }); + + /** + * PUT /settings/plan/:planId/key/:key + * Update or create plan setting (upsert) + */ + router.put('/plan/:planId/key/:key', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const { value } = req.body; + + const setting = await planSettingService.upsert(ctx, req.params.planId, req.params.key, value); + res.status(200).json({ success: true, data: setting }); + } catch (error) { + next(error); + } + }); + + /** + * PUT /settings/plan/:planId/bulk + * Bulk update plan settings + */ + router.put('/plan/:planId/bulk', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const { settings } = req.body; + + if (!Array.isArray(settings)) { + res.status(400).json({ error: 'Bad Request', message: 'settings array is required' }); + return; + } + + const result = await planSettingService.updateMultiple(ctx, req.params.planId, settings); + + res.status(200).json({ + success: true, + data: result, + message: `Updated ${result.updated}, created ${result.created} settings`, + }); + } catch (error) { + next(error); + } + }); + + /** + * POST /settings/plan/:sourcePlanId/copy/:targetPlanId + * Copy settings from one plan to another + */ + router.post('/plan/:sourcePlanId/copy/:targetPlanId', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const overwrite = req.query.overwrite === 'true'; + + const result = await planSettingService.copyFromPlan( + ctx, + req.params.sourcePlanId, + req.params.targetPlanId, + overwrite + ); + + res.status(200).json({ + success: true, + data: result, + message: `Copied ${result.copied} settings, skipped ${result.skipped}`, + }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /settings/plan/:id + * Delete plan setting + */ + router.delete('/plan/:id', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const deleted = await planSettingService.hardDelete(req.params.id); + + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Plan setting not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Plan setting deleted' }); + } catch (error) { + next(error); + } + }); + + // ==================== TENANT SETTINGS ==================== + + /** + * GET /settings/tenant + * Get all tenant settings + */ + router.get('/tenant', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); + + const filters: TenantSettingFilters = {}; + if (req.query.inheritedFrom) filters.inheritedFrom = req.query.inheritedFrom as any; + if (req.query.isOverridden !== undefined) filters.isOverridden = req.query.isOverridden === 'true'; + if (req.query.search) filters.search = req.query.search as string; + + const result = await tenantSettingService.findWithFilters(ctx, filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, + }, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /settings/tenant/overridden + * Get only overridden tenant settings + */ + router.get('/tenant/overridden', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const settings = await tenantSettingService.getOverriddenSettings(ctx); + res.status(200).json({ success: true, data: settings }); + } catch (error) { + next(error); + } + }); + + /** + * GET /settings/tenant/key/:key + * Get tenant setting by key + */ + router.get('/tenant/key/:key', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const setting = await tenantSettingService.findByKey(ctx, req.params.key); + + if (!setting) { + res.status(404).json({ error: 'Not Found', message: 'Tenant setting not found' }); + return; + } + + res.status(200).json({ success: true, data: setting }); + } catch (error) { + next(error); + } + }); + + /** + * POST /settings/tenant + * Create tenant setting + */ + router.post('/tenant', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const { key, value, inheritedFrom, isOverridden } = req.body; + + if (!key) { + res.status(400).json({ error: 'Bad Request', message: 'key is required' }); + return; + } + + const setting = await tenantSettingService.create(ctx, { key, value, inheritedFrom, isOverridden }); + res.status(201).json({ success: true, data: setting }); + } catch (error) { + if (error instanceof Error && error.message.includes('already exists')) { + res.status(409).json({ error: 'Conflict', message: error.message }); + return; + } + next(error); + } + }); + + /** + * PUT /settings/tenant/key/:key + * Update or create tenant setting (upsert) + */ + router.put('/tenant/key/:key', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const { value } = req.body; + + const setting = await tenantSettingService.upsert(ctx, req.params.key, value); + res.status(200).json({ success: true, data: setting }); + } catch (error) { + next(error); + } + }); + + /** + * POST /settings/tenant/key/:key/reset + * Reset tenant setting to inherited value + */ + router.post('/tenant/key/:key/reset', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const setting = await tenantSettingService.resetToInherited(ctx, req.params.key); + + if (!setting) { + res.status(404).json({ error: 'Not Found', message: 'Tenant setting not found' }); + return; + } + + res.status(200).json({ success: true, data: setting }); + } catch (error) { + next(error); + } + }); + + /** + * PUT /settings/tenant/bulk + * Bulk update tenant settings + */ + router.put('/tenant/bulk', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const { settings } = req.body; + + if (!Array.isArray(settings)) { + res.status(400).json({ error: 'Bad Request', message: 'settings array is required' }); + return; + } + + const result = await tenantSettingService.updateMultiple(ctx, settings); + + res.status(200).json({ + success: true, + data: result, + message: `Updated ${result.updated}, created ${result.created} settings`, + }); + } catch (error) { + next(error); + } + }); + + /** + * POST /settings/tenant/initialize/:planId + * Initialize tenant settings from plan defaults + */ + router.post('/tenant/initialize/:planId', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const initialized = await tenantSettingService.initializeFromPlan(ctx, req.params.planId); + + res.status(200).json({ + success: true, + data: { initialized }, + message: `Initialized ${initialized} settings from plan`, + }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /settings/tenant/:id + * Delete tenant setting + */ + router.delete('/tenant/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const deleted = await tenantSettingService.hardDelete(ctx, req.params.id); + + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Tenant setting not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Tenant setting deleted' }); + } catch (error) { + next(error); + } + }); + + // ==================== USER PREFERENCES ==================== + + /** + * GET /settings/preferences + * Get current user's preferences + */ + router.get('/preferences', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.user?.sub) { + res.status(401).json({ error: 'Unauthorized', message: 'User ID required' }); + return; + } + + const preferences = await userPreferenceService.getUserPreferencesWithDefaults(req.user.sub); + res.status(200).json({ success: true, data: preferences }); + } catch (error) { + next(error); + } + }); + + /** + * GET /settings/preferences/raw + * Get current user's preferences without defaults + */ + router.get('/preferences/raw', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.user?.sub) { + res.status(401).json({ error: 'Unauthorized', message: 'User ID required' }); + return; + } + + const preferences = await userPreferenceService.findByUser(req.user.sub); + res.status(200).json({ success: true, data: preferences }); + } catch (error) { + next(error); + } + }); + + /** + * GET /settings/preferences/:key + * Get specific preference for current user + */ + router.get('/preferences/:key', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.user?.sub) { + res.status(401).json({ error: 'Unauthorized', message: 'User ID required' }); + return; + } + + const preference = await userPreferenceService.findByUserAndKey(req.user.sub, req.params.key); + + if (!preference) { + res.status(404).json({ error: 'Not Found', message: 'Preference not found' }); + return; + } + + res.status(200).json({ success: true, data: preference }); + } catch (error) { + next(error); + } + }); + + /** + * PUT /settings/preferences/:key + * Update or create preference for current user + */ + router.put('/preferences/:key', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.user?.sub) { + res.status(401).json({ error: 'Unauthorized', message: 'User ID required' }); + return; + } + + const { value } = req.body; + const preference = await userPreferenceService.upsert(req.user.sub, req.params.key, value); + res.status(200).json({ success: true, data: preference }); + } catch (error) { + next(error); + } + }); + + /** + * PUT /settings/preferences + * Bulk update preferences for current user + */ + router.put('/preferences', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.user?.sub) { + res.status(401).json({ error: 'Unauthorized', message: 'User ID required' }); + return; + } + + const { preferences } = req.body; + + if (!Array.isArray(preferences)) { + res.status(400).json({ error: 'Bad Request', message: 'preferences array is required' }); + return; + } + + const result = await userPreferenceService.updateMultiple(req.user.sub, preferences); + + res.status(200).json({ + success: true, + data: result, + message: `Updated ${result.updated}, created ${result.created} preferences`, + }); + } catch (error) { + next(error); + } + }); + + /** + * POST /settings/preferences/:key/reset + * Reset preference to default value + */ + router.post('/preferences/:key/reset', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.user?.sub) { + res.status(401).json({ error: 'Unauthorized', message: 'User ID required' }); + return; + } + + const preference = await userPreferenceService.resetToDefault(req.user.sub, req.params.key); + + if (!preference) { + res.status(404).json({ error: 'Not Found', message: 'No default value for this preference' }); + return; + } + + res.status(200).json({ success: true, data: preference }); + } catch (error) { + next(error); + } + }); + + /** + * POST /settings/preferences/reset-all + * Reset all preferences to defaults + */ + router.post('/preferences/reset-all', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.user?.sub) { + res.status(401).json({ error: 'Unauthorized', message: 'User ID required' }); + return; + } + + const count = await userPreferenceService.resetAllToDefaults(req.user.sub); + + res.status(200).json({ + success: true, + data: { reset: count }, + message: `Reset ${count} preferences to defaults`, + }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /settings/preferences/:key + * Delete a specific preference + */ + router.delete('/preferences/:key', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.user?.sub) { + res.status(401).json({ error: 'Unauthorized', message: 'User ID required' }); + return; + } + + const preference = await userPreferenceService.findByUserAndKey(req.user.sub, req.params.key); + + if (!preference) { + res.status(404).json({ error: 'Not Found', message: 'Preference not found' }); + return; + } + + await userPreferenceService.hardDelete(preference.id); + res.status(200).json({ success: true, message: 'Preference deleted' }); + } catch (error) { + next(error); + } + }); + + // ==================== ADMIN: USER PREFERENCES MANAGEMENT ==================== + + /** + * GET /settings/admin/preferences/user/:userId + * Get preferences for a specific user (admin only) + */ + router.get('/admin/preferences/user/:userId', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const preferences = await userPreferenceService.findByUser(req.params.userId); + res.status(200).json({ success: true, data: preferences }); + } catch (error) { + next(error); + } + }); + + /** + * PUT /settings/admin/preferences/user/:userId/:key + * Update preference for a specific user (admin only) + */ + router.put('/admin/preferences/user/:userId/:key', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { value } = req.body; + const preference = await userPreferenceService.upsert(req.params.userId, req.params.key, value); + res.status(200).json({ success: true, data: preference }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /settings/admin/preferences/user/:userId + * Delete all preferences for a user (admin only) + */ + router.delete('/admin/preferences/user/:userId', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const deleted = await userPreferenceService.deleteByUser(req.params.userId); + res.status(200).json({ + success: true, + data: { deleted }, + message: `Deleted ${deleted} preferences`, + }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createSettingsController; diff --git a/src/modules/settings/index.ts b/src/modules/settings/index.ts new file mode 100644 index 0000000..d4a98fa --- /dev/null +++ b/src/modules/settings/index.ts @@ -0,0 +1,17 @@ +/** + * Settings Module - Main Export + * + * Handles system settings, configurations, and user preferences with + * hierarchical inheritance: system < plan < tenant < user + * + * @module Settings + */ + +// Entities +export * from './entities'; + +// Services +export * from './services'; + +// Controllers +export * from './controllers'; diff --git a/src/modules/settings/services/index.ts b/src/modules/settings/services/index.ts new file mode 100644 index 0000000..594c417 --- /dev/null +++ b/src/modules/settings/services/index.ts @@ -0,0 +1,37 @@ +/** + * Settings Services - Export + * + * @module Settings + */ + +export { SystemSettingService } from './system-setting.service'; +export type { + CreateSystemSettingDto, + UpdateSystemSettingDto, + SystemSettingFilters, + SettingDataType, +} from './system-setting.service'; + +export { PlanSettingService } from './plan-setting.service'; +export type { + CreatePlanSettingDto, + UpdatePlanSettingDto, + PlanSettingFilters, +} from './plan-setting.service'; + +export { TenantSettingService } from './tenant-setting.service'; +export type { + CreateTenantSettingDto, + UpdateTenantSettingDto, + TenantSettingFilters, + EffectiveSettingResult, + InheritedFrom, +} from './tenant-setting.service'; + +export { UserPreferenceService, DEFAULT_PREFERENCES } from './user-preference.service'; +export type { + CreateUserPreferenceDto, + UpdateUserPreferenceDto, + UserPreferenceFilters, + DefaultPreferences, +} from './user-preference.service'; diff --git a/src/modules/settings/services/plan-setting.service.ts b/src/modules/settings/services/plan-setting.service.ts new file mode 100644 index 0000000..e47cd7c --- /dev/null +++ b/src/modules/settings/services/plan-setting.service.ts @@ -0,0 +1,264 @@ +/** + * PlanSettingService - Plan-level Configuration Management + * + * Manages default settings per subscription plan. + * Hierarchy: system_settings < plan_settings < tenant_settings + * + * @module Settings + */ + +import { Repository } from 'typeorm'; +import { PlanSetting } from '../entities'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface CreatePlanSettingDto { + planId: string; + key: string; + value: any; +} + +export interface UpdatePlanSettingDto { + value: any; +} + +export interface PlanSettingFilters { + planId?: string; + search?: string; +} + +export class PlanSettingService { + private repository: Repository; + + constructor(repository: Repository) { + this.repository = repository; + } + + /** + * Create a new plan setting + */ + async create(_ctx: ServiceContext, data: CreatePlanSettingDto): Promise { + const existing = await this.findByPlanAndKey(data.planId, data.key); + if (existing) { + throw new Error(`Plan setting with key '${data.key}' already exists for this plan`); + } + + const entity = this.repository.create({ + planId: data.planId, + key: data.key, + value: data.value, + }); + + return this.repository.save(entity); + } + + /** + * Find plan setting by ID + */ + async findById(id: string): Promise { + return this.repository.findOne({ + where: { id }, + }); + } + + /** + * Find plan setting by plan ID and key + */ + async findByPlanAndKey(planId: string, key: string): Promise { + return this.repository.findOne({ + where: { planId, key }, + }); + } + + /** + * Get value for a plan setting + */ + async getValue(planId: string, key: string): Promise { + const setting = await this.findByPlanAndKey(planId, key); + return setting?.value ?? null; + } + + /** + * Get all settings for a plan + */ + async findByPlan(planId: string): Promise { + return this.repository.find({ + where: { planId }, + order: { key: 'ASC' }, + }); + } + + /** + * Get all settings for a plan as key-value map + */ + async getPlanSettingsMap(planId: string): Promise> { + const settings = await this.findByPlan(planId); + const result: Record = {}; + for (const setting of settings) { + result[setting.key] = setting.value; + } + return result; + } + + /** + * Update plan setting + */ + async update(_ctx: ServiceContext, id: string, data: UpdatePlanSettingDto): Promise { + const entity = await this.findById(id); + if (!entity) { + return null; + } + + entity.value = data.value; + return this.repository.save(entity); + } + + /** + * Update or create plan setting by plan and key (upsert) + */ + async upsert(_ctx: ServiceContext, planId: string, key: string, value: any): Promise { + const existing = await this.findByPlanAndKey(planId, key); + + if (existing) { + existing.value = value; + return this.repository.save(existing); + } + + const entity = this.repository.create({ + planId, + key, + value, + }); + + return this.repository.save(entity); + } + + /** + * Find settings with filters + */ + async findWithFilters( + filters: PlanSettingFilters, + page = 1, + limit = 50 + ): Promise> { + const qb = this.repository.createQueryBuilder('ps'); + + if (filters.planId) { + qb.andWhere('ps.plan_id = :planId', { planId: filters.planId }); + } + if (filters.search) { + qb.andWhere('ps.key ILIKE :search', { search: `%${filters.search}%` }); + } + + const skip = (page - 1) * limit; + qb.orderBy('ps.plan_id', 'ASC') + .addOrderBy('ps.key', 'ASC') + .skip(skip) + .take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Bulk update plan settings + */ + async updateMultiple( + ctx: ServiceContext, + planId: string, + settings: { key: string; value: any }[] + ): Promise<{ updated: number; created: number; errors: string[] }> { + let updated = 0; + let created = 0; + const errors: string[] = []; + + for (const { key, value } of settings) { + try { + const existing = await this.findByPlanAndKey(planId, key); + if (existing) { + existing.value = value; + await this.repository.save(existing); + updated++; + } else { + await this.create(ctx, { planId, key, value }); + created++; + } + } catch (error) { + errors.push(`${key}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + return { updated, created, errors }; + } + + /** + * Delete plan setting (hard delete) + */ + async hardDelete(id: string): Promise { + const result = await this.repository.delete({ id }); + return (result.affected || 0) > 0; + } + + /** + * Delete all settings for a plan + */ + async deleteByPlan(planId: string): Promise { + const result = await this.repository.delete({ planId }); + return result.affected || 0; + } + + /** + * Copy settings from one plan to another + */ + async copyFromPlan( + ctx: ServiceContext, + sourcePlanId: string, + targetPlanId: string, + overwrite = false + ): Promise<{ copied: number; skipped: number }> { + const sourceSettings = await this.findByPlan(sourcePlanId); + let copied = 0; + let skipped = 0; + + for (const setting of sourceSettings) { + const existing = await this.findByPlanAndKey(targetPlanId, setting.key); + + if (existing && !overwrite) { + skipped++; + continue; + } + + if (existing) { + existing.value = setting.value; + await this.repository.save(existing); + } else { + await this.create(ctx, { + planId: targetPlanId, + key: setting.key, + value: setting.value, + }); + } + copied++; + } + + return { copied, skipped }; + } +} diff --git a/src/modules/settings/services/system-setting.service.ts b/src/modules/settings/services/system-setting.service.ts new file mode 100644 index 0000000..d8998de --- /dev/null +++ b/src/modules/settings/services/system-setting.service.ts @@ -0,0 +1,369 @@ +/** + * SystemSettingService - Global System Configuration Management + * + * Manages global system-wide settings across all tenants. + * These are the base defaults that can be overridden by plan/tenant settings. + * + * @module Settings + */ + +import { Repository } from 'typeorm'; +import { SystemSetting } from '../entities'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export type SettingDataType = 'string' | 'number' | 'boolean' | 'json' | 'array' | 'secret'; + +export interface CreateSystemSettingDto { + key: string; + value: any; + dataType?: SettingDataType; + category: string; + description?: string; + isPublic?: boolean; + isEditable?: boolean; + defaultValue?: any; + validationRules?: Record; +} + +export interface UpdateSystemSettingDto { + value?: any; + description?: string; + isPublic?: boolean; + isEditable?: boolean; + defaultValue?: any; + validationRules?: Record; +} + +export interface SystemSettingFilters { + category?: string; + isPublic?: boolean; + isEditable?: boolean; + search?: string; +} + +export class SystemSettingService { + private repository: Repository; + + constructor(repository: Repository) { + this.repository = repository; + } + + /** + * Create a new system setting + */ + async create(_ctx: ServiceContext, data: CreateSystemSettingDto): Promise { + const existing = await this.findByKey(data.key); + if (existing) { + throw new Error(`System setting with key '${data.key}' already exists`); + } + + this.validateValue(data.value, data.dataType || 'string', data.validationRules); + + const entity = this.repository.create({ + key: data.key, + value: data.value, + dataType: data.dataType || 'string', + category: data.category, + description: data.description || null, + isPublic: data.isPublic ?? false, + isEditable: data.isEditable ?? true, + defaultValue: data.defaultValue ?? null, + validationRules: data.validationRules || {}, + }); + + return this.repository.save(entity); + } + + /** + * Find system setting by ID + */ + async findById(id: string): Promise { + return this.repository.findOne({ + where: { id }, + }); + } + + /** + * Find system setting by key + */ + async findByKey(key: string): Promise { + return this.repository.findOne({ + where: { key }, + }); + } + + /** + * Get value by key + */ + async getValue(key: string): Promise { + const setting = await this.findByKey(key); + if (!setting) { + return null; + } + return setting.value; + } + + /** + * Get value with default fallback + */ + async getValueOrDefault(key: string, defaultValue: any): Promise { + const value = await this.getValue(key); + return value !== null ? value : defaultValue; + } + + /** + * Update system setting + */ + async update(ctx: ServiceContext, id: string, data: UpdateSystemSettingDto): Promise { + const entity = await this.findById(id); + if (!entity) { + return null; + } + + if (!entity.isEditable) { + throw new Error('This setting is not editable'); + } + + if (data.value !== undefined) { + this.validateValue(data.value, entity.dataType, data.validationRules ?? entity.validationRules); + } + + Object.assign(entity, { + ...data, + updatedBy: ctx.userId || null, + }); + + return this.repository.save(entity); + } + + /** + * Update value by key + */ + async updateValueByKey(ctx: ServiceContext, key: string, value: any): Promise { + const setting = await this.findByKey(key); + if (!setting) { + return null; + } + + if (!setting.isEditable) { + throw new Error('This setting is not editable'); + } + + this.validateValue(value, setting.dataType, setting.validationRules); + setting.value = value; + setting.updatedBy = ctx.userId || null; + + return this.repository.save(setting); + } + + /** + * Reset setting to default value + */ + async resetToDefault(ctx: ServiceContext, key: string): Promise { + const setting = await this.findByKey(key); + if (!setting || setting.defaultValue === null) { + return null; + } + + setting.value = setting.defaultValue; + setting.updatedBy = ctx.userId || null; + + return this.repository.save(setting); + } + + /** + * Find settings with filters + */ + async findWithFilters( + filters: SystemSettingFilters, + page = 1, + limit = 50 + ): Promise> { + const qb = this.repository.createQueryBuilder('ss'); + + if (filters.category) { + qb.andWhere('ss.category = :category', { category: filters.category }); + } + if (filters.isPublic !== undefined) { + qb.andWhere('ss.is_public = :isPublic', { isPublic: filters.isPublic }); + } + if (filters.isEditable !== undefined) { + qb.andWhere('ss.is_editable = :isEditable', { isEditable: filters.isEditable }); + } + if (filters.search) { + qb.andWhere( + '(ss.key ILIKE :search OR ss.description ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + const skip = (page - 1) * limit; + qb.orderBy('ss.category', 'ASC') + .addOrderBy('ss.key', 'ASC') + .skip(skip) + .take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Find settings by category + */ + async findByCategory(category: string): Promise { + return this.repository.find({ + where: { category }, + order: { key: 'ASC' }, + }); + } + + /** + * Get all public settings + */ + async getPublicSettings(): Promise> { + const settings = await this.repository.find({ + where: { isPublic: true }, + }); + + const result: Record = {}; + for (const setting of settings) { + result[setting.key] = setting.value; + } + + return result; + } + + /** + * Bulk update settings + */ + async updateMultiple( + ctx: ServiceContext, + settings: { key: string; value: any }[] + ): Promise<{ updated: number; errors: string[] }> { + let updated = 0; + const errors: string[] = []; + + for (const { key, value } of settings) { + try { + const result = await this.updateValueByKey(ctx, key, value); + if (result) { + updated++; + } else { + errors.push(`Setting '${key}' not found`); + } + } catch (error) { + errors.push(`${key}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + return { updated, errors }; + } + + /** + * Delete system setting (hard delete) + */ + async hardDelete(id: string): Promise { + const entity = await this.findById(id); + if (!entity) { + return false; + } + + if (!entity.isEditable) { + throw new Error('This setting cannot be deleted'); + } + + const result = await this.repository.delete({ id }); + return (result.affected || 0) > 0; + } + + /** + * Get all categories + */ + async getCategories(): Promise { + const result = await this.repository + .createQueryBuilder('ss') + .select('DISTINCT ss.category', 'category') + .orderBy('ss.category', 'ASC') + .getRawMany(); + + return result.map((r) => r.category); + } + + /** + * Validate value against type and rules + */ + private validateValue( + value: any, + dataType: SettingDataType, + validationRules?: Record | null + ): void { + switch (dataType) { + case 'number': + if (typeof value !== 'number' || isNaN(value)) { + throw new Error('Value must be a valid number'); + } + break; + case 'boolean': + if (typeof value !== 'boolean') { + throw new Error('Value must be a boolean'); + } + break; + case 'json': + if (typeof value !== 'object' || value === null) { + throw new Error('Value must be a valid JSON object'); + } + break; + case 'array': + if (!Array.isArray(value)) { + throw new Error('Value must be an array'); + } + break; + case 'secret': + case 'string': + if (typeof value !== 'string') { + throw new Error('Value must be a string'); + } + break; + } + + if (validationRules) { + if (validationRules.min !== undefined && typeof value === 'number' && value < validationRules.min) { + throw new Error(`Value must be at least ${validationRules.min}`); + } + if (validationRules.max !== undefined && typeof value === 'number' && value > validationRules.max) { + throw new Error(`Value must be at most ${validationRules.max}`); + } + if (validationRules.pattern && typeof value === 'string' && !new RegExp(validationRules.pattern).test(value)) { + throw new Error('Value does not match required pattern'); + } + if (validationRules.options && !validationRules.options.includes(value)) { + throw new Error(`Value must be one of: ${validationRules.options.join(', ')}`); + } + if (validationRules.minLength !== undefined && typeof value === 'string' && value.length < validationRules.minLength) { + throw new Error(`Value must be at least ${validationRules.minLength} characters`); + } + if (validationRules.maxLength !== undefined && typeof value === 'string' && value.length > validationRules.maxLength) { + throw new Error(`Value must be at most ${validationRules.maxLength} characters`); + } + } + } +} diff --git a/src/modules/settings/services/tenant-setting.service.ts b/src/modules/settings/services/tenant-setting.service.ts new file mode 100644 index 0000000..0d6ee9d --- /dev/null +++ b/src/modules/settings/services/tenant-setting.service.ts @@ -0,0 +1,377 @@ +/** + * TenantSettingService - Tenant-level Configuration Management + * + * Manages custom settings per tenant, overriding system and plan defaults. + * Hierarchy: system_settings < plan_settings < tenant_settings + * + * @module Settings + */ + +import { Repository } from 'typeorm'; +import { TenantSetting, SystemSetting, PlanSetting } from '../entities'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export type InheritedFrom = 'system' | 'plan' | 'custom'; + +export interface CreateTenantSettingDto { + key: string; + value: any; + inheritedFrom?: InheritedFrom; + isOverridden?: boolean; +} + +export interface UpdateTenantSettingDto { + value: any; + inheritedFrom?: InheritedFrom; + isOverridden?: boolean; +} + +export interface TenantSettingFilters { + inheritedFrom?: InheritedFrom; + isOverridden?: boolean; + search?: string; +} + +export interface EffectiveSettingResult { + key: string; + value: any; + source: 'system' | 'plan' | 'tenant'; + isOverridden: boolean; +} + +export class TenantSettingService { + private repository: Repository; + private systemSettingRepository: Repository; + private planSettingRepository: Repository; + + constructor( + repository: Repository, + systemSettingRepository: Repository, + planSettingRepository: Repository + ) { + this.repository = repository; + this.systemSettingRepository = systemSettingRepository; + this.planSettingRepository = planSettingRepository; + } + + /** + * Create a new tenant setting + */ + async create(ctx: ServiceContext, data: CreateTenantSettingDto): Promise { + const existing = await this.findByKey(ctx, data.key); + if (existing) { + throw new Error(`Tenant setting with key '${data.key}' already exists`); + } + + const entity = this.repository.create({ + tenantId: ctx.tenantId, + key: data.key, + value: data.value, + inheritedFrom: data.inheritedFrom ?? 'custom', + isOverridden: data.isOverridden ?? true, + }); + + return this.repository.save(entity); + } + + /** + * Find tenant setting by ID + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId: ctx.tenantId }, + }); + } + + /** + * Find tenant setting by key + */ + async findByKey(ctx: ServiceContext, key: string): Promise { + return this.repository.findOne({ + where: { tenantId: ctx.tenantId, key }, + }); + } + + /** + * Get value for a tenant setting + */ + async getValue(ctx: ServiceContext, key: string): Promise { + const setting = await this.findByKey(ctx, key); + return setting?.value ?? null; + } + + /** + * Get effective value with inheritance hierarchy + * Order: tenant > plan > system + */ + async getEffectiveValue(ctx: ServiceContext, key: string, planId?: string): Promise { + // First check tenant setting + const tenantSetting = await this.findByKey(ctx, key); + if (tenantSetting && tenantSetting.isOverridden) { + return tenantSetting.value; + } + + // Then check plan setting + if (planId) { + const planSetting = await this.planSettingRepository.findOne({ + where: { planId, key }, + }); + if (planSetting) { + return planSetting.value; + } + } + + // Finally check system setting + const systemSetting = await this.systemSettingRepository.findOne({ + where: { key }, + }); + if (systemSetting) { + return systemSetting.value; + } + + return null; + } + + /** + * Get all effective settings with source information + */ + async getEffectiveSettings(ctx: ServiceContext, planId?: string): Promise { + const results: Map = new Map(); + + // Start with system settings (base layer) + const systemSettings = await this.systemSettingRepository.find(); + for (const setting of systemSettings) { + results.set(setting.key, { + key: setting.key, + value: setting.value, + source: 'system', + isOverridden: false, + }); + } + + // Override with plan settings + if (planId) { + const planSettings = await this.planSettingRepository.find({ + where: { planId }, + }); + for (const setting of planSettings) { + results.set(setting.key, { + key: setting.key, + value: setting.value, + source: 'plan', + isOverridden: results.has(setting.key), + }); + } + } + + // Override with tenant settings + const tenantSettings = await this.repository.find({ + where: { tenantId: ctx.tenantId }, + }); + for (const setting of tenantSettings) { + if (setting.isOverridden) { + results.set(setting.key, { + key: setting.key, + value: setting.value, + source: 'tenant', + isOverridden: results.has(setting.key), + }); + } + } + + return Array.from(results.values()); + } + + /** + * Get all tenant settings + */ + async findAll(ctx: ServiceContext): Promise { + return this.repository.find({ + where: { tenantId: ctx.tenantId }, + order: { key: 'ASC' }, + }); + } + + /** + * Get all tenant settings as key-value map + */ + async getTenantSettingsMap(ctx: ServiceContext): Promise> { + const settings = await this.findAll(ctx); + const result: Record = {}; + for (const setting of settings) { + result[setting.key] = setting.value; + } + return result; + } + + /** + * Update tenant setting + */ + async update(ctx: ServiceContext, id: string, data: UpdateTenantSettingDto): Promise { + const entity = await this.findById(ctx, id); + if (!entity) { + return null; + } + + Object.assign(entity, data); + return this.repository.save(entity); + } + + /** + * Update or create tenant setting by key (upsert) + */ + async upsert(ctx: ServiceContext, key: string, value: any): Promise { + const existing = await this.findByKey(ctx, key); + + if (existing) { + existing.value = value; + existing.isOverridden = true; + return this.repository.save(existing); + } + + return this.create(ctx, { + key, + value, + inheritedFrom: 'custom', + isOverridden: true, + }); + } + + /** + * Reset setting to inherited value (remove override) + */ + async resetToInherited(ctx: ServiceContext, key: string): Promise { + const setting = await this.findByKey(ctx, key); + if (!setting) { + return null; + } + + setting.isOverridden = false; + return this.repository.save(setting); + } + + /** + * Find settings with filters + */ + async findWithFilters( + ctx: ServiceContext, + filters: TenantSettingFilters, + page = 1, + limit = 50 + ): Promise> { + const qb = this.repository + .createQueryBuilder('ts') + .where('ts.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.inheritedFrom) { + qb.andWhere('ts.inherited_from = :inheritedFrom', { inheritedFrom: filters.inheritedFrom }); + } + if (filters.isOverridden !== undefined) { + qb.andWhere('ts.is_overridden = :isOverridden', { isOverridden: filters.isOverridden }); + } + if (filters.search) { + qb.andWhere('ts.key ILIKE :search', { search: `%${filters.search}%` }); + } + + const skip = (page - 1) * limit; + qb.orderBy('ts.key', 'ASC') + .skip(skip) + .take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Bulk update tenant settings + */ + async updateMultiple( + ctx: ServiceContext, + settings: { key: string; value: any }[] + ): Promise<{ updated: number; created: number; errors: string[] }> { + let updated = 0; + let created = 0; + const errors: string[] = []; + + for (const { key, value } of settings) { + try { + const existing = await this.findByKey(ctx, key); + if (existing) { + existing.value = value; + existing.isOverridden = true; + await this.repository.save(existing); + updated++; + } else { + await this.create(ctx, { key, value }); + created++; + } + } catch (error) { + errors.push(`${key}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + return { updated, created, errors }; + } + + /** + * Delete tenant setting (hard delete) + */ + async hardDelete(ctx: ServiceContext, id: string): Promise { + const result = await this.repository.delete({ id, tenantId: ctx.tenantId }); + return (result.affected || 0) > 0; + } + + /** + * Initialize tenant settings from plan defaults + */ + async initializeFromPlan(ctx: ServiceContext, planId: string): Promise { + const planSettings = await this.planSettingRepository.find({ + where: { planId }, + }); + + let initialized = 0; + for (const planSetting of planSettings) { + const existing = await this.findByKey(ctx, planSetting.key); + if (!existing) { + await this.create(ctx, { + key: planSetting.key, + value: planSetting.value, + inheritedFrom: 'plan', + isOverridden: false, + }); + initialized++; + } + } + + return initialized; + } + + /** + * Get overridden settings only + */ + async getOverriddenSettings(ctx: ServiceContext): Promise { + return this.repository.find({ + where: { tenantId: ctx.tenantId, isOverridden: true }, + order: { key: 'ASC' }, + }); + } +} diff --git a/src/modules/settings/services/user-preference.service.ts b/src/modules/settings/services/user-preference.service.ts new file mode 100644 index 0000000..c2cf2c0 --- /dev/null +++ b/src/modules/settings/services/user-preference.service.ts @@ -0,0 +1,392 @@ +/** + * UserPreferenceService - User Preferences Management + * + * Manages personal preferences per user (theme, language, notifications, etc.) + * These are user-specific and don't follow the setting hierarchy. + * + * @module Settings + */ + +import { Repository, In } from 'typeorm'; +import { UserPreference } from '../entities'; + +interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface CreateUserPreferenceDto { + userId: string; + key: string; + value: any; +} + +export interface UpdateUserPreferenceDto { + value: any; +} + +export interface UserPreferenceFilters { + userId?: string; + search?: string; +} + +export interface DefaultPreferences { + theme: 'light' | 'dark' | 'system'; + language: string; + timezone: string; + dateFormat: string; + timeFormat: '12h' | '24h'; + pageSize: number; + notifications: { + email: boolean; + push: boolean; + inApp: boolean; + }; +} + +export const DEFAULT_PREFERENCES: DefaultPreferences = { + theme: 'system', + language: 'es', + timezone: 'America/Mexico_City', + dateFormat: 'DD/MM/YYYY', + timeFormat: '24h', + pageSize: 25, + notifications: { + email: true, + push: true, + inApp: true, + }, +}; + +export class UserPreferenceService { + private repository: Repository; + + constructor(repository: Repository) { + this.repository = repository; + } + + /** + * Create a new user preference + */ + async create(data: CreateUserPreferenceDto): Promise { + const existing = await this.findByUserAndKey(data.userId, data.key); + if (existing) { + throw new Error(`User preference with key '${data.key}' already exists for this user`); + } + + const entity = this.repository.create({ + userId: data.userId, + key: data.key, + value: data.value, + syncedAt: new Date(), + }); + + return this.repository.save(entity); + } + + /** + * Find user preference by ID + */ + async findById(id: string): Promise { + return this.repository.findOne({ + where: { id }, + }); + } + + /** + * Find user preference by user ID and key + */ + async findByUserAndKey(userId: string, key: string): Promise { + return this.repository.findOne({ + where: { userId, key }, + }); + } + + /** + * Get value for a user preference + */ + async getValue(userId: string, key: string): Promise { + const preference = await this.findByUserAndKey(userId, key); + return preference?.value ?? null; + } + + /** + * Get value with default fallback + */ + async getValueOrDefault(userId: string, key: string, defaultValue: any): Promise { + const value = await this.getValue(userId, key); + return value !== null ? value : defaultValue; + } + + /** + * Get all preferences for a user + */ + async findByUser(userId: string): Promise { + return this.repository.find({ + where: { userId }, + order: { key: 'ASC' }, + }); + } + + /** + * Get all preferences for a user as key-value map + */ + async getUserPreferencesMap(userId: string): Promise> { + const preferences = await this.findByUser(userId); + const result: Record = {}; + for (const pref of preferences) { + result[pref.key] = pref.value; + } + return result; + } + + /** + * Get all preferences for a user with defaults filled in + */ + async getUserPreferencesWithDefaults(userId: string): Promise> { + const userPrefs = await this.getUserPreferencesMap(userId); + return { + ...DEFAULT_PREFERENCES, + ...userPrefs, + }; + } + + /** + * Update user preference + */ + async update(id: string, data: UpdateUserPreferenceDto): Promise { + const entity = await this.findById(id); + if (!entity) { + return null; + } + + entity.value = data.value; + entity.syncedAt = new Date(); + return this.repository.save(entity); + } + + /** + * Update or create user preference by user and key (upsert) + */ + async upsert(userId: string, key: string, value: any): Promise { + const existing = await this.findByUserAndKey(userId, key); + + if (existing) { + existing.value = value; + existing.syncedAt = new Date(); + return this.repository.save(existing); + } + + return this.create({ userId, key, value }); + } + + /** + * Find preferences with filters + */ + async findWithFilters( + filters: UserPreferenceFilters, + page = 1, + limit = 50 + ): Promise> { + const qb = this.repository.createQueryBuilder('up'); + + if (filters.userId) { + qb.andWhere('up.user_id = :userId', { userId: filters.userId }); + } + if (filters.search) { + qb.andWhere('up.key ILIKE :search', { search: `%${filters.search}%` }); + } + + const skip = (page - 1) * limit; + qb.orderBy('up.user_id', 'ASC') + .addOrderBy('up.key', 'ASC') + .skip(skip) + .take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Bulk update user preferences + */ + async updateMultiple( + userId: string, + preferences: { key: string; value: any }[] + ): Promise<{ updated: number; created: number; errors: string[] }> { + let updated = 0; + let created = 0; + const errors: string[] = []; + + for (const { key, value } of preferences) { + try { + const existing = await this.findByUserAndKey(userId, key); + if (existing) { + existing.value = value; + existing.syncedAt = new Date(); + await this.repository.save(existing); + updated++; + } else { + await this.create({ userId, key, value }); + created++; + } + } catch (error) { + errors.push(`${key}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + return { updated, created, errors }; + } + + /** + * Reset preference to default + */ + async resetToDefault(userId: string, key: string): Promise { + const defaultValue = (DEFAULT_PREFERENCES as Record)[key]; + if (defaultValue === undefined) { + return null; + } + + return this.upsert(userId, key, defaultValue); + } + + /** + * Reset all preferences to defaults + */ + async resetAllToDefaults(userId: string): Promise { + // Delete all existing preferences + await this.repository.delete({ userId }); + + // Create default preferences + let count = 0; + for (const [key, value] of Object.entries(DEFAULT_PREFERENCES)) { + await this.create({ userId, key, value }); + count++; + } + + return count; + } + + /** + * Delete user preference (hard delete) + */ + async hardDelete(id: string): Promise { + const result = await this.repository.delete({ id }); + return (result.affected || 0) > 0; + } + + /** + * Delete all preferences for a user + */ + async deleteByUser(userId: string): Promise { + const result = await this.repository.delete({ userId }); + return result.affected || 0; + } + + /** + * Copy preferences from one user to another + */ + async copyFromUser( + sourceUserId: string, + targetUserId: string, + overwrite = false + ): Promise<{ copied: number; skipped: number }> { + const sourcePreferences = await this.findByUser(sourceUserId); + let copied = 0; + let skipped = 0; + + for (const pref of sourcePreferences) { + const existing = await this.findByUserAndKey(targetUserId, pref.key); + + if (existing && !overwrite) { + skipped++; + continue; + } + + if (existing) { + existing.value = pref.value; + existing.syncedAt = new Date(); + await this.repository.save(existing); + } else { + await this.create({ + userId: targetUserId, + key: pref.key, + value: pref.value, + }); + } + copied++; + } + + return { copied, skipped }; + } + + /** + * Get preferences for multiple users + */ + async getPreferencesForUsers(userIds: string[], key: string): Promise> { + const preferences = await this.repository.find({ + where: { + userId: In(userIds), + key, + }, + }); + + const result = new Map(); + for (const pref of preferences) { + result.set(pref.userId, pref.value); + } + + return result; + } + + /** + * Get specific preference keys for a user + */ + async getPreferencesByKeys(userId: string, keys: string[]): Promise> { + const preferences = await this.repository.find({ + where: { + userId, + key: In(keys), + }, + }); + + const result: Record = {}; + for (const pref of preferences) { + result[pref.key] = pref.value; + } + + return result; + } + + /** + * Sync preferences (update syncedAt timestamp) + */ + async syncPreferences(userId: string): Promise { + await this.repository.update( + { userId }, + { syncedAt: new Date() } + ); + } + + /** + * Get preferences modified since a specific date + */ + async getModifiedSince(userId: string, since: Date): Promise { + return this.repository + .createQueryBuilder('up') + .where('up.user_id = :userId', { userId }) + .andWhere('up.updated_at > :since', { since }) + .orderBy('up.updated_at', 'DESC') + .getMany(); + } +}