- 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>
295 lines
7.2 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}
|