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
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:
@@ -31,3 +31,4 @@ export {
|
||||
DeletionRequestStatus,
|
||||
DataType,
|
||||
} from './data-deletion-request.entity';
|
||||
export { Settings } from './settings.entity';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user