feat: add metadata repo + seeder

This commit is contained in:
Mohamed Marrouchi 2025-01-05 21:51:29 +01:00
parent 923a34c3e4
commit ef788591bf
15 changed files with 225 additions and 138 deletions

View File

@ -6,35 +6,43 @@
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/ */
import { existsSync, writeFileSync } from 'fs'; import fs from 'fs';
import { HttpService } from '@nestjs/axios'; import { HttpService } from '@nestjs/axios';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import { getModelToken } from '@nestjs/mongoose'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { getModelToken, MongooseModule } from '@nestjs/mongoose';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { LoggerService } from '@/logger/logger.service'; import { LoggerService } from '@/logger/logger.service';
import { MetadataRepository } from '@/setting/repositories/metadata.repository';
import { Metadata, MetadataModel } from '@/setting/schemas/metadata.schema';
import { MetadataService } from '@/setting/services/metadata.service'; import { MetadataService } from '@/setting/services/metadata.service';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { Migration } from './migration.schema'; import { Migration, MigrationModel } from './migration.schema';
import { MigrationService } from './migration.service'; import { MigrationService } from './migration.service';
import { MigrationAction } from './types'; import { MigrationAction } from './types';
jest.mock('fs');
describe('MigrationService', () => { describe('MigrationService', () => {
let service: MigrationService; let service: MigrationService;
let loggerService: LoggerService; let loggerService: LoggerService;
let metadataService: MetadataService; let metadataService: MetadataService;
beforeEach(async () => { beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
// imports: [ imports: [
// rootMongooseTestModule(() => Promise.resolve()), rootMongooseTestModule(async () => await Promise.resolve()),
// MongooseModule.forFeature([MigrationModel]), MongooseModule.forFeature([MetadataModel, MigrationModel]),
// ], ],
providers: [ providers: [
MetadataRepository,
MetadataService,
MigrationService, MigrationService,
EventEmitter2,
{ {
provide: LoggerService, provide: LoggerService,
useValue: { useValue: {
@ -42,12 +50,6 @@ describe('MigrationService', () => {
error: jest.fn(), error: jest.fn(),
}, },
}, },
{
provide: MetadataService,
useValue: {
get: jest.fn(),
},
},
{ {
provide: HttpService, provide: HttpService,
useValue: {}, useValue: {},
@ -72,35 +74,18 @@ describe('MigrationService', () => {
service = module.get<MigrationService>(MigrationService); service = module.get<MigrationService>(MigrationService);
loggerService = module.get<LoggerService>(LoggerService); loggerService = module.get<LoggerService>(LoggerService);
metadataService = module.get<MetadataService>(MetadataService); metadataService = module.get<MetadataService>(MetadataService);
jest.spyOn(service, 'exit').mockImplementation(); // Mock exit to avoid Jest process termination
}); });
describe('validateMigrationPath', () => { afterEach(jest.clearAllMocks);
it('should log an error and exit if the migration path does not exist', () => { afterAll(closeInMongodConnection);
(existsSync as jest.Mock).mockReturnValue(false);
const exitSpy = jest.spyOn(service, 'exit').mockImplementation();
service.validateMigrationPath();
expect(loggerService.error).toHaveBeenCalledWith(
'Migration directory "/migrations" not exists.',
);
expect(exitSpy).toHaveBeenCalled();
});
it('should not log an error or exit if the migration path exists', () => {
(existsSync as jest.Mock).mockReturnValue(true);
const exitSpy = jest.spyOn(service, 'exit').mockImplementation();
service.validateMigrationPath();
expect(loggerService.error).not.toHaveBeenCalled();
expect(exitSpy).not.toHaveBeenCalled();
});
});
describe('create', () => { describe('create', () => {
beforeEach(() => {
jest.spyOn(service, 'exit').mockImplementation(); // Mock exit to avoid Jest process termination
});
afterEach(jest.restoreAllMocks);
it('should create a migration file and log success', () => { it('should create a migration file and log success', () => {
const mockFiles = ['12345-some-migration.migration.ts']; const mockFiles = ['12345-some-migration.migration.ts'];
jest.spyOn(service, 'getMigrationFiles').mockReturnValue(mockFiles); jest.spyOn(service, 'getMigrationFiles').mockReturnValue(mockFiles);
@ -112,15 +97,18 @@ describe('MigrationService', () => {
.mockImplementation((file) => file); .mockImplementation((file) => file);
const exitSpy = jest.spyOn(service, 'exit').mockImplementation(); const exitSpy = jest.spyOn(service, 'exit').mockImplementation();
(existsSync as jest.Mock).mockReturnValue(true); jest.spyOn(fs, 'existsSync').mockReturnValue(true);
(writeFileSync as jest.Mock).mockImplementation(); jest.spyOn(fs, 'writeFileSync').mockReturnValue();
service.create('v2.2.0'); service.create('v2.2.0');
const expectedFilePath = expect.stringMatching( const expectedFilePath = expect.stringMatching(
/\/migrations\/\d+-v-2-2-0.migration.ts$/, /\/migrations\/\d+-v-2-2-0.migration.ts$/,
); );
expect(writeFileSync).toHaveBeenCalledWith(expectedFilePath, 'template'); expect(fs.writeFileSync).toHaveBeenCalledWith(
expectedFilePath,
'template',
);
expect(loggerService.log).toHaveBeenCalledWith( expect(loggerService.log).toHaveBeenCalledWith(
expect.stringMatching(/Migration file for "v2.2.0" created/), expect.stringMatching(/Migration file for "v2.2.0" created/),
); );
@ -142,11 +130,17 @@ describe('MigrationService', () => {
}); });
describe('onApplicationBootstrap', () => { describe('onApplicationBootstrap', () => {
beforeEach(() => {
jest.spyOn(service, 'exit').mockImplementation(); // Mock exit to avoid Jest process termination
});
afterEach(jest.restoreAllMocks);
it('should log a message and execute migrations when autoMigrate is true', async () => { it('should log a message and execute migrations when autoMigrate is true', async () => {
process.env.HEXABOT_CLI = ''; process.env.HEXABOT_CLI = '';
jest jest
.spyOn(metadataService, 'get') .spyOn(metadataService, 'findOne')
.mockResolvedValue({ name: 'db-version', value: 'v2.1.9' }); .mockResolvedValue({ name: 'db-version', value: 'v2.1.9' } as Metadata);
jest.spyOn(service, 'run').mockResolvedValue(); jest.spyOn(service, 'run').mockResolvedValue();
await service.onApplicationBootstrap(); await service.onApplicationBootstrap();
@ -156,13 +150,18 @@ describe('MigrationService', () => {
); );
expect(service.run).toHaveBeenCalledWith({ expect(service.run).toHaveBeenCalledWith({
action: 'up', action: 'up',
version: 'v2.1.9',
isAutoMigrate: true, isAutoMigrate: true,
}); });
}); });
}); });
describe('run', () => { describe('run', () => {
beforeEach(() => {
jest.spyOn(service, 'exit').mockImplementation(); // Mock exit to avoid Jest process termination
});
afterEach(jest.restoreAllMocks);
it('should call runUpgrades when version is not provided and isAutoMigrate is true', async () => { it('should call runUpgrades when version is not provided and isAutoMigrate is true', async () => {
const runUpgradesSpy = jest const runUpgradesSpy = jest
.spyOn(service as any, 'runUpgrades') .spyOn(service as any, 'runUpgrades')
@ -170,11 +169,10 @@ describe('MigrationService', () => {
await service.run({ await service.run({
action: MigrationAction.UP, action: MigrationAction.UP,
version: null,
isAutoMigrate: true, isAutoMigrate: true,
}); });
expect(runUpgradesSpy).toHaveBeenCalledWith('up', null); expect(runUpgradesSpy).toHaveBeenCalledWith('up', 'v2.1.9');
expect(service.exit).not.toHaveBeenCalled(); expect(service.exit).not.toHaveBeenCalled();
}); });
@ -219,6 +217,8 @@ describe('MigrationService', () => {
let failureCallbackSpy: jest.SpyInstance; let failureCallbackSpy: jest.SpyInstance;
beforeEach(() => { beforeEach(() => {
jest.spyOn(service, 'exit').mockImplementation(); // Mock exit to avoid Jest process termination
verifyStatusSpy = jest verifyStatusSpy = jest
.spyOn(service as any, 'verifyStatus') .spyOn(service as any, 'verifyStatus')
.mockResolvedValue({ exist: false, migrationDocument: {} }); .mockResolvedValue({ exist: false, migrationDocument: {} });
@ -236,12 +236,7 @@ describe('MigrationService', () => {
.mockResolvedValue(undefined); .mockResolvedValue(undefined);
}); });
afterEach(() => { afterEach(jest.restoreAllMocks);
verifyStatusSpy.mockClear();
loadMigrationFileSpy.mockClear();
successCallbackSpy.mockClear();
failureCallbackSpy.mockClear();
});
it('should return false and not execute if migration already exists', async () => { it('should return false and not execute if migration already exists', async () => {
verifyStatusSpy.mockResolvedValue({ exist: true, migrationDocument: {} }); verifyStatusSpy.mockResolvedValue({ exist: true, migrationDocument: {} });
@ -353,6 +348,8 @@ describe('MigrationService', () => {
let runOneSpy: jest.SpyInstance; let runOneSpy: jest.SpyInstance;
beforeEach(() => { beforeEach(() => {
jest.spyOn(service, 'exit').mockImplementation(); // Mock exit to avoid Jest process termination
getAvailableUpgradeVersionsSpy = jest getAvailableUpgradeVersionsSpy = jest
.spyOn(service as any, 'getAvailableUpgradeVersions') .spyOn(service as any, 'getAvailableUpgradeVersions')
.mockReturnValue(['v2.2.0', 'v2.3.0', 'v2.4.0']); // Mock available versions .mockReturnValue(['v2.2.0', 'v2.3.0', 'v2.4.0']); // Mock available versions
@ -366,6 +363,8 @@ describe('MigrationService', () => {
runOneSpy = jest.spyOn(service as any, 'runOne').mockResolvedValue(true); runOneSpy = jest.spyOn(service as any, 'runOne').mockResolvedValue(true);
}); });
afterEach(jest.restoreAllMocks);
it('should filter versions and call runOne for each newer version', async () => { it('should filter versions and call runOne for each newer version', async () => {
const result = await (service as any).runUpgrades('up', 'v2.2.0'); const result = await (service as any).runUpgrades('up', 'v2.2.0');

View File

@ -6,7 +6,7 @@
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/ */
import { existsSync, readdirSync, writeFileSync } from 'fs'; import fs from 'fs';
import { join } from 'path'; import { join } from 'path';
import { HttpService } from '@nestjs/axios'; import { HttpService } from '@nestjs/axios';
@ -45,13 +45,11 @@ export class MigrationService implements OnApplicationBootstrap {
private readonly httpService: HttpService, private readonly httpService: HttpService,
@InjectModel(Migration.name) @InjectModel(Migration.name)
private readonly migrationModel: Model<Migration>, private readonly migrationModel: Model<Migration>,
) { ) {}
if (config.env !== 'test') {
this.validateMigrationPath();
}
}
async onApplicationBootstrap() { async onApplicationBootstrap() {
await this.ensureMigrationPathExists();
if (mongoose.connection.readyState !== 1) { if (mongoose.connection.readyState !== 1) {
await this.connect(); await this.connect();
} }
@ -60,11 +58,8 @@ export class MigrationService implements OnApplicationBootstrap {
const isCLI = Boolean(process.env.HEXABOT_CLI); const isCLI = Boolean(process.env.HEXABOT_CLI);
if (!isCLI && config.mongo.autoMigrate) { if (!isCLI && config.mongo.autoMigrate) {
this.logger.log('Executing migrations ...'); this.logger.log('Executing migrations ...');
const metadata = await this.metadataService.get('db-version');
const version = metadata ? metadata.value : INITIAL_DB_VERSION;
await this.run({ await this.run({
action: MigrationAction.UP, action: MigrationAction.UP,
version,
isAutoMigrate: true, isAutoMigrate: true,
}); });
} }
@ -85,12 +80,11 @@ export class MigrationService implements OnApplicationBootstrap {
/** /**
* Checks if the migration path is well set and exists * Checks if the migration path is well set and exists
*/ */
public validateMigrationPath() { private async ensureMigrationPathExists() {
if (!existsSync(this.migrationFilePath)) { if (config.env !== 'test' && !fs.existsSync(this.migrationFilePath)) {
this.logger.error( await fs.promises.mkdir(this.migrationFilePath, {
`Migration directory "${this.migrationFilePath}" not exists.`, recursive: true,
); });
this.exit();
} }
} }
@ -123,7 +117,7 @@ export class MigrationService implements OnApplicationBootstrap {
const filePath = join(this.migrationFilePath, migrationFileName); const filePath = join(this.migrationFilePath, migrationFileName);
const template = this.getMigrationTemplate(); const template = this.getMigrationTemplate();
try { try {
writeFileSync(filePath, template); fs.writeFileSync(filePath, template);
this.logger.log( this.logger.log(
`Migration file for "${version}" created: ${migrationFileName}`, `Migration file for "${version}" created: ${migrationFileName}`,
); );
@ -195,6 +189,10 @@ module.exports = {
public async run({ action, version, isAutoMigrate }: MigrationRunParams) { public async run({ action, version, isAutoMigrate }: MigrationRunParams) {
if (!version) { if (!version) {
if (isAutoMigrate) { if (isAutoMigrate) {
const metadata = await this.metadataService.findOne({
name: 'db-version',
});
const version = metadata ? metadata.value : INITIAL_DB_VERSION;
await this.runUpgrades(action, version); await this.runUpgrades(action, version);
} else { } else {
await this.runAll(action); await this.runAll(action);
@ -302,6 +300,12 @@ module.exports = {
const filteredVersions = versions.filter((v) => const filteredVersions = versions.filter((v) =>
this.isNewerVersion(v, version), this.isNewerVersion(v, version),
); );
if (!filteredVersions.length) {
this.logger.log('No migrations to execute ...');
return version;
}
let lastVersion = version; let lastVersion = version;
for (const version of filteredVersions) { for (const version of filteredVersions) {
@ -371,7 +375,7 @@ module.exports = {
* @returns A promise resolving to an array of migration file names. * @returns A promise resolving to an array of migration file names.
*/ */
getMigrationFiles() { getMigrationFiles() {
const files = readdirSync(this.migrationFilePath); const files = fs.readdirSync(this.migrationFilePath);
return files.filter((file) => /\.migration\.(js|ts)$/.test(file)); return files.filter((file) => /\.migration\.(js|ts)$/.test(file));
} }
@ -496,13 +500,19 @@ module.exports = {
await this.updateStatus({ version, action, migrationDocument }); await this.updateStatus({ version, action, migrationDocument });
const migrationDisplayName = `${version} [${action}]`; const migrationDisplayName = `${version} [${action}]`;
this.logger.log(`"${migrationDisplayName}" migration done`); this.logger.log(`"${migrationDisplayName}" migration done`);
// Update DB version // Create or Update DB version
const result = await this.metadataService.createOrUpdate({ await this.metadataService.updateOne(
name: 'db-version', { name: 'db-version' },
value: version, {
}); value: version,
const operation = result ? 'updated' : 'created'; },
this.logger.log(`db-version metadata ${operation} "${name}"`); {
// Create or update
upsert: true,
new: true,
},
);
this.logger.log(`db-version metadata "${version}"`);
} }
/** /**

View File

@ -6,6 +6,10 @@
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/ */
import { HttpService } from '@nestjs/axios';
import { LoggerService } from '@/logger/logger.service';
import { MigrationDocument } from './migration.schema'; import { MigrationDocument } from './migration.schema';
export enum MigrationAction { export enum MigrationAction {
@ -26,3 +30,8 @@ export interface MigrationRunParams {
export interface MigrationSuccessCallback extends MigrationRunParams { export interface MigrationSuccessCallback extends MigrationRunParams {
migrationDocument: MigrationDocument; migrationDocument: MigrationDocument;
} }
export type MigrationServices = {
logger: LoggerService;
http: HttpService;
};

View File

@ -1,5 +1,5 @@
/* /*
* Copyright © 2024 Hexastack. All rights reserved. * Copyright © 2025 Hexastack. All rights reserved.
* *
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@ -21,6 +21,8 @@ import { NlpEntitySeeder } from './nlp/seeds/nlp-entity.seed';
import { nlpEntityModels } from './nlp/seeds/nlp-entity.seed-model'; import { nlpEntityModels } from './nlp/seeds/nlp-entity.seed-model';
import { NlpValueSeeder } from './nlp/seeds/nlp-value.seed'; import { NlpValueSeeder } from './nlp/seeds/nlp-value.seed';
import { nlpValueModels } from './nlp/seeds/nlp-value.seed-model'; import { nlpValueModels } from './nlp/seeds/nlp-value.seed-model';
import { MetadataSeeder } from './setting/seeds/metadata.seed';
import { DEFAULT_METADATA } from './setting/seeds/metadata.seed-model';
import { SettingSeeder } from './setting/seeds/setting.seed'; import { SettingSeeder } from './setting/seeds/setting.seed';
import { DEFAULT_SETTINGS } from './setting/seeds/setting.seed-model'; import { DEFAULT_SETTINGS } from './setting/seeds/setting.seed-model';
import { PermissionCreateDto } from './user/dto/permission.dto'; import { PermissionCreateDto } from './user/dto/permission.dto';
@ -41,6 +43,7 @@ export async function seedDatabase(app: INestApplicationContext) {
const contextVarSeeder = app.get(ContextVarSeeder); const contextVarSeeder = app.get(ContextVarSeeder);
const roleSeeder = app.get(RoleSeeder); const roleSeeder = app.get(RoleSeeder);
const settingSeeder = app.get(SettingSeeder); const settingSeeder = app.get(SettingSeeder);
const metadataSeeder = app.get(MetadataSeeder);
const permissionSeeder = app.get(PermissionSeeder); const permissionSeeder = app.get(PermissionSeeder);
const userSeeder = app.get(UserSeeder); const userSeeder = app.get(UserSeeder);
const languageSeeder = app.get(LanguageSeeder); const languageSeeder = app.get(LanguageSeeder);
@ -106,13 +109,15 @@ export async function seedDatabase(app: INestApplicationContext) {
logger.error('Unable to seed the database with users!'); logger.error('Unable to seed the database with users!');
throw e; throw e;
} }
// Seed users }
try {
await settingSeeder.seed(DEFAULT_SETTINGS); // Seed settings and metadata
} catch (e) { try {
logger.error('Unable to seed the database with settings!'); await settingSeeder.seed(DEFAULT_SETTINGS);
throw e; await metadataSeeder.seed(DEFAULT_METADATA);
} } catch (e) {
logger.error('Unable to seed the database with settings and metadata!');
throw e;
} }
// Seed categories // Seed categories

View File

@ -0,0 +1,23 @@
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { ApiProperty, PartialType } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class MetadataCreateDto {
@ApiProperty({ description: 'Metadata name', type: String })
@IsNotEmpty()
@IsString()
name: string;
@ApiProperty({ description: 'Metadata value' })
@IsNotEmpty()
value: any;
}
export class MetadataUpdateDto extends PartialType(MetadataCreateDto) {}

View File

@ -0,0 +1,26 @@
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { BaseRepository } from '@/utils/generics/base-repository';
import { Metadata } from '../schemas/metadata.schema';
@Injectable()
export class MetadataRepository extends BaseRepository<Metadata> {
constructor(
readonly eventEmitter: EventEmitter2,
@InjectModel(Metadata.name) readonly model: Model<Metadata>,
) {
super(eventEmitter, model, Metadata);
}
}

View File

@ -9,10 +9,11 @@
import { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose'; import { Document } from 'mongoose';
import { BaseSchema } from '@/utils/generics/base-schema';
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager'; import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
@Schema({ timestamps: true }) @Schema({ timestamps: true })
export class Metadata { export class Metadata extends BaseSchema {
@Prop({ type: String, required: true, unique: true }) @Prop({ type: String, required: true, unique: true })
name: string; name: string;

View File

@ -0,0 +1,16 @@
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { MetadataCreateDto } from '../dto/metadata.dto';
export const DEFAULT_METADATA = [
{
name: 'db-version',
value: process.env.npm_package_version,
},
] as const satisfies MetadataCreateDto[];

View File

@ -0,0 +1,21 @@
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { Injectable } from '@nestjs/common';
import { BaseSeeder } from '@/utils/generics/base-seeder';
import { MetadataRepository } from '../repositories/metadata.repository';
import { Metadata } from '../schemas/metadata.schema';
@Injectable()
export class MetadataSeeder extends BaseSeeder<Metadata> {
constructor(private readonly metadataRepository: MetadataRepository) {
super(metadataRepository);
}
}

View File

@ -7,48 +7,15 @@
*/ */
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { plainToClass } from 'class-transformer';
import { HydratedDocument, Model } from 'mongoose';
import { BaseService } from '@/utils/generics/base-service';
import { MetadataRepository } from '../repositories/metadata.repository';
import { Metadata } from '../schemas/metadata.schema'; import { Metadata } from '../schemas/metadata.schema';
@Injectable() @Injectable()
export class MetadataService { export class MetadataService extends BaseService<Metadata> {
constructor( constructor(readonly repository: MetadataRepository) {
@InjectModel(Metadata.name) super(repository);
private readonly metadataModel: Model<Metadata>,
) {}
private toClassObject(metadata: HydratedDocument<Metadata>) {
return plainToClass(
Metadata,
metadata.toObject({ virtuals: true, getters: true }),
{ excludePrefixes: ['_'] },
);
}
async createOrUpdate(dto: Metadata) {
const metadata = await this.metadataModel.findOneAndUpdate(
{ name: dto.name },
dto,
{
upsert: true,
},
);
return this.toClassObject(metadata);
}
async get(name: string) {
const metadata = await this.metadataModel.findOne({ name });
return this.toClassObject(metadata);
}
async set(name: string, value: any) {
const metadata = await this.metadataModel.findOneAndUpdate(
{ name },
{ $set: { value } },
);
return this.toClassObject(metadata);
} }
} }

View File

@ -1,5 +1,5 @@
/* /*
* Copyright © 2024 Hexastack. All rights reserved. * Copyright © 2025 Hexastack. All rights reserved.
* *
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@ -11,9 +11,11 @@ import { MongooseModule } from '@nestjs/mongoose';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { SettingController } from './controllers/setting.controller'; import { SettingController } from './controllers/setting.controller';
import { MetadataRepository } from './repositories/metadata.repository';
import { SettingRepository } from './repositories/setting.repository'; import { SettingRepository } from './repositories/setting.repository';
import { MetadataModel } from './schemas/metadata.schema'; import { MetadataModel } from './schemas/metadata.schema';
import { SettingModel } from './schemas/setting.schema'; import { SettingModel } from './schemas/setting.schema';
import { MetadataSeeder } from './seeds/metadata.seed';
import { SettingSeeder } from './seeds/setting.seed'; import { SettingSeeder } from './seeds/setting.seed';
import { MetadataService } from './services/metadata.service'; import { MetadataService } from './services/metadata.service';
import { SettingService } from './services/setting.service'; import { SettingService } from './services/setting.service';
@ -28,7 +30,9 @@ import { SettingService } from './services/setting.service';
], ],
providers: [ providers: [
SettingRepository, SettingRepository,
MetadataRepository,
SettingSeeder, SettingSeeder,
MetadataSeeder,
SettingService, SettingService,
MetadataService, MetadataService,
], ],

View File

@ -1,5 +1,5 @@
/* /*
* Copyright © 2024 Hexastack. All rights reserved. * Copyright © 2025 Hexastack. All rights reserved.
* *
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@ -20,6 +20,7 @@ import {
Model, Model,
ProjectionType, ProjectionType,
Query, Query,
QueryOptions,
SortOrder, SortOrder,
UpdateQuery, UpdateQuery,
UpdateWithAggregationPipeline, UpdateWithAggregationPipeline,
@ -474,6 +475,9 @@ export abstract class BaseRepository<
async updateOne<D extends Partial<U>>( async updateOne<D extends Partial<U>>(
criteria: string | TFilterQuery<T>, criteria: string | TFilterQuery<T>,
dto: UpdateQuery<D>, dto: UpdateQuery<D>,
options: QueryOptions<D> | null = {
new: true,
},
): Promise<T | null> { ): Promise<T | null> {
const query = this.model.findOneAndUpdate<T>( const query = this.model.findOneAndUpdate<T>(
{ {
@ -482,9 +486,7 @@ export abstract class BaseRepository<
{ {
$set: dto, $set: dto,
}, },
{ options,
new: true,
},
); );
const filterCriteria = query.getFilter(); const filterCriteria = query.getFilter();
const queryUpdates = query.getUpdate(); const queryUpdates = query.getUpdate();

View File

@ -1,5 +1,5 @@
/* /*
* Copyright © 2024 Hexastack. All rights reserved. * Copyright © 2025 Hexastack. All rights reserved.
* *
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@ -84,6 +84,7 @@ describe('BaseService', () => {
expect(dummyRepository.updateOne).toHaveBeenCalledWith( expect(dummyRepository.updateOne).toHaveBeenCalledWith(
createdId, createdId,
updatedPayload, updatedPayload,
undefined,
); );
expect(result).toEqualPayload(updatedPayload); expect(result).toEqualPayload(updatedPayload);
}); });
@ -98,6 +99,7 @@ describe('BaseService', () => {
expect(dummyRepository.updateOne).toHaveBeenCalledWith( expect(dummyRepository.updateOne).toHaveBeenCalledWith(
updatedPayload, updatedPayload,
updatedCriteriaPayload, updatedCriteriaPayload,
undefined,
); );
expect(result).toEqualPayload(updatedCriteriaPayload); expect(result).toEqualPayload(updatedCriteriaPayload);
}); });

View File

@ -1,5 +1,5 @@
/* /*
* Copyright © 2024 Hexastack. All rights reserved. * Copyright © 2025 Hexastack. All rights reserved.
* *
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@ -9,7 +9,7 @@
import { ConflictException } from '@nestjs/common'; import { ConflictException } from '@nestjs/common';
import { ClassTransformOptions } from 'class-transformer'; import { ClassTransformOptions } from 'class-transformer';
import { MongoError } from 'mongodb'; import { MongoError } from 'mongodb';
import { ProjectionType } from 'mongoose'; import { ProjectionType, QueryOptions } from 'mongoose';
import { TFilterQuery } from '@/utils/types/filter.types'; import { TFilterQuery } from '@/utils/types/filter.types';
@ -173,8 +173,9 @@ export abstract class BaseService<
async updateOne<D extends Partial<Omit<T, keyof BaseSchema>>>( async updateOne<D extends Partial<Omit<T, keyof BaseSchema>>>(
criteria: string | TFilterQuery<T>, criteria: string | TFilterQuery<T>,
dto: D, dto: D,
options?: QueryOptions<D> | null,
): Promise<T | null> { ): Promise<T | null> {
return await this.repository.updateOne(criteria, dto); return await this.repository.updateOne(criteria, dto, options);
} }
async updateMany<D extends Partial<Omit<T, keyof BaseSchema>>>( async updateMany<D extends Partial<Omit<T, keyof BaseSchema>>>(

View File

@ -8,9 +8,10 @@
import mongoose from 'mongoose'; import mongoose from 'mongoose';
import { Metadata, MetadataModel } from '@/setting/schemas/metadata.schema'; import { MetadataCreateDto } from '@/setting/dto/metadata.dto';
import { MetadataModel } from '@/setting/schemas/metadata.schema';
const metadataFixtures: Metadata[] = [ const metadataFixtures: MetadataCreateDto[] = [
{ {
name: 'app-version', name: 'app-version',
value: '2.2.0', value: '2.2.0',