feat: Implement database-backed settings system for dynamic configuration
Some checks failed
ParentFlow CI/CD Pipeline / Backend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Frontend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Security Scanning (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-app/maternal-app-backend dockerfile:Dockerfile.production name:backend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-web dockerfile:Dockerfile.production name:frontend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Development (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled

- Create Settings entity with key-value storage
- Add settings table migration with default values
- Update dashboard service to read/write settings from database
- Update auth controller and service to use database settings
- Registration mode now dynamically controlled from admin dashboard
- No server restart required for settings changes
This commit is contained in:
Andrei
2025-10-08 14:41:29 +00:00
parent 2ba045aef9
commit c228ed4292
8 changed files with 224 additions and 15 deletions

View File

@@ -31,3 +31,4 @@ export {
DeletionRequestStatus,
DataType,
} from './data-deletion-request.entity';
export { Settings } from './settings.entity';

View File

@@ -0,0 +1,25 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
@Entity('settings')
export class Settings {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true, length: 100 })
key: string;
@Column({ type: 'text' })
value: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ default: 'string', length: 50 })
type: string; // 'string', 'boolean', 'number', 'json'
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -0,0 +1,82 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
export class CreateSettingsTable1735000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Create settings table
await queryRunner.createTable(
new Table({
name: 'settings',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
generationStrategy: 'uuid',
default: 'uuid_generate_v4()',
},
{
name: 'key',
type: 'varchar',
length: '100',
isUnique: true,
},
{
name: 'value',
type: 'text',
},
{
name: 'description',
type: 'text',
isNullable: true,
},
{
name: 'type',
type: 'varchar',
length: '50',
default: "'string'",
},
{
name: 'created_at',
type: 'timestamptz',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updated_at',
type: 'timestamptz',
default: 'CURRENT_TIMESTAMP',
},
],
}),
true,
);
// Create index on key for faster lookups
await queryRunner.createIndex(
'settings',
new TableIndex({
name: 'IDX_SETTINGS_KEY',
columnNames: ['key'],
}),
);
// Insert default settings
await queryRunner.query(`
INSERT INTO settings (key, value, description, type) VALUES
('registration_mode', 'public', 'Registration mode: public or invite_only', 'string'),
('require_invite_code', 'false', 'Whether invite codes are required for registration', 'boolean'),
('max_family_size', '10', 'Maximum number of members allowed in a family', 'number'),
('max_children_per_family', '10', 'Maximum number of children allowed per family', 'number'),
('enable_ai_features', 'true', 'Enable AI assistant features', 'boolean'),
('enable_voice_input', 'true', 'Enable voice input for activity tracking', 'boolean')
ON CONFLICT (key) DO NOTHING;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Drop index
await queryRunner.dropIndex('settings', 'IDX_SETTINGS_KEY');
// Drop table
await queryRunner.dropTable('settings');
}
}

View File

@@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
import { User } from '../../../database/entities/user.entity';
import { Settings } from '../../../database/entities/settings.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])],
imports: [TypeOrmModule.forFeature([User, Settings])],
controllers: [DashboardController],
providers: [DashboardService],
exports: [DashboardService],

View File

@@ -2,12 +2,15 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../../../database/entities/user.entity';
import { Settings } from '../../../database/entities/settings.entity';
@Injectable()
export class DashboardService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectRepository(Settings)
private readonly settingsRepository: Repository<Settings>,
) {}
async getStats() {
@@ -346,8 +349,34 @@ export class DashboardService {
};
}
// Helper method to get a setting value from database with fallback to env var
private async getSetting(key: string, defaultValue: any = null): Promise<any> {
const setting = await this.settingsRepository.findOne({ where: { key } });
if (setting) {
// Parse value based on type
switch (setting.type) {
case 'boolean':
return setting.value === 'true';
case 'number':
return parseFloat(setting.value);
case 'json':
return JSON.parse(setting.value);
default:
return setting.value;
}
}
return defaultValue;
}
async getSettings() {
// Return current system settings (from env vars and database)
// Get settings from database with fallbacks
const registrationMode = await this.getSetting('registration_mode', process.env.REGISTRATION_MODE || 'public');
const requireInviteCode = await this.getSetting('require_invite_code', process.env.REQUIRE_INVITE_CODE === 'true');
const maxFamilySize = await this.getSetting('max_family_size', 10);
const maxChildrenPerFamily = await this.getSetting('max_children_per_family', 10);
const enableAiFeatures = await this.getSetting('enable_ai_features', true);
const enableVoiceInput = await this.getSetting('enable_voice_input', true);
return {
// General Settings
siteName: process.env.APP_NAME || 'ParentFlow',
@@ -356,9 +385,15 @@ export class DashboardService {
timezone: process.env.TZ || 'UTC',
language: 'en',
// Registration Settings
registrationMode: process.env.REGISTRATION_MODE || 'invite_only', // 'public' or 'invite_only'
requireInviteCode: process.env.REQUIRE_INVITE_CODE === 'true' || true,
// Registration Settings (from database)
registrationMode,
requireInviteCode,
// Feature Settings (from database)
maxFamilySize,
maxChildrenPerFamily,
enableAiFeatures,
enableVoiceInput,
// Security Settings
enforcePasswordPolicy: true,
@@ -403,16 +438,52 @@ export class DashboardService {
}
async updateSettings(settings: any) {
// In a real implementation, you would:
// 1. Validate the settings
// 2. Update environment variables or configuration file
// 3. Update database records if needed
// 4. Restart services if required
// Define which settings can be stored in database
const dbSettingsMap = {
registrationMode: { key: 'registration_mode', type: 'string' },
requireInviteCode: { key: 'require_invite_code', type: 'boolean' },
maxFamilySize: { key: 'max_family_size', type: 'number' },
maxChildrenPerFamily: { key: 'max_children_per_family', type: 'number' },
enableAiFeatures: { key: 'enable_ai_features', type: 'boolean' },
enableVoiceInput: { key: 'enable_voice_input', type: 'boolean' },
};
const updatedSettings = [];
// Update database settings
for (const [settingName, config] of Object.entries(dbSettingsMap)) {
if (settings[settingName] !== undefined) {
const { key, type } = config;
let value = settings[settingName];
// Convert value to string for storage
if (type === 'boolean') {
value = value ? 'true' : 'false';
} else if (type === 'number') {
value = value.toString();
} else if (type === 'json') {
value = JSON.stringify(value);
}
// Update or create setting
let setting = await this.settingsRepository.findOne({ where: { key } });
if (setting) {
setting.value = value;
setting.type = type;
await this.settingsRepository.save(setting);
} else {
setting = this.settingsRepository.create({ key, value, type });
await this.settingsRepository.save(setting);
}
updatedSettings.push(key);
}
}
// For now, return success message
return {
success: true,
message: 'Settings updated successfully. Some changes may require a server restart.',
message: `Settings updated successfully: ${updatedSettings.join(', ')}`,
updatedSettings,
};
}
}

View File

@@ -15,12 +15,15 @@ import {
Req,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AuthService } from './auth.service';
import { PasswordResetService } from './password-reset.service';
import { MFAService } from './mfa.service';
import { SessionService } from './session.service';
import { DeviceTrustService } from './device-trust.service';
import { BiometricAuthService } from './biometric-auth.service';
import { Settings } from '../../database/entities/settings.entity';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
@@ -46,13 +49,25 @@ export class AuthController {
private readonly deviceTrustService: DeviceTrustService,
private readonly biometricAuthService: BiometricAuthService,
private readonly configService: ConfigService,
@InjectRepository(Settings)
private readonly settingsRepository: Repository<Settings>,
) {}
@Public()
@Get('registration/config')
@HttpCode(HttpStatus.OK)
async getRegistrationConfig() {
const registrationMode = this.configService.get<string>('REGISTRATION_MODE', 'public');
// Try to get from database first, fall back to env vars
let registrationMode = 'public';
const registrationModeSetting = await this.settingsRepository.findOne({
where: { key: 'registration_mode' }
});
if (registrationModeSetting) {
registrationMode = registrationModeSetting.value;
} else {
registrationMode = this.configService.get<string>('REGISTRATION_MODE', 'public');
}
const requireInviteCode = registrationMode === 'invite_only';
return {

View File

@@ -21,6 +21,7 @@ import {
PasswordResetToken,
Family,
FamilyMember,
Settings,
} from '../../database/entities';
import { InviteCode, InviteCodeUse } from '../invite-codes/invite-codes.entity';
@@ -36,6 +37,7 @@ import { InviteCode, InviteCodeUse } from '../invite-codes/invite-codes.entity';
WebAuthnCredential,
InviteCode,
InviteCodeUse,
Settings,
]),
PassportModule,
CommonModule,

View File

@@ -19,6 +19,7 @@ import {
FamilyMember,
AuditAction,
EntityType,
Settings,
} from '../../database/entities';
import { InviteCode, InviteCodeUse } from '../invite-codes/invite-codes.entity';
import { RegisterDto } from './dto/register.dto';
@@ -48,14 +49,25 @@ export class AuthService {
private inviteCodeRepository: Repository<InviteCode>,
@InjectRepository(InviteCodeUse)
private inviteCodeUseRepository: Repository<InviteCodeUse>,
@InjectRepository(Settings)
private settingsRepository: Repository<Settings>,
private jwtService: JwtService,
private configService: ConfigService,
private auditService: AuditService,
) {}
async register(registerDto: RegisterDto): Promise<AuthResponse> {
// Check registration mode and validate invite code if required
const registrationMode = this.configService.get<string>('REGISTRATION_MODE', 'public');
// Check registration mode from database first, fall back to env vars
let registrationMode = 'public';
const registrationModeSetting = await this.settingsRepository.findOne({
where: { key: 'registration_mode' }
});
if (registrationModeSetting) {
registrationMode = registrationModeSetting.value;
} else {
registrationMode = this.configService.get<string>('REGISTRATION_MODE', 'public');
}
const requireInviteCode = registrationMode === 'invite_only';
let validatedInviteCode: InviteCode | null = null;