Forráskód Böngészése

refactor: rule controller

Jason Rasmussen 1 éve
szülő
commit
6e2624da7c
32 módosított fájl, 458 hozzáadás és 913 törlés
  1. 80 813
      server/immich-openapi-specs.json
  2. 17 0
      server/src/domain/access/access.core.ts
  3. 4 0
      server/src/domain/access/access.repository.ts
  4. 3 9
      server/src/domain/album/album-response.dto.ts
  5. 1 0
      server/src/domain/album/album.service.spec.ts
  6. 2 40
      server/src/domain/album/album.service.ts
  7. 1 1
      server/src/domain/album/dto/index.ts
  8. 0 19
      server/src/domain/album/dto/rule.dto.ts
  9. 0 1
      server/src/domain/album/index.ts
  10. 1 0
      server/src/domain/index.ts
  11. 3 0
      server/src/domain/job/job.service.spec.ts
  12. 3 0
      server/src/domain/rule/index.ts
  13. 44 0
      server/src/domain/rule/rule.dto.ts
  14. 3 1
      server/src/domain/rule/rule.repository.ts
  15. 81 0
      server/src/domain/rule/rule.service.spec.ts
  16. 64 0
      server/src/domain/rule/rule.service.ts
  17. 1 0
      server/src/domain/system-config/system-config.service.spec.ts
  18. 0 1
      server/src/domain/user/user.core.ts
  19. 0 15
      server/src/immich/controllers/album.controller.ts
  20. 1 0
      server/src/immich/controllers/index.ts
  21. 44 0
      server/src/immich/controllers/rule.controller.ts
  22. 40 8
      server/src/infra/entities/rule.entity.ts
  23. 2 2
      server/src/infra/entities/system-config.entity.ts
  24. 1 0
      server/src/infra/infra.util.ts
  25. 13 1
      server/src/infra/repositories/access.repository.ts
  26. 15 2
      server/src/infra/repositories/rule.repository.ts
  27. 1 0
      server/test/fixtures/index.ts
  28. 15 0
      server/test/fixtures/rule.stub.ts
  29. 2 0
      server/test/fixtures/shared-link.stub.ts
  30. 5 0
      server/test/repositories/access.repository.mock.ts
  31. 1 0
      server/test/repositories/index.ts
  32. 10 0
      server/test/repositories/rule.repository.mock.ts

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 80 - 813
server/immich-openapi-specs.json


+ 17 - 0
server/src/domain/access/access.core.ts

@@ -19,6 +19,11 @@ export enum Permission {
   ALBUM_SHARE = 'album.share',
   ALBUM_DOWNLOAD = 'album.download',
 
+  RULE_READ = 'rule.read',
+  RULE_CREATE = 'rule.create',
+  RULE_UPDATE = 'rule.update',
+  RULE_DELETE = 'rule.delete',
+
   LIBRARY_READ = 'library.read',
   LIBRARY_DOWNLOAD = 'library.download',
 }
@@ -156,6 +161,18 @@ export class AccessCore {
       case Permission.ALBUM_REMOVE_ASSET:
         return this.repository.album.hasOwnerAccess(authUser.id, id);
 
+      case Permission.RULE_CREATE:
+        // id is albumId here
+        return (
+          (await this.repository.album.hasOwnerAccess(authUser.id, id)) ||
+          (await this.repository.album.hasSharedAlbumAccess(authUser.id, id))
+        );
+
+      case Permission.RULE_READ:
+      case Permission.RULE_UPDATE:
+      case Permission.RULE_DELETE:
+        return this.repository.rule.hasOwnerAccess(authUser.id, id);
+
       case Permission.LIBRARY_READ:
         return authUser.id === id || (await this.repository.library.hasPartnerAccess(authUser.id, id));
 

+ 4 - 0
server/src/domain/access/access.repository.ts

@@ -14,6 +14,10 @@ export interface IAccessRepository {
     hasSharedLinkAccess(sharedLinkId: string, albumId: string): Promise<boolean>;
   };
 
+  rule: {
+    hasOwnerAccess(userId: string, ruleId: string): Promise<boolean>;
+  };
+
   library: {
     hasPartnerAccess(userId: string, partnerId: string): Promise<boolean>;
   };

+ 3 - 9
server/src/domain/album/album-response.dto.ts

@@ -2,7 +2,7 @@ import { AlbumEntity } from '@app/infra/entities';
 import { ApiProperty } from '@nestjs/swagger';
 import { AssetResponseDto, mapAsset } from '../asset';
 import { mapUser, UserResponseDto } from '../user';
-import { RuleResponseDto } from './dto/rule.dto';
+import { mapRule, RuleResponseDto } from './dto';
 
 export class AlbumResponseDto {
   id!: string;
@@ -26,13 +26,7 @@ export class AlbumResponseDto {
 }
 
 export const mapAlbum = (entity: AlbumEntity, withAssets: boolean): AlbumResponseDto => {
-  const sharedUsers: UserResponseDto[] = [];
-
-  entity.sharedUsers?.forEach((user) => {
-    const userDto = mapUser(user);
-    sharedUsers.push(userDto);
-  });
-
+  const sharedUsers: UserResponseDto[] = (entity.sharedUsers || []).map(mapUser);
   const assets = entity.assets || [];
 
   const hasSharedLink = entity.sharedLinks?.length > 0;
@@ -54,7 +48,7 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean): AlbumRespons
     endDate: assets.at(-1)?.fileCreatedAt || undefined,
     assets: (withAssets ? assets : []).map((asset) => mapAsset(asset)),
     assetCount: entity.assets?.length || 0,
-    rules: entity.rules?.map((rule) => ({ key: rule.key, value: rule.value, ownerId: rule.ownerId })) || [],
+    rules: (entity.rules || []).map(mapRule),
   };
 };
 

+ 1 - 0
server/src/domain/album/album.service.spec.ts

@@ -185,6 +185,7 @@ describe(AlbumService.name, () => {
         endDate: undefined,
         hasSharedLink: false,
         updatedAt: expect.anything(),
+        rules: [],
       });
 
       expect(jobMock.queue).toHaveBeenCalledWith({

+ 2 - 40
server/src/domain/album/album.service.ts

@@ -1,4 +1,4 @@
-import { AlbumEntity, AssetEntity, RuleEntity, UserEntity } from '@app/infra/entities';
+import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/entities';
 import { BadRequestException, Inject, Injectable } from '@nestjs/common';
 import { AccessCore, IAccessRepository, Permission } from '../access';
 import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto, IAssetRepository } from '../asset';
@@ -13,8 +13,7 @@ import {
   mapAlbumWithoutAssets,
 } from './album-response.dto';
 import { IAlbumRepository } from './album.repository';
-import { AddUsersDto, AlbumInfoDto, CreateAlbumDto, CreateRuleDto, GetAlbumsDto, UpdateAlbumDto } from './dto';
-import { IRuleRepository } from './rule.repository';
+import { AddUsersDto, AlbumInfoDto, CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto } from './dto';
 
 @Injectable()
 export class AlbumService {
@@ -25,7 +24,6 @@ export class AlbumService {
     @Inject(IAssetRepository) private assetRepository: IAssetRepository,
     @Inject(IJobRepository) private jobRepository: IJobRepository,
     @Inject(IUserRepository) private userRepository: IUserRepository,
-    @Inject(IRuleRepository) private ruleRepository: IRuleRepository,
   ) {
     this.access = new AccessCore(accessRepository);
   }
@@ -104,7 +102,6 @@ export class AlbumService {
       sharedUsers: dto.sharedWithUserIds?.map((value) => ({ id: value } as UserEntity)) ?? [],
       assets: (dto.assetIds || []).map((id) => ({ id } as AssetEntity)),
       albumThumbnailAssetId: dto.assetIds?.[0] || null,
-      rules: [],
     });
 
     await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [album.id] } });
@@ -288,39 +285,4 @@ export class AlbumService {
     }
     return album;
   }
-
-  async addRule(authUser: AuthUserDto, albumId: string, dto: CreateRuleDto) {
-    await this.access.requirePermission(authUser, Permission.ALBUM_READ, albumId);
-
-    const album = await this.findOrFail(albumId);
-    const user = await this.userRepository.get(authUser.id);
-
-    if (!user) {
-      throw new BadRequestException('User not found');
-    }
-
-    const rule = new RuleEntity();
-    rule.key = dto.key;
-    rule.value = dto.value;
-    rule.album = album;
-    rule.albumId = album.id;
-    rule.user = user;
-    rule.ownerId = user.id;
-
-    await this.ruleRepository.create(rule);
-
-    return await this.findOrFail(albumId);
-  }
-
-  async removeRule(authUser: AuthUserDto, albumId: string, ruleId: string) {
-    await this.access.requirePermission(authUser, Permission.ALBUM_READ, albumId);
-
-    const album = await this.findOrFail(albumId);
-    const rule = album.rules.find((rule) => rule.id === ruleId);
-    if (!rule) {
-      throw new BadRequestException('Rule not found');
-    }
-
-    await this.ruleRepository.delete(rule);
-  }
 }

+ 1 - 1
server/src/domain/album/dto/index.ts

@@ -3,4 +3,4 @@ export * from './album-create.dto';
 export * from './album-update.dto';
 export * from './album.dto';
 export * from './get-albums.dto';
-export * from './rule.dto';
+export * from '../../rule/rule.dto';

+ 0 - 19
server/src/domain/album/dto/rule.dto.ts

@@ -1,19 +0,0 @@
-import { RuleKey } from '@app/infra/entities';
-import { ApiProperty } from '@nestjs/swagger';
-import { IsNotEmpty } from 'class-validator';
-
-export class CreateRuleDto {
-  @ApiProperty({ enumName: 'RuleKey', enum: RuleKey })
-  @IsNotEmpty()
-  key!: RuleKey;
-
-  @IsNotEmpty()
-  value!: string;
-}
-
-export class RuleResponseDto {
-  @ApiProperty({ enumName: 'RuleKey', enum: RuleKey })
-  key!: RuleKey;
-  value!: string;
-  ownerId!: string;
-}

+ 0 - 1
server/src/domain/album/index.ts

@@ -2,4 +2,3 @@ export * from './album-response.dto';
 export * from './album.repository';
 export * from './album.service';
 export * from './dto';
-export * from './rule.repository';

+ 1 - 0
server/src/domain/index.ts

@@ -15,6 +15,7 @@ export * from './media';
 export * from './metadata';
 export * from './partner';
 export * from './person';
+export * from './rule';
 export * from './search';
 export * from './server-info';
 export * from './shared-link';

+ 3 - 0
server/src/domain/job/job.service.spec.ts

@@ -95,6 +95,7 @@ describe(JobService.name, () => {
         [QueueName.THUMBNAIL_GENERATION]: expectedJobStatus,
         [QueueName.VIDEO_CONVERSION]: expectedJobStatus,
         [QueueName.RECOGNIZE_FACES]: expectedJobStatus,
+        [QueueName.SMART_ALBUM]: expectedJobStatus,
         [QueueName.SIDECAR]: expectedJobStatus,
       });
     });
@@ -224,6 +225,7 @@ describe(JobService.name, () => {
           [QueueName.RECOGNIZE_FACES]: { concurrency: 10 },
           [QueueName.SEARCH]: { concurrency: 10 },
           [QueueName.SIDECAR]: { concurrency: 10 },
+          [QueueName.SMART_ALBUM]: { concurrency: 10 },
           [QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 10 },
           [QueueName.THUMBNAIL_GENERATION]: { concurrency: 10 },
           [QueueName.VIDEO_CONVERSION]: { concurrency: 10 },
@@ -236,6 +238,7 @@ describe(JobService.name, () => {
       expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.OBJECT_TAGGING, 10);
       expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.RECOGNIZE_FACES, 10);
       expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.SIDECAR, 10);
+      expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.SMART_ALBUM, 10);
       expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.STORAGE_TEMPLATE_MIGRATION, 10);
       expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.THUMBNAIL_GENERATION, 10);
       expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.VIDEO_CONVERSION, 10);

+ 3 - 0
server/src/domain/rule/index.ts

@@ -0,0 +1,3 @@
+export * from './rule.dto';
+export * from './rule.repository';
+export * from './rule.service';

+ 44 - 0
server/src/domain/rule/rule.dto.ts

@@ -0,0 +1,44 @@
+import { RuleEntity, RuleKey } from '@app/infra/entities';
+import { ApiProperty } from '@nestjs/swagger';
+import { IsEnum, IsNotEmpty, IsOptional } from 'class-validator';
+import { ValidateUUID } from '../domain.util';
+
+export class CreateRuleDto {
+  @ValidateUUID()
+  albumId!: string;
+
+  @ApiProperty({ enumName: 'RuleKey', enum: RuleKey })
+  @IsEnum(RuleKey)
+  key!: RuleKey;
+
+  @IsNotEmpty()
+  value!: any;
+}
+
+export class UpdateRuleDto {
+  @ApiProperty({ enumName: 'RuleKey', enum: RuleKey })
+  @IsOptional()
+  @IsEnum(RuleKey)
+  key?: RuleKey;
+
+  @IsOptional()
+  @IsNotEmpty()
+  value?: any;
+}
+
+export class RuleResponseDto {
+  id!: string;
+  @ApiProperty({ enumName: 'RuleKey', enum: RuleKey })
+  key!: RuleKey;
+  value!: any;
+  ownerId!: string;
+}
+
+export const mapRule = (rule: RuleEntity): RuleResponseDto => {
+  return {
+    id: rule.id,
+    key: rule.key,
+    value: rule.value,
+    ownerId: rule.ownerId,
+  };
+};

+ 3 - 1
server/src/domain/album/rule.repository.ts → server/src/domain/rule/rule.repository.ts

@@ -3,6 +3,8 @@ import { RuleEntity } from '@app/infra/entities';
 export const IRuleRepository = 'IRuleRepository';
 
 export interface IRuleRepository {
-  create(rule: RuleEntity): Promise<RuleEntity>;
+  get(id: string): Promise<RuleEntity | null>;
+  create(rule: Partial<RuleEntity>): Promise<RuleEntity>;
+  update(rule: Partial<RuleEntity>): Promise<RuleEntity>;
   delete(rule: RuleEntity): Promise<RuleEntity>;
 }

+ 81 - 0
server/src/domain/rule/rule.service.spec.ts

@@ -0,0 +1,81 @@
+import { BadRequestException } from '@nestjs/common';
+import { authStub, IAccessRepositoryMock, newAccessRepositoryMock, newRuleRepositoryMock, ruleStub } from '@test';
+import { RuleKey } from '../../infra/entities/rule.entity';
+import { RuleResponseDto } from './rule.dto';
+import { IRuleRepository } from './rule.repository';
+import { RuleService } from './rule.service';
+
+const responseDto: RuleResponseDto = {
+  id: 'rule-1',
+  key: RuleKey.CITY,
+  value: 'Chandler',
+  ownerId: authStub.admin.id,
+};
+
+describe(RuleService.name, () => {
+  let sut: RuleService;
+  let accessMock: jest.Mocked<IAccessRepositoryMock>;
+  let ruleMock: jest.Mocked<IRuleRepository>;
+
+  beforeEach(async () => {
+    accessMock = newAccessRepositoryMock();
+    ruleMock = newRuleRepositoryMock();
+    sut = new RuleService(accessMock, ruleMock);
+  });
+
+  it('should be defined', () => {
+    expect(sut).toBeDefined();
+  });
+
+  describe('create', () => {
+    it('should require album access', async () => {
+      accessMock.album.hasOwnerAccess.mockResolvedValue(false);
+      accessMock.album.hasSharedAlbumAccess.mockResolvedValue(false);
+      await expect(
+        sut.create(authStub.admin, {
+          albumId: 'not-found-album',
+          key: RuleKey.CITY,
+          value: 'abc',
+        }),
+      ).rejects.toBeInstanceOf(BadRequestException);
+      expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'not-found-album');
+      expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, 'not-found-album');
+    });
+
+    it('should create a rule', async () => {
+      accessMock.album.hasOwnerAccess.mockResolvedValue(true);
+      ruleMock.create.mockResolvedValue(ruleStub.rule1);
+      await expect(
+        sut.create(authStub.admin, {
+          albumId: 'album-123',
+          key: RuleKey.CITY,
+          value: 'abc',
+        }),
+      ).resolves.toEqual(responseDto);
+      expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-123');
+    });
+  });
+
+  describe('get', () => {
+    it('should throw a bad request when the rule is not found', async () => {
+      accessMock.rule.hasOwnerAccess.mockResolvedValue(false);
+      await expect(sut.get(authStub.admin, 'rule-1')).rejects.toBeInstanceOf(BadRequestException);
+    });
+
+    it('should get a rule by id', async () => {
+      accessMock.rule.hasOwnerAccess.mockResolvedValue(true);
+      ruleMock.get.mockResolvedValue(ruleStub.rule1);
+      await expect(sut.get(authStub.admin, 'rule-1')).resolves.toEqual(responseDto);
+      expect(ruleMock.get).toHaveBeenCalledWith('rule-1');
+    });
+  });
+
+  describe('update', () => {
+    it('should throw a bad request when the rule is not found', async () => {
+      accessMock.rule.hasOwnerAccess.mockResolvedValue(false);
+      await expect(sut.update(authStub.admin, 'rule-1', { value: 'Atlanta' })).rejects.toBeInstanceOf(
+        BadRequestException,
+      );
+    });
+  });
+});

+ 64 - 0
server/src/domain/rule/rule.service.ts

@@ -0,0 +1,64 @@
+import { BadRequestException, Inject, Injectable } from '@nestjs/common';
+import { AccessCore, IAccessRepository, Permission } from '../access';
+import { AuthUserDto } from '../auth';
+import { CreateRuleDto, mapRule, UpdateRuleDto } from './rule.dto';
+import { IRuleRepository } from './rule.repository';
+
+@Injectable()
+export class RuleService {
+  private access: AccessCore;
+
+  constructor(
+    @Inject(IAccessRepository) accessRepository: IAccessRepository,
+    @Inject(IRuleRepository) private repository: IRuleRepository,
+  ) {
+    this.access = new AccessCore(accessRepository);
+  }
+
+  async get(authUser: AuthUserDto, id: string) {
+    await this.access.requirePermission(authUser, Permission.RULE_READ, id);
+    const rule = await this.findOrFail(id);
+    return mapRule(rule);
+  }
+
+  async create(authUser: AuthUserDto, dto: CreateRuleDto) {
+    await this.access.requirePermission(authUser, Permission.RULE_CREATE, dto.albumId);
+
+    // TODO additional validation on key and value
+
+    const rule = await this.repository.create({
+      key: dto.key,
+      value: dto.value,
+      albumId: dto.albumId,
+      ownerId: authUser.id,
+    });
+    return mapRule(rule);
+  }
+
+  async update(authUser: AuthUserDto, id: string, dto: UpdateRuleDto) {
+    await this.access.requirePermission(authUser, Permission.RULE_UPDATE, id);
+
+    // TODO additional validation on key and value
+
+    const rule = await this.repository.update({
+      id,
+      key: dto.key,
+      value: dto.value,
+    });
+    return mapRule(rule);
+  }
+
+  async remove(authUser: AuthUserDto, id: string) {
+    await this.access.requirePermission(authUser, Permission.RULE_DELETE, id);
+    const rule = await this.findOrFail(id);
+    await this.repository.delete(rule);
+  }
+
+  private async findOrFail(id: string) {
+    const rule = await this.repository.get(id);
+    if (!rule) {
+      throw new BadRequestException('Rule not found');
+    }
+    return rule;
+  }
+}

+ 1 - 0
server/src/domain/system-config/system-config.service.spec.ts

@@ -29,6 +29,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
     [QueueName.RECOGNIZE_FACES]: { concurrency: 2 },
     [QueueName.SEARCH]: { concurrency: 5 },
     [QueueName.SIDECAR]: { concurrency: 5 },
+    [QueueName.SMART_ALBUM]: { concurrency: 1 },
     [QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 5 },
     [QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
     [QueueName.VIDEO_CONVERSION]: { concurrency: 1 },

+ 0 - 1
server/src/domain/user/user.core.ts

@@ -60,7 +60,6 @@ export class UserCore {
         dto.externalPath = null;
       }
 
-      console.log(dto.memoriesEnabled);
       return this.userRepository.update(id, dto);
     } catch (e) {
       Logger.error(e, 'Failed to update user info');

+ 0 - 15
server/src/immich/controllers/album.controller.ts

@@ -8,7 +8,6 @@ import {
   BulkIdResponseDto,
   BulkIdsDto,
   CreateAlbumDto as CreateDto,
-  CreateRuleDto,
   GetAlbumsDto,
   UpdateAlbumDto as UpdateDto,
 } from '@app/domain';
@@ -93,18 +92,4 @@ export class AlbumController {
   ) {
     return this.service.removeUser(authUser, id, userId);
   }
-
-  @Post(':id/rule')
-  createRule(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: CreateRuleDto) {
-    return this.service.addRule(authUser, id, dto);
-  }
-
-  @Delete(':id/rule/:ruleId')
-  removeRule(
-    @AuthUser() authUser: AuthUserDto,
-    @Param() { id }: UUIDParamDto,
-    @Param('ruleId', new ParseMeUUIDPipe({ version: '4' })) ruleId: string,
-  ) {
-    return this.service.removeRule(authUser, id, ruleId);
-  }
 }

+ 1 - 0
server/src/immich/controllers/index.ts

@@ -7,6 +7,7 @@ export * from './job.controller';
 export * from './oauth.controller';
 export * from './partner.controller';
 export * from './person.controller';
+export * from './rule.controller';
 export * from './search.controller';
 export * from './server-info.controller';
 export * from './shared-link.controller';

+ 44 - 0
server/src/immich/controllers/rule.controller.ts

@@ -0,0 +1,44 @@
+import {
+  AuthUserDto,
+  CreateRuleDto as CreateDto,
+  RuleResponseDto,
+  RuleService,
+  UpdateRuleDto as UpdateDto,
+} from '@app/domain';
+import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common';
+import { ApiTags } from '@nestjs/swagger';
+import { Authenticated, AuthUser } from '../app.guard';
+import { UseValidation } from '../app.utils';
+import { UUIDParamDto } from './dto/uuid-param.dto';
+
+@ApiTags('Rule')
+@Controller('rule')
+@Authenticated()
+@UseValidation()
+export class RuleController {
+  constructor(private service: RuleService) {}
+
+  @Post()
+  createRule(@AuthUser() authUser: AuthUserDto, @Body() dto: CreateDto): Promise<RuleResponseDto> {
+    return this.service.create(authUser, dto);
+  }
+
+  @Get(':id')
+  getRule(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<RuleResponseDto> {
+    return this.service.get(authUser, id);
+  }
+
+  @Put(':id')
+  updateRule(
+    @AuthUser() authUser: AuthUserDto,
+    @Param() { id }: UUIDParamDto,
+    @Body() dto: UpdateDto,
+  ): Promise<RuleResponseDto> {
+    return this.service.update(authUser, id, dto);
+  }
+
+  @Delete(':id')
+  removeRule(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
+    return this.service.remove(authUser, id);
+  }
+}

+ 40 - 8
server/src/infra/entities/rule.entity.ts

@@ -1,23 +1,24 @@
-import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
+import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
+import { JSON_TRANSFORMER } from '../infra.util';
 import { AlbumEntity } from './album.entity';
 import { UserEntity } from './user.entity';
 
 @Entity('rules')
-export class RuleEntity {
+export class RuleEntity<T = RuleValue> {
   @PrimaryGeneratedColumn('uuid')
   id!: string;
 
   @Column()
   key!: RuleKey;
 
-  @Column()
-  value!: string;
+  @Column({ type: 'varchar', transformer: JSON_TRANSFORMER })
+  value!: T;
 
   @Column()
   ownerId!: string;
 
   @ManyToOne(() => UserEntity)
-  user!: UserEntity;
+  owner!: UserEntity;
 
   @Column()
   albumId!: string;
@@ -27,7 +28,38 @@ export class RuleEntity {
 }
 
 export enum RuleKey {
-  PERSON = 'personId',
-  EXIF_CITY = 'exifInfo.city',
-  DATE_AFTER = 'asset.fileCreatedAt',
+  PERSON = 'person',
+  TAKEN_AFTER = 'taken-after',
+  CITY = 'city',
+  STATE = 'state',
+  COUNTRY = 'country',
+  MAKE = 'make',
+  MODEL = 'model',
+  LOCATION = 'location',
+}
+
+export type RuleValue = string | Date | RuleGeoValue;
+
+export enum RuleValueType {
+  UUID = 'uuid',
+  STRING = 'string',
+  DATE = 'date',
+  GEO = 'geo',
 }
+
+export interface RuleGeoValue {
+  lat: number;
+  long: number;
+  radius: number;
+}
+
+export const RULE_TO_TYPE: Record<RuleKey, RuleValueType> = {
+  [RuleKey.PERSON]: RuleValueType.UUID,
+  [RuleKey.TAKEN_AFTER]: RuleValueType.DATE,
+  [RuleKey.CITY]: RuleValueType.STRING,
+  [RuleKey.STATE]: RuleValueType.STRING,
+  [RuleKey.COUNTRY]: RuleValueType.STRING,
+  [RuleKey.MAKE]: RuleValueType.STRING,
+  [RuleKey.MODEL]: RuleValueType.STRING,
+  [RuleKey.LOCATION]: RuleValueType.GEO,
+};

+ 2 - 2
server/src/infra/entities/system-config.entity.ts

@@ -1,13 +1,13 @@
 import { QueueName } from '@app/domain/job/job.constants';
 import { Column, Entity, PrimaryColumn } from 'typeorm';
+import { JSON_TRANSFORMER } from '../infra.util';
 
 @Entity('system_config')
 export class SystemConfigEntity<T = SystemConfigValue> {
   @PrimaryColumn()
   key!: SystemConfigKey;
 
-  @Column({ type: 'varchar', nullable: true, transformer: { to: JSON.stringify, from: JSON.parse } })
-  value!: T;
+  @Column({ type: 'varchar', nullable: true, transformer: JSON_TRANSFORMER }) value!: T;
 }
 
 export type SystemConfigValue = string | number | boolean;

+ 1 - 0
server/src/infra/infra.util.ts

@@ -0,0 +1 @@
+export const JSON_TRANSFORMER = { to: JSON.stringify, from: JSON.parse };

+ 13 - 1
server/src/infra/repositories/access.repository.ts

@@ -1,13 +1,14 @@
 import { IAccessRepository } from '@app/domain';
 import { InjectRepository } from '@nestjs/typeorm';
 import { Repository } from 'typeorm';
-import { AlbumEntity, AssetEntity, PartnerEntity, SharedLinkEntity } from '../entities';
+import { AlbumEntity, AssetEntity, PartnerEntity, RuleEntity, SharedLinkEntity } from '../entities';
 
 export class AccessRepository implements IAccessRepository {
   constructor(
     @InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
     @InjectRepository(AlbumEntity) private albumRepository: Repository<AlbumEntity>,
     @InjectRepository(PartnerEntity) private partnerRepository: Repository<PartnerEntity>,
+    @InjectRepository(RuleEntity) private ruleRepository: Repository<RuleEntity>,
     @InjectRepository(SharedLinkEntity) private sharedLinkRepository: Repository<SharedLinkEntity>,
   ) {}
 
@@ -156,4 +157,15 @@ export class AccessRepository implements IAccessRepository {
       });
     },
   };
+
+  rule = {
+    hasOwnerAccess: (userId: string, ruleId: string): Promise<boolean> => {
+      return this.ruleRepository.exist({
+        where: {
+          id: ruleId,
+          ownerId: userId,
+        },
+      });
+    },
+  };
 }

+ 15 - 2
server/src/infra/repositories/rule.repository.ts

@@ -6,11 +6,24 @@ import { RuleEntity } from '../entities';
 export class RuleRepository implements IRuleRepository {
   constructor(@InjectRepository(RuleEntity) private repository: Repository<RuleEntity>) {}
 
-  create(rule: RuleEntity): Promise<RuleEntity> {
-    return this.repository.save(rule);
+  get(id: string): Promise<RuleEntity | null> {
+    return this.repository.findOne({ where: { id } });
+  }
+
+  create(rule: Partial<RuleEntity>): Promise<RuleEntity> {
+    return this.save(rule);
+  }
+
+  update(rule: Partial<RuleEntity>): Promise<RuleEntity> {
+    return this.save(rule);
   }
 
   delete(rule: RuleEntity): Promise<RuleEntity> {
     return this.repository.remove(rule);
   }
+
+  private async save(rule: Partial<RuleEntity>): Promise<RuleEntity> {
+    await this.repository.save(rule);
+    return this.repository.findOneOrFail({ where: { id: rule.id } });
+  }
 }

+ 1 - 0
server/test/fixtures/index.ts

@@ -9,6 +9,7 @@ export * from './file.stub';
 export * from './media.stub';
 export * from './partner.stub';
 export * from './person.stub';
+export * from './rule.stub';
 export * from './search.stub';
 export * from './shared-link.stub';
 export * from './system-config.stub';

+ 15 - 0
server/test/fixtures/rule.stub.ts

@@ -0,0 +1,15 @@
+import { RuleEntity, RuleKey } from '@app/infra/entities';
+import { albumStub } from './album.stub';
+import { userStub } from './user.stub';
+
+export const ruleStub = {
+  rule1: Object.freeze<RuleEntity>({
+    id: 'rule-1',
+    key: RuleKey.CITY,
+    value: 'Chandler',
+    owner: userStub.admin,
+    ownerId: userStub.admin.id,
+    album: albumStub.empty,
+    albumId: albumStub.empty.id,
+  }),
+};

+ 2 - 0
server/test/fixtures/shared-link.stub.ts

@@ -80,6 +80,7 @@ const albumResponse: AlbumResponseDto = {
   hasSharedLink: false,
   assets: [],
   assetCount: 1,
+  rules: [],
 };
 
 export const sharedLinkStub = {
@@ -222,6 +223,7 @@ export const sharedLinkStub = {
           sidecarPath: null,
         },
       ],
+      rules: [],
     },
   }),
 };

+ 5 - 0
server/test/repositories/access.repository.mock.ts

@@ -4,6 +4,7 @@ export type IAccessRepositoryMock = {
   asset: jest.Mocked<IAccessRepository['asset']>;
   album: jest.Mocked<IAccessRepository['album']>;
   library: jest.Mocked<IAccessRepository['library']>;
+  rule: jest.Mocked<IAccessRepository['rule']>;
 };
 
 export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
@@ -24,5 +25,9 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
     library: {
       hasPartnerAccess: jest.fn(),
     },
+
+    rule: {
+      hasOwnerAccess: jest.fn(),
+    },
   };
 };

+ 1 - 0
server/test/repositories/index.ts

@@ -10,6 +10,7 @@ export * from './machine-learning.repository.mock';
 export * from './media.repository.mock';
 export * from './partner.repository.mock';
 export * from './person.repository.mock';
+export * from './rule.repository.mock';
 export * from './search.repository.mock';
 export * from './shared-link.repository.mock';
 export * from './smart-info.repository.mock';

+ 10 - 0
server/test/repositories/rule.repository.mock.ts

@@ -0,0 +1,10 @@
+import { IRuleRepository } from '@app/domain';
+
+export const newRuleRepositoryMock = (): jest.Mocked<IRuleRepository> => {
+  return {
+    get: jest.fn(),
+    create: jest.fn(),
+    update: jest.fn(),
+    delete: jest.fn(),
+  };
+};

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott