diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 541f7dc65985d..1a8a05fd4d77a 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -5,6 +5,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, ModuleRef } from '@ne import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; +import _ from 'lodash'; import { ClsModule } from 'nestjs-cls'; import { OpenTelemetryModule } from 'nestjs-otel'; import { commands } from 'src/commands'; @@ -13,6 +14,7 @@ import { controllers } from 'src/controllers'; import { databaseConfig } from 'src/database.config'; import { entities } from 'src/entities'; import { IEventRepository } from 'src/interfaces/event.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthGuard } from 'src/middleware/auth.guard'; import { ErrorInterceptor } from 'src/middleware/error.interceptor'; import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor'; @@ -54,15 +56,25 @@ export class ApiModule implements OnModuleInit, OnModuleDestroy { constructor( private moduleRef: ModuleRef, @Inject(IEventRepository) private eventRepository: IEventRepository, + @Inject(ILoggerRepository) private logger: ILoggerRepository, ) {} async onModuleInit() { - setupEventHandlers(this.moduleRef); - await this.eventRepository.emit('onBootstrapEvent', 'api'); + const items = setupEventHandlers(this.moduleRef); + + await this.eventRepository.emit('onBootstrap', 'api'); + + this.logger.setContext('EventLoader'); + const eventMap = _.groupBy(items, 'event'); + for (const [event, handlers] of Object.entries(eventMap)) { + for (const { priority, label } of handlers) { + this.logger.verbose(`Added ${event} {${label}${priority ? '' : ', ' + priority}} event`); + } + } } async onModuleDestroy() { - await this.eventRepository.emit('onShutdownEvent'); + await this.eventRepository.emit('onShutdown'); } } @@ -78,11 +90,11 @@ export class MicroservicesModule implements OnModuleInit, OnModuleDestroy { async onModuleInit() { setupEventHandlers(this.moduleRef); - await this.eventRepository.emit('onBootstrapEvent', 'microservices'); + await this.eventRepository.emit('onBootstrap', 'microservices'); } async onModuleDestroy() { - await this.eventRepository.emit('onShutdownEvent'); + await this.eventRepository.emit('onShutdown'); } } diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 1c632e549a342..2316e114e885e 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -4,7 +4,7 @@ import { OnEventOptions } from '@nestjs/event-emitter/dist/interfaces'; import { ApiExtension, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger'; import _ from 'lodash'; import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants'; -import { ServerEvent } from 'src/interfaces/event.interface'; +import { EmitEvent, ServerEvent } from 'src/interfaces/event.interface'; import { Metadata } from 'src/middleware/auth.guard'; import { setUnion } from 'src/utils/set'; @@ -136,11 +136,12 @@ export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GEN export const OnServerEvent = (event: ServerEvent, options?: OnEventOptions) => OnEvent(event, { suppressErrors: false, ...options }); -export type HandlerOptions = { +export type EmitConfig = { + event: EmitEvent; /** lower value has higher priority, defaults to 0 */ - priority: number; + priority?: number; }; -export const EventHandlerOptions = (options: HandlerOptions) => SetMetadata(Metadata.EVENT_HANDLER_OPTIONS, options); +export const OnEmit = (config: EmitConfig) => SetMetadata(Metadata.ON_EMIT_CONFIG, config); type LifecycleRelease = 'NEXT_RELEASE' | string; type LifecycleMetadata = { diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index 828531fdf3d91..613a6423a4534 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -4,41 +4,27 @@ import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.d export const IEventRepository = 'IEventRepository'; -export type SystemConfigUpdateEvent = { newConfig: SystemConfig; oldConfig: SystemConfig }; -export type AlbumUpdateEvent = { - id: string; - /** user id */ - updatedBy: string; -}; -export type AlbumInviteEvent = { id: string; userId: string }; -export type UserSignupEvent = { notify: boolean; id: string; tempPassword?: string }; - -type MaybePromise = Promise | T; -type Handler = (data: T) => MaybePromise; - -const noop = () => {}; -const dummyHandlers = { +type EmitEventMap = { // app events - onBootstrapEvent: noop as Handler<'api' | 'microservices'>, - onShutdownEvent: noop as () => MaybePromise, + onBootstrap: ['api' | 'microservices']; + onShutdown: []; // config events - onConfigUpdateEvent: noop as Handler, - onConfigValidateEvent: noop as Handler, + onConfigUpdate: [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; + onConfigValidate: [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; // album events - onAlbumUpdateEvent: noop as Handler, - onAlbumInviteEvent: noop as Handler, + onAlbumUpdate: [{ id: string; updatedBy: string }]; + onAlbumInvite: [{ id: string; userId: string }]; // user events - onUserSignupEvent: noop as Handler, + onUserSignup: [{ notify: boolean; id: string; tempPassword?: string }]; }; -export type EventHandlers = typeof dummyHandlers; -export type EmitEvent = keyof EventHandlers; -export type EmitEventHandler = (...args: Parameters) => MaybePromise; -export const events = Object.keys(dummyHandlers) as EmitEvent[]; -export type OnEvents = Partial; +export type EmitEvent = keyof EmitEventMap; +export type EmitHandler = (...args: ArgsOf) => Promise | void; +export type ArgOf = EmitEventMap[T][0]; +export type ArgsOf = EmitEventMap[T]; export enum ClientEvent { UPLOAD_SUCCESS = 'on_upload_success', @@ -81,8 +67,8 @@ export interface ServerEventMap { } export interface IEventRepository { - on(event: T, handler: EmitEventHandler): void; - emit(event: T, ...args: Parameters>): Promise; + on(event: T, handler: EmitHandler): void; + emit(event: T, ...args: ArgsOf): Promise; /** * Send to connected clients for a specific user diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index c4aa928dbda63..beab484950d48 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -20,7 +20,7 @@ export enum Metadata { ADMIN_ROUTE = 'admin_route', SHARED_ROUTE = 'shared_route', API_KEY_SECURITY = 'api_key', - EVENT_HANDLER_OPTIONS = 'event_handler_options', + ON_EMIT_CONFIG = 'on_emit_config', } type AdminRoute = { admin?: true }; diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 0bb973b29394a..668eac48d9de9 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -9,9 +9,10 @@ import { } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; import { + ArgsOf, ClientEventMap, EmitEvent, - EmitEventHandler, + EmitHandler, IEventRepository, ServerEvent, ServerEventMap, @@ -20,6 +21,8 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthService } from 'src/services/auth.service'; import { Instrumentation } from 'src/utils/instrumentation'; +type EmitHandlers = Partial<{ [T in EmitEvent]: EmitHandler[] }>; + @Instrumentation() @WebSocketGateway({ cors: true, @@ -28,7 +31,7 @@ import { Instrumentation } from 'src/utils/instrumentation'; }) @Injectable() export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, IEventRepository { - private emitHandlers: Partial[]>> = {}; + private emitHandlers: EmitHandlers = {}; @WebSocketServer() private server?: Server; @@ -78,12 +81,15 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect await client.leave(client.nsp.name); } - on(event: T, handler: EmitEventHandler): void { - const handlers: EmitEventHandler[] = this.emitHandlers[event] || []; - this.emitHandlers[event] = [...handlers, handler]; + on(event: T, handler: EmitHandler): void { + if (!this.emitHandlers[event]) { + this.emitHandlers[event] = []; + } + + this.emitHandlers[event].push(handler); } - async emit(event: T, ...args: Parameters>): Promise { + async emit(event: T, ...args: ArgsOf): Promise { const handlers = this.emitHandlers[event] || []; for (const handler of handlers) { await handler(...args); diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 41f8930733b01..6db39328df53e 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -380,7 +380,7 @@ describe(AlbumService.name, () => { userId: authStub.user2.user.id, albumId: albumStub.sharedWithAdmin.id, }); - expect(eventMock.emit).toHaveBeenCalledWith('onAlbumInviteEvent', { + expect(eventMock.emit).toHaveBeenCalledWith('onAlbumInvite', { id: albumStub.sharedWithAdmin.id, userId: userStub.user2.id, }); @@ -568,7 +568,7 @@ describe(AlbumService.name, () => { albumThumbnailAssetId: 'asset-1', }); expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); - expect(eventMock.emit).toHaveBeenCalledWith('onAlbumUpdateEvent', { + expect(eventMock.emit).toHaveBeenCalledWith('onAlbumUpdate', { id: 'album-123', updatedBy: authStub.admin.user.id, }); @@ -612,7 +612,7 @@ describe(AlbumService.name, () => { albumThumbnailAssetId: 'asset-1', }); expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); - expect(eventMock.emit).toHaveBeenCalledWith('onAlbumUpdateEvent', { + expect(eventMock.emit).toHaveBeenCalledWith('onAlbumUpdate', { id: 'album-123', updatedBy: authStub.user1.user.id, }); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index f8108ad0651dc..71594d20b6178 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -187,7 +187,7 @@ export class AlbumService { albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId, }); - await this.eventRepository.emit('onAlbumUpdateEvent', { id, updatedBy: auth.user.id }); + await this.eventRepository.emit('onAlbumUpdate', { id, updatedBy: auth.user.id }); } return results; @@ -235,7 +235,7 @@ export class AlbumService { } await this.albumUserRepository.create({ userId: userId, albumId: id, role }); - await this.eventRepository.emit('onAlbumInviteEvent', { id, userId }); + await this.eventRepository.emit('onAlbumInvite', { id, userId }); } return this.findOrFail(id, { withAssets: true }).then(mapAlbumWithoutAssets); diff --git a/server/src/services/database.service.spec.ts b/server/src/services/database.service.spec.ts index a21b1d7d6778b..c63428560e03c 100644 --- a/server/src/services/database.service.spec.ts +++ b/server/src/services/database.service.spec.ts @@ -45,7 +45,7 @@ describe(DatabaseService.name, () => { it('should throw an error if PostgreSQL version is below minimum supported version', async () => { databaseMock.getPostgresVersion.mockResolvedValueOnce('13.10.0'); - await expect(sut.onBootstrapEvent()).rejects.toThrow('Invalid PostgreSQL version. Found 13.10.0'); + await expect(sut.onBootstrap()).rejects.toThrow('Invalid PostgreSQL version. Found 13.10.0'); expect(databaseMock.getPostgresVersion).toHaveBeenCalledTimes(1); }); @@ -65,7 +65,7 @@ describe(DatabaseService.name, () => { availableVersion: minVersionInRange, }); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); expect(databaseMock.getPostgresVersion).toHaveBeenCalled(); expect(databaseMock.createExtension).toHaveBeenCalledWith(extension); @@ -79,7 +79,7 @@ describe(DatabaseService.name, () => { databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: null }); const message = `The ${extensionName} extension is not available in this Postgres instance. If using a container image, ensure the image has the extension installed.`; - await expect(sut.onBootstrapEvent()).rejects.toThrow(message); + await expect(sut.onBootstrap()).rejects.toThrow(message); expect(databaseMock.createExtension).not.toHaveBeenCalled(); expect(databaseMock.runMigrations).not.toHaveBeenCalled(); @@ -91,7 +91,7 @@ describe(DatabaseService.name, () => { availableVersion: versionBelowRange, }); - await expect(sut.onBootstrapEvent()).rejects.toThrow( + await expect(sut.onBootstrap()).rejects.toThrow( `The ${extensionName} extension version is ${versionBelowRange}, but Immich only supports ${extensionRange}`, ); @@ -101,7 +101,7 @@ describe(DatabaseService.name, () => { it(`should throw an error if ${extension} extension version is a nightly`, async () => { databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: '0.0.0', availableVersion: '0.0.0' }); - await expect(sut.onBootstrapEvent()).rejects.toThrow( + await expect(sut.onBootstrap()).rejects.toThrow( `The ${extensionName} extension version is 0.0.0, which means it is a nightly release.`, ); @@ -117,7 +117,7 @@ describe(DatabaseService.name, () => { }); databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); expect(databaseMock.updateVectorExtension).toHaveBeenCalledTimes(1); @@ -132,7 +132,7 @@ describe(DatabaseService.name, () => { installedVersion: minVersionInRange, }); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); @@ -145,7 +145,7 @@ describe(DatabaseService.name, () => { installedVersion: null, }); - await expect(sut.onBootstrapEvent()).rejects.toThrow(); + await expect(sut.onBootstrap()).rejects.toThrow(); expect(databaseMock.createExtension).not.toHaveBeenCalled(); expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); @@ -159,7 +159,7 @@ describe(DatabaseService.name, () => { installedVersion: minVersionInRange, }); - await expect(sut.onBootstrapEvent()).rejects.toThrow(); + await expect(sut.onBootstrap()).rejects.toThrow(); expect(databaseMock.createExtension).not.toHaveBeenCalled(); expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); @@ -173,7 +173,7 @@ describe(DatabaseService.name, () => { installedVersion: updateInRange, }); - await expect(sut.onBootstrapEvent()).rejects.toThrow( + await expect(sut.onBootstrap()).rejects.toThrow( `The database currently has ${extensionName} ${updateInRange} activated, but the Postgres instance only has ${minVersionInRange} available.`, ); @@ -189,7 +189,7 @@ describe(DatabaseService.name, () => { }); databaseMock.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension')); - await expect(sut.onBootstrapEvent()).rejects.toThrow('Failed to update extension'); + await expect(sut.onBootstrap()).rejects.toThrow('Failed to update extension'); expect(loggerMock.warn.mock.calls[0][0]).toContain( `The ${extensionName} extension can be updated to ${updateInRange}.`, @@ -206,7 +206,7 @@ describe(DatabaseService.name, () => { }); databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: true }); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); expect(loggerMock.warn).toHaveBeenCalledTimes(1); expect(loggerMock.warn.mock.calls[0][0]).toContain(extensionName); @@ -218,7 +218,7 @@ describe(DatabaseService.name, () => { it(`should reindex ${extension} indices if needed`, async () => { databaseMock.shouldReindex.mockResolvedValue(true); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); expect(databaseMock.reindex).toHaveBeenCalledTimes(2); @@ -229,7 +229,7 @@ describe(DatabaseService.name, () => { it(`should not reindex ${extension} indices if not needed`, async () => { databaseMock.shouldReindex.mockResolvedValue(false); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); expect(databaseMock.reindex).toHaveBeenCalledTimes(0); @@ -240,7 +240,7 @@ describe(DatabaseService.name, () => { it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => { process.env.DB_SKIP_MIGRATIONS = 'true'; - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); expect(databaseMock.runMigrations).not.toHaveBeenCalled(); }); @@ -255,7 +255,7 @@ describe(DatabaseService.name, () => { databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); - await expect(sut.onBootstrapEvent()).rejects.toThrow('Failed to create extension'); + await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension'); expect(loggerMock.fatal).toHaveBeenCalledTimes(1); expect(loggerMock.fatal.mock.calls[0][0]).toContain( @@ -274,7 +274,7 @@ describe(DatabaseService.name, () => { databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); - await expect(sut.onBootstrapEvent()).rejects.toThrow('Failed to create extension'); + await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension'); expect(loggerMock.fatal).toHaveBeenCalledTimes(1); expect(loggerMock.fatal.mock.calls[0][0]).toContain( diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index a2f43c58bac6f..b6d61c578d79c 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import semver from 'semver'; import { getVectorExtension } from 'src/database.config'; -import { EventHandlerOptions } from 'src/decorators'; +import { OnEmit } from 'src/decorators'; import { DatabaseExtension, DatabaseLock, @@ -10,7 +10,6 @@ import { VectorExtension, VectorIndex, } from 'src/interfaces/database.interface'; -import { OnEvents } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; type CreateFailedArgs = { name: string; extension: string; otherName: string }; @@ -61,7 +60,7 @@ const messages = { }; @Injectable() -export class DatabaseService implements OnEvents { +export class DatabaseService { constructor( @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @@ -69,8 +68,8 @@ export class DatabaseService implements OnEvents { this.logger.setContext(DatabaseService.name); } - @EventHandlerOptions({ priority: -200 }) - async onBootstrapEvent() { + @OnEmit({ event: 'onBootstrap', priority: -200 }) + async onBootstrap() { const version = await this.databaseRepository.getPostgresVersion(); const current = semver.coerce(version); const postgresRange = this.databaseRepository.getPostgresVersionRange(); diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 7f81fd44aa82f..8a74ec918996c 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -73,7 +73,7 @@ describe(LibraryService.name, () => { it('should init cron job and subscribe to config changes', async () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryScan); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); expect(systemMock.get).toHaveBeenCalled(); expect(jobMock.addCronJob).toHaveBeenCalled(); @@ -105,7 +105,7 @@ describe(LibraryService.name, () => { ), ); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); expect(storageMock.watch.mock.calls).toEqual( expect.arrayContaining([ @@ -118,7 +118,7 @@ describe(LibraryService.name, () => { it('should not initialize watcher when watching is disabled', async () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); expect(storageMock.watch).not.toHaveBeenCalled(); }); @@ -127,7 +127,7 @@ describe(LibraryService.name, () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); databaseMock.tryLock.mockResolvedValue(false); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); expect(storageMock.watch).not.toHaveBeenCalled(); }); @@ -136,7 +136,7 @@ describe(LibraryService.name, () => { describe('onConfigValidateEvent', () => { it('should allow a valid cron expression', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { library: { scan: { cronExpression: '0 0 * * *' } } } as SystemConfig, oldConfig: {} as SystemConfig, }), @@ -145,7 +145,7 @@ describe(LibraryService.name, () => { it('should fail for an invalid cron expression', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { library: { scan: { cronExpression: 'foo' } } } as SystemConfig, oldConfig: {} as SystemConfig, }), @@ -730,7 +730,7 @@ describe(LibraryService.name, () => { const mockClose = vitest.fn(); storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); await sut.delete(libraryStub.externalLibraryWithImportPaths1.id); expect(mockClose).toHaveBeenCalled(); @@ -861,7 +861,7 @@ describe(LibraryService.name, () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.getAll.mockResolvedValue([]); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); await sut.create({ ownerId: authStub.admin.user.id, importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths, @@ -917,7 +917,7 @@ describe(LibraryService.name, () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); libraryMock.getAll.mockResolvedValue([]); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); }); it('should update library', async () => { @@ -933,7 +933,7 @@ describe(LibraryService.name, () => { beforeEach(async () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); }); it('should not watch library', async () => { @@ -949,7 +949,7 @@ describe(LibraryService.name, () => { beforeEach(async () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); libraryMock.getAll.mockResolvedValue([]); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); }); it('should watch library', async () => { @@ -1107,8 +1107,8 @@ describe(LibraryService.name, () => { const mockClose = vitest.fn(); storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); - await sut.onBootstrapEvent(); - await sut.onShutdownEvent(); + await sut.onBootstrap(); + await sut.onShutdown(); expect(mockClose).toHaveBeenCalledTimes(2); }); diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index f0d7fe8cd44ee..1bee2d32c3a41 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -6,6 +6,7 @@ import path, { basename, parse } from 'node:path'; import picomatch from 'picomatch'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEmit } from 'src/decorators'; import { CreateLibraryDto, LibraryResponseDto, @@ -22,7 +23,7 @@ import { AssetType } from 'src/enum'; import { IAssetRepository, WithProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; -import { OnEvents, SystemConfigUpdateEvent } from 'src/interfaces/event.interface'; +import { ArgOf } from 'src/interfaces/event.interface'; import { IBaseJob, IEntityJob, @@ -45,7 +46,7 @@ import { validateCronExpression } from 'src/validation'; const LIBRARY_SCAN_BATCH_SIZE = 5000; @Injectable() -export class LibraryService implements OnEvents { +export class LibraryService { private configCore: SystemConfigCore; private watchLibraries = false; private watchLock = false; @@ -65,7 +66,8 @@ export class LibraryService implements OnEvents { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - async onBootstrapEvent() { + @OnEmit({ event: 'onBootstrap' }) + async onBootstrap() { const config = await this.configCore.getConfig({ withCache: false }); const { watch, scan } = config.library; @@ -102,7 +104,7 @@ export class LibraryService implements OnEvents { }); } - onConfigValidateEvent({ newConfig }: SystemConfigUpdateEvent) { + onConfigValidate({ newConfig }: ArgOf<'onConfigValidate'>) { const { scan } = newConfig.library; if (!validateCronExpression(scan.cronExpression)) { throw new Error(`Invalid cron expression ${scan.cronExpression}`); @@ -187,7 +189,8 @@ export class LibraryService implements OnEvents { } } - async onShutdownEvent() { + @OnEmit({ event: 'onShutdown' }) + async onShutdown() { await this.unwatchAll(); } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 522e1320fd64b..05f6f9f658d93 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -95,7 +95,7 @@ describe(MetadataService.name, () => { }); afterEach(async () => { - await sut.onShutdownEvent(); + await sut.onShutdown(); }); it('should be defined', () => { @@ -104,7 +104,7 @@ describe(MetadataService.name, () => { describe('onBootstrapEvent', () => { it('should pause and resume queue during init', async () => { - await sut.onBootstrapEvent('microservices'); + await sut.onBootstrap('microservices'); expect(jobMock.pause).toHaveBeenCalledTimes(1); expect(mapMock.init).toHaveBeenCalledTimes(1); @@ -114,7 +114,7 @@ describe(MetadataService.name, () => { it('should return if reverse geocoding is disabled', async () => { systemMock.get.mockResolvedValue({ reverseGeocoding: { enabled: false } }); - await sut.onBootstrapEvent('microservices'); + await sut.onBootstrap('microservices'); expect(jobMock.pause).not.toHaveBeenCalled(); expect(mapMock.init).not.toHaveBeenCalled(); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 041b35c02c31d..f1d367fb7b9f4 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -8,6 +8,7 @@ import path from 'node:path'; import { SystemConfig } from 'src/config'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEmit } from 'src/decorators'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { AssetType } from 'src/enum'; @@ -15,7 +16,7 @@ import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; -import { ClientEvent, IEventRepository, OnEvents } from 'src/interfaces/event.interface'; +import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IBaseJob, IEntityJob, @@ -86,7 +87,7 @@ const validate = (value: T): NonNullable | null => { }; @Injectable() -export class MetadataService implements OnEvents { +export class MetadataService { private storageCore: StorageCore; private configCore: SystemConfigCore; @@ -120,7 +121,8 @@ export class MetadataService implements OnEvents { ); } - async onBootstrapEvent(app: 'api' | 'microservices') { + @OnEmit({ event: 'onBootstrap' }) + async onBootstrap(app: ArgOf<'onBootstrap'>) { if (app !== 'microservices') { return; } @@ -128,7 +130,8 @@ export class MetadataService implements OnEvents { await this.init(config); } - async onConfigUpdateEvent({ newConfig }: { newConfig: SystemConfig }) { + @OnEmit({ event: 'onConfigUpdate' }) + async onConfigUpdate({ newConfig }: ArgOf<'onConfigUpdate'>) { await this.init(newConfig); } @@ -150,7 +153,8 @@ export class MetadataService implements OnEvents { } } - async onShutdownEvent() { + @OnEmit({ event: 'onShutdown' }) + async onShutdown() { await this.repository.teardown(); } diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index fe1f4edc07bfe..46ca4118d1954 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { OnEvents } from 'src/interfaces/event.interface'; +import { OnEmit } from 'src/decorators'; +import { ArgOf } from 'src/interfaces/event.interface'; import { IDeleteFilesJob, JobName } from 'src/interfaces/job.interface'; import { AssetService } from 'src/services/asset.service'; import { AuditService } from 'src/services/audit.service'; @@ -19,7 +20,7 @@ import { VersionService } from 'src/services/version.service'; import { otelShutdown } from 'src/utils/instrumentation'; @Injectable() -export class MicroservicesService implements OnEvents { +export class MicroservicesService { constructor( private auditService: AuditService, private assetService: AssetService, @@ -38,7 +39,8 @@ export class MicroservicesService implements OnEvents { private versionService: VersionService, ) {} - async onBootstrapEvent(app: 'api' | 'microservices') { + @OnEmit({ event: 'onBootstrap' }) + async onBootstrap(app: ArgOf<'onBootstrap'>) { if (app !== 'microservices') { return; } diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index f10c79c579571..74d2a12127dbd 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -90,7 +90,7 @@ describe(NotificationService.name, () => { const newConfig = configs.smtpEnabled; notificationMock.verifySmtp.mockResolvedValue(true); - await expect(sut.onConfigValidateEvent({ oldConfig, newConfig })).resolves.not.toThrow(); + await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); expect(notificationMock.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport); }); @@ -99,7 +99,7 @@ describe(NotificationService.name, () => { const newConfig = configs.smtpTransport; notificationMock.verifySmtp.mockResolvedValue(true); - await expect(sut.onConfigValidateEvent({ oldConfig, newConfig })).resolves.not.toThrow(); + await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); expect(notificationMock.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport); }); @@ -107,7 +107,7 @@ describe(NotificationService.name, () => { const oldConfig = { ...configs.smtpEnabled }; const newConfig = { ...configs.smtpEnabled }; - await expect(sut.onConfigValidateEvent({ oldConfig, newConfig })).resolves.not.toThrow(); + await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); expect(notificationMock.verifySmtp).not.toHaveBeenCalled(); }); @@ -115,19 +115,19 @@ describe(NotificationService.name, () => { const oldConfig = { ...configs.smtpEnabled }; const newConfig = { ...configs.smtpDisabled }; - await expect(sut.onConfigValidateEvent({ oldConfig, newConfig })).resolves.not.toThrow(); + await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); expect(notificationMock.verifySmtp).not.toHaveBeenCalled(); }); }); describe('onUserSignupEvent', () => { it('skips when notify is false', async () => { - await sut.onUserSignupEvent({ id: '', notify: false }); + await sut.onUserSignup({ id: '', notify: false }); expect(jobMock.queue).not.toHaveBeenCalled(); }); it('should queue notify signup event if notify is true', async () => { - await sut.onUserSignupEvent({ id: '', notify: true }); + await sut.onUserSignup({ id: '', notify: true }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_SIGNUP, data: { id: '', tempPassword: undefined }, @@ -137,7 +137,7 @@ describe(NotificationService.name, () => { describe('onAlbumUpdateEvent', () => { it('should queue notify album update event', async () => { - await sut.onAlbumUpdateEvent({ id: '', updatedBy: '42' }); + await sut.onAlbumUpdate({ id: '', updatedBy: '42' }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id: '', senderId: '42' }, @@ -147,7 +147,7 @@ describe(NotificationService.name, () => { describe('onAlbumInviteEvent', () => { it('should queue notify album invite event', async () => { - await sut.onAlbumInviteEvent({ id: '', userId: '42' }); + await sut.onAlbumInvite({ id: '', userId: '42' }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id: '', recipientId: '42' }, diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index c5f9a4f9f71c8..80abc4ca983d8 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -2,17 +2,12 @@ import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; import { isEqual } from 'lodash'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEmit } from 'src/decorators'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { AlbumEntity } from 'src/entities/album.entity'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { - AlbumInviteEvent, - AlbumUpdateEvent, - OnEvents, - SystemConfigUpdateEvent, - UserSignupEvent, -} from 'src/interfaces/event.interface'; +import { ArgOf } from 'src/interfaces/event.interface'; import { IEmailJob, IJobRepository, @@ -30,7 +25,7 @@ import { getFilenameExtension } from 'src/utils/file'; import { getPreferences } from 'src/utils/preferences'; @Injectable() -export class NotificationService implements OnEvents { +export class NotificationService { private configCore: SystemConfigCore; constructor( @@ -46,7 +41,8 @@ export class NotificationService implements OnEvents { this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } - async onConfigValidateEvent({ oldConfig, newConfig }: SystemConfigUpdateEvent) { + @OnEmit({ event: 'onConfigValidate', priority: -100 }) + async onConfigValidate({ oldConfig, newConfig }: ArgOf<'onConfigValidate'>) { try { if ( newConfig.notifications.smtp.enabled && @@ -60,17 +56,20 @@ export class NotificationService implements OnEvents { } } - async onUserSignupEvent({ notify, id, tempPassword }: UserSignupEvent) { + @OnEmit({ event: 'onUserSignup' }) + async onUserSignup({ notify, id, tempPassword }: ArgOf<'onUserSignup'>) { if (notify) { await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id, tempPassword } }); } } - async onAlbumUpdateEvent({ id, updatedBy }: AlbumUpdateEvent) { + @OnEmit({ event: 'onAlbumUpdate' }) + async onAlbumUpdate({ id, updatedBy }: ArgOf<'onAlbumUpdate'>) { await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id, senderId: updatedBy } }); } - async onAlbumInviteEvent({ id, userId }: AlbumInviteEvent) { + @OnEmit({ event: 'onAlbumInvite' }) + async onAlbumInvite({ id, userId }: ArgOf<'onAlbumInvite'>) { await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id, recipientId: userId } }); } diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index 67e19eda78826..faf4d981644a3 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -3,6 +3,7 @@ import { getBuildMetadata, getServerLicensePublicKey } from 'src/config'; import { serverVersion } from 'src/constants'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEmit } from 'src/decorators'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { ServerAboutResponseDto, @@ -16,7 +17,6 @@ import { } from 'src/dtos/server.dto'; import { SystemMetadataKey } from 'src/enum'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { OnEvents } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @@ -27,7 +27,7 @@ import { mimeTypes } from 'src/utils/mime-types'; import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isSmartSearchEnabled } from 'src/utils/misc'; @Injectable() -export class ServerService implements OnEvents { +export class ServerService { private configCore: SystemConfigCore; constructor( @@ -42,7 +42,8 @@ export class ServerService implements OnEvents { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - async onBootstrapEvent(): Promise { + @OnEmit({ event: 'onBootstrap' }) + async onBootstrap(): Promise { const featureFlags = await this.getFeatures(); if (featureFlags.configFile) { await this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, { diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index f18dc91ff100f..278e06d287db7 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -49,7 +49,7 @@ describe(SmartInfoService.name, () => { describe('onConfigValidateEvent', () => { it('should allow a valid model', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { machineLearning: { clip: { modelName: 'ViT-B-16__openai' } } } as SystemConfig, oldConfig: {} as SystemConfig, }), @@ -58,7 +58,7 @@ describe(SmartInfoService.name, () => { it('should allow including organization', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { machineLearning: { clip: { modelName: 'immich-app/ViT-B-16__openai' } } } as SystemConfig, oldConfig: {} as SystemConfig, }), @@ -67,7 +67,7 @@ describe(SmartInfoService.name, () => { it('should fail for an unsupported model', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { machineLearning: { clip: { modelName: 'test-model' } } } as SystemConfig, oldConfig: {} as SystemConfig, }), @@ -77,7 +77,7 @@ describe(SmartInfoService.name, () => { describe('onBootstrapEvent', () => { it('should return if not microservices', async () => { - await sut.onBootstrapEvent('api'); + await sut.onBootstrap('api'); expect(systemMock.get).not.toHaveBeenCalled(); expect(searchMock.getDimensionSize).not.toHaveBeenCalled(); @@ -92,7 +92,7 @@ describe(SmartInfoService.name, () => { it('should return if machine learning is disabled', async () => { systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); - await sut.onBootstrapEvent('microservices'); + await sut.onBootstrap('microservices'); expect(systemMock.get).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).not.toHaveBeenCalled(); @@ -107,7 +107,7 @@ describe(SmartInfoService.name, () => { it('should return if model and DB dimension size are equal', async () => { searchMock.getDimensionSize.mockResolvedValue(512); - await sut.onBootstrapEvent('microservices'); + await sut.onBootstrap('microservices'); expect(systemMock.get).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); @@ -123,7 +123,7 @@ describe(SmartInfoService.name, () => { searchMock.getDimensionSize.mockResolvedValue(768); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - await sut.onBootstrapEvent('microservices'); + await sut.onBootstrap('microservices'); expect(systemMock.get).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); @@ -138,7 +138,7 @@ describe(SmartInfoService.name, () => { searchMock.getDimensionSize.mockResolvedValue(768); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); - await sut.onBootstrapEvent('microservices'); + await sut.onBootstrap('microservices'); expect(systemMock.get).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); @@ -154,7 +154,7 @@ describe(SmartInfoService.name, () => { it('should return if machine learning is disabled', async () => { systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); - await sut.onConfigUpdateEvent({ + await sut.onConfigUpdate({ newConfig: systemConfigStub.machineLearningDisabled as SystemConfig, oldConfig: systemConfigStub.machineLearningDisabled as SystemConfig, }); @@ -172,7 +172,7 @@ describe(SmartInfoService.name, () => { it('should return if model and DB dimension size are equal', async () => { searchMock.getDimensionSize.mockResolvedValue(512); - await sut.onConfigUpdateEvent({ + await sut.onConfigUpdate({ newConfig: { machineLearning: { clip: { modelName: 'ViT-B-16__openai', enabled: true }, enabled: true }, } as SystemConfig, @@ -194,7 +194,7 @@ describe(SmartInfoService.name, () => { searchMock.getDimensionSize.mockResolvedValue(512); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - await sut.onConfigUpdateEvent({ + await sut.onConfigUpdate({ newConfig: { machineLearning: { clip: { modelName: 'ViT-L-14-quickgelu__dfn2b', enabled: true }, enabled: true }, } as SystemConfig, @@ -215,7 +215,7 @@ describe(SmartInfoService.name, () => { searchMock.getDimensionSize.mockResolvedValue(512); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - await sut.onConfigUpdateEvent({ + await sut.onConfigUpdate({ newConfig: { machineLearning: { clip: { modelName: 'ViT-B-32__openai', enabled: true }, enabled: true }, } as SystemConfig, @@ -237,7 +237,7 @@ describe(SmartInfoService.name, () => { searchMock.getDimensionSize.mockResolvedValue(512); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); - await sut.onConfigUpdateEvent({ + await sut.onConfigUpdate({ newConfig: { machineLearning: { clip: { modelName: 'ViT-B-32__openai', enabled: true }, enabled: true }, } as SystemConfig, diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index 1957f3885c750..883f320abf50c 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -1,9 +1,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { SystemConfig } from 'src/config'; import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEmit } from 'src/decorators'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; -import { OnEvents, SystemConfigUpdateEvent } from 'src/interfaces/event.interface'; +import { ArgOf } from 'src/interfaces/event.interface'; import { IBaseJob, IEntityJob, @@ -21,7 +22,7 @@ import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; @Injectable() -export class SmartInfoService implements OnEvents { +export class SmartInfoService { private configCore: SystemConfigCore; constructor( @@ -37,7 +38,8 @@ export class SmartInfoService implements OnEvents { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - async onBootstrapEvent(app: 'api' | 'microservices') { + @OnEmit({ event: 'onBootstrap' }) + async onBootstrap(app: ArgOf<'onBootstrap'>) { if (app !== 'microservices') { return; } @@ -46,7 +48,8 @@ export class SmartInfoService implements OnEvents { await this.init(config); } - onConfigValidateEvent({ newConfig }: SystemConfigUpdateEvent) { + @OnEmit({ event: 'onConfigValidate' }) + onConfigValidate({ newConfig }: ArgOf<'onConfigValidate'>) { try { getCLIPModelInfo(newConfig.machineLearning.clip.modelName); } catch { @@ -56,7 +59,8 @@ export class SmartInfoService implements OnEvents { } } - async onConfigUpdateEvent({ oldConfig, newConfig }: SystemConfigUpdateEvent) { + @OnEmit({ event: 'onConfigUpdate' }) + async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'onConfigUpdate'>) { await this.init(newConfig, oldConfig); } diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index 7a9b9952e0439..c1e0410a3d89c 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -76,10 +76,10 @@ describe(StorageTemplateService.name, () => { SystemConfigCore.create(systemMock, loggerMock).config$.next(defaults); }); - describe('onConfigValidateEvent', () => { + describe('onConfigValidate', () => { it('should allow valid templates', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { storageTemplate: { template: @@ -93,7 +93,7 @@ describe(StorageTemplateService.name, () => { it('should fail for an invalid template', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { storageTemplate: { template: '{{foo}}', diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 599f5e10a5186..0ee5bdd3b56de 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -15,6 +15,7 @@ import { } from 'src/constants'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEmit } from 'src/decorators'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType } from 'src/entities/move.entity'; import { AssetType } from 'src/enum'; @@ -22,7 +23,7 @@ import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; -import { OnEvents, SystemConfigUpdateEvent } from 'src/interfaces/event.interface'; +import { ArgOf } from 'src/interfaces/event.interface'; import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; @@ -46,7 +47,7 @@ interface RenderMetadata { } @Injectable() -export class StorageTemplateService implements OnEvents { +export class StorageTemplateService { private configCore: SystemConfigCore; private storageCore: StorageCore; private _template: { @@ -88,7 +89,8 @@ export class StorageTemplateService implements OnEvents { ); } - onConfigValidateEvent({ newConfig }: SystemConfigUpdateEvent) { + @OnEmit({ event: 'onConfigValidate' }) + onConfigValidate({ newConfig }: ArgOf<'onConfigValidate'>) { try { const { compiled } = this.compile(newConfig.storageTemplate.template); this.render(compiled, { diff --git a/server/src/services/storage.service.spec.ts b/server/src/services/storage.service.spec.ts index 5ce6d92d2698e..d9b4c8eefb3f3 100644 --- a/server/src/services/storage.service.spec.ts +++ b/server/src/services/storage.service.spec.ts @@ -20,9 +20,9 @@ describe(StorageService.name, () => { expect(sut).toBeDefined(); }); - describe('onBootstrapEvent', () => { + describe('onBootstrap', () => { it('should create the library folder on initialization', () => { - sut.onBootstrapEvent(); + sut.onBootstrap(); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library'); }); }); diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index 8222d7c46dd66..1535d53d95e23 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -1,12 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; -import { OnEvents } from 'src/interfaces/event.interface'; +import { OnEmit } from 'src/decorators'; import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @Injectable() -export class StorageService implements OnEvents { +export class StorageService { constructor( @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @@ -14,7 +14,8 @@ export class StorageService implements OnEvents { this.logger.setContext(StorageService.name); } - onBootstrapEvent() { + @OnEmit({ event: 'onBootstrap' }) + onBootstrap() { const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY); this.storageRepository.mkdirSync(libraryBase); } diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index 5aa800a224e7e..b4e6f903b1a03 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -13,20 +13,14 @@ import { supportedYearTokens, } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { EventHandlerOptions, OnServerEvent } from 'src/decorators'; +import { OnEmit, OnServerEvent } from 'src/decorators'; import { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from 'src/dtos/system-config.dto'; -import { - ClientEvent, - IEventRepository, - OnEvents, - ServerEvent, - SystemConfigUpdateEvent, -} from 'src/interfaces/event.interface'; +import { ArgOf, ClientEvent, IEventRepository, ServerEvent } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; @Injectable() -export class SystemConfigService implements OnEvents { +export class SystemConfigService { private core: SystemConfigCore; constructor( @@ -39,8 +33,8 @@ export class SystemConfigService implements OnEvents { this.core.config$.subscribe((config) => this.setLogLevel(config)); } - @EventHandlerOptions({ priority: -100 }) - async onBootstrapEvent() { + @OnEmit({ event: 'onBootstrap', priority: -100 }) + async onBootstrap() { const config = await this.core.getConfig({ withCache: false }); this.core.config$.next(config); } @@ -54,7 +48,8 @@ export class SystemConfigService implements OnEvents { return mapConfig(defaults); } - onConfigValidateEvent({ newConfig, oldConfig }: SystemConfigUpdateEvent) { + @OnEmit({ event: 'onConfigValidate' }) + onConfigValidate({ newConfig, oldConfig }: ArgOf<'onConfigValidate'>) { if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && this.getEnvLogLevel()) { throw new Error('Logging cannot be changed while the environment variable IMMICH_LOG_LEVEL is set.'); } @@ -68,7 +63,7 @@ export class SystemConfigService implements OnEvents { const oldConfig = await this.core.getConfig({ withCache: false }); try { - await this.eventRepository.emit('onConfigValidateEvent', { newConfig: dto, oldConfig }); + await this.eventRepository.emit('onConfigValidate', { newConfig: dto, oldConfig }); } catch (error) { this.logger.warn(`Unable to save system config due to a validation error: ${error}`); throw new BadRequestException(error instanceof Error ? error.message : error); @@ -79,7 +74,7 @@ export class SystemConfigService implements OnEvents { // TODO probably move web socket emits to a separate service this.eventRepository.clientBroadcast(ClientEvent.CONFIG_UPDATE, {}); this.eventRepository.serverSend(ServerEvent.CONFIG_UPDATE, null); - await this.eventRepository.emit('onConfigUpdateEvent', { newConfig, oldConfig }); + await this.eventRepository.emit('onConfigUpdate', { newConfig, oldConfig }); return mapConfig(newConfig); } diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 76ae3dd23a838..95eeed0475b7f 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -45,7 +45,7 @@ export class UserAdminService { const { notify, ...rest } = dto; const user = await this.userCore.createUser(rest); - await this.eventRepository.emit('onUserSignupEvent', { + await this.eventRepository.emit('onUserSignup', { notify: !!notify, id: user.id, tempPassword: user.shouldChangePassword ? rest.password : undefined, diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index 42e2b50ab5a02..2f04a510146cc 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -3,11 +3,11 @@ import { DateTime } from 'luxon'; import semver, { SemVer } from 'semver'; import { isDev, serverVersion } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnServerEvent } from 'src/decorators'; +import { OnEmit, OnServerEvent } from 'src/decorators'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { VersionCheckMetadata } from 'src/entities/system-metadata.entity'; import { SystemMetadataKey } from 'src/enum'; -import { ClientEvent, IEventRepository, OnEvents, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface'; +import { ClientEvent, IEventRepository, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; @@ -23,7 +23,7 @@ const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): Re }; @Injectable() -export class VersionService implements OnEvents { +export class VersionService { private configCore: SystemConfigCore; constructor( @@ -37,7 +37,8 @@ export class VersionService implements OnEvents { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - async onBootstrapEvent(): Promise { + @OnEmit({ event: 'onBootstrap' }) + async onBootstrap(): Promise { await this.handleVersionCheck(); } diff --git a/server/src/utils/events.ts b/server/src/utils/events.ts index 1bee4c6558a45..2dd7e7fd5d208 100644 --- a/server/src/utils/events.ts +++ b/server/src/utils/events.ts @@ -1,33 +1,57 @@ import { ModuleRef, Reflector } from '@nestjs/core'; import _ from 'lodash'; -import { HandlerOptions } from 'src/decorators'; -import { EmitEvent, EmitEventHandler, IEventRepository, OnEvents, events } from 'src/interfaces/event.interface'; +import { EmitConfig } from 'src/decorators'; +import { EmitEvent, EmitHandler, IEventRepository } from 'src/interfaces/event.interface'; import { Metadata } from 'src/middleware/auth.guard'; import { services } from 'src/services'; +type Item = { + event: T; + handler: EmitHandler; + priority: number; + label: string; +}; + export const setupEventHandlers = (moduleRef: ModuleRef) => { const reflector = moduleRef.get(Reflector, { strict: false }); const repository = moduleRef.get(IEventRepository); - const handlers: Array<{ event: EmitEvent; handler: EmitEventHandler; priority: number }> = []; + const items: Item[] = []; // discovery for (const Service of services) { - const instance = moduleRef.get(Service); - for (const event of events) { - const handler = instance[event] as EmitEventHandler; + const instance = moduleRef.get(Service); + const ctx = Object.getPrototypeOf(instance); + for (const property of Object.getOwnPropertyNames(ctx)) { + const descriptor = Object.getOwnPropertyDescriptor(ctx, property); + if (!descriptor || descriptor.get || descriptor.set) { + continue; + } + + const handler = instance[property]; if (typeof handler !== 'function') { continue; } - const options = reflector.get(Metadata.EVENT_HANDLER_OPTIONS, handler); - const priority = options?.priority || 0; + const options = reflector.get(Metadata.ON_EMIT_CONFIG, handler); + if (!options) { + continue; + } - handlers.push({ event, handler: handler.bind(instance), priority }); + items.push({ + event: options.event, + priority: options.priority || 0, + handler: handler.bind(instance), + label: `${Service.name}.${handler.name}`, + }); } } + const handlers = _.orderBy(items, ['priority'], ['asc']); + // register by priority - for (const { event, handler } of _.orderBy(handlers, ['priority'], ['asc'])) { - repository.on(event, handler); + for (const { event, handler } of handlers) { + repository.on(event as EmitEvent, handler); } + + return handlers; };