diff --git a/src/backend/configure.ts b/src/backend/configure.ts index 6899919e..81e6a03d 100644 --- a/src/backend/configure.ts +++ b/src/backend/configure.ts @@ -5,7 +5,7 @@ import { stubsRoot } from './stubs/main.js'; import { fsReadAll } from '@poppinss/utils'; async function addMigrations(command: Configure, codemods: Codemods) { - const allMigrations = ['base', 'audit', 'drops', 'preferences', 'campaigns']; + const allMigrations = ['base', 'audit', 'drops', 'preferences', 'campaigns', 'configs']; const path = command.app.migrationsPath(); const migrations = await fsReadAll(path); @@ -102,6 +102,7 @@ export async function configure(command: Configure) { await codemods.makeUsingStub(stubsRoot, 'resources/views/preview.stub', {}); await codemods.makeUsingStub(stubsRoot, 'resources/views/scripture.stub', {}); + await codemods.makeUsingStub(stubsRoot, 'commands/migrate_config.stub', {}); await codemods.makeUsingStub(stubsRoot, 'commands/make_user.stub', {}); await codemods.makeUsingStub(stubsRoot, 'commands/fix_database.stub', {}); // NOTE: remove when this is resolved: https://github.com/adonisjs/inertia-starter-kit/issues/12 diff --git a/src/backend/index.ts b/src/backend/index.ts index df6f54b8..6b5a03d5 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -14,6 +14,7 @@ export { default as Index } from './models/index.js'; export { default as Drop } from './models/drop.js'; export { default as User } from './models/user.js'; export { default as Preference } from './models/preference.js'; +export { default as Config } from './models/config.js'; export * from './factories/drop_factory.js'; export * from './factories/draft_factory.js'; export * from './factories/index_factory.js'; diff --git a/src/backend/models/config.ts b/src/backend/models/config.ts new file mode 100644 index 00000000..6b816da8 --- /dev/null +++ b/src/backend/models/config.ts @@ -0,0 +1,22 @@ +import { DateTime } from 'luxon'; +import { BaseModel, column } from '@adonisjs/lucid/orm'; + +export default class Config extends BaseModel { + @column({ isPrimary: true }) + declare id: number; + + @column() + declare key: string; + + @column() + declare version: number; + + @column() + declare data: Record; + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime; + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime; +} diff --git a/src/backend/providers/cms_provider.ts b/src/backend/providers/cms_provider.ts index 2c1560d0..dc76d81e 100644 --- a/src/backend/providers/cms_provider.ts +++ b/src/backend/providers/cms_provider.ts @@ -1,17 +1,34 @@ import { CmsService } from '../services/cms_service.js'; import type { ApplicationService } from '@adonisjs/core/types'; +import Config from '../models/config.js'; +import { CmsConfig } from '../../types.js'; export default class CmsProvider { constructor(protected app: ApplicationService) {} register() { this.app.container.singleton(CmsService, async (resolver) => { - // currently reading from config file - // NEXT: read from database - const configService = await resolver.make('config'); - const cmsConfig = configService.get('cms'); + // check to see if we have a config in the database + const persisted = await Config.query() + .where('key', 'cms') + .orderBy('version', 'desc') + .first(); - return new CmsService(cmsConfig); + let cmsConfig: CmsConfig; + if (persisted) { + cmsConfig = persisted.data as CmsConfig; + } else { + // save the default config to the database + const configService = await resolver.make('config'); + cmsConfig = configService.get('cms') as CmsConfig; + await Config.create({ + key: 'cms', + version: 1, + data: cmsConfig as Record, + }); + } + + return new CmsService(cmsConfig!); }); } } diff --git a/src/backend/services/cms_service.ts b/src/backend/services/cms_service.ts index 367902f2..2d0d0eaa 100644 --- a/src/backend/services/cms_service.ts +++ b/src/backend/services/cms_service.ts @@ -27,6 +27,10 @@ export class CmsService { return this.#config; } + public set config(config: CmsConfig) { + this.#config = config; + } + public storyFrom(ctx: HttpContext): StorySpec | undefined { const storyId = Number.parseInt(ctx.params.storyId); const story = this.#config.stories.stories.find((s) => s.id === storyId); diff --git a/src/backend/stubs/commands/migrate_config.stub b/src/backend/stubs/commands/migrate_config.stub new file mode 100644 index 00000000..39e4cec2 --- /dev/null +++ b/src/backend/stubs/commands/migrate_config.stub @@ -0,0 +1,56 @@ +{{{ + exports({ to: app.commandsPath('migrate_config.ts') }) +}}} +import { BaseCommand } from '@adonisjs/core/ace'; +import type { CommandOptions } from '@adonisjs/core/types/ace'; +import { CmsConfig, Config, defineConfig } from '@story-cms/kit'; + +export default class MigrateConfig extends BaseCommand { + static commandName = 'migrate:config'; + static description = 'Migrate the active configuration'; + + static options: CommandOptions = { startApp: true }; + + async run() { + // fetch active configuration + const active = await Config.query() + .where('key', 'cms') + .orderBy('version', 'desc') + .first(); + + if (!active) { + this.logger.info('No active configuration found, skipping migration'); + return; + } + + try { + const overrides = active.data as CmsConfig; + const newConfig = defineConfig(overrides); + if (this.isEqual(newConfig, overrides)) { + this.logger.info('Configuration is up to date, skipping migration'); + return; + } + + const config = new Config(); + config.key = 'cms'; + config.version = active.version + 1; + config.data = newConfig; + await config.save(); + this.logger.info('Configuration migrated successfully'); + } catch (error) { + this.logger.error('Error migrating configuration: ' + error); + } + } + + private isEqual(a: CmsConfig, b: CmsConfig): boolean { + const sortedStringify = (obj: unknown): string => + JSON.stringify(obj, (_key, value) => + value && typeof value === 'object' && !Array.isArray(value) + ? Object.fromEntries( + Object.entries(value).sort(([x], [y]) => x.localeCompare(y)), + ) + : value, + ); + return sortedStringify(a) === sortedStringify(b); + } +} diff --git a/src/backend/stubs/migrations/configs.stub b/src/backend/stubs/migrations/configs.stub new file mode 100644 index 00000000..de38f05a --- /dev/null +++ b/src/backend/stubs/migrations/configs.stub @@ -0,0 +1,29 @@ +{{{ + exports({ + to: app.makePath(migration.folder, migration.fileName) + }) +}}} +import { BaseSchema } from '@adonisjs/lucid/schema'; + +export default class extends BaseSchema { + protected tableName = 'configs'; + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id'); + + table.string('key').notNullable(); + table.integer('version').notNullable().defaultTo(1); + table.jsonb('data').notNullable().defaultTo('{}'); + + table.timestamp('created_at'); + table.timestamp('updated_at'); + + table.unique(['key', 'version']); + }); + } + + async down() { + this.schema.dropTable(this.tableName); + } +} diff --git a/src/backend/stubs/tests/unit/config.stub b/src/backend/stubs/tests/unit/config.stub new file mode 100644 index 00000000..2efae408 --- /dev/null +++ b/src/backend/stubs/tests/unit/config.stub @@ -0,0 +1,73 @@ +{{{ + exports({ to: app.makePath('tests/unit/configs.spec.ts') }) +}}} +import { test } from '@japa/runner'; +import testUtils from '@adonisjs/core/services/test_utils'; +import { Config } from '@story-cms/kit'; +import cmsConfig from '#config/cms'; + +test.group('Config', (group) => { + group.each.setup(() => testUtils.db().withGlobalTransaction()); + + test('can store cms config in configs table', async ({ assert }) => { + const config = new Config(); + config.key = 'cms'; + config.version = 1; + config.data = cmsConfig as Record; + await config.save(); + + assert.isTrue(config.$isPersisted); + assert.equal(config.key, 'cms'); + assert.equal(config.version, 1); + assert.deepEqual(config.data, cmsConfig); + }); + + test('can retrieve stored cms config from configs table', async ({ assert }) => { + const config = new Config(); + config.key = 'cms'; + config.version = 1; + config.data = cmsConfig as Record; + await config.save(); + + const retrieved = await Config.findOrFail(config.id); + + assert.equal(retrieved.key, 'cms'); + assert.equal(retrieved.version, 1); + assert.deepEqual(retrieved.data, cmsConfig); + assert.equal(retrieved.data.meta.name, 'Pilot CMS'); + assert.equal( + retrieved.data.meta.logo, + 'https://res.cloudinary.com/journeys/image/upload/v1756295658/logo_g8k7tf.png', + ); + }); + + test('can store multiple versions of cms config', async ({ assert }) => { + const configV1 = new Config(); + configV1.key = 'cms'; + configV1.version = 1; + configV1.data = cmsConfig as Record; + await configV1.save(); + + const modifiedConfig = { + ...cmsConfig, + meta: { ...cmsConfig.meta, name: 'Updated Pilot CMS' }, + }; + const configV2 = new Config(); + configV2.key = 'cms'; + configV2.version = 2; + configV2.data = modifiedConfig as Record; + await configV2.save(); + + const retrievedV1 = await Config.query() + .where({ key: 'cms', version: 1 }) + .firstOrFail(); + const retrievedV2 = await Config.query() + .where({ key: 'cms', version: 2 }) + .firstOrFail(); + + assert.equal(retrievedV1.version, 1); + assert.equal(retrievedV1.data.meta.name, 'Pilot CMS'); + assert.equal(retrievedV2.version, 2); + assert.equal(retrievedV2.data.meta.name, 'Updated Pilot CMS'); + }); +});