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:
parent
b937d39464
commit
8565056de3
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
3
src/modules/settings/controllers/index.ts
Normal file
3
src/modules/settings/controllers/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { SystemSettingsController } from './system-settings.controller';
|
||||||
|
export { TenantSettingsController } from './tenant-settings.controller';
|
||||||
|
export { UserPreferencesController } from './user-preferences.controller';
|
||||||
232
src/modules/settings/controllers/system-settings.controller.ts
Normal file
232
src/modules/settings/controllers/system-settings.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
366
src/modules/settings/controllers/tenant-settings.controller.ts
Normal file
366
src/modules/settings/controllers/tenant-settings.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
331
src/modules/settings/controllers/user-preferences.controller.ts
Normal file
331
src/modules/settings/controllers/user-preferences.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/modules/settings/dto/index.ts
Normal file
24
src/modules/settings/dto/index.ts
Normal 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';
|
||||||
37
src/modules/settings/dto/settings-filters.dto.ts
Normal file
37
src/modules/settings/dto/settings-filters.dto.ts
Normal 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';
|
||||||
|
}
|
||||||
26
src/modules/settings/dto/update-system-setting.dto.ts
Normal file
26
src/modules/settings/dto/update-system-setting.dto.ts
Normal 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>;
|
||||||
|
}
|
||||||
31
src/modules/settings/dto/update-tenant-setting.dto.ts
Normal file
31
src/modules/settings/dto/update-tenant-setting.dto.ts
Normal 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;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
36
src/modules/settings/dto/update-user-preference.dto.ts
Normal file
36
src/modules/settings/dto/update-user-preference.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
4
src/modules/settings/entities/index.ts
Normal file
4
src/modules/settings/entities/index.ts
Normal 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';
|
||||||
41
src/modules/settings/entities/plan-setting.entity.ts
Normal file
41
src/modules/settings/entities/plan-setting.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
73
src/modules/settings/entities/system-setting.entity.ts
Normal file
73
src/modules/settings/entities/system-setting.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
54
src/modules/settings/entities/tenant-setting.entity.ts
Normal file
54
src/modules/settings/entities/tenant-setting.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
51
src/modules/settings/entities/user-preference.entity.ts
Normal file
51
src/modules/settings/entities/user-preference.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
15
src/modules/settings/index.ts
Normal file
15
src/modules/settings/index.ts
Normal 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';
|
||||||
4
src/modules/settings/services/index.ts
Normal file
4
src/modules/settings/services/index.ts
Normal 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';
|
||||||
215
src/modules/settings/services/settings.service.ts
Normal file
215
src/modules/settings/services/settings.service.ts
Normal 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] || {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
294
src/modules/settings/services/system-settings.service.ts
Normal file
294
src/modules/settings/services/system-settings.service.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
309
src/modules/settings/services/tenant-settings.service.ts
Normal file
309
src/modules/settings/services/tenant-settings.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
305
src/modules/settings/services/user-preferences.service.ts
Normal file
305
src/modules/settings/services/user-preferences.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
130
src/modules/settings/settings.module.ts
Normal file
130
src/modules/settings/settings.module.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/modules/settings/settings.routes.ts
Normal file
62
src/modules/settings/settings.routes.ts
Normal 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;
|
||||||
Loading…
Reference in New Issue
Block a user