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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-31 01:44:26 -06:00
parent 8f8843cd10
commit 22b8e93d55
8 changed files with 2405 additions and 0 deletions

View File

@ -0,0 +1,7 @@
/**
* Settings Controllers - Export
*
* @module Settings
*/
export { createSettingsController, default as SettingsController } from './settings.controller';

View File

@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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;

View File

@ -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';

View File

@ -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';

View File

@ -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<T> {
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<PlanSetting>;
constructor(repository: Repository<PlanSetting>) {
this.repository = repository;
}
/**
* Create a new plan setting
*/
async create(_ctx: ServiceContext, data: CreatePlanSettingDto): Promise<PlanSetting> {
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<PlanSetting | null> {
return this.repository.findOne({
where: { id },
});
}
/**
* Find plan setting by plan ID and key
*/
async findByPlanAndKey(planId: string, key: string): Promise<PlanSetting | null> {
return this.repository.findOne({
where: { planId, key },
});
}
/**
* Get value for a plan setting
*/
async getValue(planId: string, key: string): Promise<any> {
const setting = await this.findByPlanAndKey(planId, key);
return setting?.value ?? null;
}
/**
* Get all settings for a plan
*/
async findByPlan(planId: string): Promise<PlanSetting[]> {
return this.repository.find({
where: { planId },
order: { key: 'ASC' },
});
}
/**
* Get all settings for a plan as key-value map
*/
async getPlanSettingsMap(planId: string): Promise<Record<string, any>> {
const settings = await this.findByPlan(planId);
const result: Record<string, any> = {};
for (const setting of settings) {
result[setting.key] = setting.value;
}
return result;
}
/**
* Update plan setting
*/
async update(_ctx: ServiceContext, id: string, data: UpdatePlanSettingDto): Promise<PlanSetting | null> {
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<PlanSetting> {
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<PaginatedResult<PlanSetting>> {
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<boolean> {
const result = await this.repository.delete({ id });
return (result.affected || 0) > 0;
}
/**
* Delete all settings for a plan
*/
async deleteByPlan(planId: string): Promise<number> {
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 };
}
}

View File

@ -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<T> {
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<string, any>;
}
export interface UpdateSystemSettingDto {
value?: any;
description?: string;
isPublic?: boolean;
isEditable?: boolean;
defaultValue?: any;
validationRules?: Record<string, any>;
}
export interface SystemSettingFilters {
category?: string;
isPublic?: boolean;
isEditable?: boolean;
search?: string;
}
export class SystemSettingService {
private repository: Repository<SystemSetting>;
constructor(repository: Repository<SystemSetting>) {
this.repository = repository;
}
/**
* Create a new system setting
*/
async create(_ctx: ServiceContext, data: CreateSystemSettingDto): Promise<SystemSetting> {
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<SystemSetting | null> {
return this.repository.findOne({
where: { id },
});
}
/**
* Find system setting by key
*/
async findByKey(key: string): Promise<SystemSetting | null> {
return this.repository.findOne({
where: { key },
});
}
/**
* Get value by key
*/
async getValue(key: string): Promise<any> {
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<any> {
const value = await this.getValue(key);
return value !== null ? value : defaultValue;
}
/**
* Update system setting
*/
async update(ctx: ServiceContext, id: string, data: UpdateSystemSettingDto): Promise<SystemSetting | null> {
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<SystemSetting | null> {
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<SystemSetting | null> {
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<PaginatedResult<SystemSetting>> {
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<SystemSetting[]> {
return this.repository.find({
where: { category },
order: { key: 'ASC' },
});
}
/**
* Get all public settings
*/
async getPublicSettings(): Promise<Record<string, any>> {
const settings = await this.repository.find({
where: { isPublic: true },
});
const result: Record<string, any> = {};
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<boolean> {
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<string[]> {
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<string, any> | 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`);
}
}
}
}

View File

@ -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<T> {
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<TenantSetting>;
private systemSettingRepository: Repository<SystemSetting>;
private planSettingRepository: Repository<PlanSetting>;
constructor(
repository: Repository<TenantSetting>,
systemSettingRepository: Repository<SystemSetting>,
planSettingRepository: Repository<PlanSetting>
) {
this.repository = repository;
this.systemSettingRepository = systemSettingRepository;
this.planSettingRepository = planSettingRepository;
}
/**
* Create a new tenant setting
*/
async create(ctx: ServiceContext, data: CreateTenantSettingDto): Promise<TenantSetting> {
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<TenantSetting | null> {
return this.repository.findOne({
where: { id, tenantId: ctx.tenantId },
});
}
/**
* Find tenant setting by key
*/
async findByKey(ctx: ServiceContext, key: string): Promise<TenantSetting | null> {
return this.repository.findOne({
where: { tenantId: ctx.tenantId, key },
});
}
/**
* Get value for a tenant setting
*/
async getValue(ctx: ServiceContext, key: string): Promise<any> {
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<any> {
// 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<EffectiveSettingResult[]> {
const results: Map<string, EffectiveSettingResult> = 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<TenantSetting[]> {
return this.repository.find({
where: { tenantId: ctx.tenantId },
order: { key: 'ASC' },
});
}
/**
* Get all tenant settings as key-value map
*/
async getTenantSettingsMap(ctx: ServiceContext): Promise<Record<string, any>> {
const settings = await this.findAll(ctx);
const result: Record<string, any> = {};
for (const setting of settings) {
result[setting.key] = setting.value;
}
return result;
}
/**
* Update tenant setting
*/
async update(ctx: ServiceContext, id: string, data: UpdateTenantSettingDto): Promise<TenantSetting | null> {
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<TenantSetting> {
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<TenantSetting | null> {
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<PaginatedResult<TenantSetting>> {
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<boolean> {
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<number> {
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<TenantSetting[]> {
return this.repository.find({
where: { tenantId: ctx.tenantId, isOverridden: true },
order: { key: 'ASC' },
});
}
}

View File

@ -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<T> {
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<UserPreference>;
constructor(repository: Repository<UserPreference>) {
this.repository = repository;
}
/**
* Create a new user preference
*/
async create(data: CreateUserPreferenceDto): Promise<UserPreference> {
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<UserPreference | null> {
return this.repository.findOne({
where: { id },
});
}
/**
* Find user preference by user ID and key
*/
async findByUserAndKey(userId: string, key: string): Promise<UserPreference | null> {
return this.repository.findOne({
where: { userId, key },
});
}
/**
* Get value for a user preference
*/
async getValue(userId: string, key: string): Promise<any> {
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<any> {
const value = await this.getValue(userId, key);
return value !== null ? value : defaultValue;
}
/**
* Get all preferences for a user
*/
async findByUser(userId: string): Promise<UserPreference[]> {
return this.repository.find({
where: { userId },
order: { key: 'ASC' },
});
}
/**
* Get all preferences for a user as key-value map
*/
async getUserPreferencesMap(userId: string): Promise<Record<string, any>> {
const preferences = await this.findByUser(userId);
const result: Record<string, any> = {};
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<DefaultPreferences & Record<string, any>> {
const userPrefs = await this.getUserPreferencesMap(userId);
return {
...DEFAULT_PREFERENCES,
...userPrefs,
};
}
/**
* Update user preference
*/
async update(id: string, data: UpdateUserPreferenceDto): Promise<UserPreference | null> {
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<UserPreference> {
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<PaginatedResult<UserPreference>> {
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<UserPreference | null> {
const defaultValue = (DEFAULT_PREFERENCES as Record<string, any>)[key];
if (defaultValue === undefined) {
return null;
}
return this.upsert(userId, key, defaultValue);
}
/**
* Reset all preferences to defaults
*/
async resetAllToDefaults(userId: string): Promise<number> {
// 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<boolean> {
const result = await this.repository.delete({ id });
return (result.affected || 0) > 0;
}
/**
* Delete all preferences for a user
*/
async deleteByUser(userId: string): Promise<number> {
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<Map<string, any>> {
const preferences = await this.repository.find({
where: {
userId: In(userIds),
key,
},
});
const result = new Map<string, any>();
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<Record<string, any>> {
const preferences = await this.repository.find({
where: {
userId,
key: In(keys),
},
});
const result: Record<string, any> = {};
for (const pref of preferences) {
result[pref.key] = pref.value;
}
return result;
}
/**
* Sync preferences (update syncedAt timestamp)
*/
async syncPreferences(userId: string): Promise<void> {
await this.repository.update(
{ userId },
{ syncedAt: new Date() }
);
}
/**
* Get preferences modified since a specific date
*/
async getModifiedSince(userId: string, since: Date): Promise<UserPreference[]> {
return this.repository
.createQueryBuilder('up')
.where('up.user_id = :userId', { userId })
.andWhere('up.updated_at > :since', { since })
.orderBy('up.updated_at', 'DESC')
.getMany();
}
}