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,
|
DeletionRequestStatus,
|
||||||
DataType,
|
DataType,
|
||||||
} from './data-deletion-request.entity';
|
} 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 { DashboardController } from './dashboard.controller';
|
||||||
import { DashboardService } from './dashboard.service';
|
import { DashboardService } from './dashboard.service';
|
||||||
import { User } from '../../../database/entities/user.entity';
|
import { User } from '../../../database/entities/user.entity';
|
||||||
|
import { Settings } from '../../../database/entities/settings.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([User])],
|
imports: [TypeOrmModule.forFeature([User, Settings])],
|
||||||
controllers: [DashboardController],
|
controllers: [DashboardController],
|
||||||
providers: [DashboardService],
|
providers: [DashboardService],
|
||||||
exports: [DashboardService],
|
exports: [DashboardService],
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { User } from '../../../database/entities/user.entity';
|
import { User } from '../../../database/entities/user.entity';
|
||||||
|
import { Settings } from '../../../database/entities/settings.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DashboardService {
|
export class DashboardService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(User)
|
@InjectRepository(User)
|
||||||
private readonly userRepository: Repository<User>,
|
private readonly userRepository: Repository<User>,
|
||||||
|
@InjectRepository(Settings)
|
||||||
|
private readonly settingsRepository: Repository<Settings>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getStats() {
|
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() {
|
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 {
|
return {
|
||||||
// General Settings
|
// General Settings
|
||||||
siteName: process.env.APP_NAME || 'ParentFlow',
|
siteName: process.env.APP_NAME || 'ParentFlow',
|
||||||
@@ -356,9 +385,15 @@ export class DashboardService {
|
|||||||
timezone: process.env.TZ || 'UTC',
|
timezone: process.env.TZ || 'UTC',
|
||||||
language: 'en',
|
language: 'en',
|
||||||
|
|
||||||
// Registration Settings
|
// Registration Settings (from database)
|
||||||
registrationMode: process.env.REGISTRATION_MODE || 'invite_only', // 'public' or 'invite_only'
|
registrationMode,
|
||||||
requireInviteCode: process.env.REQUIRE_INVITE_CODE === 'true' || true,
|
requireInviteCode,
|
||||||
|
|
||||||
|
// Feature Settings (from database)
|
||||||
|
maxFamilySize,
|
||||||
|
maxChildrenPerFamily,
|
||||||
|
enableAiFeatures,
|
||||||
|
enableVoiceInput,
|
||||||
|
|
||||||
// Security Settings
|
// Security Settings
|
||||||
enforcePasswordPolicy: true,
|
enforcePasswordPolicy: true,
|
||||||
@@ -403,16 +438,52 @@ export class DashboardService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateSettings(settings: any) {
|
async updateSettings(settings: any) {
|
||||||
// In a real implementation, you would:
|
// Define which settings can be stored in database
|
||||||
// 1. Validate the settings
|
const dbSettingsMap = {
|
||||||
// 2. Update environment variables or configuration file
|
registrationMode: { key: 'registration_mode', type: 'string' },
|
||||||
// 3. Update database records if needed
|
requireInviteCode: { key: 'require_invite_code', type: 'boolean' },
|
||||||
// 4. Restart services if required
|
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 {
|
return {
|
||||||
success: true,
|
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,
|
Req,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { PasswordResetService } from './password-reset.service';
|
import { PasswordResetService } from './password-reset.service';
|
||||||
import { MFAService } from './mfa.service';
|
import { MFAService } from './mfa.service';
|
||||||
import { SessionService } from './session.service';
|
import { SessionService } from './session.service';
|
||||||
import { DeviceTrustService } from './device-trust.service';
|
import { DeviceTrustService } from './device-trust.service';
|
||||||
import { BiometricAuthService } from './biometric-auth.service';
|
import { BiometricAuthService } from './biometric-auth.service';
|
||||||
|
import { Settings } from '../../database/entities/settings.entity';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||||
@@ -46,13 +49,25 @@ export class AuthController {
|
|||||||
private readonly deviceTrustService: DeviceTrustService,
|
private readonly deviceTrustService: DeviceTrustService,
|
||||||
private readonly biometricAuthService: BiometricAuthService,
|
private readonly biometricAuthService: BiometricAuthService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
|
@InjectRepository(Settings)
|
||||||
|
private readonly settingsRepository: Repository<Settings>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Get('registration/config')
|
@Get('registration/config')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async getRegistrationConfig() {
|
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';
|
const requireInviteCode = registrationMode === 'invite_only';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
PasswordResetToken,
|
PasswordResetToken,
|
||||||
Family,
|
Family,
|
||||||
FamilyMember,
|
FamilyMember,
|
||||||
|
Settings,
|
||||||
} from '../../database/entities';
|
} from '../../database/entities';
|
||||||
import { InviteCode, InviteCodeUse } from '../invite-codes/invite-codes.entity';
|
import { InviteCode, InviteCodeUse } from '../invite-codes/invite-codes.entity';
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ import { InviteCode, InviteCodeUse } from '../invite-codes/invite-codes.entity';
|
|||||||
WebAuthnCredential,
|
WebAuthnCredential,
|
||||||
InviteCode,
|
InviteCode,
|
||||||
InviteCodeUse,
|
InviteCodeUse,
|
||||||
|
Settings,
|
||||||
]),
|
]),
|
||||||
PassportModule,
|
PassportModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
FamilyMember,
|
FamilyMember,
|
||||||
AuditAction,
|
AuditAction,
|
||||||
EntityType,
|
EntityType,
|
||||||
|
Settings,
|
||||||
} from '../../database/entities';
|
} from '../../database/entities';
|
||||||
import { InviteCode, InviteCodeUse } from '../invite-codes/invite-codes.entity';
|
import { InviteCode, InviteCodeUse } from '../invite-codes/invite-codes.entity';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
@@ -48,14 +49,25 @@ export class AuthService {
|
|||||||
private inviteCodeRepository: Repository<InviteCode>,
|
private inviteCodeRepository: Repository<InviteCode>,
|
||||||
@InjectRepository(InviteCodeUse)
|
@InjectRepository(InviteCodeUse)
|
||||||
private inviteCodeUseRepository: Repository<InviteCodeUse>,
|
private inviteCodeUseRepository: Repository<InviteCodeUse>,
|
||||||
|
@InjectRepository(Settings)
|
||||||
|
private settingsRepository: Repository<Settings>,
|
||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private auditService: AuditService,
|
private auditService: AuditService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async register(registerDto: RegisterDto): Promise<AuthResponse> {
|
async register(registerDto: RegisterDto): Promise<AuthResponse> {
|
||||||
// Check registration mode and validate invite code if required
|
// Check registration mode from database first, fall back to env vars
|
||||||
const registrationMode = this.configService.get<string>('REGISTRATION_MODE', 'public');
|
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';
|
const requireInviteCode = registrationMode === 'invite_only';
|
||||||
|
|
||||||
let validatedInviteCode: InviteCode | null = null;
|
let validatedInviteCode: InviteCode | null = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user