feat(settings): Add services and controllers for settings module
- SystemSettingService: Global system-wide settings with CRUD, get/set by key, validation rules, and bulk operations - PlanSettingService: Plan-level defaults with inheritance support and copy between plans functionality - TenantSettingService: Tenant-specific settings with hierarchy (system < plan < tenant), effective value resolution, and override management - UserPreferenceService: Personal preferences with defaults, sync support, and cross-user operations - SettingsController: Unified REST endpoints for all settings types with proper authentication and authorization Implements hierarchical inheritance: system_settings < plan_settings < tenant_settings < user_preferences. All services follow ServiceContext pattern with tenantId for multi-tenant support. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
8f8843cd10
commit
22b8e93d55
7
src/modules/settings/controllers/index.ts
Normal file
7
src/modules/settings/controllers/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Settings Controllers - Export
|
||||
*
|
||||
* @module Settings
|
||||
*/
|
||||
|
||||
export { createSettingsController, default as SettingsController } from './settings.controller';
|
||||
942
src/modules/settings/controllers/settings.controller.ts
Normal file
942
src/modules/settings/controllers/settings.controller.ts
Normal file
@ -0,0 +1,942 @@
|
||||
/**
|
||||
* SettingsController - Unified Settings Management Controller
|
||||
*
|
||||
* REST endpoints for managing settings with hierarchical inheritance.
|
||||
* Handles system, plan, tenant settings and user preferences.
|
||||
*
|
||||
* @module Settings
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
SystemSettingService,
|
||||
PlanSettingService,
|
||||
TenantSettingService,
|
||||
UserPreferenceService,
|
||||
SystemSettingFilters,
|
||||
TenantSettingFilters,
|
||||
} from '../services';
|
||||
import {
|
||||
SystemSetting,
|
||||
PlanSetting,
|
||||
TenantSetting,
|
||||
UserPreference,
|
||||
} from '../entities';
|
||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||
import { AuthService } from '../../auth/services/auth.service';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||
|
||||
interface ServiceContext {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export function createSettingsController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
|
||||
// Repositories
|
||||
const systemSettingRepository = dataSource.getRepository(SystemSetting);
|
||||
const planSettingRepository = dataSource.getRepository(PlanSetting);
|
||||
const tenantSettingRepository = dataSource.getRepository(TenantSetting);
|
||||
const userPreferenceRepository = dataSource.getRepository(UserPreference);
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const tenantRepository = dataSource.getRepository(Tenant);
|
||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||
|
||||
// Services
|
||||
const systemSettingService = new SystemSettingService(systemSettingRepository);
|
||||
const planSettingService = new PlanSettingService(planSettingRepository);
|
||||
const tenantSettingService = new TenantSettingService(
|
||||
tenantSettingRepository,
|
||||
systemSettingRepository,
|
||||
planSettingRepository
|
||||
);
|
||||
const userPreferenceService = new UserPreferenceService(userPreferenceRepository);
|
||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||
|
||||
// Helper to create service context
|
||||
const getContext = (req: Request): ServiceContext => {
|
||||
if (!req.tenantId) {
|
||||
throw new Error('Tenant ID is required');
|
||||
}
|
||||
return {
|
||||
tenantId: req.tenantId,
|
||||
userId: req.user?.sub,
|
||||
};
|
||||
};
|
||||
|
||||
// ==================== EFFECTIVE SETTINGS ====================
|
||||
|
||||
/**
|
||||
* GET /settings/effective
|
||||
* Get all effective settings with inheritance applied
|
||||
*/
|
||||
router.get('/effective', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
const planId = req.query.planId as string | undefined;
|
||||
|
||||
const settings = await tenantSettingService.getEffectiveSettings(ctx, planId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: settings,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /settings/effective/:key
|
||||
* Get effective value for a specific key
|
||||
*/
|
||||
router.get('/effective/:key', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
const planId = req.query.planId as string | undefined;
|
||||
|
||||
const value = await tenantSettingService.getEffectiveValue(ctx, req.params.key, planId);
|
||||
|
||||
if (value === null) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Setting not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: { key: req.params.key, value },
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== SYSTEM SETTINGS ====================
|
||||
|
||||
/**
|
||||
* GET /settings/system
|
||||
* Get system settings (admin only)
|
||||
*/
|
||||
router.get('/system', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
|
||||
const filters: SystemSettingFilters = {};
|
||||
if (req.query.category) filters.category = req.query.category as string;
|
||||
if (req.query.isPublic !== undefined) filters.isPublic = req.query.isPublic === 'true';
|
||||
if (req.query.search) filters.search = req.query.search as string;
|
||||
|
||||
const result = await systemSettingService.findWithFilters(filters, page, limit);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: {
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
limit: result.limit,
|
||||
totalPages: result.totalPages,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /settings/system/public
|
||||
* Get public system settings (no auth required)
|
||||
*/
|
||||
router.get('/system/public', async (_req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const settings = await systemSettingService.getPublicSettings();
|
||||
res.status(200).json({ success: true, data: settings });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /settings/system/categories
|
||||
* Get all system setting categories
|
||||
*/
|
||||
router.get('/system/categories', authMiddleware.authenticate, async (_req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const categories = await systemSettingService.getCategories();
|
||||
res.status(200).json({ success: true, data: categories });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /settings/system/category/:category
|
||||
* Get system settings by category
|
||||
*/
|
||||
router.get('/system/category/:category', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const settings = await systemSettingService.findByCategory(req.params.category);
|
||||
res.status(200).json({ success: true, data: settings });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /settings/system/key/:key
|
||||
* Get system setting by key
|
||||
*/
|
||||
router.get('/system/key/:key', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const setting = await systemSettingService.findByKey(req.params.key);
|
||||
|
||||
if (!setting) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'System setting not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: setting });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /settings/system
|
||||
* Create system setting (admin only)
|
||||
*/
|
||||
router.post('/system', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
const { key, value, dataType, category, description, isPublic, isEditable, defaultValue, validationRules } = req.body;
|
||||
|
||||
if (!key || !category) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'key and category are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const setting = await systemSettingService.create(ctx, {
|
||||
key,
|
||||
value,
|
||||
dataType,
|
||||
category,
|
||||
description,
|
||||
isPublic,
|
||||
isEditable,
|
||||
defaultValue,
|
||||
validationRules,
|
||||
});
|
||||
|
||||
res.status(201).json({ success: true, data: setting });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('already exists')) {
|
||||
res.status(409).json({ error: 'Conflict', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /settings/system/:id
|
||||
* Update system setting
|
||||
*/
|
||||
router.put('/system/:id', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
const setting = await systemSettingService.update(ctx, req.params.id, req.body);
|
||||
|
||||
if (!setting) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'System setting not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: setting });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not editable')) {
|
||||
res.status(403).json({ error: 'Forbidden', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /settings/system/key/:key/value
|
||||
* Update system setting value by key
|
||||
*/
|
||||
router.put('/system/key/:key/value', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
const { value } = req.body;
|
||||
|
||||
if (value === undefined) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'value is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const setting = await systemSettingService.updateValueByKey(ctx, req.params.key, value);
|
||||
|
||||
if (!setting) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'System setting not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: setting });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (error.message.includes('not editable') || error.message.includes('must be'))) {
|
||||
res.status(400).json({ error: 'Bad Request', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /settings/system/key/:key/reset
|
||||
* Reset system setting to default value
|
||||
*/
|
||||
router.post('/system/key/:key/reset', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
const setting = await systemSettingService.resetToDefault(ctx, req.params.key);
|
||||
|
||||
if (!setting) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'System setting not found or no default value' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: setting });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /settings/system/bulk
|
||||
* Bulk update system settings
|
||||
*/
|
||||
router.put('/system/bulk', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
const { settings } = req.body;
|
||||
|
||||
if (!Array.isArray(settings)) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'settings array is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await systemSettingService.updateMultiple(ctx, settings);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: `Updated ${result.updated} settings`,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /settings/system/:id
|
||||
* Delete system setting
|
||||
*/
|
||||
router.delete('/system/:id', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const deleted = await systemSettingService.hardDelete(req.params.id);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'System setting not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, message: 'System setting deleted' });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('cannot be deleted')) {
|
||||
res.status(403).json({ error: 'Forbidden', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== PLAN SETTINGS ====================
|
||||
|
||||
/**
|
||||
* GET /settings/plan/:planId
|
||||
* Get all settings for a plan
|
||||
*/
|
||||
router.get('/plan/:planId', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const settings = await planSettingService.findByPlan(req.params.planId);
|
||||
res.status(200).json({ success: true, data: settings });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /settings/plan/:planId/key/:key
|
||||
* Get plan setting by plan ID and key
|
||||
*/
|
||||
router.get('/plan/:planId/key/:key', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const setting = await planSettingService.findByPlanAndKey(req.params.planId, req.params.key);
|
||||
|
||||
if (!setting) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Plan setting not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: setting });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /settings/plan
|
||||
* Create plan setting
|
||||
*/
|
||||
router.post('/plan', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
const { planId, key, value } = req.body;
|
||||
|
||||
if (!planId || !key) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'planId and key are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const setting = await planSettingService.create(ctx, { planId, key, value });
|
||||
res.status(201).json({ success: true, data: setting });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('already exists')) {
|
||||
res.status(409).json({ error: 'Conflict', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /settings/plan/:planId/key/:key
|
||||
* Update or create plan setting (upsert)
|
||||
*/
|
||||
router.put('/plan/:planId/key/:key', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
const { value } = req.body;
|
||||
|
||||
const setting = await planSettingService.upsert(ctx, req.params.planId, req.params.key, value);
|
||||
res.status(200).json({ success: true, data: setting });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /settings/plan/:planId/bulk
|
||||
* Bulk update plan settings
|
||||
*/
|
||||
router.put('/plan/:planId/bulk', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
const { settings } = req.body;
|
||||
|
||||
if (!Array.isArray(settings)) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'settings array is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await planSettingService.updateMultiple(ctx, req.params.planId, settings);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: `Updated ${result.updated}, created ${result.created} settings`,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /settings/plan/:sourcePlanId/copy/:targetPlanId
|
||||
* Copy settings from one plan to another
|
||||
*/
|
||||
router.post('/plan/:sourcePlanId/copy/:targetPlanId', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
const overwrite = req.query.overwrite === 'true';
|
||||
|
||||
const result = await planSettingService.copyFromPlan(
|
||||
ctx,
|
||||
req.params.sourcePlanId,
|
||||
req.params.targetPlanId,
|
||||
overwrite
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: `Copied ${result.copied} settings, skipped ${result.skipped}`,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /settings/plan/:id
|
||||
* Delete plan setting
|
||||
*/
|
||||
router.delete('/plan/:id', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const deleted = await planSettingService.hardDelete(req.params.id);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Plan setting not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, message: 'Plan setting deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== TENANT SETTINGS ====================
|
||||
|
||||
/**
|
||||
* GET /settings/tenant
|
||||
* Get all tenant settings
|
||||
*/
|
||||
router.get('/tenant', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
|
||||
const filters: TenantSettingFilters = {};
|
||||
if (req.query.inheritedFrom) filters.inheritedFrom = req.query.inheritedFrom as any;
|
||||
if (req.query.isOverridden !== undefined) filters.isOverridden = req.query.isOverridden === 'true';
|
||||
if (req.query.search) filters.search = req.query.search as string;
|
||||
|
||||
const result = await tenantSettingService.findWithFilters(ctx, filters, page, limit);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: {
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
limit: result.limit,
|
||||
totalPages: result.totalPages,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /settings/tenant/overridden
|
||||
* Get only overridden tenant settings
|
||||
*/
|
||||
router.get('/tenant/overridden', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
const settings = await tenantSettingService.getOverriddenSettings(ctx);
|
||||
res.status(200).json({ success: true, data: settings });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /settings/tenant/key/:key
|
||||
* Get tenant setting by key
|
||||
*/
|
||||
router.get('/tenant/key/:key', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
const setting = await tenantSettingService.findByKey(ctx, req.params.key);
|
||||
|
||||
if (!setting) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Tenant setting not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: setting });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /settings/tenant
|
||||
* Create tenant setting
|
||||
*/
|
||||
router.post('/tenant', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
const { key, value, inheritedFrom, isOverridden } = req.body;
|
||||
|
||||
if (!key) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'key is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const setting = await tenantSettingService.create(ctx, { key, value, inheritedFrom, isOverridden });
|
||||
res.status(201).json({ success: true, data: setting });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('already exists')) {
|
||||
res.status(409).json({ error: 'Conflict', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /settings/tenant/key/:key
|
||||
* Update or create tenant setting (upsert)
|
||||
*/
|
||||
router.put('/tenant/key/:key', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
const { value } = req.body;
|
||||
|
||||
const setting = await tenantSettingService.upsert(ctx, req.params.key, value);
|
||||
res.status(200).json({ success: true, data: setting });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /settings/tenant/key/:key/reset
|
||||
* Reset tenant setting to inherited value
|
||||
*/
|
||||
router.post('/tenant/key/:key/reset', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
const setting = await tenantSettingService.resetToInherited(ctx, req.params.key);
|
||||
|
||||
if (!setting) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Tenant setting not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: setting });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /settings/tenant/bulk
|
||||
* Bulk update tenant settings
|
||||
*/
|
||||
router.put('/tenant/bulk', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
const { settings } = req.body;
|
||||
|
||||
if (!Array.isArray(settings)) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'settings array is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await tenantSettingService.updateMultiple(ctx, settings);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: `Updated ${result.updated}, created ${result.created} settings`,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /settings/tenant/initialize/:planId
|
||||
* Initialize tenant settings from plan defaults
|
||||
*/
|
||||
router.post('/tenant/initialize/:planId', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
const initialized = await tenantSettingService.initializeFromPlan(ctx, req.params.planId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: { initialized },
|
||||
message: `Initialized ${initialized} settings from plan`,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /settings/tenant/:id
|
||||
* Delete tenant setting
|
||||
*/
|
||||
router.delete('/tenant/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
const deleted = await tenantSettingService.hardDelete(ctx, req.params.id);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Tenant setting not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, message: 'Tenant setting deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== USER PREFERENCES ====================
|
||||
|
||||
/**
|
||||
* GET /settings/preferences
|
||||
* Get current user's preferences
|
||||
*/
|
||||
router.get('/preferences', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user?.sub) {
|
||||
res.status(401).json({ error: 'Unauthorized', message: 'User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const preferences = await userPreferenceService.getUserPreferencesWithDefaults(req.user.sub);
|
||||
res.status(200).json({ success: true, data: preferences });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /settings/preferences/raw
|
||||
* Get current user's preferences without defaults
|
||||
*/
|
||||
router.get('/preferences/raw', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user?.sub) {
|
||||
res.status(401).json({ error: 'Unauthorized', message: 'User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const preferences = await userPreferenceService.findByUser(req.user.sub);
|
||||
res.status(200).json({ success: true, data: preferences });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /settings/preferences/:key
|
||||
* Get specific preference for current user
|
||||
*/
|
||||
router.get('/preferences/:key', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user?.sub) {
|
||||
res.status(401).json({ error: 'Unauthorized', message: 'User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const preference = await userPreferenceService.findByUserAndKey(req.user.sub, req.params.key);
|
||||
|
||||
if (!preference) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Preference not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: preference });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /settings/preferences/:key
|
||||
* Update or create preference for current user
|
||||
*/
|
||||
router.put('/preferences/:key', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user?.sub) {
|
||||
res.status(401).json({ error: 'Unauthorized', message: 'User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { value } = req.body;
|
||||
const preference = await userPreferenceService.upsert(req.user.sub, req.params.key, value);
|
||||
res.status(200).json({ success: true, data: preference });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /settings/preferences
|
||||
* Bulk update preferences for current user
|
||||
*/
|
||||
router.put('/preferences', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user?.sub) {
|
||||
res.status(401).json({ error: 'Unauthorized', message: 'User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { preferences } = req.body;
|
||||
|
||||
if (!Array.isArray(preferences)) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'preferences array is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await userPreferenceService.updateMultiple(req.user.sub, preferences);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: `Updated ${result.updated}, created ${result.created} preferences`,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /settings/preferences/:key/reset
|
||||
* Reset preference to default value
|
||||
*/
|
||||
router.post('/preferences/:key/reset', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user?.sub) {
|
||||
res.status(401).json({ error: 'Unauthorized', message: 'User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const preference = await userPreferenceService.resetToDefault(req.user.sub, req.params.key);
|
||||
|
||||
if (!preference) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'No default value for this preference' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: preference });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /settings/preferences/reset-all
|
||||
* Reset all preferences to defaults
|
||||
*/
|
||||
router.post('/preferences/reset-all', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user?.sub) {
|
||||
res.status(401).json({ error: 'Unauthorized', message: 'User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const count = await userPreferenceService.resetAllToDefaults(req.user.sub);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: { reset: count },
|
||||
message: `Reset ${count} preferences to defaults`,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /settings/preferences/:key
|
||||
* Delete a specific preference
|
||||
*/
|
||||
router.delete('/preferences/:key', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user?.sub) {
|
||||
res.status(401).json({ error: 'Unauthorized', message: 'User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const preference = await userPreferenceService.findByUserAndKey(req.user.sub, req.params.key);
|
||||
|
||||
if (!preference) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Preference not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
await userPreferenceService.hardDelete(preference.id);
|
||||
res.status(200).json({ success: true, message: 'Preference deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== ADMIN: USER PREFERENCES MANAGEMENT ====================
|
||||
|
||||
/**
|
||||
* GET /settings/admin/preferences/user/:userId
|
||||
* Get preferences for a specific user (admin only)
|
||||
*/
|
||||
router.get('/admin/preferences/user/:userId', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const preferences = await userPreferenceService.findByUser(req.params.userId);
|
||||
res.status(200).json({ success: true, data: preferences });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /settings/admin/preferences/user/:userId/:key
|
||||
* Update preference for a specific user (admin only)
|
||||
*/
|
||||
router.put('/admin/preferences/user/:userId/:key', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const { value } = req.body;
|
||||
const preference = await userPreferenceService.upsert(req.params.userId, req.params.key, value);
|
||||
res.status(200).json({ success: true, data: preference });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /settings/admin/preferences/user/:userId
|
||||
* Delete all preferences for a user (admin only)
|
||||
*/
|
||||
router.delete('/admin/preferences/user/:userId', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const deleted = await userPreferenceService.deleteByUser(req.params.userId);
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: { deleted },
|
||||
message: `Deleted ${deleted} preferences`,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createSettingsController;
|
||||
17
src/modules/settings/index.ts
Normal file
17
src/modules/settings/index.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Settings Module - Main Export
|
||||
*
|
||||
* Handles system settings, configurations, and user preferences with
|
||||
* hierarchical inheritance: system < plan < tenant < user
|
||||
*
|
||||
* @module Settings
|
||||
*/
|
||||
|
||||
// Entities
|
||||
export * from './entities';
|
||||
|
||||
// Services
|
||||
export * from './services';
|
||||
|
||||
// Controllers
|
||||
export * from './controllers';
|
||||
37
src/modules/settings/services/index.ts
Normal file
37
src/modules/settings/services/index.ts
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Settings Services - Export
|
||||
*
|
||||
* @module Settings
|
||||
*/
|
||||
|
||||
export { SystemSettingService } from './system-setting.service';
|
||||
export type {
|
||||
CreateSystemSettingDto,
|
||||
UpdateSystemSettingDto,
|
||||
SystemSettingFilters,
|
||||
SettingDataType,
|
||||
} from './system-setting.service';
|
||||
|
||||
export { PlanSettingService } from './plan-setting.service';
|
||||
export type {
|
||||
CreatePlanSettingDto,
|
||||
UpdatePlanSettingDto,
|
||||
PlanSettingFilters,
|
||||
} from './plan-setting.service';
|
||||
|
||||
export { TenantSettingService } from './tenant-setting.service';
|
||||
export type {
|
||||
CreateTenantSettingDto,
|
||||
UpdateTenantSettingDto,
|
||||
TenantSettingFilters,
|
||||
EffectiveSettingResult,
|
||||
InheritedFrom,
|
||||
} from './tenant-setting.service';
|
||||
|
||||
export { UserPreferenceService, DEFAULT_PREFERENCES } from './user-preference.service';
|
||||
export type {
|
||||
CreateUserPreferenceDto,
|
||||
UpdateUserPreferenceDto,
|
||||
UserPreferenceFilters,
|
||||
DefaultPreferences,
|
||||
} from './user-preference.service';
|
||||
264
src/modules/settings/services/plan-setting.service.ts
Normal file
264
src/modules/settings/services/plan-setting.service.ts
Normal file
@ -0,0 +1,264 @@
|
||||
/**
|
||||
* PlanSettingService - Plan-level Configuration Management
|
||||
*
|
||||
* Manages default settings per subscription plan.
|
||||
* Hierarchy: system_settings < plan_settings < tenant_settings
|
||||
*
|
||||
* @module Settings
|
||||
*/
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { PlanSetting } from '../entities';
|
||||
|
||||
interface ServiceContext {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface CreatePlanSettingDto {
|
||||
planId: string;
|
||||
key: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface UpdatePlanSettingDto {
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface PlanSettingFilters {
|
||||
planId?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class PlanSettingService {
|
||||
private repository: Repository<PlanSetting>;
|
||||
|
||||
constructor(repository: Repository<PlanSetting>) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new plan setting
|
||||
*/
|
||||
async create(_ctx: ServiceContext, data: CreatePlanSettingDto): Promise<PlanSetting> {
|
||||
const existing = await this.findByPlanAndKey(data.planId, data.key);
|
||||
if (existing) {
|
||||
throw new Error(`Plan setting with key '${data.key}' already exists for this plan`);
|
||||
}
|
||||
|
||||
const entity = this.repository.create({
|
||||
planId: data.planId,
|
||||
key: data.key,
|
||||
value: data.value,
|
||||
});
|
||||
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find plan setting by ID
|
||||
*/
|
||||
async findById(id: string): Promise<PlanSetting | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find plan setting by plan ID and key
|
||||
*/
|
||||
async findByPlanAndKey(planId: string, key: string): Promise<PlanSetting | null> {
|
||||
return this.repository.findOne({
|
||||
where: { planId, key },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value for a plan setting
|
||||
*/
|
||||
async getValue(planId: string, key: string): Promise<any> {
|
||||
const setting = await this.findByPlanAndKey(planId, key);
|
||||
return setting?.value ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all settings for a plan
|
||||
*/
|
||||
async findByPlan(planId: string): Promise<PlanSetting[]> {
|
||||
return this.repository.find({
|
||||
where: { planId },
|
||||
order: { key: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all settings for a plan as key-value map
|
||||
*/
|
||||
async getPlanSettingsMap(planId: string): Promise<Record<string, any>> {
|
||||
const settings = await this.findByPlan(planId);
|
||||
const result: Record<string, any> = {};
|
||||
for (const setting of settings) {
|
||||
result[setting.key] = setting.value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update plan setting
|
||||
*/
|
||||
async update(_ctx: ServiceContext, id: string, data: UpdatePlanSettingDto): Promise<PlanSetting | null> {
|
||||
const entity = await this.findById(id);
|
||||
if (!entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
entity.value = data.value;
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or create plan setting by plan and key (upsert)
|
||||
*/
|
||||
async upsert(_ctx: ServiceContext, planId: string, key: string, value: any): Promise<PlanSetting> {
|
||||
const existing = await this.findByPlanAndKey(planId, key);
|
||||
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
return this.repository.save(existing);
|
||||
}
|
||||
|
||||
const entity = this.repository.create({
|
||||
planId,
|
||||
key,
|
||||
value,
|
||||
});
|
||||
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find settings with filters
|
||||
*/
|
||||
async findWithFilters(
|
||||
filters: PlanSettingFilters,
|
||||
page = 1,
|
||||
limit = 50
|
||||
): Promise<PaginatedResult<PlanSetting>> {
|
||||
const qb = this.repository.createQueryBuilder('ps');
|
||||
|
||||
if (filters.planId) {
|
||||
qb.andWhere('ps.plan_id = :planId', { planId: filters.planId });
|
||||
}
|
||||
if (filters.search) {
|
||||
qb.andWhere('ps.key ILIKE :search', { search: `%${filters.search}%` });
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
qb.orderBy('ps.plan_id', 'ASC')
|
||||
.addOrderBy('ps.key', 'ASC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk update plan settings
|
||||
*/
|
||||
async updateMultiple(
|
||||
ctx: ServiceContext,
|
||||
planId: string,
|
||||
settings: { key: string; value: any }[]
|
||||
): Promise<{ updated: number; created: number; errors: string[] }> {
|
||||
let updated = 0;
|
||||
let created = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const { key, value } of settings) {
|
||||
try {
|
||||
const existing = await this.findByPlanAndKey(planId, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
await this.repository.save(existing);
|
||||
updated++;
|
||||
} else {
|
||||
await this.create(ctx, { planId, key, value });
|
||||
created++;
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`${key}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { updated, created, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete plan setting (hard delete)
|
||||
*/
|
||||
async hardDelete(id: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ id });
|
||||
return (result.affected || 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all settings for a plan
|
||||
*/
|
||||
async deleteByPlan(planId: string): Promise<number> {
|
||||
const result = await this.repository.delete({ planId });
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy settings from one plan to another
|
||||
*/
|
||||
async copyFromPlan(
|
||||
ctx: ServiceContext,
|
||||
sourcePlanId: string,
|
||||
targetPlanId: string,
|
||||
overwrite = false
|
||||
): Promise<{ copied: number; skipped: number }> {
|
||||
const sourceSettings = await this.findByPlan(sourcePlanId);
|
||||
let copied = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const setting of sourceSettings) {
|
||||
const existing = await this.findByPlanAndKey(targetPlanId, setting.key);
|
||||
|
||||
if (existing && !overwrite) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
existing.value = setting.value;
|
||||
await this.repository.save(existing);
|
||||
} else {
|
||||
await this.create(ctx, {
|
||||
planId: targetPlanId,
|
||||
key: setting.key,
|
||||
value: setting.value,
|
||||
});
|
||||
}
|
||||
copied++;
|
||||
}
|
||||
|
||||
return { copied, skipped };
|
||||
}
|
||||
}
|
||||
369
src/modules/settings/services/system-setting.service.ts
Normal file
369
src/modules/settings/services/system-setting.service.ts
Normal file
@ -0,0 +1,369 @@
|
||||
/**
|
||||
* SystemSettingService - Global System Configuration Management
|
||||
*
|
||||
* Manages global system-wide settings across all tenants.
|
||||
* These are the base defaults that can be overridden by plan/tenant settings.
|
||||
*
|
||||
* @module Settings
|
||||
*/
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { SystemSetting } from '../entities';
|
||||
|
||||
interface ServiceContext {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export type SettingDataType = 'string' | 'number' | 'boolean' | 'json' | 'array' | 'secret';
|
||||
|
||||
export interface CreateSystemSettingDto {
|
||||
key: string;
|
||||
value: any;
|
||||
dataType?: SettingDataType;
|
||||
category: string;
|
||||
description?: string;
|
||||
isPublic?: boolean;
|
||||
isEditable?: boolean;
|
||||
defaultValue?: any;
|
||||
validationRules?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface UpdateSystemSettingDto {
|
||||
value?: any;
|
||||
description?: string;
|
||||
isPublic?: boolean;
|
||||
isEditable?: boolean;
|
||||
defaultValue?: any;
|
||||
validationRules?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SystemSettingFilters {
|
||||
category?: string;
|
||||
isPublic?: boolean;
|
||||
isEditable?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class SystemSettingService {
|
||||
private repository: Repository<SystemSetting>;
|
||||
|
||||
constructor(repository: Repository<SystemSetting>) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new system setting
|
||||
*/
|
||||
async create(_ctx: ServiceContext, data: CreateSystemSettingDto): Promise<SystemSetting> {
|
||||
const existing = await this.findByKey(data.key);
|
||||
if (existing) {
|
||||
throw new Error(`System setting with key '${data.key}' already exists`);
|
||||
}
|
||||
|
||||
this.validateValue(data.value, data.dataType || 'string', data.validationRules);
|
||||
|
||||
const entity = this.repository.create({
|
||||
key: data.key,
|
||||
value: data.value,
|
||||
dataType: data.dataType || 'string',
|
||||
category: data.category,
|
||||
description: data.description || null,
|
||||
isPublic: data.isPublic ?? false,
|
||||
isEditable: data.isEditable ?? true,
|
||||
defaultValue: data.defaultValue ?? null,
|
||||
validationRules: data.validationRules || {},
|
||||
});
|
||||
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find system setting by ID
|
||||
*/
|
||||
async findById(id: string): Promise<SystemSetting | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find system setting by key
|
||||
*/
|
||||
async findByKey(key: string): Promise<SystemSetting | null> {
|
||||
return this.repository.findOne({
|
||||
where: { key },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value by key
|
||||
*/
|
||||
async getValue(key: string): Promise<any> {
|
||||
const setting = await this.findByKey(key);
|
||||
if (!setting) {
|
||||
return null;
|
||||
}
|
||||
return setting.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value with default fallback
|
||||
*/
|
||||
async getValueOrDefault(key: string, defaultValue: any): Promise<any> {
|
||||
const value = await this.getValue(key);
|
||||
return value !== null ? value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update system setting
|
||||
*/
|
||||
async update(ctx: ServiceContext, id: string, data: UpdateSystemSettingDto): Promise<SystemSetting | null> {
|
||||
const entity = await this.findById(id);
|
||||
if (!entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!entity.isEditable) {
|
||||
throw new Error('This setting is not editable');
|
||||
}
|
||||
|
||||
if (data.value !== undefined) {
|
||||
this.validateValue(data.value, entity.dataType, data.validationRules ?? entity.validationRules);
|
||||
}
|
||||
|
||||
Object.assign(entity, {
|
||||
...data,
|
||||
updatedBy: ctx.userId || null,
|
||||
});
|
||||
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update value by key
|
||||
*/
|
||||
async updateValueByKey(ctx: ServiceContext, key: string, value: any): Promise<SystemSetting | null> {
|
||||
const setting = await this.findByKey(key);
|
||||
if (!setting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!setting.isEditable) {
|
||||
throw new Error('This setting is not editable');
|
||||
}
|
||||
|
||||
this.validateValue(value, setting.dataType, setting.validationRules);
|
||||
setting.value = value;
|
||||
setting.updatedBy = ctx.userId || null;
|
||||
|
||||
return this.repository.save(setting);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset setting to default value
|
||||
*/
|
||||
async resetToDefault(ctx: ServiceContext, key: string): Promise<SystemSetting | null> {
|
||||
const setting = await this.findByKey(key);
|
||||
if (!setting || setting.defaultValue === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setting.value = setting.defaultValue;
|
||||
setting.updatedBy = ctx.userId || null;
|
||||
|
||||
return this.repository.save(setting);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find settings with filters
|
||||
*/
|
||||
async findWithFilters(
|
||||
filters: SystemSettingFilters,
|
||||
page = 1,
|
||||
limit = 50
|
||||
): Promise<PaginatedResult<SystemSetting>> {
|
||||
const qb = this.repository.createQueryBuilder('ss');
|
||||
|
||||
if (filters.category) {
|
||||
qb.andWhere('ss.category = :category', { category: filters.category });
|
||||
}
|
||||
if (filters.isPublic !== undefined) {
|
||||
qb.andWhere('ss.is_public = :isPublic', { isPublic: filters.isPublic });
|
||||
}
|
||||
if (filters.isEditable !== undefined) {
|
||||
qb.andWhere('ss.is_editable = :isEditable', { isEditable: filters.isEditable });
|
||||
}
|
||||
if (filters.search) {
|
||||
qb.andWhere(
|
||||
'(ss.key ILIKE :search OR ss.description ILIKE :search)',
|
||||
{ search: `%${filters.search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
qb.orderBy('ss.category', 'ASC')
|
||||
.addOrderBy('ss.key', 'ASC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find settings by category
|
||||
*/
|
||||
async findByCategory(category: string): Promise<SystemSetting[]> {
|
||||
return this.repository.find({
|
||||
where: { category },
|
||||
order: { key: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all public settings
|
||||
*/
|
||||
async getPublicSettings(): Promise<Record<string, any>> {
|
||||
const settings = await this.repository.find({
|
||||
where: { isPublic: true },
|
||||
});
|
||||
|
||||
const result: Record<string, any> = {};
|
||||
for (const setting of settings) {
|
||||
result[setting.key] = setting.value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk update settings
|
||||
*/
|
||||
async updateMultiple(
|
||||
ctx: ServiceContext,
|
||||
settings: { key: string; value: any }[]
|
||||
): Promise<{ updated: number; errors: string[] }> {
|
||||
let updated = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const { key, value } of settings) {
|
||||
try {
|
||||
const result = await this.updateValueByKey(ctx, key, value);
|
||||
if (result) {
|
||||
updated++;
|
||||
} else {
|
||||
errors.push(`Setting '${key}' not found`);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`${key}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { updated, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete system setting (hard delete)
|
||||
*/
|
||||
async hardDelete(id: string): Promise<boolean> {
|
||||
const entity = await this.findById(id);
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!entity.isEditable) {
|
||||
throw new Error('This setting cannot be deleted');
|
||||
}
|
||||
|
||||
const result = await this.repository.delete({ id });
|
||||
return (result.affected || 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all categories
|
||||
*/
|
||||
async getCategories(): Promise<string[]> {
|
||||
const result = await this.repository
|
||||
.createQueryBuilder('ss')
|
||||
.select('DISTINCT ss.category', 'category')
|
||||
.orderBy('ss.category', 'ASC')
|
||||
.getRawMany();
|
||||
|
||||
return result.map((r) => r.category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate value against type and rules
|
||||
*/
|
||||
private validateValue(
|
||||
value: any,
|
||||
dataType: SettingDataType,
|
||||
validationRules?: Record<string, any> | null
|
||||
): void {
|
||||
switch (dataType) {
|
||||
case 'number':
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
throw new Error('Value must be a valid number');
|
||||
}
|
||||
break;
|
||||
case 'boolean':
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new Error('Value must be a boolean');
|
||||
}
|
||||
break;
|
||||
case 'json':
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new Error('Value must be a valid JSON object');
|
||||
}
|
||||
break;
|
||||
case 'array':
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error('Value must be an array');
|
||||
}
|
||||
break;
|
||||
case 'secret':
|
||||
case 'string':
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error('Value must be a string');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (validationRules) {
|
||||
if (validationRules.min !== undefined && typeof value === 'number' && value < validationRules.min) {
|
||||
throw new Error(`Value must be at least ${validationRules.min}`);
|
||||
}
|
||||
if (validationRules.max !== undefined && typeof value === 'number' && value > validationRules.max) {
|
||||
throw new Error(`Value must be at most ${validationRules.max}`);
|
||||
}
|
||||
if (validationRules.pattern && typeof value === 'string' && !new RegExp(validationRules.pattern).test(value)) {
|
||||
throw new Error('Value does not match required pattern');
|
||||
}
|
||||
if (validationRules.options && !validationRules.options.includes(value)) {
|
||||
throw new Error(`Value must be one of: ${validationRules.options.join(', ')}`);
|
||||
}
|
||||
if (validationRules.minLength !== undefined && typeof value === 'string' && value.length < validationRules.minLength) {
|
||||
throw new Error(`Value must be at least ${validationRules.minLength} characters`);
|
||||
}
|
||||
if (validationRules.maxLength !== undefined && typeof value === 'string' && value.length > validationRules.maxLength) {
|
||||
throw new Error(`Value must be at most ${validationRules.maxLength} characters`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
377
src/modules/settings/services/tenant-setting.service.ts
Normal file
377
src/modules/settings/services/tenant-setting.service.ts
Normal file
@ -0,0 +1,377 @@
|
||||
/**
|
||||
* TenantSettingService - Tenant-level Configuration Management
|
||||
*
|
||||
* Manages custom settings per tenant, overriding system and plan defaults.
|
||||
* Hierarchy: system_settings < plan_settings < tenant_settings
|
||||
*
|
||||
* @module Settings
|
||||
*/
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { TenantSetting, SystemSetting, PlanSetting } from '../entities';
|
||||
|
||||
interface ServiceContext {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export type InheritedFrom = 'system' | 'plan' | 'custom';
|
||||
|
||||
export interface CreateTenantSettingDto {
|
||||
key: string;
|
||||
value: any;
|
||||
inheritedFrom?: InheritedFrom;
|
||||
isOverridden?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateTenantSettingDto {
|
||||
value: any;
|
||||
inheritedFrom?: InheritedFrom;
|
||||
isOverridden?: boolean;
|
||||
}
|
||||
|
||||
export interface TenantSettingFilters {
|
||||
inheritedFrom?: InheritedFrom;
|
||||
isOverridden?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface EffectiveSettingResult {
|
||||
key: string;
|
||||
value: any;
|
||||
source: 'system' | 'plan' | 'tenant';
|
||||
isOverridden: boolean;
|
||||
}
|
||||
|
||||
export class TenantSettingService {
|
||||
private repository: Repository<TenantSetting>;
|
||||
private systemSettingRepository: Repository<SystemSetting>;
|
||||
private planSettingRepository: Repository<PlanSetting>;
|
||||
|
||||
constructor(
|
||||
repository: Repository<TenantSetting>,
|
||||
systemSettingRepository: Repository<SystemSetting>,
|
||||
planSettingRepository: Repository<PlanSetting>
|
||||
) {
|
||||
this.repository = repository;
|
||||
this.systemSettingRepository = systemSettingRepository;
|
||||
this.planSettingRepository = planSettingRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new tenant setting
|
||||
*/
|
||||
async create(ctx: ServiceContext, data: CreateTenantSettingDto): Promise<TenantSetting> {
|
||||
const existing = await this.findByKey(ctx, data.key);
|
||||
if (existing) {
|
||||
throw new Error(`Tenant setting with key '${data.key}' already exists`);
|
||||
}
|
||||
|
||||
const entity = this.repository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
key: data.key,
|
||||
value: data.value,
|
||||
inheritedFrom: data.inheritedFrom ?? 'custom',
|
||||
isOverridden: data.isOverridden ?? true,
|
||||
});
|
||||
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find tenant setting by ID
|
||||
*/
|
||||
async findById(ctx: ServiceContext, id: string): Promise<TenantSetting | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find tenant setting by key
|
||||
*/
|
||||
async findByKey(ctx: ServiceContext, key: string): Promise<TenantSetting | null> {
|
||||
return this.repository.findOne({
|
||||
where: { tenantId: ctx.tenantId, key },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value for a tenant setting
|
||||
*/
|
||||
async getValue(ctx: ServiceContext, key: string): Promise<any> {
|
||||
const setting = await this.findByKey(ctx, key);
|
||||
return setting?.value ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective value with inheritance hierarchy
|
||||
* Order: tenant > plan > system
|
||||
*/
|
||||
async getEffectiveValue(ctx: ServiceContext, key: string, planId?: string): Promise<any> {
|
||||
// First check tenant setting
|
||||
const tenantSetting = await this.findByKey(ctx, key);
|
||||
if (tenantSetting && tenantSetting.isOverridden) {
|
||||
return tenantSetting.value;
|
||||
}
|
||||
|
||||
// Then check plan setting
|
||||
if (planId) {
|
||||
const planSetting = await this.planSettingRepository.findOne({
|
||||
where: { planId, key },
|
||||
});
|
||||
if (planSetting) {
|
||||
return planSetting.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Finally check system setting
|
||||
const systemSetting = await this.systemSettingRepository.findOne({
|
||||
where: { key },
|
||||
});
|
||||
if (systemSetting) {
|
||||
return systemSetting.value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all effective settings with source information
|
||||
*/
|
||||
async getEffectiveSettings(ctx: ServiceContext, planId?: string): Promise<EffectiveSettingResult[]> {
|
||||
const results: Map<string, EffectiveSettingResult> = new Map();
|
||||
|
||||
// Start with system settings (base layer)
|
||||
const systemSettings = await this.systemSettingRepository.find();
|
||||
for (const setting of systemSettings) {
|
||||
results.set(setting.key, {
|
||||
key: setting.key,
|
||||
value: setting.value,
|
||||
source: 'system',
|
||||
isOverridden: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Override with plan settings
|
||||
if (planId) {
|
||||
const planSettings = await this.planSettingRepository.find({
|
||||
where: { planId },
|
||||
});
|
||||
for (const setting of planSettings) {
|
||||
results.set(setting.key, {
|
||||
key: setting.key,
|
||||
value: setting.value,
|
||||
source: 'plan',
|
||||
isOverridden: results.has(setting.key),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Override with tenant settings
|
||||
const tenantSettings = await this.repository.find({
|
||||
where: { tenantId: ctx.tenantId },
|
||||
});
|
||||
for (const setting of tenantSettings) {
|
||||
if (setting.isOverridden) {
|
||||
results.set(setting.key, {
|
||||
key: setting.key,
|
||||
value: setting.value,
|
||||
source: 'tenant',
|
||||
isOverridden: results.has(setting.key),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(results.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tenant settings
|
||||
*/
|
||||
async findAll(ctx: ServiceContext): Promise<TenantSetting[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId: ctx.tenantId },
|
||||
order: { key: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tenant settings as key-value map
|
||||
*/
|
||||
async getTenantSettingsMap(ctx: ServiceContext): Promise<Record<string, any>> {
|
||||
const settings = await this.findAll(ctx);
|
||||
const result: Record<string, any> = {};
|
||||
for (const setting of settings) {
|
||||
result[setting.key] = setting.value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tenant setting
|
||||
*/
|
||||
async update(ctx: ServiceContext, id: string, data: UpdateTenantSettingDto): Promise<TenantSetting | null> {
|
||||
const entity = await this.findById(ctx, id);
|
||||
if (!entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object.assign(entity, data);
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or create tenant setting by key (upsert)
|
||||
*/
|
||||
async upsert(ctx: ServiceContext, key: string, value: any): Promise<TenantSetting> {
|
||||
const existing = await this.findByKey(ctx, key);
|
||||
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
existing.isOverridden = true;
|
||||
return this.repository.save(existing);
|
||||
}
|
||||
|
||||
return this.create(ctx, {
|
||||
key,
|
||||
value,
|
||||
inheritedFrom: 'custom',
|
||||
isOverridden: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset setting to inherited value (remove override)
|
||||
*/
|
||||
async resetToInherited(ctx: ServiceContext, key: string): Promise<TenantSetting | null> {
|
||||
const setting = await this.findByKey(ctx, key);
|
||||
if (!setting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setting.isOverridden = false;
|
||||
return this.repository.save(setting);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find settings with filters
|
||||
*/
|
||||
async findWithFilters(
|
||||
ctx: ServiceContext,
|
||||
filters: TenantSettingFilters,
|
||||
page = 1,
|
||||
limit = 50
|
||||
): Promise<PaginatedResult<TenantSetting>> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('ts')
|
||||
.where('ts.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.inheritedFrom) {
|
||||
qb.andWhere('ts.inherited_from = :inheritedFrom', { inheritedFrom: filters.inheritedFrom });
|
||||
}
|
||||
if (filters.isOverridden !== undefined) {
|
||||
qb.andWhere('ts.is_overridden = :isOverridden', { isOverridden: filters.isOverridden });
|
||||
}
|
||||
if (filters.search) {
|
||||
qb.andWhere('ts.key ILIKE :search', { search: `%${filters.search}%` });
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
qb.orderBy('ts.key', 'ASC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk update tenant settings
|
||||
*/
|
||||
async updateMultiple(
|
||||
ctx: ServiceContext,
|
||||
settings: { key: string; value: any }[]
|
||||
): Promise<{ updated: number; created: number; errors: string[] }> {
|
||||
let updated = 0;
|
||||
let created = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const { key, value } of settings) {
|
||||
try {
|
||||
const existing = await this.findByKey(ctx, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
existing.isOverridden = true;
|
||||
await this.repository.save(existing);
|
||||
updated++;
|
||||
} else {
|
||||
await this.create(ctx, { key, value });
|
||||
created++;
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`${key}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { updated, created, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete tenant setting (hard delete)
|
||||
*/
|
||||
async hardDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ id, tenantId: ctx.tenantId });
|
||||
return (result.affected || 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize tenant settings from plan defaults
|
||||
*/
|
||||
async initializeFromPlan(ctx: ServiceContext, planId: string): Promise<number> {
|
||||
const planSettings = await this.planSettingRepository.find({
|
||||
where: { planId },
|
||||
});
|
||||
|
||||
let initialized = 0;
|
||||
for (const planSetting of planSettings) {
|
||||
const existing = await this.findByKey(ctx, planSetting.key);
|
||||
if (!existing) {
|
||||
await this.create(ctx, {
|
||||
key: planSetting.key,
|
||||
value: planSetting.value,
|
||||
inheritedFrom: 'plan',
|
||||
isOverridden: false,
|
||||
});
|
||||
initialized++;
|
||||
}
|
||||
}
|
||||
|
||||
return initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overridden settings only
|
||||
*/
|
||||
async getOverriddenSettings(ctx: ServiceContext): Promise<TenantSetting[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId: ctx.tenantId, isOverridden: true },
|
||||
order: { key: 'ASC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
392
src/modules/settings/services/user-preference.service.ts
Normal file
392
src/modules/settings/services/user-preference.service.ts
Normal file
@ -0,0 +1,392 @@
|
||||
/**
|
||||
* UserPreferenceService - User Preferences Management
|
||||
*
|
||||
* Manages personal preferences per user (theme, language, notifications, etc.)
|
||||
* These are user-specific and don't follow the setting hierarchy.
|
||||
*
|
||||
* @module Settings
|
||||
*/
|
||||
|
||||
import { Repository, In } from 'typeorm';
|
||||
import { UserPreference } from '../entities';
|
||||
|
||||
interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface CreateUserPreferenceDto {
|
||||
userId: string;
|
||||
key: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface UpdateUserPreferenceDto {
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface UserPreferenceFilters {
|
||||
userId?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface DefaultPreferences {
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
language: string;
|
||||
timezone: string;
|
||||
dateFormat: string;
|
||||
timeFormat: '12h' | '24h';
|
||||
pageSize: number;
|
||||
notifications: {
|
||||
email: boolean;
|
||||
push: boolean;
|
||||
inApp: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFERENCES: DefaultPreferences = {
|
||||
theme: 'system',
|
||||
language: 'es',
|
||||
timezone: 'America/Mexico_City',
|
||||
dateFormat: 'DD/MM/YYYY',
|
||||
timeFormat: '24h',
|
||||
pageSize: 25,
|
||||
notifications: {
|
||||
email: true,
|
||||
push: true,
|
||||
inApp: true,
|
||||
},
|
||||
};
|
||||
|
||||
export class UserPreferenceService {
|
||||
private repository: Repository<UserPreference>;
|
||||
|
||||
constructor(repository: Repository<UserPreference>) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user preference
|
||||
*/
|
||||
async create(data: CreateUserPreferenceDto): Promise<UserPreference> {
|
||||
const existing = await this.findByUserAndKey(data.userId, data.key);
|
||||
if (existing) {
|
||||
throw new Error(`User preference with key '${data.key}' already exists for this user`);
|
||||
}
|
||||
|
||||
const entity = this.repository.create({
|
||||
userId: data.userId,
|
||||
key: data.key,
|
||||
value: data.value,
|
||||
syncedAt: new Date(),
|
||||
});
|
||||
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user preference by ID
|
||||
*/
|
||||
async findById(id: string): Promise<UserPreference | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user preference by user ID and key
|
||||
*/
|
||||
async findByUserAndKey(userId: string, key: string): Promise<UserPreference | null> {
|
||||
return this.repository.findOne({
|
||||
where: { userId, key },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value for a user preference
|
||||
*/
|
||||
async getValue(userId: string, key: string): Promise<any> {
|
||||
const preference = await this.findByUserAndKey(userId, key);
|
||||
return preference?.value ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value with default fallback
|
||||
*/
|
||||
async getValueOrDefault(userId: string, key: string, defaultValue: any): Promise<any> {
|
||||
const value = await this.getValue(userId, key);
|
||||
return value !== null ? value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all preferences for a user
|
||||
*/
|
||||
async findByUser(userId: string): Promise<UserPreference[]> {
|
||||
return this.repository.find({
|
||||
where: { userId },
|
||||
order: { key: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all preferences for a user as key-value map
|
||||
*/
|
||||
async getUserPreferencesMap(userId: string): Promise<Record<string, any>> {
|
||||
const preferences = await this.findByUser(userId);
|
||||
const result: Record<string, any> = {};
|
||||
for (const pref of preferences) {
|
||||
result[pref.key] = pref.value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all preferences for a user with defaults filled in
|
||||
*/
|
||||
async getUserPreferencesWithDefaults(userId: string): Promise<DefaultPreferences & Record<string, any>> {
|
||||
const userPrefs = await this.getUserPreferencesMap(userId);
|
||||
return {
|
||||
...DEFAULT_PREFERENCES,
|
||||
...userPrefs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user preference
|
||||
*/
|
||||
async update(id: string, data: UpdateUserPreferenceDto): Promise<UserPreference | null> {
|
||||
const entity = await this.findById(id);
|
||||
if (!entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
entity.value = data.value;
|
||||
entity.syncedAt = new Date();
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or create user preference by user and key (upsert)
|
||||
*/
|
||||
async upsert(userId: string, key: string, value: any): Promise<UserPreference> {
|
||||
const existing = await this.findByUserAndKey(userId, key);
|
||||
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
existing.syncedAt = new Date();
|
||||
return this.repository.save(existing);
|
||||
}
|
||||
|
||||
return this.create({ userId, key, value });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find preferences with filters
|
||||
*/
|
||||
async findWithFilters(
|
||||
filters: UserPreferenceFilters,
|
||||
page = 1,
|
||||
limit = 50
|
||||
): Promise<PaginatedResult<UserPreference>> {
|
||||
const qb = this.repository.createQueryBuilder('up');
|
||||
|
||||
if (filters.userId) {
|
||||
qb.andWhere('up.user_id = :userId', { userId: filters.userId });
|
||||
}
|
||||
if (filters.search) {
|
||||
qb.andWhere('up.key ILIKE :search', { search: `%${filters.search}%` });
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
qb.orderBy('up.user_id', 'ASC')
|
||||
.addOrderBy('up.key', 'ASC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk update user preferences
|
||||
*/
|
||||
async updateMultiple(
|
||||
userId: string,
|
||||
preferences: { key: string; value: any }[]
|
||||
): Promise<{ updated: number; created: number; errors: string[] }> {
|
||||
let updated = 0;
|
||||
let created = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const { key, value } of preferences) {
|
||||
try {
|
||||
const existing = await this.findByUserAndKey(userId, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
existing.syncedAt = new Date();
|
||||
await this.repository.save(existing);
|
||||
updated++;
|
||||
} else {
|
||||
await this.create({ userId, key, value });
|
||||
created++;
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`${key}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { updated, created, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset preference to default
|
||||
*/
|
||||
async resetToDefault(userId: string, key: string): Promise<UserPreference | null> {
|
||||
const defaultValue = (DEFAULT_PREFERENCES as Record<string, any>)[key];
|
||||
if (defaultValue === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.upsert(userId, key, defaultValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all preferences to defaults
|
||||
*/
|
||||
async resetAllToDefaults(userId: string): Promise<number> {
|
||||
// Delete all existing preferences
|
||||
await this.repository.delete({ userId });
|
||||
|
||||
// Create default preferences
|
||||
let count = 0;
|
||||
for (const [key, value] of Object.entries(DEFAULT_PREFERENCES)) {
|
||||
await this.create({ userId, key, value });
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user preference (hard delete)
|
||||
*/
|
||||
async hardDelete(id: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ id });
|
||||
return (result.affected || 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all preferences for a user
|
||||
*/
|
||||
async deleteByUser(userId: string): Promise<number> {
|
||||
const result = await this.repository.delete({ userId });
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy preferences from one user to another
|
||||
*/
|
||||
async copyFromUser(
|
||||
sourceUserId: string,
|
||||
targetUserId: string,
|
||||
overwrite = false
|
||||
): Promise<{ copied: number; skipped: number }> {
|
||||
const sourcePreferences = await this.findByUser(sourceUserId);
|
||||
let copied = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const pref of sourcePreferences) {
|
||||
const existing = await this.findByUserAndKey(targetUserId, pref.key);
|
||||
|
||||
if (existing && !overwrite) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
existing.value = pref.value;
|
||||
existing.syncedAt = new Date();
|
||||
await this.repository.save(existing);
|
||||
} else {
|
||||
await this.create({
|
||||
userId: targetUserId,
|
||||
key: pref.key,
|
||||
value: pref.value,
|
||||
});
|
||||
}
|
||||
copied++;
|
||||
}
|
||||
|
||||
return { copied, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preferences for multiple users
|
||||
*/
|
||||
async getPreferencesForUsers(userIds: string[], key: string): Promise<Map<string, any>> {
|
||||
const preferences = await this.repository.find({
|
||||
where: {
|
||||
userId: In(userIds),
|
||||
key,
|
||||
},
|
||||
});
|
||||
|
||||
const result = new Map<string, any>();
|
||||
for (const pref of preferences) {
|
||||
result.set(pref.userId, pref.value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific preference keys for a user
|
||||
*/
|
||||
async getPreferencesByKeys(userId: string, keys: string[]): Promise<Record<string, any>> {
|
||||
const preferences = await this.repository.find({
|
||||
where: {
|
||||
userId,
|
||||
key: In(keys),
|
||||
},
|
||||
});
|
||||
|
||||
const result: Record<string, any> = {};
|
||||
for (const pref of preferences) {
|
||||
result[pref.key] = pref.value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync preferences (update syncedAt timestamp)
|
||||
*/
|
||||
async syncPreferences(userId: string): Promise<void> {
|
||||
await this.repository.update(
|
||||
{ userId },
|
||||
{ syncedAt: new Date() }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preferences modified since a specific date
|
||||
*/
|
||||
async getModifiedSince(userId: string, since: Date): Promise<UserPreference[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('up')
|
||||
.where('up.user_id = :userId', { userId })
|
||||
.andWhere('up.updated_at > :since', { since })
|
||||
.orderBy('up.updated_at', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user