feat(settings): Add complete backend Settings module

- Entities: SystemSetting, PlanSetting, TenantSetting, UserPreference
- Services: SystemSettingsService, TenantSettingsService, UserPreferencesService
- Controllers: system-settings, tenant-settings, user-preferences
- DTOs: update-system-setting, update-tenant-setting, update-user-preference, settings-filters
- Module and routes registration

Implements RF-SETTINGS-001, RF-SETTINGS-002, RF-SETTINGS-003

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-26 16:33:55 -06:00
parent b937d39464
commit 8565056de3
24 changed files with 2687 additions and 0 deletions

View File

@ -23,6 +23,7 @@ import { PurchasesModule } from './modules/purchases';
import { InvoicesModule } from './modules/invoices'; import { InvoicesModule } from './modules/invoices';
import { ReportsModule } from './modules/reports'; import { ReportsModule } from './modules/reports';
import { DashboardModule } from './modules/dashboard'; import { DashboardModule } from './modules/dashboard';
import { SettingsModule } from './modules/settings';
// Import entities from all modules for TypeORM // Import entities from all modules for TypeORM
import { import {
@ -113,6 +114,13 @@ import {
PaymentAllocation, PaymentAllocation,
} from './modules/invoices/entities'; } from './modules/invoices/entities';
import {
SystemSetting,
PlanSetting,
TenantSetting,
UserPreference,
} from './modules/settings/entities';
/** /**
* Get all entities for TypeORM configuration * Get all entities for TypeORM configuration
*/ */
@ -181,6 +189,11 @@ export function getAllEntities() {
InvoiceItem, InvoiceItem,
Payment, Payment,
PaymentAllocation, PaymentAllocation,
// Settings
SystemSetting,
PlanSetting,
TenantSetting,
UserPreference,
]; ];
} }
@ -240,6 +253,11 @@ export interface ModuleOptions {
enabled: boolean; enabled: boolean;
basePath?: string; basePath?: string;
}; };
settings?: {
enabled: boolean;
basePath?: string;
cacheTtlMs?: number;
};
} }
/** /**
@ -259,6 +277,7 @@ const defaultModuleOptions: ModuleOptions = {
invoices: { enabled: true, basePath: '/api' }, invoices: { enabled: true, basePath: '/api' },
reports: { enabled: true, basePath: '/api' }, reports: { enabled: true, basePath: '/api' },
dashboard: { enabled: true, basePath: '/api' }, dashboard: { enabled: true, basePath: '/api' },
settings: { enabled: true, basePath: '/api/v1' },
}; };
/** /**
@ -398,6 +417,17 @@ export function initializeModules(
app.use(dashboardModule.router); app.use(dashboardModule.router);
console.log('✅ Dashboard module initialized'); console.log('✅ Dashboard module initialized');
} }
// Initialize Settings Module
if (config.settings?.enabled) {
const settingsModule = new SettingsModule({
dataSource,
basePath: config.settings.basePath,
cacheTtlMs: config.settings.cacheTtlMs,
});
app.use(settingsModule.router);
console.log('✅ Settings module initialized');
}
} }
/** /**
@ -477,6 +507,7 @@ export async function createApplication(dataSourceConfig: any): Promise<Express>
invoices: true, invoices: true,
reports: true, reports: true,
dashboard: true, dashboard: true,
settings: true,
}, },
}); });
}); });

View File

@ -86,6 +86,14 @@ import {
WithholdingType, WithholdingType,
} from '../modules/fiscal/entities/index.js'; } from '../modules/fiscal/entities/index.js';
// Import Settings Entities
import {
SystemSetting,
PlanSetting,
TenantSetting,
UserPreference,
} from '../modules/settings/entities/index.js';
/** /**
* TypeORM DataSource configuration * TypeORM DataSource configuration
* *
@ -171,6 +179,11 @@ export const AppDataSource = new DataSource({
PaymentMethod, PaymentMethod,
PaymentType, PaymentType,
WithholdingType, WithholdingType,
// Settings Entities
SystemSetting,
PlanSetting,
TenantSetting,
UserPreference,
], ],
// Directorios de migraciones (para uso futuro) // Directorios de migraciones (para uso futuro)

View File

@ -0,0 +1,3 @@
export { SystemSettingsController } from './system-settings.controller';
export { TenantSettingsController } from './tenant-settings.controller';
export { UserPreferencesController } from './user-preferences.controller';

View File

@ -0,0 +1,232 @@
import { Request, Response, NextFunction, Router } from 'express';
import { SystemSettingsService } from '../services';
import { UpdateSystemSettingDto, CreateSystemSettingDto, SystemSettingsFiltersDto } from '../dto';
import { logger } from '../../../shared/utils/logger.js';
/**
* SystemSettingsController
*
* REST API for managing global system settings.
*
* Endpoints:
* - GET /api/v1/settings/system - List all settings
* - GET /api/v1/settings/system/public - List public settings (no auth)
* - GET /api/v1/settings/system/categories - List categories
* - GET /api/v1/settings/system/:key - Get setting by key
* - PUT /api/v1/settings/system/:key - Update setting
* - POST /api/v1/settings/system/:key/reset - Reset to default
* - POST /api/v1/settings/system - Create new setting (admin)
*/
export class SystemSettingsController {
public router: Router;
constructor(private readonly systemSettingsService: SystemSettingsService) {
this.router = Router();
this.initializeRoutes();
}
private initializeRoutes(): void {
// Public routes (no authentication required)
this.router.get('/public', this.getPublicSettings.bind(this));
// Protected routes
this.router.get('/categories', this.getCategories.bind(this));
this.router.get('/', this.getAll.bind(this));
this.router.get('/:key', this.getByKey.bind(this));
this.router.put('/:key', this.update.bind(this));
this.router.post('/:key/reset', this.reset.bind(this));
this.router.post('/', this.create.bind(this));
}
/**
* GET /api/v1/settings/system
* Get all system settings with optional filtering
*/
private async getAll(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { category, isPublic, isEditable, search } = req.query;
const filters: SystemSettingsFiltersDto = {
category: category as string,
isPublic: isPublic !== undefined ? isPublic === 'true' : undefined,
isEditable: isEditable !== undefined ? isEditable === 'true' : undefined,
search: search as string,
};
const settings = await this.systemSettingsService.getAll(filters);
res.json({
success: true,
data: settings,
total: settings.length,
});
} catch (error) {
next(error);
}
}
/**
* GET /api/v1/settings/system/public
* Get all public system settings (no authentication required)
*/
private async getPublicSettings(_req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const settings = await this.systemSettingsService.getPublicSettings();
res.json({
success: true,
data: settings,
total: settings.length,
});
} catch (error) {
next(error);
}
}
/**
* GET /api/v1/settings/system/categories
* Get all unique setting categories
*/
private async getCategories(_req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const categories = await this.systemSettingsService.getCategories();
res.json({
success: true,
data: categories,
});
} catch (error) {
next(error);
}
}
/**
* GET /api/v1/settings/system/:key
* Get a single setting by key
*/
private async getByKey(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { key } = req.params;
const setting = await this.systemSettingsService.getByKey(key);
if (!setting) {
res.status(404).json({
success: false,
error: `Setting '${key}' not found`,
});
return;
}
res.json({
success: true,
data: setting,
});
} catch (error) {
next(error);
}
}
/**
* PUT /api/v1/settings/system/:key
* Update a system setting
*/
private async update(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { key } = req.params;
const userId = req.headers['x-user-id'] as string;
const dto: UpdateSystemSettingDto = req.body;
const setting = await this.systemSettingsService.update(key, dto, userId);
if (!setting) {
res.status(404).json({
success: false,
error: `Setting '${key}' not found`,
});
return;
}
logger.info('System setting updated via API', { key, userId });
res.json({
success: true,
data: setting,
});
} catch (error) {
if ((error as Error).message.includes('not editable')) {
res.status(403).json({
success: false,
error: (error as Error).message,
});
return;
}
next(error);
}
}
/**
* POST /api/v1/settings/system/:key/reset
* Reset a setting to its default value
*/
private async reset(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { key } = req.params;
const userId = req.headers['x-user-id'] as string;
const setting = await this.systemSettingsService.reset(key, userId);
if (!setting) {
res.status(404).json({
success: false,
error: `Setting '${key}' not found`,
});
return;
}
logger.info('System setting reset via API', { key, userId });
res.json({
success: true,
data: setting,
});
} catch (error) {
if ((error as Error).message.includes('no default value')) {
res.status(400).json({
success: false,
error: (error as Error).message,
});
return;
}
next(error);
}
}
/**
* POST /api/v1/settings/system
* Create a new system setting (admin only)
*/
private async create(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.headers['x-user-id'] as string;
const dto: CreateSystemSettingDto = req.body;
const setting = await this.systemSettingsService.create(dto, userId);
logger.info('System setting created via API', { key: dto.key, userId });
res.status(201).json({
success: true,
data: setting,
});
} catch (error) {
if ((error as Error).message.includes('already exists')) {
res.status(409).json({
success: false,
error: (error as Error).message,
});
return;
}
next(error);
}
}
}

View File

@ -0,0 +1,366 @@
import { Request, Response, NextFunction, Router } from 'express';
import { TenantSettingsService } from '../services';
import {
UpdateTenantSettingDto,
CreateTenantSettingDto,
BulkUpdateTenantSettingsDto,
TenantSettingsFiltersDto,
} from '../dto';
import { logger } from '../../../shared/utils/logger.js';
/**
* TenantSettingsController
*
* REST API for managing tenant-specific settings.
*
* Endpoints:
* - GET /api/v1/settings/tenant - List all tenant settings
* - GET /api/v1/settings/tenant/effective - Get all effective settings (with inheritance)
* - GET /api/v1/settings/tenant/:key - Get setting by key
* - GET /api/v1/settings/tenant/:key/effective - Get effective value
* - PUT /api/v1/settings/tenant/:key - Update setting
* - POST /api/v1/settings/tenant - Create new setting
* - POST /api/v1/settings/tenant/bulk - Bulk update settings
* - POST /api/v1/settings/tenant/:key/reset - Reset to inherited value
* - DELETE /api/v1/settings/tenant/:key - Delete setting override
*/
export class TenantSettingsController {
public router: Router;
constructor(private readonly tenantSettingsService: TenantSettingsService) {
this.router = Router();
this.initializeRoutes();
}
private initializeRoutes(): void {
this.router.get('/effective', this.getAllEffective.bind(this));
this.router.get('/', this.getAll.bind(this));
this.router.get('/:key/effective', this.getEffective.bind(this));
this.router.get('/:key', this.get.bind(this));
this.router.put('/:key', this.update.bind(this));
this.router.post('/bulk', this.bulkUpdate.bind(this));
this.router.post('/:key/reset', this.reset.bind(this));
this.router.post('/', this.create.bind(this));
this.router.delete('/:key', this.delete.bind(this));
}
/**
* Helper to get tenant ID from request
*/
private getTenantId(req: Request): string | null {
return (req.headers['x-tenant-id'] as string) || null;
}
/**
* Helper to get plan ID from request (optional)
*/
private getPlanId(req: Request): string | undefined {
return req.headers['x-plan-id'] as string;
}
/**
* GET /api/v1/settings/tenant
* Get all tenant settings with optional filtering
*/
private async getAll(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
if (!tenantId) {
res.status(400).json({
success: false,
error: 'Tenant ID is required',
});
return;
}
const { inheritedFrom, isOverridden, search } = req.query;
const filters: TenantSettingsFiltersDto = {
inheritedFrom: inheritedFrom as 'system' | 'plan' | 'custom',
isOverridden: isOverridden !== undefined ? isOverridden === 'true' : undefined,
search: search as string,
};
const settings = await this.tenantSettingsService.getAll(tenantId, filters);
res.json({
success: true,
data: settings,
total: settings.length,
});
} catch (error) {
next(error);
}
}
/**
* GET /api/v1/settings/tenant/effective
* Get all effective settings with inheritance resolution
*/
private async getAllEffective(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
if (!tenantId) {
res.status(400).json({
success: false,
error: 'Tenant ID is required',
});
return;
}
const planId = this.getPlanId(req);
const settings = await this.tenantSettingsService.getAllEffective(tenantId, planId);
res.json({
success: true,
data: settings,
total: settings.length,
});
} catch (error) {
next(error);
}
}
/**
* GET /api/v1/settings/tenant/:key
* Get a tenant setting by key (direct, without inheritance)
*/
private async get(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
if (!tenantId) {
res.status(400).json({
success: false,
error: 'Tenant ID is required',
});
return;
}
const { key } = req.params;
const setting = await this.tenantSettingsService.get(tenantId, key);
if (!setting) {
res.status(404).json({
success: false,
error: `Tenant setting '${key}' not found`,
});
return;
}
res.json({
success: true,
data: setting,
});
} catch (error) {
next(error);
}
}
/**
* GET /api/v1/settings/tenant/:key/effective
* Get the effective value for a setting with inheritance
*/
private async getEffective(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
if (!tenantId) {
res.status(400).json({
success: false,
error: 'Tenant ID is required',
});
return;
}
const { key } = req.params;
const planId = this.getPlanId(req);
const effective = await this.tenantSettingsService.getEffective(tenantId, key, planId);
if (!effective) {
res.status(404).json({
success: false,
error: `Setting '${key}' not found`,
});
return;
}
res.json({
success: true,
data: effective,
});
} catch (error) {
next(error);
}
}
/**
* PUT /api/v1/settings/tenant/:key
* Update a tenant setting
*/
private async update(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
if (!tenantId) {
res.status(400).json({
success: false,
error: 'Tenant ID is required',
});
return;
}
const { key } = req.params;
const dto: UpdateTenantSettingDto = req.body;
const setting = await this.tenantSettingsService.update(tenantId, key, dto);
logger.info('Tenant setting updated via API', { tenantId, key });
res.json({
success: true,
data: setting,
});
} catch (error) {
next(error);
}
}
/**
* POST /api/v1/settings/tenant
* Create a new tenant setting
*/
private async create(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
if (!tenantId) {
res.status(400).json({
success: false,
error: 'Tenant ID is required',
});
return;
}
const dto: CreateTenantSettingDto = req.body;
const setting = await this.tenantSettingsService.create(tenantId, dto);
logger.info('Tenant setting created via API', { tenantId, key: dto.key });
res.status(201).json({
success: true,
data: setting,
});
} catch (error) {
if ((error as Error).message.includes('already exists')) {
res.status(409).json({
success: false,
error: (error as Error).message,
});
return;
}
next(error);
}
}
/**
* POST /api/v1/settings/tenant/bulk
* Bulk update tenant settings
*/
private async bulkUpdate(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
if (!tenantId) {
res.status(400).json({
success: false,
error: 'Tenant ID is required',
});
return;
}
const dto: BulkUpdateTenantSettingsDto = req.body;
const settings = await this.tenantSettingsService.bulkUpdate(tenantId, dto.settings);
logger.info('Tenant settings bulk updated via API', {
tenantId,
count: dto.settings.length,
});
res.json({
success: true,
data: settings,
total: settings.length,
});
} catch (error) {
next(error);
}
}
/**
* POST /api/v1/settings/tenant/:key/reset
* Reset a tenant setting to inherited value
*/
private async reset(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
if (!tenantId) {
res.status(400).json({
success: false,
error: 'Tenant ID is required',
});
return;
}
const { key } = req.params;
const deleted = await this.tenantSettingsService.reset(tenantId, key);
if (!deleted) {
res.status(404).json({
success: false,
error: `Tenant setting '${key}' not found`,
});
return;
}
logger.info('Tenant setting reset via API', { tenantId, key });
res.json({
success: true,
message: `Setting '${key}' reset to inherited value`,
});
} catch (error) {
next(error);
}
}
/**
* DELETE /api/v1/settings/tenant/:key
* Delete a tenant setting override
*/
private async delete(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
if (!tenantId) {
res.status(400).json({
success: false,
error: 'Tenant ID is required',
});
return;
}
const { key } = req.params;
const deleted = await this.tenantSettingsService.delete(tenantId, key);
if (!deleted) {
res.status(404).json({
success: false,
error: `Tenant setting '${key}' not found`,
});
return;
}
logger.info('Tenant setting deleted via API', { tenantId, key });
res.status(204).send();
} catch (error) {
next(error);
}
}
}

View File

@ -0,0 +1,331 @@
import { Request, Response, NextFunction, Router } from 'express';
import { UserPreferencesService } from '../services';
import {
UpdateUserPreferenceDto,
BulkUpdateUserPreferencesDto,
SetUserPreferencesDto,
UserPreferencesFiltersDto,
} from '../dto';
import { logger } from '../../../shared/utils/logger.js';
/**
* UserPreferencesController
*
* REST API for managing user-specific preferences.
*
* Endpoints:
* - GET /api/v1/settings/user - Get all user preferences
* - GET /api/v1/settings/user/grouped - Get preferences grouped by category
* - GET /api/v1/settings/user/:key - Get preference by key
* - PATCH /api/v1/settings/user/:key - Update preference
* - PATCH /api/v1/settings/user - Bulk update preferences
* - PUT /api/v1/settings/user - Set all preferences
* - POST /api/v1/settings/user/:key/reset - Reset preference to default
* - POST /api/v1/settings/user/reset - Reset all preferences
* - DELETE /api/v1/settings/user/:key - Delete preference
*/
export class UserPreferencesController {
public router: Router;
constructor(private readonly userPreferencesService: UserPreferencesService) {
this.router = Router();
this.initializeRoutes();
}
private initializeRoutes(): void {
this.router.get('/grouped', this.getGrouped.bind(this));
this.router.get('/', this.getAll.bind(this));
this.router.get('/:key', this.get.bind(this));
this.router.patch('/:key', this.update.bind(this));
this.router.patch('/', this.bulkUpdate.bind(this));
this.router.put('/', this.setAll.bind(this));
this.router.post('/reset', this.resetAll.bind(this));
this.router.post('/:key/reset', this.reset.bind(this));
this.router.delete('/:key', this.delete.bind(this));
}
/**
* Helper to get user ID from request
*/
private getUserId(req: Request): string | null {
return (req.headers['x-user-id'] as string) || null;
}
/**
* GET /api/v1/settings/user
* Get all user preferences
*/
private async getAll(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const userId = this.getUserId(req);
if (!userId) {
res.status(400).json({
success: false,
error: 'User ID is required',
});
return;
}
const { category, search } = req.query;
const filters: UserPreferencesFiltersDto = {
category: category as string,
search: search as string,
};
const preferences = await this.userPreferencesService.getAll(userId, filters);
res.json({
success: true,
data: preferences,
total: preferences.length,
});
} catch (error) {
next(error);
}
}
/**
* GET /api/v1/settings/user/grouped
* Get all preferences grouped by category
*/
private async getGrouped(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const userId = this.getUserId(req);
if (!userId) {
res.status(400).json({
success: false,
error: 'User ID is required',
});
return;
}
const grouped = await this.userPreferencesService.getGroupedByCategory(userId);
res.json({
success: true,
data: grouped,
});
} catch (error) {
next(error);
}
}
/**
* GET /api/v1/settings/user/:key
* Get a single preference by key
*/
private async get(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const userId = this.getUserId(req);
if (!userId) {
res.status(400).json({
success: false,
error: 'User ID is required',
});
return;
}
const { key } = req.params;
const value = await this.userPreferencesService.getValue(userId, key);
res.json({
success: true,
data: {
key,
value,
},
});
} catch (error) {
next(error);
}
}
/**
* PATCH /api/v1/settings/user/:key
* Update a single preference
*/
private async update(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const userId = this.getUserId(req);
if (!userId) {
res.status(400).json({
success: false,
error: 'User ID is required',
});
return;
}
const { key } = req.params;
const dto: UpdateUserPreferenceDto = req.body;
const preference = await this.userPreferencesService.update(userId, key, dto);
logger.debug('User preference updated via API', { userId, key });
res.json({
success: true,
data: preference,
});
} catch (error) {
next(error);
}
}
/**
* PATCH /api/v1/settings/user
* Bulk update preferences
*/
private async bulkUpdate(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const userId = this.getUserId(req);
if (!userId) {
res.status(400).json({
success: false,
error: 'User ID is required',
});
return;
}
const dto: BulkUpdateUserPreferencesDto = req.body;
const preferences = await this.userPreferencesService.bulkUpdate(userId, dto);
logger.info('User preferences bulk updated via API', {
userId,
count: dto.preferences.length,
});
res.json({
success: true,
data: preferences,
total: preferences.length,
});
} catch (error) {
next(error);
}
}
/**
* PUT /api/v1/settings/user
* Set all standard preferences at once
*/
private async setAll(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const userId = this.getUserId(req);
if (!userId) {
res.status(400).json({
success: false,
error: 'User ID is required',
});
return;
}
const dto: SetUserPreferencesDto = req.body;
const preferences = await this.userPreferencesService.setPreferences(userId, dto);
logger.info('User preferences set via API', { userId });
res.json({
success: true,
data: preferences,
total: preferences.length,
});
} catch (error) {
next(error);
}
}
/**
* POST /api/v1/settings/user/:key/reset
* Reset a preference to default
*/
private async reset(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const userId = this.getUserId(req);
if (!userId) {
res.status(400).json({
success: false,
error: 'User ID is required',
});
return;
}
const { key } = req.params;
const preference = await this.userPreferencesService.reset(userId, key);
logger.debug('User preference reset via API', { userId, key });
res.json({
success: true,
data: preference,
message: `Preference '${key}' reset to default`,
});
} catch (error) {
next(error);
}
}
/**
* POST /api/v1/settings/user/reset
* Reset all preferences to defaults
*/
private async resetAll(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const userId = this.getUserId(req);
if (!userId) {
res.status(400).json({
success: false,
error: 'User ID is required',
});
return;
}
const preferences = await this.userPreferencesService.resetAll(userId);
logger.info('User preferences reset to defaults via API', { userId });
res.json({
success: true,
data: preferences,
total: preferences.length,
message: 'All preferences reset to defaults',
});
} catch (error) {
next(error);
}
}
/**
* DELETE /api/v1/settings/user/:key
* Delete a preference
*/
private async delete(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const userId = this.getUserId(req);
if (!userId) {
res.status(400).json({
success: false,
error: 'User ID is required',
});
return;
}
const { key } = req.params;
const deleted = await this.userPreferencesService.delete(userId, key);
if (!deleted) {
res.status(404).json({
success: false,
error: `Preference '${key}' not found`,
});
return;
}
logger.debug('User preference deleted via API', { userId, key });
res.status(204).send();
} catch (error) {
next(error);
}
}
}

View File

@ -0,0 +1,24 @@
export type {
UpdateSystemSettingDto,
CreateSystemSettingDto,
} from './update-system-setting.dto';
export type {
UpdateTenantSettingDto,
CreateTenantSettingDto,
BulkUpdateTenantSettingsDto,
} from './update-tenant-setting.dto';
export type {
UpdateUserPreferenceDto,
PreferenceItem,
BulkUpdateUserPreferencesDto,
SetUserPreferencesDto,
} from './update-user-preference.dto';
export type {
SystemSettingsFiltersDto,
TenantSettingsFiltersDto,
UserPreferencesFiltersDto,
SettingsPaginationDto,
} from './settings-filters.dto';

View File

@ -0,0 +1,37 @@
/**
* Filters for querying system settings.
*/
export interface SystemSettingsFiltersDto {
category?: string;
isPublic?: boolean;
isEditable?: boolean;
search?: string;
}
/**
* Filters for querying tenant settings.
*/
export interface TenantSettingsFiltersDto {
category?: string;
inheritedFrom?: 'system' | 'plan' | 'custom';
isOverridden?: boolean;
search?: string;
}
/**
* Filters for querying user preferences.
*/
export interface UserPreferencesFiltersDto {
category?: string;
search?: string;
}
/**
* Pagination parameters for settings queries.
*/
export interface SettingsPaginationDto {
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}

View File

@ -0,0 +1,26 @@
/**
* DTO for updating a system setting.
* Only allows updating the value and editable fields that admins should modify.
*/
export interface UpdateSystemSettingDto {
value?: any;
description?: string;
isPublic?: boolean;
isEditable?: boolean;
}
/**
* DTO for creating a system setting.
* Used for initial setup or adding new system-wide settings.
*/
export interface CreateSystemSettingDto {
key: string;
value: any;
dataType?: 'string' | 'number' | 'boolean' | 'json' | 'array' | 'secret';
category: string;
description?: string;
isPublic?: boolean;
isEditable?: boolean;
defaultValue?: any;
validationRules?: Record<string, any>;
}

View File

@ -0,0 +1,31 @@
/**
* DTO for updating a tenant setting.
* Allows tenant admins to customize settings for their organization.
*/
export interface UpdateTenantSettingDto {
value: any;
isOverridden?: boolean;
}
/**
* DTO for creating/upserting a tenant setting.
* If setting exists, it updates; otherwise creates new.
*/
export interface CreateTenantSettingDto {
key: string;
value: any;
inheritedFrom?: 'system' | 'plan' | 'custom';
isOverridden?: boolean;
}
/**
* DTO for bulk updating tenant settings.
* Useful for settings pages that submit multiple changes at once.
*/
export interface BulkUpdateTenantSettingsDto {
settings: Array<{
key: string;
value: any;
isOverridden?: boolean;
}>;
}

View File

@ -0,0 +1,36 @@
/**
* DTO for updating a single user preference.
*/
export interface UpdateUserPreferenceDto {
value: any;
}
/**
* Single preference item for bulk operations.
*/
export interface PreferenceItem {
key: string;
value: any;
}
/**
* DTO for bulk updating user preferences.
* Supports updating multiple preferences in a single request.
*/
export interface BulkUpdateUserPreferencesDto {
preferences: PreferenceItem[];
}
/**
* DTO for setting all user preferences at once.
* Typically used for initial preference setup or reset.
*/
export interface SetUserPreferencesDto {
theme?: 'light' | 'dark' | 'system';
language?: string;
dateFormat?: string;
numberFormat?: string;
notificationsEmail?: boolean;
notificationsPush?: boolean;
notificationsInApp?: boolean;
}

View File

@ -0,0 +1,4 @@
export { SystemSetting } from './system-setting.entity';
export { PlanSetting } from './plan-setting.entity';
export { TenantSetting } from './tenant-setting.entity';
export { UserPreference } from './user-preference.entity';

View File

@ -0,0 +1,41 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
Unique,
} from 'typeorm';
/**
* Plan Settings Entity (schema: core_settings.plan_settings)
*
* Default configuration settings per subscription plan.
* These settings override system defaults for tenants on a specific plan.
*
* Hierarchy: system_settings < plan_settings < tenant_settings
*/
@Entity({ name: 'plan_settings', schema: 'core_settings' })
@Unique(['planId', 'key'])
export class PlanSetting {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'plan_id', type: 'uuid' })
planId: string;
@Index()
@Column({ name: 'key', type: 'varchar', length: 100 })
key: string;
@Column({ name: 'value', type: 'jsonb' })
value: any;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -0,0 +1,73 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
Unique,
} from 'typeorm';
/**
* System Settings Entity (schema: core_settings.system_settings)
*
* Global system configuration settings that apply across all tenants.
* These settings define default behaviors and system-wide parameters.
*
* Data types supported:
* - string: Text values
* - number: Numeric values
* - boolean: True/false values
* - json: Complex JSON objects
* - array: JSON arrays
* - secret: Encrypted sensitive values (displayed as masked)
*/
@Entity({ name: 'system_settings', schema: 'core_settings' })
@Unique(['key'])
export class SystemSetting {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'key', type: 'varchar', length: 100 })
key: string;
@Column({ name: 'value', type: 'jsonb' })
value: any;
@Column({
name: 'data_type',
type: 'varchar',
length: 20,
default: 'string',
})
dataType: 'string' | 'number' | 'boolean' | 'json' | 'array' | 'secret';
@Index()
@Column({ name: 'category', type: 'varchar', length: 50 })
category: string;
@Column({ name: 'description', type: 'text', nullable: true })
description: string | null;
@Column({ name: 'is_public', type: 'boolean', default: false })
isPublic: boolean;
@Column({ name: 'is_editable', type: 'boolean', default: true })
isEditable: boolean;
@Column({ name: 'default_value', type: 'jsonb', nullable: true })
defaultValue: any;
@Column({ name: 'validation_rules', type: 'jsonb', default: '{}' })
validationRules: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy: string | null;
}

View File

@ -0,0 +1,54 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
Unique,
} from 'typeorm';
/**
* Tenant Settings Entity (schema: core_settings.tenant_settings)
*
* Custom configuration settings per tenant.
* These settings override both system and plan defaults.
*
* Hierarchy: system_settings < plan_settings < tenant_settings
*
* RLS Policy: tenant_id = current_setting('app.current_tenant_id')
*/
@Entity({ name: 'tenant_settings', schema: 'core_settings' })
@Unique(['tenantId', 'key'])
export class TenantSetting {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'key', type: 'varchar', length: 100 })
key: string;
@Column({ name: 'value', type: 'jsonb' })
value: any;
@Column({
name: 'inherited_from',
type: 'varchar',
length: 20,
default: 'custom',
})
inheritedFrom: 'system' | 'plan' | 'custom';
@Column({ name: 'is_overridden', type: 'boolean', default: true })
isOverridden: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -0,0 +1,51 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
Unique,
} from 'typeorm';
/**
* User Preferences Entity (schema: core_settings.user_preferences)
*
* Personal preferences for individual users.
* These are user-specific settings like theme, language, notification preferences.
*
* Common keys:
* - ui.theme: 'light' | 'dark' | 'system'
* - ui.language: ISO language code
* - ui.dateFormat: Date format string
* - ui.numberFormat: Number format locale
* - notifications.email: boolean
* - notifications.push: boolean
* - notifications.inApp: boolean
*/
@Entity({ name: 'user_preferences', schema: 'core_settings' })
@Unique(['userId', 'key'])
export class UserPreference {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Index()
@Column({ name: 'key', type: 'varchar', length: 100 })
key: string;
@Column({ name: 'value', type: 'jsonb' })
value: any;
@Column({ name: 'synced_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
syncedAt: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -0,0 +1,15 @@
// Module exports
export { SettingsModule, SettingsModuleOptions } from './settings.module';
export { createSettingsRoutes } from './settings.routes';
// Entity exports
export * from './entities';
// Service exports
export * from './services';
// Controller exports
export * from './controllers';
// DTO exports
export * from './dto';

View File

@ -0,0 +1,4 @@
export { SystemSettingsService } from './system-settings.service';
export { TenantSettingsService, EffectiveSetting } from './tenant-settings.service';
export { UserPreferencesService } from './user-preferences.service';
export { SettingsService, UserSettingsContext } from './settings.service';

View File

@ -0,0 +1,215 @@
import { SystemSettingsService } from './system-settings.service';
import { TenantSettingsService, EffectiveSetting } from './tenant-settings.service';
import { UserPreferencesService } from './user-preferences.service';
import { SystemSetting, TenantSetting, UserPreference } from '../entities';
import { logger } from '../../../shared/utils/logger.js';
/**
* Combined settings response for a user context
*/
export interface UserSettingsContext {
system: SystemSetting[];
tenant: EffectiveSetting[];
user: Record<string, any>;
}
/**
* SettingsService
*
* Orchestrator service that combines system, tenant, and user preferences
* to provide a unified settings interface.
*
* Features:
* - Get all settings for a user context
* - Resolve effective settings with proper inheritance
* - Validate settings against rules
*/
export class SettingsService {
constructor(
private readonly systemSettingsService: SystemSettingsService,
private readonly tenantSettingsService: TenantSettingsService,
private readonly userPreferencesService: UserPreferencesService
) {}
/**
* Get all settings for a user context
* Combines system, tenant effective, and user preferences
*/
async getSettingsContext(
userId: string,
tenantId: string,
planId?: string
): Promise<UserSettingsContext> {
const [system, tenant, user] = await Promise.all([
this.systemSettingsService.getPublicSettings(),
this.tenantSettingsService.getAllEffective(tenantId, planId),
this.userPreferencesService.getAllAsMap(userId),
]);
logger.debug('Settings context loaded', {
userId,
tenantId,
systemCount: system.length,
tenantCount: tenant.length,
userCount: Object.keys(user).length,
});
return { system, tenant, user };
}
/**
* Get a specific setting value with full inheritance resolution
*/
async getSetting(
key: string,
userId: string,
tenantId: string,
planId?: string
): Promise<any> {
// User preferences have highest priority for user-specific keys
if (key.startsWith('ui.') || key.startsWith('notifications.')) {
const userValue = await this.userPreferencesService.getValue(userId, key);
if (userValue !== null) {
return userValue;
}
}
// Tenant settings next
const tenantSetting = await this.tenantSettingsService.getEffective(
tenantId,
key,
planId
);
if (tenantSetting) {
return tenantSetting.value;
}
// Fall back to system setting
const systemValue = await this.systemSettingsService.getValue(key);
return systemValue;
}
/**
* Get multiple settings at once
*/
async getSettings(
keys: string[],
userId: string,
tenantId: string,
planId?: string
): Promise<Record<string, any>> {
const results: Record<string, any> = {};
await Promise.all(
keys.map(async (key) => {
results[key] = await this.getSetting(key, userId, tenantId, planId);
})
);
return results;
}
/**
* Validate a setting value against its validation rules
*/
async validateSetting(key: string, value: any): Promise<{ valid: boolean; errors: string[] }> {
const setting = await this.systemSettingsService.getByKey(key);
if (!setting) {
return { valid: true, errors: [] };
}
const errors: string[] = [];
const rules = setting.validationRules || {};
// Type validation
if (setting.dataType === 'number' && typeof value !== 'number') {
errors.push(`Value must be a number`);
}
if (setting.dataType === 'boolean' && typeof value !== 'boolean') {
errors.push(`Value must be a boolean`);
}
if (setting.dataType === 'string' && typeof value !== 'string') {
errors.push(`Value must be a string`);
}
if (setting.dataType === 'array' && !Array.isArray(value)) {
errors.push(`Value must be an array`);
}
// Range validation
if (rules.min !== undefined && typeof value === 'number' && value < rules.min) {
errors.push(`Value must be at least ${rules.min}`);
}
if (rules.max !== undefined && typeof value === 'number' && value > rules.max) {
errors.push(`Value must be at most ${rules.max}`);
}
// Length validation
if (rules.minLength !== undefined && typeof value === 'string' && value.length < rules.minLength) {
errors.push(`Value must be at least ${rules.minLength} characters`);
}
if (rules.maxLength !== undefined && typeof value === 'string' && value.length > rules.maxLength) {
errors.push(`Value must be at most ${rules.maxLength} characters`);
}
// Enum validation
if (rules.enum && !rules.enum.includes(value)) {
errors.push(`Value must be one of: ${rules.enum.join(', ')}`);
}
// Pattern validation
if (rules.pattern && typeof value === 'string') {
const regex = new RegExp(rules.pattern);
if (!regex.test(value)) {
errors.push(`Value does not match required pattern`);
}
}
return {
valid: errors.length === 0,
errors,
};
}
/**
* Clear all caches
*/
clearCache(): void {
this.systemSettingsService.clearCache();
logger.info('Settings cache cleared');
}
/**
* Initialize settings for a new user
*/
async initializeUser(userId: string): Promise<void> {
await this.userPreferencesService.initializeDefaults(userId);
logger.info('User settings initialized', { userId });
}
/**
* Get settings by category across all levels
*/
async getByCategory(
category: string,
userId: string,
tenantId: string,
planId?: string
): Promise<{
system: SystemSetting[];
tenant: EffectiveSetting[];
user: Record<string, any>;
}> {
const [systemAll, tenantAll, userAll] = await Promise.all([
this.systemSettingsService.getByCategory(category),
this.tenantSettingsService.getAllEffective(tenantId, planId),
this.userPreferencesService.getGroupedByCategory(userId),
]);
return {
system: systemAll,
tenant: tenantAll.filter((s) => s.category === category),
user: userAll[category] || {},
};
}
}

View File

@ -0,0 +1,294 @@
import { Repository, ILike, FindOptionsWhere } from 'typeorm';
import { SystemSetting } from '../entities';
import { UpdateSystemSettingDto, CreateSystemSettingDto, SystemSettingsFiltersDto } from '../dto';
import { logger } from '../../../shared/utils/logger.js';
/**
* Cache entry for system settings
*/
interface CacheEntry {
value: any;
expiresAt: number;
}
/**
* SystemSettingsService
*
* Manages global system settings that apply across all tenants.
* Implements in-memory caching for performance optimization.
*
* Features:
* - CRUD operations for system settings
* - In-memory cache with TTL
* - Category-based filtering
* - Reset to default values
*/
export class SystemSettingsService {
private cache: Map<string, CacheEntry> = new Map();
private cacheTtlMs: number = 5 * 60 * 1000; // 5 minutes default
constructor(private readonly settingRepository: Repository<SystemSetting>) {}
/**
* Sets the cache TTL in milliseconds
*/
setCacheTtl(ttlMs: number): void {
this.cacheTtlMs = ttlMs;
}
/**
* Clears the entire cache
*/
clearCache(): void {
this.cache.clear();
logger.debug('System settings cache cleared');
}
/**
* Clears a specific key from cache
*/
clearCacheKey(key: string): void {
this.cache.delete(key);
this.cache.delete(`all:${key}`);
logger.debug('System settings cache key cleared', { key });
}
/**
* Get all system settings with optional filtering
*/
async getAll(filters: SystemSettingsFiltersDto = {}): Promise<SystemSetting[]> {
const { category, isPublic, isEditable, search } = filters;
const where: FindOptionsWhere<SystemSetting>[] = [];
const baseWhere: FindOptionsWhere<SystemSetting> = {};
if (category) {
baseWhere.category = category;
}
if (isPublic !== undefined) {
baseWhere.isPublic = isPublic;
}
if (isEditable !== undefined) {
baseWhere.isEditable = isEditable;
}
if (search) {
where.push(
{ ...baseWhere, key: ILike(`%${search}%`) },
{ ...baseWhere, description: ILike(`%${search}%`) }
);
} else {
where.push(baseWhere);
}
const settings = await this.settingRepository.find({
where: where.length > 0 ? where : undefined,
order: { category: 'ASC', key: 'ASC' },
});
return settings;
}
/**
* Get all public system settings (for unauthenticated access)
*/
async getPublicSettings(): Promise<SystemSetting[]> {
const cacheKey = 'public:all';
const cached = this.getFromCache(cacheKey);
if (cached !== undefined) {
return cached;
}
const settings = await this.settingRepository.find({
where: { isPublic: true },
order: { category: 'ASC', key: 'ASC' },
});
this.setCache(cacheKey, settings);
return settings;
}
/**
* Get settings by category
*/
async getByCategory(category: string): Promise<SystemSetting[]> {
const cacheKey = `category:${category}`;
const cached = this.getFromCache(cacheKey);
if (cached !== undefined) {
return cached;
}
const settings = await this.settingRepository.find({
where: { category },
order: { key: 'ASC' },
});
this.setCache(cacheKey, settings);
return settings;
}
/**
* Get a single setting by key
*/
async getByKey(key: string): Promise<SystemSetting | null> {
const cacheKey = `key:${key}`;
const cached = this.getFromCache(cacheKey);
if (cached !== undefined) {
return cached;
}
const setting = await this.settingRepository.findOne({
where: { key },
});
if (setting) {
this.setCache(cacheKey, setting);
}
return setting;
}
/**
* Get the value of a setting by key
*/
async getValue(key: string): Promise<any> {
const setting = await this.getByKey(key);
return setting?.value ?? null;
}
/**
* Update a system setting by key
*/
async update(
key: string,
dto: UpdateSystemSettingDto,
updatedBy?: string
): Promise<SystemSetting | null> {
const setting = await this.settingRepository.findOne({ where: { key } });
if (!setting) {
logger.warn('System setting not found for update', { key });
return null;
}
if (!setting.isEditable) {
logger.warn('Attempted to update non-editable setting', { key });
throw new Error(`Setting '${key}' is not editable`);
}
Object.assign(setting, {
...dto,
updatedBy,
});
const updated = await this.settingRepository.save(setting);
this.clearCacheKey(key);
this.cache.delete('public:all');
this.cache.delete(`category:${setting.category}`);
logger.info('System setting updated', { key, updatedBy });
return updated;
}
/**
* Reset a setting to its default value
*/
async reset(key: string, updatedBy?: string): Promise<SystemSetting | null> {
const setting = await this.settingRepository.findOne({ where: { key } });
if (!setting) {
logger.warn('System setting not found for reset', { key });
return null;
}
if (setting.defaultValue === null || setting.defaultValue === undefined) {
logger.warn('Setting has no default value', { key });
throw new Error(`Setting '${key}' has no default value`);
}
setting.value = setting.defaultValue;
setting.updatedBy = updatedBy ?? null;
const updated = await this.settingRepository.save(setting);
this.clearCacheKey(key);
this.cache.delete('public:all');
this.cache.delete(`category:${setting.category}`);
logger.info('System setting reset to default', { key, updatedBy });
return updated;
}
/**
* Create a new system setting (admin only)
*/
async create(dto: CreateSystemSettingDto, createdBy?: string): Promise<SystemSetting> {
const existing = await this.settingRepository.findOne({ where: { key: dto.key } });
if (existing) {
throw new Error(`Setting with key '${dto.key}' already exists`);
}
const setting = this.settingRepository.create({
...dto,
updatedBy: createdBy,
});
const created = await this.settingRepository.save(setting);
this.clearCache();
logger.info('System setting created', { key: dto.key, createdBy });
return created;
}
/**
* Get all unique categories
*/
async getCategories(): Promise<string[]> {
const cacheKey = 'categories:all';
const cached = this.getFromCache(cacheKey);
if (cached !== undefined) {
return cached;
}
const result = await this.settingRepository
.createQueryBuilder('setting')
.select('DISTINCT setting.category', 'category')
.orderBy('setting.category', 'ASC')
.getRawMany();
const categories = result.map((r: { category: string }) => r.category);
this.setCache(cacheKey, categories);
return categories;
}
/**
* Cache helper: get from cache if not expired
*/
private getFromCache(key: string): any {
const entry = this.cache.get(key);
if (entry && entry.expiresAt > Date.now()) {
return entry.value;
}
if (entry) {
this.cache.delete(key);
}
return undefined;
}
/**
* Cache helper: set value in cache with TTL
*/
private setCache(key: string, value: any): void {
this.cache.set(key, {
value,
expiresAt: Date.now() + this.cacheTtlMs,
});
}
}

View File

@ -0,0 +1,309 @@
import { Repository, FindOptionsWhere, ILike } from 'typeorm';
import { TenantSetting, SystemSetting, PlanSetting } from '../entities';
import { UpdateTenantSettingDto, CreateTenantSettingDto, TenantSettingsFiltersDto } from '../dto';
import { logger } from '../../../shared/utils/logger.js';
/**
* Effective setting result combining value with source information
*/
export interface EffectiveSetting {
key: string;
value: any;
source: 'system' | 'plan' | 'tenant';
isOverridden: boolean;
category?: string;
description?: string;
}
/**
* TenantSettingsService
*
* Manages tenant-specific settings with inheritance from system and plan defaults.
*
* Inheritance hierarchy:
* 1. Tenant Settings (highest priority)
* 2. Plan Settings (subscription plan defaults)
* 3. System Settings (lowest priority, global defaults)
*
* Features:
* - Get effective settings with inheritance resolution
* - Override system/plan settings at tenant level
* - Reset to inherited values
*/
export class TenantSettingsService {
constructor(
private readonly tenantSettingRepository: Repository<TenantSetting>,
private readonly systemSettingRepository: Repository<SystemSetting>,
private readonly planSettingRepository: Repository<PlanSetting>
) {}
/**
* Get the effective value for a setting key, applying inheritance
*/
async getEffective(tenantId: string, key: string, planId?: string): Promise<EffectiveSetting | null> {
// 1. Check tenant override first
const tenantSetting = await this.tenantSettingRepository.findOne({
where: { tenantId, key, isOverridden: true },
});
if (tenantSetting) {
return {
key,
value: tenantSetting.value,
source: 'tenant',
isOverridden: true,
};
}
// 2. Check plan settings if planId provided
if (planId) {
const planSetting = await this.planSettingRepository.findOne({
where: { planId, key },
});
if (planSetting) {
return {
key,
value: planSetting.value,
source: 'plan',
isOverridden: false,
};
}
}
// 3. Fall back to system setting
const systemSetting = await this.systemSettingRepository.findOne({
where: { key },
});
if (systemSetting) {
return {
key,
value: systemSetting.value ?? systemSetting.defaultValue,
source: 'system',
isOverridden: false,
category: systemSetting.category,
description: systemSetting.description ?? undefined,
};
}
return null;
}
/**
* Get all effective settings for a tenant, merging all sources
*/
async getAllEffective(tenantId: string, planId?: string): Promise<EffectiveSetting[]> {
const effectiveSettings: Map<string, EffectiveSetting> = new Map();
// 1. Start with system settings (public ones)
const systemSettings = await this.systemSettingRepository.find({
where: { isPublic: true },
order: { category: 'ASC', key: 'ASC' },
});
for (const setting of systemSettings) {
effectiveSettings.set(setting.key, {
key: setting.key,
value: setting.value ?? setting.defaultValue,
source: 'system',
isOverridden: false,
category: setting.category,
description: setting.description ?? undefined,
});
}
// 2. Apply plan settings (if planId provided)
if (planId) {
const planSettings = await this.planSettingRepository.find({
where: { planId },
});
for (const setting of planSettings) {
const existing = effectiveSettings.get(setting.key);
effectiveSettings.set(setting.key, {
key: setting.key,
value: setting.value,
source: 'plan',
isOverridden: false,
category: existing?.category,
description: existing?.description,
});
}
}
// 3. Apply tenant overrides
const tenantSettings = await this.tenantSettingRepository.find({
where: { tenantId, isOverridden: true },
});
for (const setting of tenantSettings) {
const existing = effectiveSettings.get(setting.key);
effectiveSettings.set(setting.key, {
key: setting.key,
value: setting.value,
source: 'tenant',
isOverridden: true,
category: existing?.category,
description: existing?.description,
});
}
return Array.from(effectiveSettings.values()).sort((a, b) => {
const catCompare = (a.category || '').localeCompare(b.category || '');
if (catCompare !== 0) return catCompare;
return a.key.localeCompare(b.key);
});
}
/**
* Get tenant setting by key (direct, without inheritance)
*/
async get(tenantId: string, key: string): Promise<TenantSetting | null> {
return this.tenantSettingRepository.findOne({
where: { tenantId, key },
});
}
/**
* Get all tenant settings with optional filtering
*/
async getAll(
tenantId: string,
filters: TenantSettingsFiltersDto = {}
): Promise<TenantSetting[]> {
const { inheritedFrom, isOverridden, search } = filters;
const where: FindOptionsWhere<TenantSetting>[] = [];
const baseWhere: FindOptionsWhere<TenantSetting> = { tenantId };
if (inheritedFrom) {
baseWhere.inheritedFrom = inheritedFrom;
}
if (isOverridden !== undefined) {
baseWhere.isOverridden = isOverridden;
}
if (search) {
where.push({ ...baseWhere, key: ILike(`%${search}%`) });
} else {
where.push(baseWhere);
}
return this.tenantSettingRepository.find({
where,
order: { key: 'ASC' },
});
}
/**
* Update or create a tenant setting (upsert)
*/
async update(
tenantId: string,
key: string,
dto: UpdateTenantSettingDto
): Promise<TenantSetting> {
let setting = await this.tenantSettingRepository.findOne({
where: { tenantId, key },
});
if (setting) {
Object.assign(setting, dto);
const updated = await this.tenantSettingRepository.save(setting);
logger.info('Tenant setting updated', { tenantId, key });
return updated;
}
// Create new setting
setting = this.tenantSettingRepository.create({
tenantId,
key,
value: dto.value,
inheritedFrom: 'custom',
isOverridden: dto.isOverridden ?? true,
});
const created = await this.tenantSettingRepository.save(setting);
logger.info('Tenant setting created', { tenantId, key });
return created;
}
/**
* Create a new tenant setting
*/
async create(tenantId: string, dto: CreateTenantSettingDto): Promise<TenantSetting> {
const existing = await this.tenantSettingRepository.findOne({
where: { tenantId, key: dto.key },
});
if (existing) {
throw new Error(`Setting '${dto.key}' already exists for this tenant`);
}
const setting = this.tenantSettingRepository.create({
...dto,
tenantId,
inheritedFrom: dto.inheritedFrom ?? 'custom',
isOverridden: dto.isOverridden ?? true,
});
const created = await this.tenantSettingRepository.save(setting);
logger.info('Tenant setting created', { tenantId, key: dto.key });
return created;
}
/**
* Reset a tenant setting to inherited value
*/
async reset(tenantId: string, key: string): Promise<boolean> {
const result = await this.tenantSettingRepository.delete({ tenantId, key });
const deleted = (result.affected ?? 0) > 0;
if (deleted) {
logger.info('Tenant setting reset to inherited', { tenantId, key });
}
return deleted;
}
/**
* Bulk update tenant settings
*/
async bulkUpdate(
tenantId: string,
settings: Array<{ key: string; value: any; isOverridden?: boolean }>
): Promise<TenantSetting[]> {
const results: TenantSetting[] = [];
for (const { key, value, isOverridden } of settings) {
const updated = await this.update(tenantId, key, {
value,
isOverridden: isOverridden ?? true,
});
results.push(updated);
}
logger.info('Tenant settings bulk updated', {
tenantId,
count: settings.length,
});
return results;
}
/**
* Delete a tenant setting override
*/
async delete(tenantId: string, key: string): Promise<boolean> {
const result = await this.tenantSettingRepository.delete({ tenantId, key });
const deleted = (result.affected ?? 0) > 0;
if (deleted) {
logger.info('Tenant setting deleted', { tenantId, key });
}
return deleted;
}
}

View File

@ -0,0 +1,305 @@
import { Repository, FindOptionsWhere, ILike } from 'typeorm';
import { UserPreference } from '../entities';
import {
UpdateUserPreferenceDto,
BulkUpdateUserPreferencesDto,
SetUserPreferencesDto,
UserPreferencesFiltersDto,
} from '../dto';
import { logger } from '../../../shared/utils/logger.js';
/**
* Default user preferences
*/
const DEFAULT_PREFERENCES: Record<string, any> = {
'ui.theme': 'system',
'ui.language': 'es-MX',
'ui.dateFormat': 'DD/MM/YYYY',
'ui.numberFormat': 'es-MX',
'notifications.email': true,
'notifications.push': true,
'notifications.inApp': true,
};
/**
* UserPreferencesService
*
* Manages personal preferences for individual users.
*
* Features:
* - CRUD operations for user preferences
* - Bulk update support
* - Default values for new users
* - Sync timestamp tracking
*/
export class UserPreferencesService {
constructor(private readonly preferenceRepository: Repository<UserPreference>) {}
/**
* Get all preferences for a user
*/
async getAll(
userId: string,
filters: UserPreferencesFiltersDto = {}
): Promise<UserPreference[]> {
const { category, search } = filters;
const where: FindOptionsWhere<UserPreference>[] = [];
const baseWhere: FindOptionsWhere<UserPreference> = { userId };
if (search) {
where.push({ ...baseWhere, key: ILike(`%${search}%`) });
} else {
where.push(baseWhere);
}
const preferences = await this.preferenceRepository.find({
where,
order: { key: 'ASC' },
});
// If filtering by category, filter in memory (keys start with category.)
if (category) {
const prefix = `${category}.`;
return preferences.filter((p) => p.key.startsWith(prefix));
}
return preferences;
}
/**
* Get all preferences as a key-value map
*/
async getAllAsMap(userId: string): Promise<Record<string, any>> {
const preferences = await this.preferenceRepository.find({
where: { userId },
});
const map: Record<string, any> = {};
for (const pref of preferences) {
map[pref.key] = pref.value;
}
// Fill in defaults for missing keys
for (const [key, defaultValue] of Object.entries(DEFAULT_PREFERENCES)) {
if (map[key] === undefined) {
map[key] = defaultValue;
}
}
return map;
}
/**
* Get a single preference by key
*/
async get(userId: string, key: string): Promise<UserPreference | null> {
return this.preferenceRepository.findOne({
where: { userId, key },
});
}
/**
* Get preference value with default fallback
*/
async getValue(userId: string, key: string): Promise<any> {
const preference = await this.get(userId, key);
if (preference) {
return preference.value;
}
return DEFAULT_PREFERENCES[key] ?? null;
}
/**
* Update or create a single preference
*/
async update(
userId: string,
key: string,
dto: UpdateUserPreferenceDto
): Promise<UserPreference> {
let preference = await this.preferenceRepository.findOne({
where: { userId, key },
});
if (preference) {
preference.value = dto.value;
preference.syncedAt = new Date();
const updated = await this.preferenceRepository.save(preference);
logger.debug('User preference updated', { userId, key });
return updated;
}
// Create new preference
preference = this.preferenceRepository.create({
userId,
key,
value: dto.value,
syncedAt: new Date(),
});
const created = await this.preferenceRepository.save(preference);
logger.debug('User preference created', { userId, key });
return created;
}
/**
* Bulk update multiple preferences
*/
async bulkUpdate(
userId: string,
dto: BulkUpdateUserPreferencesDto
): Promise<UserPreference[]> {
const results: UserPreference[] = [];
for (const { key, value } of dto.preferences) {
const updated = await this.update(userId, key, { value });
results.push(updated);
}
logger.info('User preferences bulk updated', {
userId,
count: dto.preferences.length,
});
return results;
}
/**
* Set all standard preferences at once
*/
async setPreferences(
userId: string,
dto: SetUserPreferencesDto
): Promise<UserPreference[]> {
const preferences: Array<{ key: string; value: any }> = [];
if (dto.theme !== undefined) {
preferences.push({ key: 'ui.theme', value: dto.theme });
}
if (dto.language !== undefined) {
preferences.push({ key: 'ui.language', value: dto.language });
}
if (dto.dateFormat !== undefined) {
preferences.push({ key: 'ui.dateFormat', value: dto.dateFormat });
}
if (dto.numberFormat !== undefined) {
preferences.push({ key: 'ui.numberFormat', value: dto.numberFormat });
}
if (dto.notificationsEmail !== undefined) {
preferences.push({ key: 'notifications.email', value: dto.notificationsEmail });
}
if (dto.notificationsPush !== undefined) {
preferences.push({ key: 'notifications.push', value: dto.notificationsPush });
}
if (dto.notificationsInApp !== undefined) {
preferences.push({ key: 'notifications.inApp', value: dto.notificationsInApp });
}
return this.bulkUpdate(userId, { preferences });
}
/**
* Reset a preference to default value
*/
async reset(userId: string, key: string): Promise<UserPreference | null> {
const defaultValue = DEFAULT_PREFERENCES[key];
if (defaultValue === undefined) {
// No default, delete the preference
const result = await this.preferenceRepository.delete({ userId, key });
if ((result.affected ?? 0) > 0) {
logger.info('User preference deleted (no default)', { userId, key });
}
return null;
}
// Reset to default value
return this.update(userId, key, { value: defaultValue });
}
/**
* Reset all preferences to defaults
*/
async resetAll(userId: string): Promise<UserPreference[]> {
// Delete all existing preferences
await this.preferenceRepository.delete({ userId });
// Create with defaults
const results: UserPreference[] = [];
for (const [key, value] of Object.entries(DEFAULT_PREFERENCES)) {
const preference = this.preferenceRepository.create({
userId,
key,
value,
syncedAt: new Date(),
});
results.push(await this.preferenceRepository.save(preference));
}
logger.info('User preferences reset to defaults', { userId });
return results;
}
/**
* Delete a specific preference
*/
async delete(userId: string, key: string): Promise<boolean> {
const result = await this.preferenceRepository.delete({ userId, key });
const deleted = (result.affected ?? 0) > 0;
if (deleted) {
logger.debug('User preference deleted', { userId, key });
}
return deleted;
}
/**
* Initialize default preferences for a new user
*/
async initializeDefaults(userId: string): Promise<UserPreference[]> {
const existing = await this.preferenceRepository.find({
where: { userId },
});
if (existing.length > 0) {
logger.debug('User already has preferences', { userId, count: existing.length });
return existing;
}
const results: UserPreference[] = [];
for (const [key, value] of Object.entries(DEFAULT_PREFERENCES)) {
const preference = this.preferenceRepository.create({
userId,
key,
value,
syncedAt: new Date(),
});
results.push(await this.preferenceRepository.save(preference));
}
logger.info('User preferences initialized with defaults', { userId });
return results;
}
/**
* Get preferences grouped by category
*/
async getGroupedByCategory(userId: string): Promise<Record<string, Record<string, any>>> {
const preferences = await this.getAllAsMap(userId);
const grouped: Record<string, Record<string, any>> = {};
for (const [key, value] of Object.entries(preferences)) {
const [category, ...rest] = key.split('.');
const subKey = rest.join('.');
if (!grouped[category]) {
grouped[category] = {};
}
grouped[category][subKey] = value;
}
return grouped;
}
}

View File

@ -0,0 +1,130 @@
import { Router } from 'express';
import { DataSource } from 'typeorm';
import {
SystemSettingsService,
TenantSettingsService,
UserPreferencesService,
SettingsService,
} from './services';
import {
SystemSettingsController,
TenantSettingsController,
UserPreferencesController,
} from './controllers';
import {
SystemSetting,
PlanSetting,
TenantSetting,
UserPreference,
} from './entities';
/**
* Options for configuring the Settings module
*/
export interface SettingsModuleOptions {
dataSource: DataSource;
basePath?: string;
cacheTtlMs?: number;
}
/**
* SettingsModule
*
* Complete settings management module for multi-tenant applications.
*
* Features:
* - System settings (global configuration)
* - Plan settings (subscription-based defaults)
* - Tenant settings (organization overrides)
* - User preferences (personal settings)
*
* Settings inheritance: system -> plan -> tenant -> user
*
* Usage:
* ```typescript
* const settingsModule = new SettingsModule({
* dataSource: AppDataSource,
* basePath: '/api/v1',
* });
*
* app.use(settingsModule.router);
* ```
*/
export class SettingsModule {
public router: Router;
public systemSettingsService: SystemSettingsService;
public tenantSettingsService: TenantSettingsService;
public userPreferencesService: UserPreferencesService;
public settingsService: SettingsService;
private dataSource: DataSource;
private basePath: string;
private cacheTtlMs: number;
constructor(options: SettingsModuleOptions) {
this.dataSource = options.dataSource;
this.basePath = options.basePath || '';
this.cacheTtlMs = options.cacheTtlMs || 5 * 60 * 1000; // 5 minutes default
this.router = Router();
this.initializeServices();
this.initializeRoutes();
}
/**
* Initialize all services with their repositories
*/
private initializeServices(): void {
const systemSettingRepository = this.dataSource.getRepository(SystemSetting);
const planSettingRepository = this.dataSource.getRepository(PlanSetting);
const tenantSettingRepository = this.dataSource.getRepository(TenantSetting);
const userPreferenceRepository = this.dataSource.getRepository(UserPreference);
// Create services
this.systemSettingsService = new SystemSettingsService(systemSettingRepository);
this.systemSettingsService.setCacheTtl(this.cacheTtlMs);
this.tenantSettingsService = new TenantSettingsService(
tenantSettingRepository,
systemSettingRepository,
planSettingRepository
);
this.userPreferencesService = new UserPreferencesService(userPreferenceRepository);
// Create orchestrator service
this.settingsService = new SettingsService(
this.systemSettingsService,
this.tenantSettingsService,
this.userPreferencesService
);
}
/**
* Initialize routes with controllers
*/
private initializeRoutes(): void {
const systemSettingsController = new SystemSettingsController(this.systemSettingsService);
const tenantSettingsController = new TenantSettingsController(this.tenantSettingsService);
const userPreferencesController = new UserPreferencesController(this.userPreferencesService);
// Mount routes
this.router.use(`${this.basePath}/settings/system`, systemSettingsController.router);
this.router.use(`${this.basePath}/settings/tenant`, tenantSettingsController.router);
this.router.use(`${this.basePath}/settings/user`, userPreferencesController.router);
}
/**
* Get all entities managed by this module
*/
static getEntities(): Function[] {
return [SystemSetting, PlanSetting, TenantSetting, UserPreference];
}
/**
* Clear all caches in the module
*/
clearCache(): void {
this.systemSettingsService.clearCache();
}
}

View File

@ -0,0 +1,62 @@
import { Router } from 'express';
import { DataSource } from 'typeorm';
import {
SystemSetting,
PlanSetting,
TenantSetting,
UserPreference,
} from './entities';
import {
SystemSettingsService,
TenantSettingsService,
UserPreferencesService,
} from './services';
import {
SystemSettingsController,
TenantSettingsController,
UserPreferencesController,
} from './controllers';
/**
* Creates and configures the settings routes
*
* Routes:
* - /api/v1/settings/system/* - System settings (global configuration)
* - /api/v1/settings/tenant/* - Tenant settings (organization overrides)
* - /api/v1/settings/user/* - User preferences (personal settings)
*
* @param dataSource - TypeORM DataSource instance
* @returns Configured Express Router
*/
export function createSettingsRoutes(dataSource: DataSource): Router {
const router = Router();
// Initialize repositories
const systemSettingRepository = dataSource.getRepository(SystemSetting);
const planSettingRepository = dataSource.getRepository(PlanSetting);
const tenantSettingRepository = dataSource.getRepository(TenantSetting);
const userPreferenceRepository = dataSource.getRepository(UserPreference);
// Initialize services
const systemSettingsService = new SystemSettingsService(systemSettingRepository);
const tenantSettingsService = new TenantSettingsService(
tenantSettingRepository,
systemSettingRepository,
planSettingRepository
);
const userPreferencesService = new UserPreferencesService(userPreferenceRepository);
// Initialize controllers
const systemSettingsController = new SystemSettingsController(systemSettingsService);
const tenantSettingsController = new TenantSettingsController(tenantSettingsService);
const userPreferencesController = new UserPreferencesController(userPreferencesService);
// Mount routes
router.use('/system', systemSettingsController.router);
router.use('/tenant', tenantSettingsController.router);
router.use('/user', userPreferencesController.router);
return router;
}
export default createSettingsRoutes;