erp-core-backend-v2/src/modules/settings/services/system-settings.service.ts
Adrian Flores Cortes 8565056de3 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>
2026-01-26 16:33:55 -06:00

295 lines
7.2 KiB
TypeScript

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,
});
}
}