From 6ce1533117ca3f71801fd06dee86088d9e9f3fb1 Mon Sep 17 00:00:00 2001
From: Jason Rasmussen <jason@rasm.me>
Date: Wed, 15 Jan 2025 23:31:26 -0500
Subject: [PATCH] fix: activity types (#15368)

---
 server/src/app.module.ts                      |  6 ++--
 server/src/bin/sync-sql.ts                    | 12 ++++---
 server/src/database.ts                        |  3 ++
 server/src/dtos/activity.dto.ts               | 11 ++++---
 server/src/interfaces/activity.interface.ts   | 13 --------
 server/src/queries/activity.repository.sql    |  6 +++-
 .../src/repositories/activity.repository.ts   | 15 ++++-----
 server/src/repositories/index.ts              |  7 ++--
 server/src/services/activity.service.spec.ts  |  2 +-
 server/src/services/activity.service.ts       |  6 ++--
 server/src/services/base.service.ts           |  4 +--
 server/src/types.ts                           |  9 ++++++
 server/test/fixtures/activity.stub.ts         | 32 +++++++++++--------
 .../repositories/activity.repository.mock.ts  |  2 +-
 server/test/utils.ts                          |  4 ++-
 15 files changed, 75 insertions(+), 57 deletions(-)
 create mode 100644 server/src/database.ts
 delete mode 100644 server/src/interfaces/activity.interface.ts

diff --git a/server/src/app.module.ts b/server/src/app.module.ts
index d0422756b6..5e6432408f 100644
--- a/server/src/app.module.ts
+++ b/server/src/app.module.ts
@@ -20,14 +20,14 @@ import { ErrorInterceptor } from 'src/middleware/error.interceptor';
 import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
 import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
 import { LoggingInterceptor } from 'src/middleware/logging.interceptor';
-import { repositories } from 'src/repositories';
+import { providers, repositories } from 'src/repositories';
 import { ConfigRepository } from 'src/repositories/config.repository';
 import { teardownTelemetry } from 'src/repositories/telemetry.repository';
 import { services } from 'src/services';
 import { CliService } from 'src/services/cli.service';
 import { DatabaseService } from 'src/services/database.service';
 
-const common = [...services, ...repositories];
+const common = [...services, ...providers, ...repositories];
 
 const middleware = [
   FileUploadInterceptor,
@@ -73,7 +73,7 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
   }
 
   async onModuleInit() {
-    this.telemetryRepository.setup({ repositories: repositories.map(({ useClass }) => useClass) });
+    this.telemetryRepository.setup({ repositories: [...providers.map(({ useClass }) => useClass), ...repositories] });
 
     this.jobRepository.setup({ services });
     if (this.worker === ImmichWorker.MICROSERVICES) {
diff --git a/server/src/bin/sync-sql.ts b/server/src/bin/sync-sql.ts
index 22dae63750..21eff3b306 100644
--- a/server/src/bin/sync-sql.ts
+++ b/server/src/bin/sync-sql.ts
@@ -12,7 +12,7 @@ import { format } from 'sql-formatter';
 import { GENERATE_SQL_KEY, GenerateSqlQueries } from 'src/decorators';
 import { entities } from 'src/entities';
 import { ILoggerRepository } from 'src/interfaces/logger.interface';
-import { repositories } from 'src/repositories';
+import { providers, repositories } from 'src/repositories';
 import { AccessRepository } from 'src/repositories/access.repository';
 import { ConfigRepository } from 'src/repositories/config.repository';
 import { AuthService } from 'src/services/auth.service';
@@ -43,7 +43,7 @@ export class SqlLogger implements Logger {
 
 const reflector = new Reflector();
 
-type Repository = (typeof repositories)[0]['useClass'];
+type Repository = (typeof providers)[0]['useClass'];
 type Provider = { provide: any; useClass: Repository };
 type SqlGeneratorOptions = { targetDir: string };
 
@@ -57,7 +57,11 @@ class SqlGenerator {
   async run() {
     try {
       await this.setup();
-      for (const repository of repositories) {
+      const targets = [
+        ...providers,
+        ...repositories.map((repository) => ({ provide: repository, useClass: repository as any })),
+      ];
+      for (const repository of targets) {
         if (repository.provide === ILoggerRepository) {
           continue;
         }
@@ -99,7 +103,7 @@ class SqlGenerator {
         TypeOrmModule.forFeature(entities),
         OpenTelemetryModule.forRoot(otel),
       ],
-      providers: [...repositories, AuthService, SchedulerRegistry],
+      providers: [...providers, ...repositories, AuthService, SchedulerRegistry],
     }).compile();
 
     this.app = await moduleFixture.createNestApplication().init();
diff --git a/server/src/database.ts b/server/src/database.ts
new file mode 100644
index 0000000000..fce9ede561
--- /dev/null
+++ b/server/src/database.ts
@@ -0,0 +1,3 @@
+export const columns = {
+  userDto: ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'],
+} as const;
diff --git a/server/src/dtos/activity.dto.ts b/server/src/dtos/activity.dto.ts
index 4bc0065244..9a0307f46b 100644
--- a/server/src/dtos/activity.dto.ts
+++ b/server/src/dtos/activity.dto.ts
@@ -1,7 +1,8 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { IsEnum, IsNotEmpty, IsString, ValidateIf } from 'class-validator';
-import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
-import { ActivityEntity } from 'src/entities/activity.entity';
+import { mapUser, UserResponseDto } from 'src/dtos/user.dto';
+import { UserEntity } from 'src/entities/user.entity';
+import { ActivityItem } from 'src/types';
 import { Optional, ValidateUUID } from 'src/validation';
 
 export enum ReactionType {
@@ -67,13 +68,13 @@ export class ActivityCreateDto extends ActivityDto {
   comment?: string;
 }
 
-export function mapActivity(activity: ActivityEntity): ActivityResponseDto {
+export const mapActivity = (activity: ActivityItem): ActivityResponseDto => {
   return {
     id: activity.id,
     assetId: activity.assetId,
     createdAt: activity.createdAt,
     comment: activity.comment,
     type: activity.isLiked ? ReactionType.LIKE : ReactionType.COMMENT,
-    user: mapUser(activity.user),
+    user: mapUser(activity.user as unknown as UserEntity),
   };
-}
+};
diff --git a/server/src/interfaces/activity.interface.ts b/server/src/interfaces/activity.interface.ts
deleted file mode 100644
index c42d3cc8aa..0000000000
--- a/server/src/interfaces/activity.interface.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { Insertable } from 'kysely';
-import { Activity } from 'src/db';
-import { ActivityEntity } from 'src/entities/activity.entity';
-import { ActivitySearch } from 'src/repositories/activity.repository';
-
-export const IActivityRepository = 'IActivityRepository';
-
-export interface IActivityRepository {
-  search(options: ActivitySearch): Promise<ActivityEntity[]>;
-  create(activity: Insertable<Activity>): Promise<ActivityEntity>;
-  delete(id: string): Promise<void>;
-  getStatistics(options: { albumId: string; assetId?: string }): Promise<number>;
-}
diff --git a/server/src/queries/activity.repository.sql b/server/src/queries/activity.repository.sql
index 5f25a7dcbd..8e9bb11f25 100644
--- a/server/src/queries/activity.repository.sql
+++ b/server/src/queries/activity.repository.sql
@@ -9,7 +9,11 @@ select
     from
       (
         select
-          *
+          "id",
+          "name",
+          "email",
+          "profileImagePath",
+          "profileChangedAt"
         from
           "users"
         where
diff --git a/server/src/repositories/activity.repository.ts b/server/src/repositories/activity.repository.ts
index 6ed82abdfc..99d3192341 100644
--- a/server/src/repositories/activity.repository.ts
+++ b/server/src/repositories/activity.repository.ts
@@ -2,10 +2,9 @@ import { Injectable } from '@nestjs/common';
 import { ExpressionBuilder, Insertable, Kysely } from 'kysely';
 import { jsonObjectFrom } from 'kysely/helpers/postgres';
 import { InjectKysely } from 'nestjs-kysely';
+import { columns } from 'src/database';
 import { Activity, DB } from 'src/db';
 import { DummyValue, GenerateSql } from 'src/decorators';
-import { ActivityEntity } from 'src/entities/activity.entity';
-import { IActivityRepository } from 'src/interfaces/activity.interface';
 import { asUuid } from 'src/utils/database';
 
 export interface ActivitySearch {
@@ -19,18 +18,18 @@ const withUser = (eb: ExpressionBuilder<DB, 'activity'>) => {
   return jsonObjectFrom(
     eb
       .selectFrom('users')
-      .selectAll()
+      .select(columns.userDto)
       .whereRef('users.id', '=', 'activity.userId')
       .where('users.deletedAt', 'is', null),
   ).as('user');
 };
 
 @Injectable()
-export class ActivityRepository implements IActivityRepository {
+export class ActivityRepository {
   constructor(@InjectKysely() private db: Kysely<DB>) {}
 
   @GenerateSql({ params: [{ albumId: DummyValue.UUID }] })
-  search(options: ActivitySearch): Promise<ActivityEntity[]> {
+  search(options: ActivitySearch) {
     const { userId, assetId, albumId, isLiked } = options;
 
     return this.db
@@ -44,14 +43,14 @@ export class ActivityRepository implements IActivityRepository {
       .$if(!!albumId, (qb) => qb.where('activity.albumId', '=', albumId!))
       .$if(isLiked !== undefined, (qb) => qb.where('activity.isLiked', '=', isLiked!))
       .orderBy('activity.createdAt', 'asc')
-      .execute() as unknown as Promise<ActivityEntity[]>;
+      .execute();
   }
 
   async create(activity: Insertable<Activity>) {
     return this.save(activity);
   }
 
-  async delete(id: string): Promise<void> {
+  async delete(id: string) {
     await this.db.deleteFrom('activity').where('id', '=', asUuid(id)).execute();
   }
 
@@ -79,6 +78,6 @@ export class ActivityRepository implements IActivityRepository {
       .selectAll('activity')
       .select(withUser)
       .where('activity.id', '=', asUuid(id))
-      .executeTakeFirstOrThrow() as unknown as Promise<ActivityEntity>;
+      .executeTakeFirstOrThrow();
   }
 }
diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts
index eb6a5d6f71..c48233f08f 100644
--- a/server/src/repositories/index.ts
+++ b/server/src/repositories/index.ts
@@ -1,5 +1,4 @@
 import { IAccessRepository } from 'src/interfaces/access.interface';
-import { IActivityRepository } from 'src/interfaces/activity.interface';
 import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
 import { IAlbumRepository } from 'src/interfaces/album.interface';
 import { IKeyRepository } from 'src/interfaces/api-key.interface';
@@ -78,8 +77,12 @@ import { VersionHistoryRepository } from 'src/repositories/version-history.repos
 import { ViewRepository } from 'src/repositories/view-repository';
 
 export const repositories = [
+  //
+  ActivityRepository,
+];
+
+export const providers = [
   { provide: IAccessRepository, useClass: AccessRepository },
-  { provide: IActivityRepository, useClass: ActivityRepository },
   { provide: IAlbumRepository, useClass: AlbumRepository },
   { provide: IAlbumUserRepository, useClass: AlbumUserRepository },
   { provide: IAssetRepository, useClass: AssetRepository },
diff --git a/server/src/services/activity.service.spec.ts b/server/src/services/activity.service.spec.ts
index f9a8e6ce47..4ee656abe5 100644
--- a/server/src/services/activity.service.spec.ts
+++ b/server/src/services/activity.service.spec.ts
@@ -1,7 +1,7 @@
 import { BadRequestException } from '@nestjs/common';
 import { ReactionType } from 'src/dtos/activity.dto';
-import { IActivityRepository } from 'src/interfaces/activity.interface';
 import { ActivityService } from 'src/services/activity.service';
+import { IActivityRepository } from 'src/types';
 import { activityStub } from 'test/fixtures/activity.stub';
 import { authStub } from 'test/fixtures/auth.stub';
 import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
diff --git a/server/src/services/activity.service.ts b/server/src/services/activity.service.ts
index ea7f8b5c0a..feb1074fb2 100644
--- a/server/src/services/activity.service.ts
+++ b/server/src/services/activity.service.ts
@@ -5,15 +5,15 @@ import {
   ActivityResponseDto,
   ActivitySearchDto,
   ActivityStatisticsResponseDto,
+  mapActivity,
   MaybeDuplicate,
   ReactionLevel,
   ReactionType,
-  mapActivity,
 } from 'src/dtos/activity.dto';
 import { AuthDto } from 'src/dtos/auth.dto';
-import { ActivityEntity } from 'src/entities/activity.entity';
 import { Permission } from 'src/enum';
 import { BaseService } from 'src/services/base.service';
+import { ActivityItem } from 'src/types';
 
 @Injectable()
 export class ActivityService extends BaseService {
@@ -43,7 +43,7 @@ export class ActivityService extends BaseService {
       albumId: dto.albumId,
     };
 
-    let activity: ActivityEntity | null = null;
+    let activity: ActivityItem | undefined;
     let duplicate = false;
 
     if (dto.type === ReactionType.LIKE) {
diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts
index 82852c27e2..9e024daacd 100644
--- a/server/src/services/base.service.ts
+++ b/server/src/services/base.service.ts
@@ -7,7 +7,6 @@ import { StorageCore } from 'src/cores/storage.core';
 import { Users } from 'src/db';
 import { UserEntity } from 'src/entities/user.entity';
 import { IAccessRepository } from 'src/interfaces/access.interface';
-import { IActivityRepository } from 'src/interfaces/activity.interface';
 import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
 import { IAlbumRepository } from 'src/interfaces/album.interface';
 import { IKeyRepository } from 'src/interfaces/api-key.interface';
@@ -45,6 +44,7 @@ import { ITrashRepository } from 'src/interfaces/trash.interface';
 import { IUserRepository } from 'src/interfaces/user.interface';
 import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface';
 import { IViewRepository } from 'src/interfaces/view.interface';
+import { ActivityRepository } from 'src/repositories/activity.repository';
 import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access';
 import { getConfig, updateConfig } from 'src/utils/config';
 
@@ -54,7 +54,7 @@ export class BaseService {
   constructor(
     @Inject(ILoggerRepository) protected logger: ILoggerRepository,
     @Inject(IAccessRepository) protected accessRepository: IAccessRepository,
-    @Inject(IActivityRepository) protected activityRepository: IActivityRepository,
+    protected activityRepository: ActivityRepository,
     @Inject(IAuditRepository) protected auditRepository: IAuditRepository,
     @Inject(IAlbumRepository) protected albumRepository: IAlbumRepository,
     @Inject(IAlbumUserRepository) protected albumUserRepository: IAlbumUserRepository,
diff --git a/server/src/types.ts b/server/src/types.ts
index c55de4160d..0d3b037f9e 100644
--- a/server/src/types.ts
+++ b/server/src/types.ts
@@ -1,5 +1,6 @@
 import { UserEntity } from 'src/entities/user.entity';
 import { Permission } from 'src/enum';
+import { ActivityRepository } from 'src/repositories/activity.repository';
 
 export type AuthApiKey = {
   id: string;
@@ -7,3 +8,11 @@ export type AuthApiKey = {
   user: UserEntity;
   permissions: Permission[];
 };
+
+export type RepositoryInterface<T extends object> = Pick<T, keyof T>;
+
+export type IActivityRepository = RepositoryInterface<ActivityRepository>;
+
+export type ActivityItem =
+  | Awaited<ReturnType<IActivityRepository['create']>>
+  | Awaited<ReturnType<IActivityRepository['search']>>[0];
diff --git a/server/test/fixtures/activity.stub.ts b/server/test/fixtures/activity.stub.ts
index 4805f6604d..9578bcd4a1 100644
--- a/server/test/fixtures/activity.stub.ts
+++ b/server/test/fixtures/activity.stub.ts
@@ -1,33 +1,39 @@
-import { ActivityEntity } from 'src/entities/activity.entity';
+import { ActivityItem } from 'src/types';
 import { albumStub } from 'test/fixtures/album.stub';
 import { assetStub } from 'test/fixtures/asset.stub';
-import { authStub } from 'test/fixtures/auth.stub';
-import { userStub } from 'test/fixtures/user.stub';
 
 export const activityStub = {
-  oneComment: Object.freeze<ActivityEntity>({
+  oneComment: Object.freeze<ActivityItem>({
     id: 'activity-1',
     comment: 'comment',
     isLiked: false,
-    userId: authStub.admin.user.id,
-    user: userStub.admin,
+    userId: 'admin_id',
+    user: {
+      id: 'admin_id',
+      name: 'admin',
+      email: 'admin@test.com',
+      profileImagePath: '',
+      profileChangedAt: new Date('2021-01-01'),
+    },
     assetId: assetStub.image.id,
-    asset: assetStub.image,
     albumId: albumStub.oneAsset.id,
-    album: albumStub.oneAsset,
     createdAt: new Date(),
     updatedAt: new Date(),
   }),
-  liked: Object.freeze<ActivityEntity>({
+  liked: Object.freeze<ActivityItem>({
     id: 'activity-2',
     comment: null,
     isLiked: true,
-    userId: authStub.admin.user.id,
-    user: userStub.admin,
+    userId: 'admin_id',
+    user: {
+      id: 'admin_id',
+      name: 'admin',
+      email: 'admin@test.com',
+      profileImagePath: '',
+      profileChangedAt: new Date('2021-01-01'),
+    },
     assetId: assetStub.image.id,
-    asset: assetStub.image,
     albumId: albumStub.oneAsset.id,
-    album: albumStub.oneAsset,
     createdAt: new Date(),
     updatedAt: new Date(),
   }),
diff --git a/server/test/repositories/activity.repository.mock.ts b/server/test/repositories/activity.repository.mock.ts
index 9d29d90ab8..bcc27774e3 100644
--- a/server/test/repositories/activity.repository.mock.ts
+++ b/server/test/repositories/activity.repository.mock.ts
@@ -1,4 +1,4 @@
-import { IActivityRepository } from 'src/interfaces/activity.interface';
+import { IActivityRepository } from 'src/types';
 import { Mocked, vitest } from 'vitest';
 
 export const newActivityRepositoryMock = (): Mocked<IActivityRepository> => {
diff --git a/server/test/utils.ts b/server/test/utils.ts
index 7f5b75020c..bc0ada3259 100644
--- a/server/test/utils.ts
+++ b/server/test/utils.ts
@@ -3,7 +3,9 @@ import { Writable } from 'node:stream';
 import { PNG } from 'pngjs';
 import { ImmichWorker } from 'src/enum';
 import { IMetadataRepository } from 'src/interfaces/metadata.interface';
+import { ActivityRepository } from 'src/repositories/activity.repository';
 import { BaseService } from 'src/services/base.service';
+import { IActivityRepository } from 'src/types';
 import { newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
 import { newActivityRepositoryMock } from 'test/repositories/activity.repository.mock';
 import { newAlbumUserRepositoryMock } from 'test/repositories/album-user.repository.mock';
@@ -104,7 +106,7 @@ export const newTestService = <T extends BaseService>(
   const sut = new Service(
     loggerMock,
     accessMock,
-    activityMock,
+    activityMock as IActivityRepository as ActivityRepository,
     auditMock,
     albumMock,
     albumUserMock,