diff --git a/packages/backend/src/controllers/api/v1/admin/config/update.ee.js b/packages/backend/src/controllers/api/v1/admin/config/update.ee.js index 9c3e7801..288b2e10 100644 --- a/packages/backend/src/controllers/api/v1/admin/config/update.ee.js +++ b/packages/backend/src/controllers/api/v1/admin/config/update.ee.js @@ -3,19 +3,17 @@ import { renderObject } from '../../../../../helpers/renderer.js'; import Config from '../../../../../models/config.js'; export default async (request, response) => { - const config = configParams(request); + const updatedConfig = await Config.update(configParams(request)); - await Config.batchUpdate(config); - - renderObject(response, config); + renderObject(response, updatedConfig); }; const configParams = (request) => { const updatableConfigurationKeys = [ - 'logo.svgData', - 'palette.primary.dark', - 'palette.primary.light', - 'palette.primary.main', + 'logoSvgData', + 'palettePrimaryDark', + 'palettePrimaryLight', + 'palettePrimaryMain', 'title', ]; diff --git a/packages/backend/src/controllers/api/v1/admin/config/update.ee.test.js b/packages/backend/src/controllers/api/v1/admin/config/update.ee.test.js index 465978de..bf4b103c 100644 --- a/packages/backend/src/controllers/api/v1/admin/config/update.ee.test.js +++ b/packages/backend/src/controllers/api/v1/admin/config/update.ee.test.js @@ -5,7 +5,7 @@ import app from '../../../../../app.js'; import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; import { createUser } from '../../../../../../test/factories/user.js'; import { createRole } from '../../../../../../test/factories/role.js'; -import { createBulkConfig } from '../../../../../../test/factories/config.js'; +import { updateConfig } from '../../../../../../test/factories/config.js'; import * as license from '../../../../../helpers/license.ee.js'; describe('PATCH /api/v1/admin/config', () => { @@ -30,13 +30,13 @@ describe('PATCH /api/v1/admin/config', () => { const appConfig = { title, - 'palette.primary.main': palettePrimaryMain, - 'palette.primary.dark': palettePrimaryDark, - 'palette.primary.light': palettePrimaryLight, - 'logo.svgData': logoSvgData, + palettePrimaryMain: palettePrimaryMain, + palettePrimaryDark: palettePrimaryDark, + palettePrimaryLight: palettePrimaryLight, + logoSvgData: logoSvgData, }; - await createBulkConfig(appConfig); + await updateConfig(appConfig); const newTitle = 'Updated title'; @@ -51,7 +51,7 @@ describe('PATCH /api/v1/admin/config', () => { .expect(200); expect(response.body.data.title).toEqual(newTitle); - expect(response.body.meta.type).toEqual('Object'); + expect(response.body.meta.type).toEqual('Config'); }); it('should return created config for unexisting config', async () => { @@ -68,7 +68,7 @@ describe('PATCH /api/v1/admin/config', () => { .expect(200); expect(response.body.data.title).toEqual(newTitle); - expect(response.body.meta.type).toEqual('Object'); + expect(response.body.meta.type).toEqual('Config'); }); it('should return null for deleted config entry', async () => { @@ -83,6 +83,6 @@ describe('PATCH /api/v1/admin/config', () => { .expect(200); expect(response.body.data.title).toBeNull(); - expect(response.body.meta.type).toEqual('Object'); + expect(response.body.meta.type).toEqual('Config'); }); }); diff --git a/packages/backend/src/controllers/api/v1/automatisch/config.ee.js b/packages/backend/src/controllers/api/v1/automatisch/config.ee.js index e8538cde..db0b0224 100644 --- a/packages/backend/src/controllers/api/v1/automatisch/config.ee.js +++ b/packages/backend/src/controllers/api/v1/automatisch/config.ee.js @@ -3,7 +3,7 @@ import Config from '../../../../models/config.js'; import { renderObject } from '../../../../helpers/renderer.js'; export default async (request, response) => { - const defaultConfig = { + const staticConfig = { disableNotificationsPage: appConfig.disableNotificationsPage, disableFavicon: appConfig.disableFavicon, additionalDrawerLink: appConfig.additionalDrawerLink, @@ -11,15 +11,12 @@ export default async (request, response) => { additionalDrawerLinkText: appConfig.additionalDrawerLinkText, }; - let config = await Config.query().orderBy('key', 'asc'); + const dynamicConfig = await Config.get(); - config = config.reduce((computedConfig, configEntry) => { - const { key, value } = configEntry; + const dynamicAndStaticConfig = { + ...dynamicConfig, + ...staticConfig, + }; - computedConfig[key] = value?.data; - - return computedConfig; - }, defaultConfig); - - renderObject(response, config); + renderObject(response, dynamicAndStaticConfig); }; diff --git a/packages/backend/src/controllers/api/v1/automatisch/config.ee.test.js b/packages/backend/src/controllers/api/v1/automatisch/config.ee.test.js index 28b8bde3..4405dc05 100644 --- a/packages/backend/src/controllers/api/v1/automatisch/config.ee.test.js +++ b/packages/backend/src/controllers/api/v1/automatisch/config.ee.test.js @@ -1,6 +1,6 @@ import { vi, expect, describe, it } from 'vitest'; import request from 'supertest'; -import { createConfig } from '../../../../../test/factories/config.js'; +import { updateConfig } from '../../../../../test/factories/config.js'; import app from '../../../../app.js'; import configMock from '../../../../../test/mocks/rest/api/v1/automatisch/config.js'; import * as license from '../../../../helpers/license.ee.js'; @@ -10,52 +10,35 @@ describe('GET /api/v1/automatisch/config', () => { it('should return Automatisch config', async () => { vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); - const logoConfig = await createConfig({ - key: 'logo.svgData', - value: { data: 'Sample' }, - }); - - const primaryDarkConfig = await createConfig({ - key: 'palette.primary.dark', - value: { data: '#001F52' }, - }); - - const primaryLightConfig = await createConfig({ - key: 'palette.primary.light', - value: { data: '#4286FF' }, - }); - - const primaryMainConfig = await createConfig({ - key: 'palette.primary.main', - value: { data: '#0059F7' }, - }); - - const titleConfig = await createConfig({ - key: 'title', - value: { data: 'Sample Title' }, + const logoConfig = await updateConfig({ + logoSvgData: 'Sample', + palettePrimaryDark: '#001f52', + palettePrimrayLight: '#4286FF', + palettePrimaryMain: '#0059F7', + title: 'Sample Title', }); const response = await request(app) .get('/api/v1/automatisch/config') .expect(200); - const expectedPayload = configMock( - logoConfig, - primaryDarkConfig, - primaryLightConfig, - primaryMainConfig, - titleConfig - ); + const expectedPayload = configMock(logoConfig); expect(response.body).toEqual(expectedPayload); }); it('should return additional environment variables', async () => { - vi.spyOn(appConfig, 'disableNotificationsPage', 'get').mockReturnValue(true); + vi.spyOn(appConfig, 'disableNotificationsPage', 'get').mockReturnValue( + true + ); vi.spyOn(appConfig, 'disableFavicon', 'get').mockReturnValue(true); vi.spyOn(appConfig, 'additionalDrawerLink', 'get').mockReturnValue('link'); - vi.spyOn(appConfig, 'additionalDrawerLinkIcon', 'get').mockReturnValue('icon'); - vi.spyOn(appConfig, 'additionalDrawerLinkText', 'get').mockReturnValue('text'); + vi.spyOn(appConfig, 'additionalDrawerLinkIcon', 'get').mockReturnValue( + 'icon' + ); + vi.spyOn(appConfig, 'additionalDrawerLinkText', 'get').mockReturnValue( + 'text' + ); expect(appConfig.disableNotificationsPage).toEqual(true); expect(appConfig.disableFavicon).toEqual(true); diff --git a/packages/backend/src/controllers/api/v1/installation/users/create-user.test.js b/packages/backend/src/controllers/api/v1/installation/users/create-user.test.js index 9c4d9bc6..a94f4c61 100644 --- a/packages/backend/src/controllers/api/v1/installation/users/create-user.test.js +++ b/packages/backend/src/controllers/api/v1/installation/users/create-user.test.js @@ -5,7 +5,7 @@ import Config from '../../../../../models/config.js'; import User from '../../../../../models/user.js'; import { createRole } from '../../../../../../test/factories/role'; import { createUser } from '../../../../../../test/factories/user'; -import { createInstallationCompletedConfig } from '../../../../../../test/factories/config'; +import { markInstallationCompleted } from '../../../../../../test/factories/config'; describe('POST /api/v1/installation/users', () => { let adminRole; @@ -59,7 +59,7 @@ describe('POST /api/v1/installation/users', () => { describe('for completed installations', () => { beforeEach(async () => { - await createInstallationCompletedConfig(); + await markInstallationCompleted(); }); it('should respond with HTTP 403 when installation completed', async () => { diff --git a/packages/backend/src/db/migrations/20240919100138_make_config_horizontal_scale.js b/packages/backend/src/db/migrations/20240919100138_make_config_horizontal_scale.js new file mode 100644 index 00000000..59ea0a80 --- /dev/null +++ b/packages/backend/src/db/migrations/20240919100138_make_config_horizontal_scale.js @@ -0,0 +1,102 @@ +function getValueForKey(rows, key) { + const row = rows.find((row) => row.key === key); + + return row?.value?.data || null; +} + +export async function up(knex) { + await knex.schema.alterTable('config', (table) => { + table.dropPrimary(); + table.dropUnique('key'); + }); + + await knex.schema.renameTable('config', 'config_old'); + + await knex.schema.createTable('config', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.boolean('installation_completed').defaultTo(false); + table.text('logo_svg_data'); + table.text('palette_primary_dark'); + table.text('palette_primary_light'); + table.text('palette_primary_main'); + table.string('title'); + + table.timestamps(true, true); + }); + + const oldConfig = await knex('config_old').select('key', 'value'); + + const singletonData = { + logo_svg_data: getValueForKey(oldConfig, 'logo.svgData'), + palette_primary_dark: getValueForKey(oldConfig, 'palette.primary.dark'), + palette_primary_light: getValueForKey(oldConfig, 'palette.primary.light'), + palette_primary_main: getValueForKey(oldConfig, 'palette.primary.main'), + title: getValueForKey(oldConfig, 'title'), + installation_completed: getValueForKey(oldConfig, 'installation.completed'), + }; + + await knex('config').insert(singletonData); + + await knex.schema.dropTable('config_old'); +} + +export async function down(knex) { + await knex.schema.alterTable('config', (table) => { + table.dropPrimary(); + }); + + await knex.schema.renameTable('config', 'config_old'); + + await knex.schema.createTable('config', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.string('key').unique().notNullable(); + table.jsonb('value').notNullable().defaultTo({}); + + table.timestamps(true, true); + }); + + const configRow = await knex('config_old').first(); + + const config = [ + { + key: 'logo.svgData', + value: { + data: configRow.logo_svg_data, + }, + }, + { + key: 'palette.primary.dark', + value: { + data: configRow.palette_primary_dark, + }, + }, + { + key: 'palette.primary.light', + value: { + data: configRow.palette_primary_light, + }, + }, + { + key: 'palette.primary.main', + value: { + data: configRow.palette_primary_main, + }, + }, + { + key: 'title', + value: { + data: configRow.title, + }, + }, + { + key: 'installation.completed', + value: { + data: configRow.installation_completed, + }, + }, + ]; + + await knex('config').insert(config); + + await knex.schema.dropTable('config_old'); +} diff --git a/packages/backend/src/models/config.js b/packages/backend/src/models/config.js index 71ee614a..109cb7dc 100644 --- a/packages/backend/src/models/config.js +++ b/packages/backend/src/models/config.js @@ -5,68 +5,47 @@ class Config extends Base { static jsonSchema = { type: 'object', - required: ['key', 'value'], properties: { id: { type: 'string', format: 'uuid' }, - key: { type: 'string', minLength: 1 }, - value: { type: 'object' }, + installationCompleted: { type: 'boolean' }, + logoSvgData: { type: ['string', 'null'] }, + palettePrimaryDark: { type: ['string', 'null'] }, + palettePrimaryLight: { type: ['string', 'null'] }, + palettePrimaryMain: { type: ['string', 'null'] }, + title: { type: ['string', 'null'] }, }, }; + static async get() { + const existingConfig = await this.query().limit(1).first(); + + if (!existingConfig) { + return await this.query().insertAndFetch({}); + } + + return existingConfig; + } + + static async update(config) { + const configEntry = await this.get(); + + return await configEntry.$query().patchAndFetch(config); + } + static async isInstallationCompleted() { - const installationCompletedEntry = await this.query() - .where({ - key: 'installation.completed', - }) - .first(); + const config = await this.get(); - const installationCompleted = - installationCompletedEntry?.value?.data === true; - - return installationCompleted; + return config.installationCompleted; } static async markInstallationCompleted() { - return await this.query().insert({ - key: 'installation.completed', - value: { - data: true, - }, + const config = await this.get(); + + return await config.$query().patchAndFetch({ + installationCompleted: true, }); } - - static async batchUpdate(config) { - const configKeys = Object.keys(config); - const updates = []; - - for (const key of configKeys) { - const newValue = config[key]; - - if (newValue) { - const entryUpdate = Config.query() - .insert({ - key, - value: { - data: newValue, - }, - }) - .onConflict('key') - .merge({ - value: { - data: newValue, - }, - }); - - updates.push(entryUpdate); - } else { - const entryUpdate = Config.query().findOne({ key }).delete(); - updates.push(entryUpdate); - } - } - - return await Promise.all(updates); - } } export default Config; diff --git a/packages/backend/test/factories/config.js b/packages/backend/test/factories/config.js index 45f6395d..8e42569c 100644 --- a/packages/backend/test/factories/config.js +++ b/packages/backend/test/factories/config.js @@ -1,35 +1,11 @@ -import { faker } from '@faker-js/faker'; import Config from '../../src/models/config'; -export const createConfig = async (params = {}) => { - const configData = { - key: params?.key || faker.lorem.word(), - value: params?.value || { data: 'sampleConfig' }, - }; - - const config = await Config.query().insertAndFetch(configData); - - return config; +export const updateConfig = async (params = {}) => { + return await Config.update(params); }; -export const createBulkConfig = async (params = {}) => { - const updateQueries = Object.entries(params).map(([key, value]) => { - const config = { - key, - value: { data: value }, - }; - - return createConfig(config); - }); - - await Promise.all(updateQueries); - - return await Config.query().whereIn('key', Object.keys(params)); -}; - -export const createInstallationCompletedConfig = async () => { - return await createConfig({ - key: 'installation.completed', - value: { data: true }, +export const markInstallationCompleted = async () => { + return await updateConfig({ + installationCompleted: true, }); }; diff --git a/packages/backend/test/mocks/rest/api/v1/automatisch/config.js b/packages/backend/test/mocks/rest/api/v1/automatisch/config.js index ba5cb838..0d35887c 100644 --- a/packages/backend/test/mocks/rest/api/v1/automatisch/config.js +++ b/packages/backend/test/mocks/rest/api/v1/automatisch/config.js @@ -1,19 +1,13 @@ -const infoMock = ( - logoConfig, - primaryDarkConfig, - primaryLightConfig, - primaryMainConfig, - titleConfig -) => { +const configMock = (config) => { return { data: { disableFavicon: false, disableNotificationsPage: false, - 'logo.svgData': logoConfig.value.data, - 'palette.primary.dark': primaryDarkConfig.value.data, - 'palette.primary.light': primaryLightConfig.value.data, - 'palette.primary.main': primaryMainConfig.value.data, - title: titleConfig.value.data, + logoSvgData: config.logoSvgData, + palettePrimaryDark: config.palettePrimaryDark, + palettePrimaryMain: config.palettePrimaryMain, + palettePrimaryLight: config.palettePrimaryLight, + title: config.title, }, meta: { count: 1, @@ -25,4 +19,4 @@ const infoMock = ( }; }; -export default infoMock; +export default configMock;