From b4cb84acf3e3e5e76e5df2a4039aa1cd73bd7d79 Mon Sep 17 00:00:00 2001 From: junjiequan Date: Tue, 11 Nov 2025 14:35:30 +0100 Subject: [PATCH 01/17] PR --- src/admin/admin.service.ts | 21 ++------ src/app-config/app-config.controller.ts | 21 ++++++++ src/app-config/app-config.module.ts | 20 ++++++++ src/app-config/app-config.service.ts | 55 +++++++++++++++++++++ src/app-config/dto/app-config.dto.ts | 36 ++++++++++++++ src/app-config/schemas/app-config.schema.ts | 43 ++++++++++++++++ src/app.module.ts | 2 + src/config/frontend.config.json | 5 +- 8 files changed, 182 insertions(+), 21 deletions(-) create mode 100644 src/app-config/app-config.controller.ts create mode 100644 src/app-config/app-config.module.ts create mode 100644 src/app-config/app-config.service.ts create mode 100644 src/app-config/dto/app-config.dto.ts create mode 100644 src/app-config/schemas/app-config.schema.ts diff --git a/src/admin/admin.service.ts b/src/admin/admin.service.ts index 4a6bdf4e8..8fdab515f 100644 --- a/src/admin/admin.service.ts +++ b/src/admin/admin.service.ts @@ -6,9 +6,10 @@ export class AdminService { constructor(private configService: ConfigService) {} async getConfig(): Promise | null> { - const modifiedConfig = this.applyBackendConfigAdjustments(); + const config = + this.configService.get>("frontendConfig") || null; - return modifiedConfig; + return config; } async getTheme(): Promise | null> { @@ -16,20 +17,4 @@ export class AdminService { this.configService.get>("frontendTheme") || null; return theme; } - - // NOTE: Adjusts backend config values for frontend use (e.g., file upload limits). - // Add future backend-dependent adjustments here as needed. - private applyBackendConfigAdjustments(): Record | null { - const config = - this.configService.get>("frontendConfig") || null; - if (!config) { - return null; - } - const postEncodedMaxFileUploadSize = - this.configService.get("maxFileUploadSizeInMb") || "16mb"; - return { - ...config, - maxFileUploadSizeInMb: postEncodedMaxFileUploadSize, - }; - } } diff --git a/src/app-config/app-config.controller.ts b/src/app-config/app-config.controller.ts new file mode 100644 index 000000000..58c4c3c9e --- /dev/null +++ b/src/app-config/app-config.controller.ts @@ -0,0 +1,21 @@ +import { Body, Controller, Get, Put } from "@nestjs/common"; +import { ApiTags } from "@nestjs/swagger"; +import { AppConfigService } from "./app-config.service"; +import { AllowAny } from "src/auth/decorators/allow-any.decorator"; + +@ApiTags("app-config") +@Controller("app-config") +export class AppConfigController { + constructor(private readonly appConfigService: AppConfigService) {} + + @AllowAny() + @Get("config") + async getConfig(): Promise | null> { + return this.appConfigService.getConfig(); + } + + @Put("config") + async updateConfig(@Body() config: Record): Promise { + await this.appConfigService.updateConfig(config); + } +} diff --git a/src/app-config/app-config.module.ts b/src/app-config/app-config.module.ts new file mode 100644 index 000000000..ae7c31866 --- /dev/null +++ b/src/app-config/app-config.module.ts @@ -0,0 +1,20 @@ +import { Module } from "@nestjs/common"; +import { AppConfigService } from "./app-config.service"; +import { AppConfigController } from "./app-config.controller"; +import { MongooseModule } from "@nestjs/mongoose"; +import { AppConfig, AppConfigSchema } from "./schemas/app-config.schema"; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { + name: AppConfig.name, + schema: AppConfigSchema, + }, + ]), + ], + controllers: [AppConfigController], + providers: [AppConfigService], + exports: [AppConfigService], +}) +export class AppConfigModule {} diff --git a/src/app-config/app-config.service.ts b/src/app-config/app-config.service.ts new file mode 100644 index 000000000..988bd8cc9 --- /dev/null +++ b/src/app-config/app-config.service.ts @@ -0,0 +1,55 @@ +import { Injectable, OnModuleInit } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { AppConfig, AppConfigDocument } from "./schemas/app-config.schema"; +import { InjectModel } from "@nestjs/mongoose"; +import { Model } from "mongoose"; + +@Injectable() +export class AppConfigService implements OnModuleInit { + constructor( + private configService: ConfigService, + @InjectModel(AppConfig.name) + private appConfigModel: Model, + ) {} + + async onModuleInit() { + await this.getOrCreateAppConfig(); + } + + async getConfig(): Promise | null> { + const config = + this.configService.get>("frontendConfig") || null; + + return config; + } + + async getTheme(): Promise | null> { + const theme = + this.configService.get>("frontendTheme") || null; + return theme; + } + + async updateConfig(config: Record): Promise { + console.log("Updating config to:", config); + // Implement the actual update logic here. + } + + async getOrCreateAppConfig(): Promise { + const existing = await this.appConfigModel + .findOne({ _id: "frontend" }) // TODO: use id from param + .lean(); + + if (!existing) { + const defaultConfig = + this.configService.get>("frontendConfig") || {}; + + const created = await this.appConfigModel.create({ + _id: "frontend", + value: defaultConfig, + }); + return created.toObject(); + } + + return existing; + } +} diff --git a/src/app-config/dto/app-config.dto.ts b/src/app-config/dto/app-config.dto.ts new file mode 100644 index 000000000..5f96bc456 --- /dev/null +++ b/src/app-config/dto/app-config.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, IsOptional, IsObject } from "class-validator"; + +export class AppConfigDto { + @ApiProperty({ + type: String, + description: "Unique config identifier (e.g. 'frontend', 'backend', etc.)", + example: "frontend", + }) + @IsString() + _id: string; + + @ApiProperty({ + type: Object, + description: "Configuration content as a JSON object", + }) + @IsObject() + value: Record; + + @ApiProperty({ + type: String, + required: false, + description: "Optional description of this configuration entry", + }) + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ + type: String, + description: "User or system that last updated the configuration", + example: "system", + }) + @IsString() + updatedBy: string; +} diff --git a/src/app-config/schemas/app-config.schema.ts b/src/app-config/schemas/app-config.schema.ts new file mode 100644 index 000000000..a207f6a46 --- /dev/null +++ b/src/app-config/schemas/app-config.schema.ts @@ -0,0 +1,43 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { ApiProperty } from "@nestjs/swagger"; +import { Document } from "mongoose"; + +export type AppConfigDocument = AppConfig & Document; + +@Schema({ + collection: "AppConfig", + timestamps: true, +}) +export class AppConfig { + @ApiProperty({ + type: String, + description: "Unique config identifier (e.g. 'frontend', 'backend', etc.)", + }) + @Prop({ type: String, unique: true, required: true }) + _id: string; + + @ApiProperty({ + type: Object, + description: "Configuration content as a JSON object", + }) + @Prop({ type: Object, required: true, default: {} }) + value: Record; + + @ApiProperty({ + type: String, + required: false, + description: "Optional description of this configuration entry", + }) + @Prop({ type: String, required: false }) + description?: string; + + @ApiProperty({ + type: String, + required: false, + description: "User or system that last updated the configuration", + }) + @Prop({ type: String, required: true, default: "system" }) + updatedBy: string; +} + +export const AppConfigSchema = SchemaFactory.createForClass(AppConfig); diff --git a/src/app.module.ts b/src/app.module.ts index 03beaac1e..24e11092c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -42,6 +42,7 @@ import { } from "./common/schemas/generic-history.schema"; import { HistoryModule } from "./history/history.module"; import { MaskSensitiveDataInterceptorModule } from "./common/interceptors/mask-sensitive-data.interceptor"; +import { AppConfigModule } from "./app-config/app-config.module"; @Module({ imports: [ @@ -50,6 +51,7 @@ import { MaskSensitiveDataInterceptorModule } from "./common/interceptors/mask-s isGlobal: true, cache: true, }), + AppConfigModule, AuthModule, CaslModule, AttachmentsModule, diff --git a/src/config/frontend.config.json b/src/config/frontend.config.json index 6f6669638..ca1c0cf0e 100644 --- a/src/config/frontend.config.json +++ b/src/config/frontend.config.json @@ -5,7 +5,7 @@ }, "checkBoxFilterClickTrigger": false, "accessTokenPrefix": "Bearer ", - "addDatasetEnabled": false, + "addDatasetEnabled": true, "archiveWorkflowEnabled": false, "datasetReduceEnabled": true, "datasetJsonScientificMetadata": true, @@ -244,8 +244,7 @@ "description": "Filter by creation time of the dataset", "enabled": true } - ], - "conditions": [] + ] }, "defaultProposalsListSettings": { "columns": [ From 1962f38ae0298205f346a2fda5e95c5b84c3ea1e Mon Sep 17 00:00:00 2001 From: junjiequan Date: Mon, 17 Nov 2025 15:38:55 +0100 Subject: [PATCH 02/17] init-2 --- src/app-config/app-config.controller.ts | 21 --- src/app-config/app-config.module.ts | 20 --- src/app-config/app-config.service.ts | 55 ------- src/app.module.ts | 4 +- .../dto/runtime-config.dto.ts} | 4 +- .../runtime-config.controller.ts | 31 ++++ src/runtime-config/runtime-config.module.ts | 23 +++ src/runtime-config/runtime-config.service.ts | 97 ++++++++++++ .../schemas/runtime-config.schema.ts} | 12 +- src/runtime-config/utils.spec.ts | 147 ++++++++++++++++++ src/runtime-config/utils.ts | 111 +++++++++++++ 11 files changed, 419 insertions(+), 106 deletions(-) delete mode 100644 src/app-config/app-config.controller.ts delete mode 100644 src/app-config/app-config.module.ts delete mode 100644 src/app-config/app-config.service.ts rename src/{app-config/dto/app-config.dto.ts => runtime-config/dto/runtime-config.dto.ts} (91%) create mode 100644 src/runtime-config/runtime-config.controller.ts create mode 100644 src/runtime-config/runtime-config.module.ts create mode 100644 src/runtime-config/runtime-config.service.ts rename src/{app-config/schemas/app-config.schema.ts => runtime-config/schemas/runtime-config.schema.ts} (75%) create mode 100644 src/runtime-config/utils.spec.ts create mode 100644 src/runtime-config/utils.ts diff --git a/src/app-config/app-config.controller.ts b/src/app-config/app-config.controller.ts deleted file mode 100644 index 58c4c3c9e..000000000 --- a/src/app-config/app-config.controller.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Body, Controller, Get, Put } from "@nestjs/common"; -import { ApiTags } from "@nestjs/swagger"; -import { AppConfigService } from "./app-config.service"; -import { AllowAny } from "src/auth/decorators/allow-any.decorator"; - -@ApiTags("app-config") -@Controller("app-config") -export class AppConfigController { - constructor(private readonly appConfigService: AppConfigService) {} - - @AllowAny() - @Get("config") - async getConfig(): Promise | null> { - return this.appConfigService.getConfig(); - } - - @Put("config") - async updateConfig(@Body() config: Record): Promise { - await this.appConfigService.updateConfig(config); - } -} diff --git a/src/app-config/app-config.module.ts b/src/app-config/app-config.module.ts deleted file mode 100644 index ae7c31866..000000000 --- a/src/app-config/app-config.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Module } from "@nestjs/common"; -import { AppConfigService } from "./app-config.service"; -import { AppConfigController } from "./app-config.controller"; -import { MongooseModule } from "@nestjs/mongoose"; -import { AppConfig, AppConfigSchema } from "./schemas/app-config.schema"; - -@Module({ - imports: [ - MongooseModule.forFeature([ - { - name: AppConfig.name, - schema: AppConfigSchema, - }, - ]), - ], - controllers: [AppConfigController], - providers: [AppConfigService], - exports: [AppConfigService], -}) -export class AppConfigModule {} diff --git a/src/app-config/app-config.service.ts b/src/app-config/app-config.service.ts deleted file mode 100644 index 988bd8cc9..000000000 --- a/src/app-config/app-config.service.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Injectable, OnModuleInit } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { AppConfig, AppConfigDocument } from "./schemas/app-config.schema"; -import { InjectModel } from "@nestjs/mongoose"; -import { Model } from "mongoose"; - -@Injectable() -export class AppConfigService implements OnModuleInit { - constructor( - private configService: ConfigService, - @InjectModel(AppConfig.name) - private appConfigModel: Model, - ) {} - - async onModuleInit() { - await this.getOrCreateAppConfig(); - } - - async getConfig(): Promise | null> { - const config = - this.configService.get>("frontendConfig") || null; - - return config; - } - - async getTheme(): Promise | null> { - const theme = - this.configService.get>("frontendTheme") || null; - return theme; - } - - async updateConfig(config: Record): Promise { - console.log("Updating config to:", config); - // Implement the actual update logic here. - } - - async getOrCreateAppConfig(): Promise { - const existing = await this.appConfigModel - .findOne({ _id: "frontend" }) // TODO: use id from param - .lean(); - - if (!existing) { - const defaultConfig = - this.configService.get>("frontendConfig") || {}; - - const created = await this.appConfigModel.create({ - _id: "frontend", - value: defaultConfig, - }); - return created.toObject(); - } - - return existing; - } -} diff --git a/src/app.module.ts b/src/app.module.ts index 24e11092c..a46e81634 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -42,7 +42,7 @@ import { } from "./common/schemas/generic-history.schema"; import { HistoryModule } from "./history/history.module"; import { MaskSensitiveDataInterceptorModule } from "./common/interceptors/mask-sensitive-data.interceptor"; -import { AppConfigModule } from "./app-config/app-config.module"; +import { RuntimeConfigModule } from "./runtime-config/runtime-config.module"; @Module({ imports: [ @@ -51,7 +51,7 @@ import { AppConfigModule } from "./app-config/app-config.module"; isGlobal: true, cache: true, }), - AppConfigModule, + RuntimeConfigModule, AuthModule, CaslModule, AttachmentsModule, diff --git a/src/app-config/dto/app-config.dto.ts b/src/runtime-config/dto/runtime-config.dto.ts similarity index 91% rename from src/app-config/dto/app-config.dto.ts rename to src/runtime-config/dto/runtime-config.dto.ts index 5f96bc456..c5b32709c 100644 --- a/src/app-config/dto/app-config.dto.ts +++ b/src/runtime-config/dto/runtime-config.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from "@nestjs/swagger"; import { IsString, IsOptional, IsObject } from "class-validator"; -export class AppConfigDto { +export class OutputRuntimeConfigDto { @ApiProperty({ type: String, description: "Unique config identifier (e.g. 'frontend', 'backend', etc.)", @@ -15,7 +15,7 @@ export class AppConfigDto { description: "Configuration content as a JSON object", }) @IsObject() - value: Record; + data: Record; @ApiProperty({ type: String, diff --git a/src/runtime-config/runtime-config.controller.ts b/src/runtime-config/runtime-config.controller.ts new file mode 100644 index 000000000..d84ac91f3 --- /dev/null +++ b/src/runtime-config/runtime-config.controller.ts @@ -0,0 +1,31 @@ +import { Body, Controller, Get, Param, Put, Req } from "@nestjs/common"; +import { ApiTags } from "@nestjs/swagger"; +import { Request } from "express"; +import { AllowAny } from "src/auth/decorators/allow-any.decorator"; +import { JWTUser } from "src/auth/interfaces/jwt-user.interface"; +import { OutputRuntimeConfigDto } from "./dto/runtime-config.dto"; +import { RuntimeConfigService } from "./runtime-config.service"; + +@ApiTags("runtime-config") +@Controller("runtime-config") +export class RuntimeConfigController { + constructor(private readonly runtimeConfigService: RuntimeConfigService) {} + + @AllowAny() + @Get("data") + async getConfig( + @Param("id") id: string, + ): Promise { + return this.runtimeConfigService.getConfig(id); + } + + @Put("data") + async updateConfig( + @Req() request: Request, + @Param("id") id: string, + @Body() config: Record, + ): Promise { + const user: JWTUser = request.user as JWTUser; + await this.runtimeConfigService.updateConfig(id, config, user.username); + } +} diff --git a/src/runtime-config/runtime-config.module.ts b/src/runtime-config/runtime-config.module.ts new file mode 100644 index 000000000..8bd9a4522 --- /dev/null +++ b/src/runtime-config/runtime-config.module.ts @@ -0,0 +1,23 @@ +import { Module } from "@nestjs/common"; +import { MongooseModule } from "@nestjs/mongoose"; +import { RuntimeConfigService } from "./runtime-config.service"; +import { RuntimeConfigController } from "./runtime-config.controller"; +import { + RuntimeConfig, + RuntimeConfigSchema, +} from "./schemas/runtime-config.schema"; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { + name: RuntimeConfig.name, + schema: RuntimeConfigSchema, + }, + ]), + ], + controllers: [RuntimeConfigController], + providers: [RuntimeConfigService], + exports: [RuntimeConfigService], +}) +export class RuntimeConfigModule {} diff --git a/src/runtime-config/runtime-config.service.ts b/src/runtime-config/runtime-config.service.ts new file mode 100644 index 000000000..0c4a6e1a0 --- /dev/null +++ b/src/runtime-config/runtime-config.service.ts @@ -0,0 +1,97 @@ +import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { InjectModel } from "@nestjs/mongoose"; +import { Model } from "mongoose"; +import { isEqual } from "lodash"; +import { reconcileData, diffChanges } from "./utils"; +import { OutputRuntimeConfigDto } from "./dto/runtime-config.dto"; +import { + RuntimeConfig, + RuntimeConfigDocument, +} from "./schemas/runtime-config.schema"; + +enum ConfigKeys { + "frontend-config" = "frontendConfig", + "frontend-theme" = "frontendTheme", +} + +@Injectable() +export class RuntimeConfigService implements OnModuleInit { + constructor( + private configService: ConfigService, + @InjectModel(RuntimeConfig.name) + private runtimeConfigModel: Model, + ) {} + + async onModuleInit() { + await this.syncConfigDiff("frontend-config", ConfigKeys["frontend-config"]); + await this.syncConfigDiff("frontend-theme", ConfigKeys["frontend-theme"]); + } + + async getConfig(id: string): Promise { + return await this.runtimeConfigModel.findOne({ _id: id }).lean(); + } + + async updateConfig( + id: string, + config: Record, + updatedBy: string, + ): Promise { + await this.runtimeConfigModel.updateOne( + { _id: id }, + { $set: { data: config, updatedBy } }, + ); + Logger.log( + `Updated app config entry '${id}' by user '${updatedBy}'`, + "AppConfigService", + ); + } + + async syncConfigDiff(configId: string, configKey: string): Promise { + const autoSyncEnabled = this.configService.get( + `${configKey}DbAutoSyncEnabled`, + ); + + const sourceConfig = + this.configService.get>(configKey) || {}; + + const existing = await this.runtimeConfigModel + .findOne({ _id: configId }) + .lean(); + + // If no existing config, create one with default values + if (!existing) { + await this.runtimeConfigModel.create({ + _id: configId, + data: sourceConfig, + }); + Logger.log( + `Created app config entry '${configId}' with default values from json file`, + "AppConfigService", + ); + return; + } + + if (!autoSyncEnabled) return; + + const dbValue = existing.data || {}; + + const updatedConfig = reconcileData(dbValue, sourceConfig); + + if (isEqual(updatedConfig, dbValue)) { + return; + } + + const changes = diffChanges(dbValue, updatedConfig); + + await this.runtimeConfigModel.updateOne( + { _id: configId }, + { data: updatedConfig }, + ); + + if (changes.length > 0) { + Logger.log("Changed fields:"); + for (const c of changes) Logger.log(" • " + c); + } + } +} diff --git a/src/app-config/schemas/app-config.schema.ts b/src/runtime-config/schemas/runtime-config.schema.ts similarity index 75% rename from src/app-config/schemas/app-config.schema.ts rename to src/runtime-config/schemas/runtime-config.schema.ts index a207f6a46..642950cdf 100644 --- a/src/app-config/schemas/app-config.schema.ts +++ b/src/runtime-config/schemas/runtime-config.schema.ts @@ -2,13 +2,13 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { ApiProperty } from "@nestjs/swagger"; import { Document } from "mongoose"; -export type AppConfigDocument = AppConfig & Document; +export type RuntimeConfigDocument = RuntimeConfig & Document; @Schema({ - collection: "AppConfig", + collection: "RuntimeConfig", timestamps: true, }) -export class AppConfig { +export class RuntimeConfig { @ApiProperty({ type: String, description: "Unique config identifier (e.g. 'frontend', 'backend', etc.)", @@ -18,10 +18,10 @@ export class AppConfig { @ApiProperty({ type: Object, - description: "Configuration content as a JSON object", + description: " configuration data stored as JSON", }) @Prop({ type: Object, required: true, default: {} }) - value: Record; + data: Record; @ApiProperty({ type: String, @@ -40,4 +40,4 @@ export class AppConfig { updatedBy: string; } -export const AppConfigSchema = SchemaFactory.createForClass(AppConfig); +export const RuntimeConfigSchema = SchemaFactory.createForClass(RuntimeConfig); diff --git a/src/runtime-config/utils.spec.ts b/src/runtime-config/utils.spec.ts new file mode 100644 index 000000000..e0c827423 --- /dev/null +++ b/src/runtime-config/utils.spec.ts @@ -0,0 +1,147 @@ +import { reconcileData, isPlainObject } from "./utils"; + +describe("isPlainObject", () => { + test("returns true for plain objects", () => { + expect(isPlainObject({})).toBe(true); + expect(isPlainObject({ a: 1 })).toBe(true); + }); + + test("returns false for arrays", () => { + expect(isPlainObject([])).toBe(false); + }); + + test("returns false for null", () => { + expect(isPlainObject(null)).toBe(false); + }); + + test("returns false for primitives", () => { + expect(isPlainObject(1)).toBe(false); + expect(isPlainObject("x")).toBe(false); + expect(isPlainObject(true)).toBe(false); + }); +}); + +describe("reconcileData", () => { + // + // PRIMITIVES + // + test("keeps DB primitive if present", () => { + expect(reconcileData(99, 5)).toBe(99); + expect(reconcileData("db", "src")).toBe("db"); + expect(reconcileData(true, false)).toBe(true); + }); + + test("creates primitive if target missing", () => { + expect(reconcileData(undefined, 5)).toBe(5); + expect(reconcileData(undefined, "x")).toBe("x"); + expect(reconcileData(undefined, null)).toBe(null); + }); + + // + // OBJECTS + // + test("adds missing object keys", () => { + const target = { a: 1 }; + const source = { a: 1, b: 2 }; + expect(reconcileData(target, source)).toEqual({ a: 1, b: 2 }); + }); + + test("removes keys not in source", () => { + const target = { a: 1, b: 2 }; + const source = { a: 1 }; + expect(reconcileData(target, source)).toEqual({ a: 1 }); + }); + + test("keeps primitive values inside objects", () => { + const target = { a: 1 }; + const source = { a: 999 }; // different primitive + expect(reconcileData(target, source)).toEqual({ a: 1 }); // preserved DB value + }); + + test("creates nested primitives when missing", () => { + const target = {}; + const source = { a: { b: 10 } }; + expect(reconcileData(target, source)).toEqual({ a: { b: 10 } }); + }); + + test("preserves nested DB values", () => { + const target = { a: { b: 777 } }; + const source = { a: { b: 10 } }; + expect(reconcileData(target, source)).toEqual({ a: { b: 777 } }); + }); + + test("removes nested keys", () => { + const target = { a: { b: 1, c: 2 } }; + const source = { a: { b: 1 } }; + expect(reconcileData(target, source)).toEqual({ a: { b: 1 } }); + }); + + // + // ARRAYS + // + test("syncs array structure but keeps DB primitive values inside", () => { + const target = [100, 200]; + const source = [1, 2]; + expect(reconcileData(target, source)).toEqual([100, 200]); + }); + + test("creates array primitives when missing", () => { + const target: unknown[] = []; + const source = [1, 2, 3]; + expect(reconcileData(target, source)).toEqual([1, 2, 3]); + }); + + test("removes extra array elements", () => { + const target: unknown[] = [1, 2, 3]; + const source = [1]; + expect(reconcileData(target, source)).toEqual([1]); + }); + + test("preserves nested array object values", () => { + const target = [{ a: 999 }]; + const source = [{ a: 5 }]; + expect(reconcileData(target, source)).toEqual([{ a: 999 }]); + }); + + test("syncs nested array objects and removes extra keys", () => { + const target = [{ a: 1, old: true }]; + const source = [{ a: 2 }]; + expect(reconcileData(target, source)).toEqual([{ a: 1 }]); // a kept, old removed + }); + + // + // COMPLEX FULL STRUCTURE SYNC + // + test("deep complex structure sync", () => { + const target = { + type: "attachments", + label: "Gallery", + order: 99, // overridden by preserve rule → keep 99 + options: { + limit: 5, + size: "large", + }, + oldKey: true, + }; + + const source = { + type: "attachments", + label: "Gallery", + order: 5, + options: { + limit: 2, // keep DB = 5 + }, + }; + + expect(reconcileData(target, source)).toEqual({ + type: "attachments", + label: "Gallery", + order: 99, // preserved + options: { + limit: 5, // preserved + }, + // size removed + // oldKey removed + }); + }); +}); diff --git a/src/runtime-config/utils.ts b/src/runtime-config/utils.ts new file mode 100644 index 000000000..60d90e5cb --- /dev/null +++ b/src/runtime-config/utils.ts @@ -0,0 +1,111 @@ +import { isEqual } from "lodash"; + +export const isPlainObject = ( + value: unknown, +): value is Record => { + return typeof value === "object" && value !== null && !Array.isArray(value); +}; + +export const reconcileData = (target: unknown, source: unknown): unknown => { + // Primitives → keep existing DB value if present; only create new values if DB has none. + if (typeof source !== "object" || source === null) { + return target !== undefined ? target : source; + } + + // Arrays → match array length and structure, but don't overwrite primitive values + if (Array.isArray(source)) { + const tgtArr: unknown[] = Array.isArray(target) ? target : []; + + return source.map((srcItem, index) => { + console.log("srcItem,index", srcItem, index); + const tgtItem = tgtArr[index]; + return reconcileData(tgtItem, srcItem); + }); + } + + // Objects → sync keys + if (isPlainObject(source)) { + const result: Record = {}; + + const srcObj = source as Record; + const tgtObj = isPlainObject(target) + ? (target as Record) + : {}; + + // update keys + for (const key of Object.keys(srcObj)) { + const nextTarget = key in tgtObj ? tgtObj[key] : undefined; + result[key] = reconcileData(nextTarget, srcObj[key]); + } + + return result; + } + + return source; +}; + +export const diffChanges = ( + oldValue: unknown, + newValue: unknown, + path: string[] = [], + out: string[] = [], +): string[] => { + const fullPath = path.join("."); + + // ---------------------------------------- + // PRIMITIVES + // ---------------------------------------- + if ( + typeof oldValue !== "object" || + oldValue === null || + typeof newValue !== "object" || + newValue === null + ) { + if (!isEqual(oldValue, newValue)) { + out.push( + `${fullPath}: ${JSON.stringify(oldValue)} → ${JSON.stringify(newValue)}`, + ); + } + return out; + } + + // ---------------------------------------- + // ARRAYS + // ---------------------------------------- + if (Array.isArray(newValue)) { + const oldArr = Array.isArray(oldValue) ? oldValue : []; + if (!isEqual(oldArr, newValue)) { + out.push(`${fullPath}: array changed`); + } + return out; + } + + // ---------------------------------------- + // OBJECTS + // ---------------------------------------- + const oldObj = oldValue as Record; + const newObj = newValue as Record; + + // added keys + for (const key of Object.keys(newObj)) { + if (!(key in oldObj)) { + out.push(`${fullPath}.${key} added`); + } + } + + // removed keys + for (const key of Object.keys(oldObj)) { + if (!(key in newObj)) { + out.push(`${fullPath}.${key} removed`); + } + } + + // modified keys + for (const key of Object.keys(newObj)) { + if (key in oldObj) { + diffChanges(oldObj[key], newObj[key], [...path, key], out); + } + } + + return out; +}; From 105e4b3bd308a630342232d5a14ecbaaa33f458a Mon Sep 17 00:00:00 2001 From: junjiequan Date: Tue, 18 Nov 2025 14:25:28 +0100 Subject: [PATCH 03/17] add history plugin for runtime service and reuse computeDeltaWithOriginals for sync change logging --- src/casl/casl-ability.factory.ts | 23 +++ src/config/configuration.ts | 6 + src/config/frontend.config.json | 165 +----------------- .../runtime-config.controller.ts | 43 ++++- src/runtime-config/runtime-config.module.ts | 24 ++- src/runtime-config/runtime-config.service.ts | 73 +++++--- src/runtime-config/utils.ts | 77 +------- 7 files changed, 144 insertions(+), 267 deletions(-) diff --git a/src/casl/casl-ability.factory.ts b/src/casl/casl-ability.factory.ts index 74efab5ce..7ff460434 100644 --- a/src/casl/casl-ability.factory.ts +++ b/src/casl/casl-ability.factory.ts @@ -29,6 +29,7 @@ import { UserIdentity } from "src/users/schemas/user-identity.schema"; import { UserSettings } from "src/users/schemas/user-settings.schema"; import { User } from "src/users/schemas/user.schema"; import { Action } from "./action.enum"; +import { RuntimeConfig } from "src/runtime-config/schemas/runtime-config.schema"; type Subjects = | string @@ -49,6 +50,7 @@ type Subjects = | typeof UserSettings | typeof ElasticSearchActions | typeof Datablock + | typeof RuntimeConfig > | "all"; type PossibleAbilities = [Action, Subjects]; @@ -84,6 +86,7 @@ export class CaslAbilityFactory { attachments: this.attachmentEndpointAccess, history: this.historyEndpointAccess, datablocks: this.datablockEndpointAccess, + runtimeconfig: this.runtimeConfigEndpointAccess, }; endpointAccess(endpoint: string, user: JWTUser) { @@ -908,6 +911,26 @@ export class CaslAbilityFactory { item.constructor as ExtractSubjectType, }); } + runtimeConfigEndpointAccess(user: JWTUser) { + const { can, build } = new AbilityBuilder( + createMongoAbility, + ); + + can(Action.Read, RuntimeConfig); + if ( + user && + user.currentGroups.some((g) => this.accessGroups?.admin.includes(g)) + ) { + /* + / user that belongs to any of the group listed in ADMIN_GROUPS + */ + can(Action.Update, RuntimeConfig); + } + return build({ + detectSubjectType: (item) => + item.constructor as ExtractSubjectType, + }); + } policyEndpointAccess(user: JWTUser) { const { can, build } = new AbilityBuilder( diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 68cde662b..9c218b713 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -193,6 +193,12 @@ const configuration = () => { ); const config = { + configSyncToDb: { + enabled: boolean(process.env.CONFIG_SYNC_TO_DB_ENABLED || false), + configList: process.env.CONFIG_SYNC_TO_DB_LIST + ? process.env.CONFIG_SYNC_TO_DB_LIST.split(",").map((v) => v.trim()) + : ["frontendConfig", "frontendTheme"], + }, maxFileUploadSizeInMb: process.env.MAX_FILE_UPLOAD_SIZE || "16mb", // 16MB by default versions: { api: "3", diff --git a/src/config/frontend.config.json b/src/config/frontend.config.json index ca1c0cf0e..5d8fa7265 100644 --- a/src/config/frontend.config.json +++ b/src/config/frontend.config.json @@ -1,16 +1,4 @@ { - "defaultMainPage": { - "nonAuthenticatedUser": "DATASETS", - "authenticatedUser": "PROPOSALS" - }, - "checkBoxFilterClickTrigger": false, - "accessTokenPrefix": "Bearer ", - "addDatasetEnabled": true, - "archiveWorkflowEnabled": false, - "datasetReduceEnabled": true, - "datasetJsonScientificMetadata": true, - "editDatasetEnabled": true, - "editDatasetSampleEnabled": true, "editMetadataEnabled": true, "addSampleEnabled": false, "externalAuthEndpoint": "/api/v3/auth/msad", @@ -244,7 +232,8 @@ "description": "Filter by creation time of the dataset", "enabled": true } - ] + ], + "conditions": [] }, "defaultProposalsListSettings": { "columns": [ @@ -344,13 +333,9 @@ }, "dateFormat": "yyyy-MM-dd HH:mm", "datasetDetailComponent": { - "enableCustomizedComponent": false, + "enableCustomizedComponent": true, "customization": [ { - "type": "regular", - "label": "General Information", - "order": 0, - "row": 1, "col": 8, "fields": [ { @@ -362,129 +347,6 @@ "element": "copy", "source": "scientificMetadata.run_number.value", "order": 1 - }, - { - "element": "text", - "source": "creationTime", - "order": 2 - }, - { - "element": "text", - "source": "type", - "order": 3 - }, - { - "element": "text", - "source": "datasetName", - "order": 4 - }, - { - "element": "tag", - "source": "keywords", - "order": 5 - } - ] - }, - { - "type": "attachments", - "label": "Gallery", - "order": 1, - "col": 2, - "row": 2, - "options": { - "limit": 5, - "size": "medium" - } - }, - { - "type": "regular", - "label": "Contact Information", - "order": 2, - "col": 2, - "row": 1, - "fields": [ - { - "element": "text", - "source": "principalInvestigator", - "order": 0 - }, - { - "element": "linky", - "source": "contactEmail", - "order": 1 - } - ] - }, - { - "type": "regular", - "label": "Files Information", - "order": 3, - "col": 2, - "row": 1, - "fields": [ - { - "element": "text", - "source": "scientificMetadata.runnumber", - "order": 0 - }, - { - "element": "text", - "source": "sourceFolderHost", - "order": 1 - }, - { - "element": "text", - "source": "numberOfFiles", - "order": 2 - }, - { - "element": "text", - "source": "size", - "order": 3 - }, - { - "element": "text", - "source": "numberOfFilesArchived", - "order": 4 - }, - { - "element": "text", - "source": "packedSize", - "order": 5 - } - ] - }, - { - "type": "regular", - "label": "Related Documents", - "order": 4, - "col": 4, - "row": 1, - "fields": [ - { - "element": "internalLink", - "source": "proposalIds", - "order": 0 - }, - { - "element": "internalLink", - "source": "instrumentIds", - "order": 1 - }, - { - "element": "tag", - "source": "sampleIds", - "order": 2 - }, - { - "element": "tag", - "source": "inputDatasets", - "order": 3 - }, - { - "element": "internalLink", - "source": "creationLocation", - "order": 4 } ] }, @@ -496,7 +358,7 @@ "row": 1, "options": { "limit": 2, - "size": "small" + "size": "large" } }, { @@ -506,25 +368,6 @@ "order": 6, "col": 9, "row": 1 - }, - { - "type": "scientificMetadata", - "label": "Scientific Metadata JSON", - "viewMode": "json", - "order": 6 - }, - { - "type": "scientificMetadata", - "label": "Scientific Metadata Tree", - "viewMode": "tree", - "order": 6 - }, - { - "type": "datasetJsonView", - "label": "Dataset JsonView", - "order": 7, - "col": 10, - "row": 2 } ] }, diff --git a/src/runtime-config/runtime-config.controller.ts b/src/runtime-config/runtime-config.controller.ts index d84ac91f3..edf4238d2 100644 --- a/src/runtime-config/runtime-config.controller.ts +++ b/src/runtime-config/runtime-config.controller.ts @@ -1,31 +1,58 @@ -import { Body, Controller, Get, Param, Put, Req } from "@nestjs/common"; -import { ApiTags } from "@nestjs/swagger"; +import { + Body, + Controller, + Get, + Param, + Put, + Req, + UseGuards, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiBody, ApiTags } from "@nestjs/swagger"; import { Request } from "express"; import { AllowAny } from "src/auth/decorators/allow-any.decorator"; import { JWTUser } from "src/auth/interfaces/jwt-user.interface"; import { OutputRuntimeConfigDto } from "./dto/runtime-config.dto"; import { RuntimeConfigService } from "./runtime-config.service"; - +import { PoliciesGuard } from "src/casl/guards/policies.guard"; +import { Action } from "src/casl/action.enum"; +import { AppAbility } from "src/casl/casl-ability.factory"; +import { CheckPolicies } from "src/casl/decorators/check-policies.decorator"; +import { RuntimeConfig } from "./schemas/runtime-config.schema"; +@ApiBearerAuth() @ApiTags("runtime-config") @Controller("runtime-config") export class RuntimeConfigController { constructor(private readonly runtimeConfigService: RuntimeConfigService) {} @AllowAny() - @Get("data") + @Get("data/:id") async getConfig( @Param("id") id: string, ): Promise { - return this.runtimeConfigService.getConfig(id); + const config = await this.runtimeConfigService.getConfig(id); + + return config; } - @Put("data") + @UseGuards(PoliciesGuard) + @CheckPolicies("runtimeconfig", (ability: AppAbility) => + ability.can(Action.Update, RuntimeConfig), + ) + @Put("data/:id") + @ApiBody({ + type: Object, + description: "Runtime config object", + }) async updateConfig( @Req() request: Request, @Param("id") id: string, @Body() config: Record, - ): Promise { + ): Promise { const user: JWTUser = request.user as JWTUser; - await this.runtimeConfigService.updateConfig(id, config, user.username); + return await this.runtimeConfigService.updateConfig( + id, + config, + user.username, + ); } } diff --git a/src/runtime-config/runtime-config.module.ts b/src/runtime-config/runtime-config.module.ts index 8bd9a4522..8bc063b71 100644 --- a/src/runtime-config/runtime-config.module.ts +++ b/src/runtime-config/runtime-config.module.ts @@ -6,13 +6,35 @@ import { RuntimeConfig, RuntimeConfigSchema, } from "./schemas/runtime-config.schema"; +import { + GenericHistory, + GenericHistorySchema, +} from "src/common/schemas/generic-history.schema"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { CaslModule } from "src/casl/casl.module"; +import { applyHistoryPluginOnce } from "src/common/mongoose/plugins/history.plugin.util"; @Module({ imports: [ + CaslModule, + ConfigModule, MongooseModule.forFeature([ + { + name: GenericHistory.name, + schema: GenericHistorySchema, + }, + ]), + MongooseModule.forFeatureAsync([ { name: RuntimeConfig.name, - schema: RuntimeConfigSchema, + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => { + const schema = RuntimeConfigSchema; + applyHistoryPluginOnce(schema, configService); + + return schema; + }, }, ]), ], diff --git a/src/runtime-config/runtime-config.service.ts b/src/runtime-config/runtime-config.service.ts index 0c4a6e1a0..03a97ac90 100644 --- a/src/runtime-config/runtime-config.service.ts +++ b/src/runtime-config/runtime-config.service.ts @@ -1,19 +1,20 @@ -import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; +import { + Injectable, + Logger, + NotFoundException, + OnModuleInit, +} from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { InjectModel } from "@nestjs/mongoose"; import { Model } from "mongoose"; import { isEqual } from "lodash"; -import { reconcileData, diffChanges } from "./utils"; +import { reconcileData } from "./utils"; import { OutputRuntimeConfigDto } from "./dto/runtime-config.dto"; import { RuntimeConfig, RuntimeConfigDocument, } from "./schemas/runtime-config.schema"; - -enum ConfigKeys { - "frontend-config" = "frontendConfig", - "frontend-theme" = "frontendTheme", -} +import { computeDeltaWithOriginals } from "src/common/utils/delta.util"; @Injectable() export class RuntimeConfigService implements OnModuleInit { @@ -24,36 +25,51 @@ export class RuntimeConfigService implements OnModuleInit { ) {} async onModuleInit() { - await this.syncConfigDiff("frontend-config", ConfigKeys["frontend-config"]); - await this.syncConfigDiff("frontend-theme", ConfigKeys["frontend-theme"]); + const configList: string[] = this.configService.get( + "configSyncToDb.configList", + )!; + + for (const configId of configList) await this.syncConfigDiff(configId); } async getConfig(id: string): Promise { - return await this.runtimeConfigModel.findOne({ _id: id }).lean(); + const data = await this.runtimeConfigModel.findOne({ _id: id }).lean(); + + if (!data) { + throw new NotFoundException(`Config '${id}' not found`); + } + return data; } async updateConfig( id: string, config: Record, updatedBy: string, - ): Promise { - await this.runtimeConfigModel.updateOne( + ): Promise { + const updatedDoc = await this.runtimeConfigModel.findByIdAndUpdate( { _id: id }, { $set: { data: config, updatedBy } }, + { new: true }, ); + + if (!updatedDoc) { + throw new NotFoundException(`Config '${id}' not found`); + } Logger.log( `Updated app config entry '${id}' by user '${updatedBy}'`, - "AppConfigService", + "RuntimeConfigService", ); + + return updatedDoc; } - async syncConfigDiff(configId: string, configKey: string): Promise { + async syncConfigDiff(configId: string): Promise { const autoSyncEnabled = this.configService.get( - `${configKey}DbAutoSyncEnabled`, + `configSyncToDb.enabled`, ); const sourceConfig = - this.configService.get>(configKey) || {}; + this.configService.get>(configId) || {}; const existing = await this.runtimeConfigModel .findOne({ _id: configId }) @@ -66,8 +82,8 @@ export class RuntimeConfigService implements OnModuleInit { data: sourceConfig, }); Logger.log( - `Created app config entry '${configId}' with default values from json file`, - "AppConfigService", + `Created runtime config entry '${configId}' with default values from json file`, + "RuntimeConfigService", ); return; } @@ -76,22 +92,31 @@ export class RuntimeConfigService implements OnModuleInit { const dbValue = existing.data || {}; - const updatedConfig = reconcileData(dbValue, sourceConfig); + const updatedConfig = reconcileData(dbValue, sourceConfig) as Record< + string, + unknown + >; if (isEqual(updatedConfig, dbValue)) { return; } - const changes = diffChanges(dbValue, updatedConfig); + const { delta, originals } = computeDeltaWithOriginals( + dbValue, + updatedConfig, + ); await this.runtimeConfigModel.updateOne( { _id: configId }, { data: updatedConfig }, ); - if (changes.length > 0) { - Logger.log("Changed fields:"); - for (const c of changes) Logger.log(" • " + c); - } + Logger.log( + { + before: JSON.stringify(originals), + after: JSON.stringify(delta), + }, + `RuntimeConfigService - [${configId}] synchronized changes`, + ); } } diff --git a/src/runtime-config/utils.ts b/src/runtime-config/utils.ts index 60d90e5cb..f40e9294f 100644 --- a/src/runtime-config/utils.ts +++ b/src/runtime-config/utils.ts @@ -1,5 +1,3 @@ -import { isEqual } from "lodash"; - export const isPlainObject = ( value: unknown, ): value is Record => { @@ -7,23 +5,22 @@ export const isPlainObject = ( }; export const reconcileData = (target: unknown, source: unknown): unknown => { - // Primitives → keep existing DB value if present; only create new values if DB has none. + // Primitives: keep existing DB value if present; only create new values if DB has none. if (typeof source !== "object" || source === null) { return target !== undefined ? target : source; } - // Arrays → match array length and structure, but don't overwrite primitive values + // Arrays: match array length and structure, but don't overwrite primitive values if (Array.isArray(source)) { const tgtArr: unknown[] = Array.isArray(target) ? target : []; return source.map((srcItem, index) => { - console.log("srcItem,index", srcItem, index); - const tgtItem = tgtArr[index]; + const tgtItem = tgtArr[index] as unknown; return reconcileData(tgtItem, srcItem); }); } - // Objects → sync keys + // Objects: sync keys if (isPlainObject(source)) { const result: Record = {}; @@ -43,69 +40,3 @@ export const reconcileData = (target: unknown, source: unknown): unknown => { return source; }; - -export const diffChanges = ( - oldValue: unknown, - newValue: unknown, - path: string[] = [], - out: string[] = [], -): string[] => { - const fullPath = path.join("."); - - // ---------------------------------------- - // PRIMITIVES - // ---------------------------------------- - if ( - typeof oldValue !== "object" || - oldValue === null || - typeof newValue !== "object" || - newValue === null - ) { - if (!isEqual(oldValue, newValue)) { - out.push( - `${fullPath}: ${JSON.stringify(oldValue)} → ${JSON.stringify(newValue)}`, - ); - } - return out; - } - - // ---------------------------------------- - // ARRAYS - // ---------------------------------------- - if (Array.isArray(newValue)) { - const oldArr = Array.isArray(oldValue) ? oldValue : []; - if (!isEqual(oldArr, newValue)) { - out.push(`${fullPath}: array changed`); - } - return out; - } - - // ---------------------------------------- - // OBJECTS - // ---------------------------------------- - const oldObj = oldValue as Record; - const newObj = newValue as Record; - - // added keys - for (const key of Object.keys(newObj)) { - if (!(key in oldObj)) { - out.push(`${fullPath}.${key} added`); - } - } - - // removed keys - for (const key of Object.keys(oldObj)) { - if (!(key in newObj)) { - out.push(`${fullPath}.${key} removed`); - } - } - - // modified keys - for (const key of Object.keys(newObj)) { - if (key in oldObj) { - diffChanges(oldObj[key], newObj[key], [...path, key], out); - } - } - - return out; -}; From e7e05c50e2503631bde0f7ebb5c9000886e0a34b Mon Sep 17 00:00:00 2001 From: junjiequan Date: Thu, 20 Nov 2025 09:58:25 +0100 Subject: [PATCH 04/17] revert frontend config --- src/config/frontend.config.json | 162 +++++++++++++++++++++++++++++++- 1 file changed, 160 insertions(+), 2 deletions(-) diff --git a/src/config/frontend.config.json b/src/config/frontend.config.json index 5d8fa7265..6f6669638 100644 --- a/src/config/frontend.config.json +++ b/src/config/frontend.config.json @@ -1,4 +1,16 @@ { + "defaultMainPage": { + "nonAuthenticatedUser": "DATASETS", + "authenticatedUser": "PROPOSALS" + }, + "checkBoxFilterClickTrigger": false, + "accessTokenPrefix": "Bearer ", + "addDatasetEnabled": false, + "archiveWorkflowEnabled": false, + "datasetReduceEnabled": true, + "datasetJsonScientificMetadata": true, + "editDatasetEnabled": true, + "editDatasetSampleEnabled": true, "editMetadataEnabled": true, "addSampleEnabled": false, "externalAuthEndpoint": "/api/v3/auth/msad", @@ -333,9 +345,13 @@ }, "dateFormat": "yyyy-MM-dd HH:mm", "datasetDetailComponent": { - "enableCustomizedComponent": true, + "enableCustomizedComponent": false, "customization": [ { + "type": "regular", + "label": "General Information", + "order": 0, + "row": 1, "col": 8, "fields": [ { @@ -347,6 +363,129 @@ "element": "copy", "source": "scientificMetadata.run_number.value", "order": 1 + }, + { + "element": "text", + "source": "creationTime", + "order": 2 + }, + { + "element": "text", + "source": "type", + "order": 3 + }, + { + "element": "text", + "source": "datasetName", + "order": 4 + }, + { + "element": "tag", + "source": "keywords", + "order": 5 + } + ] + }, + { + "type": "attachments", + "label": "Gallery", + "order": 1, + "col": 2, + "row": 2, + "options": { + "limit": 5, + "size": "medium" + } + }, + { + "type": "regular", + "label": "Contact Information", + "order": 2, + "col": 2, + "row": 1, + "fields": [ + { + "element": "text", + "source": "principalInvestigator", + "order": 0 + }, + { + "element": "linky", + "source": "contactEmail", + "order": 1 + } + ] + }, + { + "type": "regular", + "label": "Files Information", + "order": 3, + "col": 2, + "row": 1, + "fields": [ + { + "element": "text", + "source": "scientificMetadata.runnumber", + "order": 0 + }, + { + "element": "text", + "source": "sourceFolderHost", + "order": 1 + }, + { + "element": "text", + "source": "numberOfFiles", + "order": 2 + }, + { + "element": "text", + "source": "size", + "order": 3 + }, + { + "element": "text", + "source": "numberOfFilesArchived", + "order": 4 + }, + { + "element": "text", + "source": "packedSize", + "order": 5 + } + ] + }, + { + "type": "regular", + "label": "Related Documents", + "order": 4, + "col": 4, + "row": 1, + "fields": [ + { + "element": "internalLink", + "source": "proposalIds", + "order": 0 + }, + { + "element": "internalLink", + "source": "instrumentIds", + "order": 1 + }, + { + "element": "tag", + "source": "sampleIds", + "order": 2 + }, + { + "element": "tag", + "source": "inputDatasets", + "order": 3 + }, + { + "element": "internalLink", + "source": "creationLocation", + "order": 4 } ] }, @@ -358,7 +497,7 @@ "row": 1, "options": { "limit": 2, - "size": "large" + "size": "small" } }, { @@ -368,6 +507,25 @@ "order": 6, "col": 9, "row": 1 + }, + { + "type": "scientificMetadata", + "label": "Scientific Metadata JSON", + "viewMode": "json", + "order": 6 + }, + { + "type": "scientificMetadata", + "label": "Scientific Metadata Tree", + "viewMode": "tree", + "order": 6 + }, + { + "type": "datasetJsonView", + "label": "Dataset JsonView", + "order": 7, + "col": 10, + "row": 2 } ] }, From 7d24f30c14e7c91f708fe9c1235ed9fd1cade6b2 Mon Sep 17 00:00:00 2001 From: junjiequan Date: Fri, 21 Nov 2025 14:32:55 +0100 Subject: [PATCH 05/17] fix unit test --- src/admin/admin.service.spec.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/admin/admin.service.spec.ts b/src/admin/admin.service.spec.ts index 9791c4331..a632485dd 100644 --- a/src/admin/admin.service.spec.ts +++ b/src/admin/admin.service.spec.ts @@ -51,6 +51,7 @@ const mockConfig: Record = { searchSamples: true, sftpHost: "login.esss.dk", sourceFolder: "/data/ess", + maxFileUploadSizeInMb: "12mb", maxDirectDownloadSize: 5000000000, maxFileSizeWarning: "Some files are above and cannot be downloaded directly. These file can be downloaded via sftp host: in directory: ", @@ -92,7 +93,6 @@ describe("AdminService", () => { const mockConfigService = { get: jest.fn((propertyPath: string) => { const config = { - maxFileUploadSizeInMb: "12mb", frontendConfig: mockConfig, frontendTheme: mockTheme, } as Record; @@ -120,13 +120,10 @@ describe("AdminService", () => { }); describe("getConfig", () => { - it("should return modified config", async () => { + it("should return frontend config", async () => { const result = await service.getConfig(); - expect(result).toEqual({ - ...mockConfig, - maxFileUploadSizeInMb: "12mb", - }); + expect(result).toEqual(mockConfig); }); }); From 0e8137cb3efbe0abbfc90f6cb3258ca5e18f15a1 Mon Sep 17 00:00:00 2001 From: junjiequan Date: Wed, 3 Dec 2025 10:29:28 +0100 Subject: [PATCH 06/17] add apiokReponse --- src/runtime-config/runtime-config.controller.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/runtime-config/runtime-config.controller.ts b/src/runtime-config/runtime-config.controller.ts index edf4238d2..3b549e94e 100644 --- a/src/runtime-config/runtime-config.controller.ts +++ b/src/runtime-config/runtime-config.controller.ts @@ -7,7 +7,12 @@ import { Req, UseGuards, } from "@nestjs/common"; -import { ApiBearerAuth, ApiBody, ApiTags } from "@nestjs/swagger"; +import { + ApiBearerAuth, + ApiBody, + ApiOkResponse, + ApiTags, +} from "@nestjs/swagger"; import { Request } from "express"; import { AllowAny } from "src/auth/decorators/allow-any.decorator"; import { JWTUser } from "src/auth/interfaces/jwt-user.interface"; @@ -25,6 +30,7 @@ export class RuntimeConfigController { constructor(private readonly runtimeConfigService: RuntimeConfigService) {} @AllowAny() + @ApiOkResponse({ type: OutputRuntimeConfigDto }) @Get("data/:id") async getConfig( @Param("id") id: string, @@ -43,6 +49,7 @@ export class RuntimeConfigController { type: Object, description: "Runtime config object", }) + @ApiOkResponse({ type: OutputRuntimeConfigDto }) async updateConfig( @Req() request: Request, @Param("id") id: string, From cbf66e7e22d4c8b4231c27e9adbadbb7233a3a4d Mon Sep 17 00:00:00 2001 From: junjiequan Date: Thu, 8 Jan 2026 13:53:42 +0100 Subject: [PATCH 07/17] move run-time config into config folder --- src/app.module.ts | 2 +- src/casl/casl-ability.factory.ts | 2 +- src/{ => config}/runtime-config/dto/runtime-config.dto.ts | 0 src/{ => config}/runtime-config/runtime-config.controller.ts | 0 src/{ => config}/runtime-config/runtime-config.module.ts | 0 src/{ => config}/runtime-config/runtime-config.service.ts | 0 .../runtime-config/schemas/runtime-config.schema.ts | 0 src/{ => config}/runtime-config/utils.spec.ts | 0 src/{ => config}/runtime-config/utils.ts | 0 9 files changed, 2 insertions(+), 2 deletions(-) rename src/{ => config}/runtime-config/dto/runtime-config.dto.ts (100%) rename src/{ => config}/runtime-config/runtime-config.controller.ts (100%) rename src/{ => config}/runtime-config/runtime-config.module.ts (100%) rename src/{ => config}/runtime-config/runtime-config.service.ts (100%) rename src/{ => config}/runtime-config/schemas/runtime-config.schema.ts (100%) rename src/{ => config}/runtime-config/utils.spec.ts (100%) rename src/{ => config}/runtime-config/utils.ts (100%) diff --git a/src/app.module.ts b/src/app.module.ts index 72ca25819..054d8066a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -42,7 +42,7 @@ import { } from "./common/schemas/generic-history.schema"; import { HistoryModule } from "./history/history.module"; import { MaskSensitiveDataInterceptorModule } from "./common/interceptors/mask-sensitive-data.interceptor"; -import { RuntimeConfigModule } from "./runtime-config/runtime-config.module"; +import { RuntimeConfigModule } from "./config/runtime-config/runtime-config.module"; @Module({ imports: [ diff --git a/src/casl/casl-ability.factory.ts b/src/casl/casl-ability.factory.ts index c425ddcc7..ed6b95789 100644 --- a/src/casl/casl-ability.factory.ts +++ b/src/casl/casl-ability.factory.ts @@ -29,7 +29,7 @@ import { UserIdentity } from "src/users/schemas/user-identity.schema"; import { UserSettings } from "src/users/schemas/user-settings.schema"; import { User } from "src/users/schemas/user.schema"; import { Action } from "./action.enum"; -import { RuntimeConfig } from "src/runtime-config/schemas/runtime-config.schema"; +import { RuntimeConfig } from "src/config/runtime-config/schemas/runtime-config.schema"; import { accessibleBy } from "@casl/mongoose"; type Subjects = diff --git a/src/runtime-config/dto/runtime-config.dto.ts b/src/config/runtime-config/dto/runtime-config.dto.ts similarity index 100% rename from src/runtime-config/dto/runtime-config.dto.ts rename to src/config/runtime-config/dto/runtime-config.dto.ts diff --git a/src/runtime-config/runtime-config.controller.ts b/src/config/runtime-config/runtime-config.controller.ts similarity index 100% rename from src/runtime-config/runtime-config.controller.ts rename to src/config/runtime-config/runtime-config.controller.ts diff --git a/src/runtime-config/runtime-config.module.ts b/src/config/runtime-config/runtime-config.module.ts similarity index 100% rename from src/runtime-config/runtime-config.module.ts rename to src/config/runtime-config/runtime-config.module.ts diff --git a/src/runtime-config/runtime-config.service.ts b/src/config/runtime-config/runtime-config.service.ts similarity index 100% rename from src/runtime-config/runtime-config.service.ts rename to src/config/runtime-config/runtime-config.service.ts diff --git a/src/runtime-config/schemas/runtime-config.schema.ts b/src/config/runtime-config/schemas/runtime-config.schema.ts similarity index 100% rename from src/runtime-config/schemas/runtime-config.schema.ts rename to src/config/runtime-config/schemas/runtime-config.schema.ts diff --git a/src/runtime-config/utils.spec.ts b/src/config/runtime-config/utils.spec.ts similarity index 100% rename from src/runtime-config/utils.spec.ts rename to src/config/runtime-config/utils.spec.ts diff --git a/src/runtime-config/utils.ts b/src/config/runtime-config/utils.ts similarity index 100% rename from src/runtime-config/utils.ts rename to src/config/runtime-config/utils.ts From b8bd9954cda9b1deee401a8ecbd8dce6bfd35772 Mon Sep 17 00:00:00 2001 From: junjiequan Date: Thu, 8 Jan 2026 15:50:13 +0100 Subject: [PATCH 08/17] Restart application overwrittes previous config record with config file --- .../runtime-config/runtime-config.service.ts | 73 ++++----- src/config/runtime-config/utils.spec.ts | 147 ------------------ src/config/runtime-config/utils.ts | 42 ----- 3 files changed, 27 insertions(+), 235 deletions(-) delete mode 100644 src/config/runtime-config/utils.spec.ts delete mode 100644 src/config/runtime-config/utils.ts diff --git a/src/config/runtime-config/runtime-config.service.ts b/src/config/runtime-config/runtime-config.service.ts index 03a97ac90..9b86b1d56 100644 --- a/src/config/runtime-config/runtime-config.service.ts +++ b/src/config/runtime-config/runtime-config.service.ts @@ -7,14 +7,11 @@ import { import { ConfigService } from "@nestjs/config"; import { InjectModel } from "@nestjs/mongoose"; import { Model } from "mongoose"; -import { isEqual } from "lodash"; -import { reconcileData } from "./utils"; import { OutputRuntimeConfigDto } from "./dto/runtime-config.dto"; import { RuntimeConfig, RuntimeConfigDocument, } from "./schemas/runtime-config.schema"; -import { computeDeltaWithOriginals } from "src/common/utils/delta.util"; @Injectable() export class RuntimeConfigService implements OnModuleInit { @@ -29,94 +26,78 @@ export class RuntimeConfigService implements OnModuleInit { "configSyncToDb.configList", )!; - for (const configId of configList) await this.syncConfigDiff(configId); + for (const configId of configList) await this.syncConfig(configId); } - async getConfig(id: string): Promise { - const data = await this.runtimeConfigModel.findOne({ _id: id }).lean(); + async getConfig(cid: string): Promise { + const data = await this.runtimeConfigModel.findOne({ cid: cid }).lean(); if (!data) { - throw new NotFoundException(`Config '${id}' not found`); + throw new NotFoundException(`Config '${cid}' not found`); } return data; } async updateConfig( - id: string, + cid: string, config: Record, updatedBy: string, ): Promise { - const updatedDoc = await this.runtimeConfigModel.findByIdAndUpdate( - { _id: id }, + const updatedDoc = await this.runtimeConfigModel.findOneAndUpdate( + { cid: cid }, { $set: { data: config, updatedBy } }, { new: true }, ); if (!updatedDoc) { - throw new NotFoundException(`Config '${id}' not found`); + throw new NotFoundException(`Config '${cid}' not found`); } Logger.log( - `Updated app config entry '${id}' by user '${updatedBy}'`, + `Updated app config entry '${cid}' by user '${updatedBy}'`, "RuntimeConfigService", ); return updatedDoc; } - async syncConfigDiff(configId: string): Promise { - const autoSyncEnabled = this.configService.get( - `configSyncToDb.enabled`, - ); - + async syncConfig(configId: string): Promise { const sourceConfig = this.configService.get>(configId) || {}; + if (!sourceConfig || Object.keys(sourceConfig).length === 0) { + Logger.warn( + `Config file: ${configId} is empty or missing, skipping sync`, + "RuntimeConfigService", + ); + return; + } + const existing = await this.runtimeConfigModel - .findOne({ _id: configId }) + .findOne({ cid: configId }) .lean(); - // If no existing config, create one with default values + // If no existing config, create one from config file if (!existing) { await this.runtimeConfigModel.create({ - _id: configId, + cid: configId, data: sourceConfig, }); Logger.log( - `Created runtime config entry '${configId}' with default values from json file`, + `Created runtime config entry: '${configId}' with config file`, "RuntimeConfigService", ); return; } - if (!autoSyncEnabled) return; - - const dbValue = existing.data || {}; - - const updatedConfig = reconcileData(dbValue, sourceConfig) as Record< - string, - unknown - >; - - if (isEqual(updatedConfig, dbValue)) { - return; - } - - const { delta, originals } = computeDeltaWithOriginals( - dbValue, - updatedConfig, - ); - + // overwrite existing config with config file await this.runtimeConfigModel.updateOne( - { _id: configId }, - { data: updatedConfig }, + { cid: configId }, + { data: sourceConfig }, ); Logger.log( - { - before: JSON.stringify(originals), - after: JSON.stringify(delta), - }, - `RuntimeConfigService - [${configId}] synchronized changes`, + `RuntimeConfigService - [${configId}] synchronized with config file`, + "RuntimeConfigService", ); } } diff --git a/src/config/runtime-config/utils.spec.ts b/src/config/runtime-config/utils.spec.ts deleted file mode 100644 index e0c827423..000000000 --- a/src/config/runtime-config/utils.spec.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { reconcileData, isPlainObject } from "./utils"; - -describe("isPlainObject", () => { - test("returns true for plain objects", () => { - expect(isPlainObject({})).toBe(true); - expect(isPlainObject({ a: 1 })).toBe(true); - }); - - test("returns false for arrays", () => { - expect(isPlainObject([])).toBe(false); - }); - - test("returns false for null", () => { - expect(isPlainObject(null)).toBe(false); - }); - - test("returns false for primitives", () => { - expect(isPlainObject(1)).toBe(false); - expect(isPlainObject("x")).toBe(false); - expect(isPlainObject(true)).toBe(false); - }); -}); - -describe("reconcileData", () => { - // - // PRIMITIVES - // - test("keeps DB primitive if present", () => { - expect(reconcileData(99, 5)).toBe(99); - expect(reconcileData("db", "src")).toBe("db"); - expect(reconcileData(true, false)).toBe(true); - }); - - test("creates primitive if target missing", () => { - expect(reconcileData(undefined, 5)).toBe(5); - expect(reconcileData(undefined, "x")).toBe("x"); - expect(reconcileData(undefined, null)).toBe(null); - }); - - // - // OBJECTS - // - test("adds missing object keys", () => { - const target = { a: 1 }; - const source = { a: 1, b: 2 }; - expect(reconcileData(target, source)).toEqual({ a: 1, b: 2 }); - }); - - test("removes keys not in source", () => { - const target = { a: 1, b: 2 }; - const source = { a: 1 }; - expect(reconcileData(target, source)).toEqual({ a: 1 }); - }); - - test("keeps primitive values inside objects", () => { - const target = { a: 1 }; - const source = { a: 999 }; // different primitive - expect(reconcileData(target, source)).toEqual({ a: 1 }); // preserved DB value - }); - - test("creates nested primitives when missing", () => { - const target = {}; - const source = { a: { b: 10 } }; - expect(reconcileData(target, source)).toEqual({ a: { b: 10 } }); - }); - - test("preserves nested DB values", () => { - const target = { a: { b: 777 } }; - const source = { a: { b: 10 } }; - expect(reconcileData(target, source)).toEqual({ a: { b: 777 } }); - }); - - test("removes nested keys", () => { - const target = { a: { b: 1, c: 2 } }; - const source = { a: { b: 1 } }; - expect(reconcileData(target, source)).toEqual({ a: { b: 1 } }); - }); - - // - // ARRAYS - // - test("syncs array structure but keeps DB primitive values inside", () => { - const target = [100, 200]; - const source = [1, 2]; - expect(reconcileData(target, source)).toEqual([100, 200]); - }); - - test("creates array primitives when missing", () => { - const target: unknown[] = []; - const source = [1, 2, 3]; - expect(reconcileData(target, source)).toEqual([1, 2, 3]); - }); - - test("removes extra array elements", () => { - const target: unknown[] = [1, 2, 3]; - const source = [1]; - expect(reconcileData(target, source)).toEqual([1]); - }); - - test("preserves nested array object values", () => { - const target = [{ a: 999 }]; - const source = [{ a: 5 }]; - expect(reconcileData(target, source)).toEqual([{ a: 999 }]); - }); - - test("syncs nested array objects and removes extra keys", () => { - const target = [{ a: 1, old: true }]; - const source = [{ a: 2 }]; - expect(reconcileData(target, source)).toEqual([{ a: 1 }]); // a kept, old removed - }); - - // - // COMPLEX FULL STRUCTURE SYNC - // - test("deep complex structure sync", () => { - const target = { - type: "attachments", - label: "Gallery", - order: 99, // overridden by preserve rule → keep 99 - options: { - limit: 5, - size: "large", - }, - oldKey: true, - }; - - const source = { - type: "attachments", - label: "Gallery", - order: 5, - options: { - limit: 2, // keep DB = 5 - }, - }; - - expect(reconcileData(target, source)).toEqual({ - type: "attachments", - label: "Gallery", - order: 99, // preserved - options: { - limit: 5, // preserved - }, - // size removed - // oldKey removed - }); - }); -}); diff --git a/src/config/runtime-config/utils.ts b/src/config/runtime-config/utils.ts deleted file mode 100644 index f40e9294f..000000000 --- a/src/config/runtime-config/utils.ts +++ /dev/null @@ -1,42 +0,0 @@ -export const isPlainObject = ( - value: unknown, -): value is Record => { - return typeof value === "object" && value !== null && !Array.isArray(value); -}; - -export const reconcileData = (target: unknown, source: unknown): unknown => { - // Primitives: keep existing DB value if present; only create new values if DB has none. - if (typeof source !== "object" || source === null) { - return target !== undefined ? target : source; - } - - // Arrays: match array length and structure, but don't overwrite primitive values - if (Array.isArray(source)) { - const tgtArr: unknown[] = Array.isArray(target) ? target : []; - - return source.map((srcItem, index) => { - const tgtItem = tgtArr[index] as unknown; - return reconcileData(tgtItem, srcItem); - }); - } - - // Objects: sync keys - if (isPlainObject(source)) { - const result: Record = {}; - - const srcObj = source as Record; - const tgtObj = isPlainObject(target) - ? (target as Record) - : {}; - - // update keys - for (const key of Object.keys(srcObj)) { - const nextTarget = key in tgtObj ? tgtObj[key] : undefined; - result[key] = reconcileData(nextTarget, srcObj[key]); - } - - return result; - } - - return source; -}; From 07d9b9073176985572745894d3bc96e19ec5d03d Mon Sep 17 00:00:00 2001 From: junjiequan Date: Thu, 8 Jan 2026 15:51:05 +0100 Subject: [PATCH 09/17] remove description and replace _id with cid --- src/config/runtime-config/dto/runtime-config.dto.ts | 13 ++----------- .../runtime-config/schemas/runtime-config.schema.ts | 12 ++---------- 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/src/config/runtime-config/dto/runtime-config.dto.ts b/src/config/runtime-config/dto/runtime-config.dto.ts index c5b32709c..f3477c3df 100644 --- a/src/config/runtime-config/dto/runtime-config.dto.ts +++ b/src/config/runtime-config/dto/runtime-config.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsString, IsOptional, IsObject } from "class-validator"; +import { IsString, IsObject } from "class-validator"; export class OutputRuntimeConfigDto { @ApiProperty({ @@ -8,7 +8,7 @@ export class OutputRuntimeConfigDto { example: "frontend", }) @IsString() - _id: string; + cid: string; @ApiProperty({ type: Object, @@ -17,15 +17,6 @@ export class OutputRuntimeConfigDto { @IsObject() data: Record; - @ApiProperty({ - type: String, - required: false, - description: "Optional description of this configuration entry", - }) - @IsOptional() - @IsString() - description?: string; - @ApiProperty({ type: String, description: "User or system that last updated the configuration", diff --git a/src/config/runtime-config/schemas/runtime-config.schema.ts b/src/config/runtime-config/schemas/runtime-config.schema.ts index 642950cdf..0dac7f31b 100644 --- a/src/config/runtime-config/schemas/runtime-config.schema.ts +++ b/src/config/runtime-config/schemas/runtime-config.schema.ts @@ -13,8 +13,8 @@ export class RuntimeConfig { type: String, description: "Unique config identifier (e.g. 'frontend', 'backend', etc.)", }) - @Prop({ type: String, unique: true, required: true }) - _id: string; + @Prop({ type: String, unique: true, required: true, index: true }) + cid: string; @ApiProperty({ type: Object, @@ -23,14 +23,6 @@ export class RuntimeConfig { @Prop({ type: Object, required: true, default: {} }) data: Record; - @ApiProperty({ - type: String, - required: false, - description: "Optional description of this configuration entry", - }) - @Prop({ type: String, required: false }) - description?: string; - @ApiProperty({ type: String, required: false, From d14a93127fbce78d29f4ae5ec00bc8db810c2300 Mon Sep 17 00:00:00 2001 From: junjiequan Date: Thu, 8 Jan 2026 15:51:21 +0100 Subject: [PATCH 10/17] add missing decorators --- src/config/configuration.ts | 11 +++++-- .../runtime-config.controller.ts | 32 +++++++++++++++---- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 5ad847d18..1115bfbc1 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -194,9 +194,16 @@ const configuration = () => { const config = { configSyncToDb: { - enabled: boolean(process.env.CONFIG_SYNC_TO_DB_ENABLED || false), configList: process.env.CONFIG_SYNC_TO_DB_LIST - ? process.env.CONFIG_SYNC_TO_DB_LIST.split(",").map((v) => v.trim()) + ? [ + ...new Set([ + "frontendConfig", + "frontendTheme", + ...(process.env.CONFIG_SYNC_TO_DB_LIST?.split(",").map((v) => + v.trim(), + ) ?? []), + ]), + ] // Always include frontendConfig and frontendTheme : ["frontendConfig", "frontendTheme"], }, maxFileUploadSizeInMb: process.env.MAX_FILE_UPLOAD_SIZE || "16mb", // 16MB by default diff --git a/src/config/runtime-config/runtime-config.controller.ts b/src/config/runtime-config/runtime-config.controller.ts index 3b549e94e..784a70b54 100644 --- a/src/config/runtime-config/runtime-config.controller.ts +++ b/src/config/runtime-config/runtime-config.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Get, + HttpStatus, Param, Put, Req, @@ -10,7 +11,11 @@ import { import { ApiBearerAuth, ApiBody, + ApiNotFoundResponse, ApiOkResponse, + ApiOperation, + ApiParam, + ApiResponse, ApiTags, } from "@nestjs/swagger"; import { Request } from "express"; @@ -30,12 +35,20 @@ export class RuntimeConfigController { constructor(private readonly runtimeConfigService: RuntimeConfigService) {} @AllowAny() + @ApiParam({ + name: "id", + description: "Runtime config cid (e.g. frontendConfig, frontendTheme)", + type: String, + }) + @ApiResponse({ status: HttpStatus.OK, type: OutputRuntimeConfigDto }) + @ApiOperation({ summary: "Get runtime configuration by cid" }) @ApiOkResponse({ type: OutputRuntimeConfigDto }) - @Get("data/:id") + @ApiNotFoundResponse({ description: "Config ':id' not found" }) + @Get(":id") async getConfig( - @Param("id") id: string, + @Param("id") cid: string, ): Promise { - const config = await this.runtimeConfigService.getConfig(id); + const config = await this.runtimeConfigService.getConfig(cid); return config; } @@ -44,20 +57,27 @@ export class RuntimeConfigController { @CheckPolicies("runtimeconfig", (ability: AppAbility) => ability.can(Action.Update, RuntimeConfig), ) - @Put("data/:id") + @Put(":id") + @ApiParam({ + name: "id", + description: "Runtime config cid (e.g. frontendConfig, frontendTheme)", + schema: { type: "string" }, + }) @ApiBody({ type: Object, description: "Runtime config object", }) @ApiOkResponse({ type: OutputRuntimeConfigDto }) + @ApiNotFoundResponse({ description: "Config ':id' not found" }) + @ApiOperation({ summary: "Overwrite runtime configuration by cid" }) async updateConfig( @Req() request: Request, - @Param("id") id: string, + @Param("id") cid: string, @Body() config: Record, ): Promise { const user: JWTUser = request.user as JWTUser; return await this.runtimeConfigService.updateConfig( - id, + cid, config, user.username, ); From 00c0cd1e43349050c5835d9825e0936eeabe362e Mon Sep 17 00:00:00 2001 From: junjiequan Date: Fri, 9 Jan 2026 10:06:18 +0100 Subject: [PATCH 11/17] Replace previous admin/config fetch with RuntimeConfigService.getConfig --- src/admin/admin.module.ts | 2 ++ src/admin/admin.service.ts | 14 ++++++-------- .../runtime-config/runtime-config.service.ts | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/admin/admin.module.ts b/src/admin/admin.module.ts index 87159e3ae..f78a66667 100644 --- a/src/admin/admin.module.ts +++ b/src/admin/admin.module.ts @@ -1,8 +1,10 @@ import { Module } from "@nestjs/common"; import { AdminService } from "./admin.service"; import { AdminController } from "./admin.controller"; +import { RuntimeConfigModule } from "src/config/runtime-config/runtime-config.module"; @Module({ + imports: [RuntimeConfigModule], controllers: [AdminController], providers: [AdminService], exports: [AdminService], diff --git a/src/admin/admin.service.ts b/src/admin/admin.service.ts index 8fdab515f..a6b550650 100644 --- a/src/admin/admin.service.ts +++ b/src/admin/admin.service.ts @@ -1,20 +1,18 @@ import { Injectable } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; +import { RuntimeConfigService } from "src/config/runtime-config/runtime-config.service"; @Injectable() export class AdminService { - constructor(private configService: ConfigService) {} + constructor(private runtimeConfigService: RuntimeConfigService) {} async getConfig(): Promise | null> { - const config = - this.configService.get>("frontendConfig") || null; + const config = await this.runtimeConfigService.getConfig("frontendConfig"); - return config; + return config?.data || null; } async getTheme(): Promise | null> { - const theme = - this.configService.get>("frontendTheme") || null; - return theme; + const theme = await this.runtimeConfigService.getConfig("frontendTheme"); + return theme?.data || null; } } diff --git a/src/config/runtime-config/runtime-config.service.ts b/src/config/runtime-config/runtime-config.service.ts index 9b86b1d56..660e75c74 100644 --- a/src/config/runtime-config/runtime-config.service.ts +++ b/src/config/runtime-config/runtime-config.service.ts @@ -65,7 +65,7 @@ export class RuntimeConfigService implements OnModuleInit { this.configService.get>(configId) || {}; if (!sourceConfig || Object.keys(sourceConfig).length === 0) { - Logger.warn( + Logger.error( `Config file: ${configId} is empty or missing, skipping sync`, "RuntimeConfigService", ); From 3e7b2e330d26a124b3dd516dc5d8b8c4ff1871c3 Mon Sep 17 00:00:00 2001 From: junjiequan Date: Fri, 9 Jan 2026 10:21:55 +0100 Subject: [PATCH 12/17] fix admin service unit test --- src/admin/admin.service.spec.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/admin/admin.service.spec.ts b/src/admin/admin.service.spec.ts index a632485dd..593936a35 100644 --- a/src/admin/admin.service.spec.ts +++ b/src/admin/admin.service.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from "@nestjs/testing"; -import { ConfigService } from "@nestjs/config"; import { AdminService } from "./admin.service"; +import { RuntimeConfigService } from "src/config/runtime-config/runtime-config.service"; const mockConfig: Record = { accessTokenPrefix: "Bearer ", @@ -90,11 +90,11 @@ const mockTheme: Record = { describe("AdminService", () => { let service: AdminService; - const mockConfigService = { - get: jest.fn((propertyPath: string) => { + const mockRuntimeConfigService = { + getConfig: jest.fn((propertyPath: string) => { const config = { - frontendConfig: mockConfig, - frontendTheme: mockTheme, + frontendConfig: { cid: "frontendConfig", data: mockConfig }, + frontendTheme: { cid: "frontendTheme", data: mockTheme }, } as Record; return config[propertyPath]; @@ -106,8 +106,8 @@ describe("AdminService", () => { providers: [ AdminService, { - provide: ConfigService, - useValue: mockConfigService, + provide: RuntimeConfigService, + useValue: mockRuntimeConfigService, }, ], }).compile(); From 07f51d6377be2c479206655efb7f05bc427248d2 Mon Sep 17 00:00:00 2001 From: junjiequan Date: Mon, 12 Jan 2026 14:24:47 +0100 Subject: [PATCH 13/17] update doc --- src/config/runtime-config/runtime-config.md | 54 +++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/config/runtime-config/runtime-config.md diff --git a/src/config/runtime-config/runtime-config.md b/src/config/runtime-config/runtime-config.md new file mode 100644 index 000000000..e9aef34fa --- /dev/null +++ b/src/config/runtime-config/runtime-config.md @@ -0,0 +1,54 @@ +--- +title: Runtime Configuration System Overview +audience: Technical +created_by: Junjie Quan +created_on: 2026-01-08 +--- + +# Runtime Configuration: Technical Documentation + +## Overview + +SciCat frontend supports runtime-editable configuration stored in the backend and fetched dynamically at application startup. + +Configuration is identified by a configuration ID (cid) and accessed via the runtime-config API. + +## Predefined Configuration IDs + +```json +CONFIG_SYNC_TO_DB_LIST="frontendConfig, frontendTheme" +``` + +- When provided, it is parsed as a comma-separated list of configuration IDs. +- When not provided, the system always includes the following defaults: - frontendConfig - frontendTheme + These default configuration IDs are always synchronized to ensure frontend functionality. + +### Runtime Config API + +- `GET /api/v3/runtime-config/:id` + Retrieves the current runtime configuration by `cid`. Response includes: `cid`, `data`, `updatedBy` + +- `PUT /api/v3/runtime-config/:id` + Updates the runtime configuration and persists changes to the database. This endpoint accepts only the data object in the request body and `updatedBy` is automatically set from the user name. + +## Backend Synchronization Flow + +On backend startup: + +1. `CONFIG_SYNC_TO_DB_LIST` is read from environment variables. +2. For each configuration ID: + - If no record exists in the database, a new record is created from the corresponding config file. + - If a record already exists, it is overwritten with values from the config file. +3. After initialization, all changes are managed via the `runtime-config` API. + +## Extensibility + +The system is designed to support additional runtime configurations in the future. + +For any new configuration to be editable via the UI: + +1. The configuration ID must be included in CONFIG_SYNC_TO_DB_LIST +2. A corresponding JSONForms Schema & UI Schema must be provided +3. The schema files must be created in the frontend + +Without schema support, configuration values may exist in the database but will not be editable via the UI. From 5a167a1a75f0ae05d388291a745f67236b3607fe Mon Sep 17 00:00:00 2001 From: junjiequan Date: Mon, 12 Jan 2026 14:55:00 +0100 Subject: [PATCH 14/17] add un-immutable createdBy --- .../runtime-config/schemas/runtime-config.schema.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/config/runtime-config/schemas/runtime-config.schema.ts b/src/config/runtime-config/schemas/runtime-config.schema.ts index 0dac7f31b..824b90bdb 100644 --- a/src/config/runtime-config/schemas/runtime-config.schema.ts +++ b/src/config/runtime-config/schemas/runtime-config.schema.ts @@ -18,7 +18,7 @@ export class RuntimeConfig { @ApiProperty({ type: Object, - description: " configuration data stored as JSON", + description: "Configuration data stored as JSON", }) @Prop({ type: Object, required: true, default: {} }) data: Record; @@ -30,6 +30,14 @@ export class RuntimeConfig { }) @Prop({ type: String, required: true, default: "system" }) updatedBy: string; + + @ApiProperty({ + type: String, + required: false, + description: "User that created the configuration", + }) + @Prop({ type: String, required: true, default: "system", immutable: true }) + createdBy: string; } export const RuntimeConfigSchema = SchemaFactory.createForClass(RuntimeConfig); From d52464f93083841c5574a5654f0a1cc20f031d41 Mon Sep 17 00:00:00 2001 From: junjiequan Date: Wed, 21 Jan 2026 13:55:40 +0100 Subject: [PATCH 15/17] fixes based on comment --- .../developer-guide}/runtime-config.md | 0 .../dto/output-runtime-config.dto.ts | 39 +++++++++++++++++++ .../runtime-config/dto/runtime-config.dto.ts | 27 ------------- .../dto/update-runtime-config.dto.ts | 9 +++++ .../runtime-config.controller.ts | 9 +++-- .../runtime-config/runtime-config.service.ts | 27 +++++++------ .../schemas/runtime-config.schema.ts | 19 +-------- 7 files changed, 71 insertions(+), 59 deletions(-) rename {src/config/runtime-config => docs/developer-guide}/runtime-config.md (100%) create mode 100644 src/config/runtime-config/dto/output-runtime-config.dto.ts delete mode 100644 src/config/runtime-config/dto/runtime-config.dto.ts create mode 100644 src/config/runtime-config/dto/update-runtime-config.dto.ts diff --git a/src/config/runtime-config/runtime-config.md b/docs/developer-guide/runtime-config.md similarity index 100% rename from src/config/runtime-config/runtime-config.md rename to docs/developer-guide/runtime-config.md diff --git a/src/config/runtime-config/dto/output-runtime-config.dto.ts b/src/config/runtime-config/dto/output-runtime-config.dto.ts new file mode 100644 index 000000000..f8736cc7a --- /dev/null +++ b/src/config/runtime-config/dto/output-runtime-config.dto.ts @@ -0,0 +1,39 @@ +import { IsString, IsObject, IsDateString } from "class-validator"; + +export class OutputRuntimeConfigDto { + /** + * Unique config identifier (e.g. 'frontendConfig', 'frontendTheme', etc.) + */ + @IsString() + cid: string; + + /** + * Configuration content as a JSON object + */ + @IsObject() + data: Record; + + /** + * User or system that last updated the configuration + */ + @IsString() + updatedBy: string; + + /** + * User or system that created the configuration + */ + @IsString() + createdBy: string; + + /** + * Date/time when the configuration was created. ISO 8601 format. + */ + @IsDateString() + createdAt: Date; + + /** + * Date/time when the configuration was last updated. ISO 8601 format. + */ + @IsDateString() + updatedAt: Date; +} diff --git a/src/config/runtime-config/dto/runtime-config.dto.ts b/src/config/runtime-config/dto/runtime-config.dto.ts deleted file mode 100644 index f3477c3df..000000000 --- a/src/config/runtime-config/dto/runtime-config.dto.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IsString, IsObject } from "class-validator"; - -export class OutputRuntimeConfigDto { - @ApiProperty({ - type: String, - description: "Unique config identifier (e.g. 'frontend', 'backend', etc.)", - example: "frontend", - }) - @IsString() - cid: string; - - @ApiProperty({ - type: Object, - description: "Configuration content as a JSON object", - }) - @IsObject() - data: Record; - - @ApiProperty({ - type: String, - description: "User or system that last updated the configuration", - example: "system", - }) - @IsString() - updatedBy: string; -} diff --git a/src/config/runtime-config/dto/update-runtime-config.dto.ts b/src/config/runtime-config/dto/update-runtime-config.dto.ts new file mode 100644 index 000000000..6321f095f --- /dev/null +++ b/src/config/runtime-config/dto/update-runtime-config.dto.ts @@ -0,0 +1,9 @@ +import { IsObject } from "class-validator"; + +export class UpdateRuntimeConfigDto { + /** + * Configuration content as a JSON object + */ + @IsObject() + data: Record; +} diff --git a/src/config/runtime-config/runtime-config.controller.ts b/src/config/runtime-config/runtime-config.controller.ts index 784a70b54..bc47062d0 100644 --- a/src/config/runtime-config/runtime-config.controller.ts +++ b/src/config/runtime-config/runtime-config.controller.ts @@ -21,13 +21,14 @@ import { import { Request } from "express"; import { AllowAny } from "src/auth/decorators/allow-any.decorator"; import { JWTUser } from "src/auth/interfaces/jwt-user.interface"; -import { OutputRuntimeConfigDto } from "./dto/runtime-config.dto"; +import { OutputRuntimeConfigDto } from "./dto/output-runtime-config.dto"; import { RuntimeConfigService } from "./runtime-config.service"; import { PoliciesGuard } from "src/casl/guards/policies.guard"; import { Action } from "src/casl/action.enum"; import { AppAbility } from "src/casl/casl-ability.factory"; import { CheckPolicies } from "src/casl/decorators/check-policies.decorator"; import { RuntimeConfig } from "./schemas/runtime-config.schema"; +import { UpdateRuntimeConfigDto } from "./dto/update-runtime-config.dto"; @ApiBearerAuth() @ApiTags("runtime-config") @Controller("runtime-config") @@ -73,13 +74,13 @@ export class RuntimeConfigController { async updateConfig( @Req() request: Request, @Param("id") cid: string, - @Body() config: Record, + @Body() updateRuntimeConfigDto: UpdateRuntimeConfigDto, ): Promise { const user: JWTUser = request.user as JWTUser; return await this.runtimeConfigService.updateConfig( cid, - config, - user.username, + updateRuntimeConfigDto, + user, ); } } diff --git a/src/config/runtime-config/runtime-config.service.ts b/src/config/runtime-config/runtime-config.service.ts index 660e75c74..3b1ad8bd0 100644 --- a/src/config/runtime-config/runtime-config.service.ts +++ b/src/config/runtime-config/runtime-config.service.ts @@ -7,11 +7,14 @@ import { import { ConfigService } from "@nestjs/config"; import { InjectModel } from "@nestjs/mongoose"; import { Model } from "mongoose"; -import { OutputRuntimeConfigDto } from "./dto/runtime-config.dto"; +import { OutputRuntimeConfigDto } from "./dto/output-runtime-config.dto"; +import { JWTUser } from "src/auth/interfaces/jwt-user.interface"; import { RuntimeConfig, RuntimeConfigDocument, } from "./schemas/runtime-config.schema"; +import { addCreatedByFields, addUpdatedByField } from "src/common/utils"; +import { UpdateRuntimeConfigDto } from "./dto/update-runtime-config.dto"; @Injectable() export class RuntimeConfigService implements OnModuleInit { @@ -25,7 +28,6 @@ export class RuntimeConfigService implements OnModuleInit { const configList: string[] = this.configService.get( "configSyncToDb.configList", )!; - for (const configId of configList) await this.syncConfig(configId); } @@ -40,12 +42,14 @@ export class RuntimeConfigService implements OnModuleInit { async updateConfig( cid: string, - config: Record, - updatedBy: string, + updateRuntimeConfigDto: UpdateRuntimeConfigDto, + user: JWTUser, ): Promise { + const updateData = addUpdatedByField(updateRuntimeConfigDto, user.username); + const updatedDoc = await this.runtimeConfigModel.findOneAndUpdate( { cid: cid }, - { $set: { data: config, updatedBy } }, + { $set: { ...updateData } }, { new: true }, ); @@ -53,7 +57,7 @@ export class RuntimeConfigService implements OnModuleInit { throw new NotFoundException(`Config '${cid}' not found`); } Logger.log( - `Updated app config entry '${cid}' by user '${updatedBy}'`, + `Updated app config entry '${cid}' by user '${updateData.updatedBy}'`, "RuntimeConfigService", ); @@ -78,10 +82,11 @@ export class RuntimeConfigService implements OnModuleInit { // If no existing config, create one from config file if (!existing) { - await this.runtimeConfigModel.create({ - cid: configId, - data: sourceConfig, - }); + const createData = addCreatedByFields( + { cid: configId, data: sourceConfig }, + "system", + ); + await this.runtimeConfigModel.create({ ...createData }); Logger.log( `Created runtime config entry: '${configId}' with config file`, "RuntimeConfigService", @@ -92,7 +97,7 @@ export class RuntimeConfigService implements OnModuleInit { // overwrite existing config with config file await this.runtimeConfigModel.updateOne( { cid: configId }, - { data: sourceConfig }, + { data: sourceConfig, updatedBy: "system" }, ); Logger.log( diff --git a/src/config/runtime-config/schemas/runtime-config.schema.ts b/src/config/runtime-config/schemas/runtime-config.schema.ts index 824b90bdb..b56373521 100644 --- a/src/config/runtime-config/schemas/runtime-config.schema.ts +++ b/src/config/runtime-config/schemas/runtime-config.schema.ts @@ -1,6 +1,7 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { ApiProperty } from "@nestjs/swagger"; import { Document } from "mongoose"; +import { QueryableClass } from "src/common/schemas/queryable.schema"; export type RuntimeConfigDocument = RuntimeConfig & Document; @@ -8,7 +9,7 @@ export type RuntimeConfigDocument = RuntimeConfig & Document; collection: "RuntimeConfig", timestamps: true, }) -export class RuntimeConfig { +export class RuntimeConfig extends QueryableClass { @ApiProperty({ type: String, description: "Unique config identifier (e.g. 'frontend', 'backend', etc.)", @@ -22,22 +23,6 @@ export class RuntimeConfig { }) @Prop({ type: Object, required: true, default: {} }) data: Record; - - @ApiProperty({ - type: String, - required: false, - description: "User or system that last updated the configuration", - }) - @Prop({ type: String, required: true, default: "system" }) - updatedBy: string; - - @ApiProperty({ - type: String, - required: false, - description: "User that created the configuration", - }) - @Prop({ type: String, required: true, default: "system", immutable: true }) - createdBy: string; } export const RuntimeConfigSchema = SchemaFactory.createForClass(RuntimeConfig); From bbcd9b8ba4f48f769cd46a7750e70bc46257399a Mon Sep 17 00:00:00 2001 From: junjiequan Date: Wed, 21 Jan 2026 13:55:54 +0100 Subject: [PATCH 16/17] unit and api tests included --- .../attachments.v4.controller.spec.ts | 1 - .../runtime-config.controller.spec.ts | 92 +++++++++++ .../runtime-config.service.spec.ts | 154 ++++++++++++++++++ test/RuntimeConfig.js | 53 ++++++ 4 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 src/config/runtime-config/runtime-config.controller.spec.ts create mode 100644 src/config/runtime-config/runtime-config.service.spec.ts create mode 100644 test/RuntimeConfig.js diff --git a/src/attachments/attachments.v4.controller.spec.ts b/src/attachments/attachments.v4.controller.spec.ts index 1a8ec4844..df06a21e7 100644 --- a/src/attachments/attachments.v4.controller.spec.ts +++ b/src/attachments/attachments.v4.controller.spec.ts @@ -41,7 +41,6 @@ describe("AttachmentsController - findOneAndUpdate", () => { providers: [ { provide: AttachmentsV4Service, - useValue: mockAttachmentsV4Service, useValue: { findOneAndUpdate: jest .fn() diff --git a/src/config/runtime-config/runtime-config.controller.spec.ts b/src/config/runtime-config/runtime-config.controller.spec.ts new file mode 100644 index 000000000..cd360aba2 --- /dev/null +++ b/src/config/runtime-config/runtime-config.controller.spec.ts @@ -0,0 +1,92 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { RuntimeConfigController } from "./runtime-config.controller"; +import { RuntimeConfigService } from "./runtime-config.service"; +import { NotFoundException } from "@nestjs/common"; +import { Request } from "express"; +import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; + +class RuntimeConfigServiceMock { + getConfig = jest.fn(); + updateConfig = jest.fn(); +} + +class CaslAbilityFactoryMock {} + +describe("RuntimeConfigController", () => { + let controller: RuntimeConfigController; + let service: RuntimeConfigServiceMock; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [RuntimeConfigController], + providers: [ + { provide: RuntimeConfigService, useClass: RuntimeConfigServiceMock }, + { provide: CaslAbilityFactory, useClass: CaslAbilityFactoryMock }, + ], + }).compile(); + + controller = await module.resolve( + RuntimeConfigController, + ); + service = module.get(RuntimeConfigService); + }); + + it("should be defined", () => { + expect(controller).toBeDefined(); + }); + + describe("getConfig", () => { + it("should return config when found", async () => { + const cfg = { cid: "frontendConfig", data: { a: 1 } }; + service.getConfig.mockResolvedValue(cfg); + + const res = await controller.getConfig("frontendConfig"); + expect(res).toEqual(cfg); + expect(service.getConfig).toHaveBeenCalledWith("frontendConfig"); + }); + + it("should throw NotFoundException when missing", async () => { + service.getConfig.mockRejectedValue(new NotFoundException("not found")); + + await expect(controller.getConfig("missing")).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe("updateConfig", () => { + it("should call service.updateConfig with user from request", async () => { + const cfg = { cid: "frontendConfig", data: { a: 2 } }; + service.updateConfig.mockResolvedValue(cfg); + + const mockReq = { + user: { username: "adminIngestor" }, + } as unknown as Request; + + const dto = { data: { a: 2 } }; + + const res = await controller.updateConfig(mockReq, "frontendConfig", dto); + + expect(res).toEqual(cfg); + expect(service.updateConfig).toHaveBeenCalledWith( + "frontendConfig", + dto, + mockReq.user, + ); + }); + + it("should throw NotFoundException from service when config id does not exist", async () => { + service.updateConfig.mockRejectedValue( + new NotFoundException("not found"), + ); + + const mockReq = { + user: { username: "adminIngestor" }, + } as unknown as Request; + + await expect( + controller.updateConfig(mockReq, "missing", { data: {} }), + ).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/src/config/runtime-config/runtime-config.service.spec.ts b/src/config/runtime-config/runtime-config.service.spec.ts new file mode 100644 index 000000000..148b03394 --- /dev/null +++ b/src/config/runtime-config/runtime-config.service.spec.ts @@ -0,0 +1,154 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { NotFoundException } from "@nestjs/common"; +import { getModelToken } from "@nestjs/mongoose"; +import { ConfigService } from "@nestjs/config"; +import { RuntimeConfigService } from "./runtime-config.service"; +import { RuntimeConfig } from "./schemas/runtime-config.schema"; +import { JWTUser } from "src/auth/interfaces/jwt-user.interface"; + +class ConfigServiceMock { + get = jest.fn(); +} + +class RuntimeConfigModelMock { + findOne = jest.fn(); + findOneAndUpdate = jest.fn(); + updateOne = jest.fn(); + create = jest.fn(); +} + +describe("RuntimeConfigService", () => { + let service: RuntimeConfigService; + let configService: ConfigServiceMock; + let model: RuntimeConfigModelMock; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RuntimeConfigService, + { provide: ConfigService, useClass: ConfigServiceMock }, + { + provide: getModelToken(RuntimeConfig.name), + useClass: RuntimeConfigModelMock, + }, + ], + }).compile(); + + service = module.get(RuntimeConfigService); + configService = module.get(ConfigService); + model = module.get(getModelToken(RuntimeConfig.name)); + }); + + afterEach(() => jest.clearAllMocks()); + + describe("getConfig", () => { + it("returns config when found", async () => { + model.findOne.mockReturnValue({ + lean: () => ({ cid: "c1", data: { a: 1 } }), + }); + + await expect(service.getConfig("c1")).resolves.toEqual({ + cid: "c1", + data: { a: 1 }, + }); + expect(model.findOne).toHaveBeenCalledWith({ cid: "c1" }); + }); + + it("throws NotFoundException when missing", async () => { + model.findOne.mockReturnValue({ lean: () => null }); + + await expect(service.getConfig("missing")).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe("updateConfig", () => { + it("updates and returns updated doc", async () => { + const updated = { cid: "c1", data: { a: 2 }, updatedBy: "admin" }; + model.findOneAndUpdate.mockResolvedValue(updated); + + const dto = { data: { a: 2 } }; + const user = { username: "admin" }; + const res = await service.updateConfig("c1", dto, user as JWTUser); + + expect(res).toEqual(updated); + expect(model.findOneAndUpdate).toHaveBeenCalledWith( + { cid: "c1" }, + { + $set: expect.objectContaining({ data: { a: 2 }, updatedBy: "admin" }), + }, + { new: true }, + ); + }); + + it("throws NotFoundException if cid not found", async () => { + model.findOneAndUpdate.mockResolvedValue(null); + + await expect( + service.updateConfig("missing", { data: {} }, { + username: "admin", + } as JWTUser), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("syncConfig", () => { + it("skips when config is empty/missing", async () => { + configService.get.mockReturnValue({}); + + await service.syncConfig("frontendConfig"); + + expect(model.findOne).not.toHaveBeenCalled(); + expect(model.create).not.toHaveBeenCalled(); + expect(model.updateOne).not.toHaveBeenCalled(); + }); + + it("creates entry if not existing", async () => { + const source = { foo: "bar" }; + configService.get.mockReturnValue(source); + model.findOne.mockReturnValue({ lean: () => null }); + model.create.mockResolvedValue({ cid: "x" }); + + await service.syncConfig("frontendConfig"); + + expect(model.create).toHaveBeenCalledWith( + expect.objectContaining({ + cid: "frontendConfig", + data: source, + createdBy: "system", + updatedBy: "system", + }), + ); + }); + + it("overwrites entry if existing", async () => { + const source = { foo: "bar" }; + configService.get.mockReturnValue(source); + model.findOne.mockReturnValue({ + lean: () => ({ cid: "frontendConfig" }), + }); + model.updateOne.mockResolvedValue({ acknowledged: true }); + + await service.syncConfig("frontendConfig"); + + expect(model.updateOne).toHaveBeenCalledWith( + { cid: "frontendConfig" }, + { data: source, updatedBy: "system" }, + ); + }); + }); + + describe("onModuleInit", () => { + it("syncs each configId from configList", async () => { + configService.get.mockReturnValue(["a", "b"]); + const spy = jest.spyOn(service, "syncConfig").mockResolvedValue(); + + await service.onModuleInit(); + + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledWith("a"); + expect(spy).toHaveBeenCalledWith("b"); + }); + }); +}); diff --git a/test/RuntimeConfig.js b/test/RuntimeConfig.js new file mode 100644 index 000000000..cb007c03a --- /dev/null +++ b/test/RuntimeConfig.js @@ -0,0 +1,53 @@ +"use strict"; +const utils = require("./LoginUtils"); +const { TestData } = require("./TestData"); + +let accessTokenAdmin = null; +let accessTokenUser1 = null; + +describe("RuntimeConfig ACL", () => { + const cid = "frontendConfig"; + + before(async () => { + accessTokenAdmin = await utils.getToken(appUrl, { + username: "adminIngestor", + password: TestData.Accounts["adminIngestor"]["password"], + }); + + accessTokenUser1 = await utils.getToken(appUrl, { + username: "user1", + password: TestData.Accounts["user1"]["password"], + }); + }); + + it("should allow any user to fetch config", async () => { + return request(appUrl) + .get(`/api/v3/runtime-config/${encodeURIComponent(cid)}`) + .set("Accept", "application/json") + .set({ Authorization: `Bearer ${accessTokenUser1}` }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.have.property("cid").and.be.equal(cid); + res.body.should.have.property("data"); + }); + }); + + it("should forbid non-admin user to update config", async () => { + return request(appUrl) + .put(`/api/v3/runtime-config/${encodeURIComponent(cid)}`) + .send({ data: { test: true } }) + .set("Accept", "application/json") + .set({ Authorization: `Bearer ${accessTokenUser1}` }) + .expect(TestData.AccessForbiddenStatusCode); + }); + + it("should allow admin to update config", async () => { + return request(appUrl) + .put(`/api/v3/runtime-config/${encodeURIComponent(cid)}`) + .send({ data: { test: true } }) + .set("Accept", "application/json") + .set({ Authorization: `Bearer ${accessTokenAdmin}` }) + .expect(TestData.SuccessfulPatchStatusCode); + }); +}); From d46b3259e65c56cfdab165b4f73a21d8e083131a Mon Sep 17 00:00:00 2001 From: junjiequan Date: Mon, 26 Jan 2026 12:03:28 +0100 Subject: [PATCH 17/17] udpate runtime-config controller tag --- src/config/runtime-config/runtime-config.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/runtime-config/runtime-config.controller.ts b/src/config/runtime-config/runtime-config.controller.ts index bc47062d0..fb13da0af 100644 --- a/src/config/runtime-config/runtime-config.controller.ts +++ b/src/config/runtime-config/runtime-config.controller.ts @@ -30,7 +30,7 @@ import { CheckPolicies } from "src/casl/decorators/check-policies.decorator"; import { RuntimeConfig } from "./schemas/runtime-config.schema"; import { UpdateRuntimeConfigDto } from "./dto/update-runtime-config.dto"; @ApiBearerAuth() -@ApiTags("runtime-config") +@ApiTags("runtime configurations") @Controller("runtime-config") export class RuntimeConfigController { constructor(private readonly runtimeConfigService: RuntimeConfigService) {}