diff --git a/e2e/src/api/specs/user-admin.e2e-spec.ts b/e2e/src/api/specs/user-admin.e2e-spec.ts index b0696dcada..2d6e08b5fb 100644 --- a/e2e/src/api/specs/user-admin.e2e-spec.ts +++ b/e2e/src/api/specs/user-admin.e2e-spec.ts @@ -1,4 +1,5 @@ import { + JobName, LoginResponseDto, createStack, deleteUserAdmin, @@ -327,6 +328,8 @@ describe('/admin/users', () => { { headers: asBearerAuth(user.accessToken) }, ); + await utils.waitForQueueFinish(admin.accessToken, JobName.BackgroundTask); + const { status, body } = await request(app) .delete(`/admin/users/${user.userId}`) .send({ force: true }) diff --git a/server/src/app.module.ts b/server/src/app.module.ts index a1cd1edfdf..8d261463e7 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -19,7 +19,6 @@ import { ConfigRepository } from 'src/repositories/config.repository'; import { EventRepository } from 'src/repositories/event.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository'; -import { UserRepository } from 'src/repositories/user.repository'; import { services } from 'src/services'; import { AuthService } from 'src/services/auth.service'; import { CliService } from 'src/services/cli.service'; @@ -56,7 +55,6 @@ class BaseModule implements OnModuleInit, OnModuleDestroy { private jobService: JobService, private telemetryRepository: TelemetryRepository, private authService: AuthService, - private userRepository: UserRepository, ) { logger.setAppName(this.worker); } diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index ec4c8a8f52..ea80139f1d 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -17,7 +17,7 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { NotificationDto } from 'src/dtos/notification.dto'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { SyncAssetExifV1, SyncAssetV1 } from 'src/dtos/sync.dto'; -import { ImmichWorker, MetadataKey, QueueName } from 'src/enum'; +import { ImmichWorker, MetadataKey, QueueName, UserAvatarColor, UserStatus } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { JobItem, JobSource } from 'src/types'; @@ -82,11 +82,33 @@ type EventMap = { // user events UserSignup: [{ notify: boolean; id: string; password?: string }]; + UserCreate: [UserEvent]; + UserDelete: [UserEvent]; + UserRestore: [UserEvent]; // websocket events WebsocketConnect: [{ userId: string }]; }; +type UserEvent = { + name: string; + id: string; + createdAt: Date; + updatedAt: Date; + deletedAt: Date | null; + status: UserStatus; + email: string; + profileImagePath: string; + isAdmin: boolean; + shouldChangePassword: boolean; + avatarColor: UserAvatarColor | null; + oauthId: string; + storageLabel: string | null; + quotaSizeInBytes: number | null; + quotaUsageInBytes: number; + profileChangedAt: Date; +}; + export const serverEvents = ['ConfigUpdate'] as const; export type ServerEvents = (typeof serverEvents)[number]; diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 5a2dd42c3c..de5e5862c7 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -198,8 +198,8 @@ export class BaseService { } async createUser(dto: Insertable & { email: string }): Promise { - const user = await this.userRepository.getByEmail(dto.email); - if (user) { + const exists = await this.userRepository.getByEmail(dto.email); + if (exists) { throw new BadRequestException('User exists'); } @@ -218,7 +218,10 @@ export class BaseService { payload.storageLabel = sanitize(payload.storageLabel.replaceAll('.', '')); } - this.telemetryRepository.api.addToGauge(`immich.users.total`, 1); - return this.userRepository.create(payload); + const user = await this.userRepository.create(payload); + + await this.eventRepository.emit('UserCreate', user); + + return user; } } diff --git a/server/src/services/index.ts b/server/src/services/index.ts index cad38ca1f4..d8af35e8dc 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -34,6 +34,7 @@ import { SyncService } from 'src/services/sync.service'; import { SystemConfigService } from 'src/services/system-config.service'; import { SystemMetadataService } from 'src/services/system-metadata.service'; import { TagService } from 'src/services/tag.service'; +import { TelemetryService } from 'src/services/telemetry.service'; import { TimelineService } from 'src/services/timeline.service'; import { TrashService } from 'src/services/trash.service'; import { UserAdminService } from 'src/services/user-admin.service'; @@ -78,6 +79,7 @@ export const services = [ SystemConfigService, SystemMetadataService, TagService, + TelemetryService, TimelineService, TrashService, UserAdminService, diff --git a/server/src/services/telemetry.service.ts b/server/src/services/telemetry.service.ts new file mode 100644 index 0000000000..a831a0e9ff --- /dev/null +++ b/server/src/services/telemetry.service.ts @@ -0,0 +1,26 @@ +import { OnEvent } from 'src/decorators'; +import { ImmichWorker } from 'src/enum'; +import { BaseService } from 'src/services/base.service'; + +export class TelemetryService extends BaseService { + @OnEvent({ name: 'AppBootstrap', workers: [ImmichWorker.Api] }) + async onBootstrap(): Promise { + const userCount = await this.userRepository.getCount(); + this.telemetryRepository.api.addToGauge('immich.users.total', userCount); + } + + @OnEvent({ name: 'UserCreate' }) + onUserCreate() { + this.telemetryRepository.api.addToGauge(`immich.users.total`, 1); + } + + @OnEvent({ name: 'UserDelete' }) + onUserDelete() { + this.telemetryRepository.api.addToGauge(`immich.users.total`, -1); + } + + @OnEvent({ name: 'UserRestore' }) + onUserRestore() { + this.telemetryRepository.api.addToGauge(`immich.users.total`, 1); + } +} diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 2684dca0c9..046ea6b45a 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -103,7 +103,8 @@ export class UserAdminService extends BaseService { const status = force ? UserStatus.Removing : UserStatus.Deleted; const user = await this.userRepository.update(id, { status, deletedAt: new Date() }); - this.telemetryRepository.api.addToGauge(`immich.users.total`, -1); + + await this.eventRepository.emit('UserDelete', user); if (force) { await this.jobRepository.queue({ name: JobName.UserDelete, data: { id: user.id, force } }); @@ -116,7 +117,7 @@ export class UserAdminService extends BaseService { await this.findOrFail(id, { withDeleted: true }); await this.albumRepository.restoreAll(id); const user = await this.userRepository.restore(id); - this.telemetryRepository.api.addToGauge('immich.users.total', 1); + await this.eventRepository.emit('UserRestore', user); return mapUserAdmin(user); } diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index fc71777673..6849b17ac3 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -3,14 +3,14 @@ import { Updateable } from 'kysely'; import { DateTime } from 'luxon'; import { SALT_ROUNDS } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; -import { OnEvent, OnJob } from 'src/decorators'; +import { OnJob } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto'; -import { CacheControl, ImmichWorker, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum'; +import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum'; import { UserFindOptions } from 'src/repositories/user.repository'; import { UserTable } from 'src/schema/tables/user.table'; import { BaseService } from 'src/services/base.service'; @@ -213,12 +213,6 @@ export class UserService extends BaseService { }; } - @OnEvent({ name: 'AppBootstrap', workers: [ImmichWorker.Api] }) - async onBootstrap(): Promise { - const userCount = await this.userRepository.getCount(); - this.telemetryRepository.api.addToGauge('immich.users.total', userCount); - } - @OnJob({ name: JobName.UserSyncUsage, queue: QueueName.BackgroundTask }) async handleUserSyncUsage(): Promise { await this.userRepository.syncUsage(); diff --git a/server/test/medium/specs/services/auth.service.spec.ts b/server/test/medium/specs/services/auth.service.spec.ts index 60d3210f4c..f8bc3f1259 100644 --- a/server/test/medium/specs/services/auth.service.spec.ts +++ b/server/test/medium/specs/services/auth.service.spec.ts @@ -44,7 +44,8 @@ beforeAll(async () => { describe(AuthService.name, () => { describe('adminSignUp', () => { it(`should sign up the admin`, async () => { - const { sut } = setup(); + const { sut, ctx } = setup(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); const dto = { name: 'Admin', email: 'admin@immich.cloud', password: 'password' }; await expect(sut.adminSignUp(dto)).resolves.toEqual( diff --git a/server/test/medium/specs/services/user.service.spec.ts b/server/test/medium/specs/services/user.service.spec.ts index 0d72d39950..24a06404b1 100644 --- a/server/test/medium/specs/services/user.service.spec.ts +++ b/server/test/medium/specs/services/user.service.spec.ts @@ -3,10 +3,10 @@ import { DateTime } from 'luxon'; import { ImmichEnvironment, JobName, JobStatus } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; +import { EventRepository } from 'src/repositories/event.repository'; import { JobRepository } from 'src/repositories/job.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; -import { TelemetryRepository } from 'src/repositories/telemetry.repository'; import { UserRepository } from 'src/repositories/user.repository'; import { DB } from 'src/schema'; import { UserService } from 'src/services/user.service'; @@ -22,7 +22,7 @@ const setup = (db?: Kysely) => { return newMediumService(UserService, { database: db || defaultDatabase, real: [CryptoRepository, ConfigRepository, SystemMetadataRepository, UserRepository], - mock: [LoggingRepository, JobRepository, TelemetryRepository], + mock: [LoggingRepository, JobRepository, EventRepository], }); }; @@ -35,7 +35,8 @@ beforeAll(async () => { describe(UserService.name, () => { describe('create', () => { it('should create a user', async () => { - const { sut } = setup(); + const { sut, ctx } = setup(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); const user = mediumFactory.userInsert(); await expect(sut.createUser({ name: user.name, email: user.email })).resolves.toEqual( expect.objectContaining({ name: user.name, email: user.email }), @@ -43,14 +44,16 @@ describe(UserService.name, () => { }); it('should reject user with duplicate email', async () => { - const { sut } = setup(); + const { sut, ctx } = setup(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); const user = mediumFactory.userInsert(); await expect(sut.createUser({ email: user.email })).resolves.toMatchObject({ email: user.email }); await expect(sut.createUser({ email: user.email })).rejects.toThrow('User exists'); }); it('should not return password', async () => { - const { sut } = setup(); + const { sut, ctx } = setup(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); const dto = mediumFactory.userInsert({ password: 'password' }); const user = await sut.createUser({ email: dto.email, password: 'password' }); expect((user as any).password).toBeUndefined();