refactor: rule controller

This commit is contained in:
Jason Rasmussen
2023-08-11 21:57:55 -04:00
parent a37adc0c96
commit 6e2624da7c
32 changed files with 391 additions and 976 deletions
+13 -876
View File
@@ -384,101 +384,6 @@
]
}
},
"/album/{id}/rule": {
"post": {
"operationId": "createRule",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateRuleDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AlbumEntity"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Album"
]
}
},
"/album/{id}/rule/{ruleId}": {
"delete": {
"operationId": "removeRule",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "ruleId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Album"
]
}
},
"/album/{id}/user/{userId}": {
"delete": {
"operationId": "removeUserFromAlbum",
@@ -4835,85 +4740,6 @@
],
"type": "object"
},
"AlbumEntity": {
"properties": {
"albumName": {
"type": "string"
},
"albumThumbnailAsset": {
"allOf": [
{
"$ref": "#/components/schemas/AssetEntity"
}
],
"nullable": true
},
"albumThumbnailAssetId": {
"nullable": true,
"type": "string"
},
"assets": {
"items": {
"$ref": "#/components/schemas/AssetEntity"
},
"type": "array"
},
"createdAt": {
"format": "date-time",
"type": "string"
},
"description": {
"type": "string"
},
"id": {
"type": "string"
},
"owner": {
"$ref": "#/components/schemas/UserEntity"
},
"ownerId": {
"type": "string"
},
"rules": {
"items": {
"$ref": "#/components/schemas/RuleEntity"
},
"type": "array"
},
"sharedLinks": {
"items": {
"$ref": "#/components/schemas/SharedLinkEntity"
},
"type": "array"
},
"sharedUsers": {
"items": {
"$ref": "#/components/schemas/UserEntity"
},
"type": "array"
},
"updatedAt": {
"format": "date-time",
"type": "string"
}
},
"required": [
"id",
"owner",
"ownerId",
"albumName",
"description",
"createdAt",
"updatedAt",
"albumThumbnailAsset",
"albumThumbnailAssetId",
"sharedUsers",
"assets",
"sharedLinks",
"rules"
],
"type": "object"
},
"AlbumResponseDto": {
"properties": {
"albumName": {
@@ -5125,223 +4951,6 @@
],
"type": "object"
},
"AssetEntity": {
"properties": {
"albums": {
"items": {
"$ref": "#/components/schemas/AlbumEntity"
},
"type": "array"
},
"checksum": {
"type": "object"
},
"createdAt": {
"format": "date-time",
"type": "string"
},
"deviceAssetId": {
"type": "string"
},
"deviceId": {
"type": "string"
},
"duration": {
"nullable": true,
"type": "string"
},
"encodedVideoPath": {
"nullable": true,
"type": "string"
},
"exifInfo": {
"$ref": "#/components/schemas/ExifEntity"
},
"faces": {
"items": {
"$ref": "#/components/schemas/AssetFaceEntity"
},
"type": "array"
},
"fileCreatedAt": {
"format": "date-time",
"type": "string"
},
"fileModifiedAt": {
"format": "date-time",
"type": "string"
},
"id": {
"type": "string"
},
"isArchived": {
"type": "boolean"
},
"isFavorite": {
"type": "boolean"
},
"isReadOnly": {
"type": "boolean"
},
"isVisible": {
"type": "boolean"
},
"livePhotoVideo": {
"allOf": [
{
"$ref": "#/components/schemas/AssetEntity"
}
],
"nullable": true
},
"livePhotoVideoId": {
"nullable": true,
"type": "string"
},
"originalFileName": {
"type": "string"
},
"originalPath": {
"type": "string"
},
"owner": {
"$ref": "#/components/schemas/UserEntity"
},
"ownerId": {
"type": "string"
},
"resizePath": {
"nullable": true,
"type": "string"
},
"sharedLinks": {
"items": {
"$ref": "#/components/schemas/SharedLinkEntity"
},
"type": "array"
},
"sidecarPath": {
"nullable": true,
"type": "string"
},
"smartInfo": {
"$ref": "#/components/schemas/SmartInfoEntity"
},
"tags": {
"items": {
"$ref": "#/components/schemas/TagEntity"
},
"type": "array"
},
"thumbhash": {
"nullable": true,
"type": "object"
},
"type": {
"enum": [
"IMAGE",
"VIDEO",
"AUDIO",
"OTHER"
],
"type": "string"
},
"updatedAt": {
"format": "date-time",
"type": "string"
},
"webpPath": {
"nullable": true,
"type": "string"
}
},
"required": [
"id",
"deviceAssetId",
"owner",
"ownerId",
"deviceId",
"type",
"originalPath",
"resizePath",
"webpPath",
"thumbhash",
"encodedVideoPath",
"createdAt",
"updatedAt",
"fileCreatedAt",
"fileModifiedAt",
"isFavorite",
"isArchived",
"isReadOnly",
"checksum",
"duration",
"isVisible",
"livePhotoVideo",
"livePhotoVideoId",
"originalFileName",
"sidecarPath",
"tags",
"sharedLinks",
"faces"
],
"type": "object"
},
"AssetFaceEntity": {
"properties": {
"asset": {
"$ref": "#/components/schemas/AssetEntity"
},
"assetId": {
"type": "string"
},
"boundingBoxX1": {
"type": "number"
},
"boundingBoxX2": {
"type": "number"
},
"boundingBoxY1": {
"type": "number"
},
"boundingBoxY2": {
"type": "number"
},
"embedding": {
"items": {
"type": "number"
},
"nullable": true,
"type": "array"
},
"imageHeight": {
"type": "number"
},
"imageWidth": {
"type": "number"
},
"person": {
"$ref": "#/components/schemas/PersonEntity"
},
"personId": {
"type": "string"
}
},
"required": [
"assetId",
"personId",
"embedding",
"imageWidth",
"imageHeight",
"boundingBoxX1",
"boundingBoxY1",
"boundingBoxX2",
"boundingBoxY2",
"asset",
"person"
],
"type": "object"
},
"AssetFileUploadResponseDto": {
"properties": {
"duplicate": {
@@ -5788,21 +5397,6 @@
],
"type": "object"
},
"CreateRuleDto": {
"properties": {
"key": {
"$ref": "#/components/schemas/RuleKey"
},
"value": {
"type": "string"
}
},
"required": [
"key",
"value"
],
"type": "object"
},
"CreateTagDto": {
"properties": {
"name": {
@@ -5984,142 +5578,6 @@
],
"type": "object"
},
"ExifEntity": {
"properties": {
"asset": {
"$ref": "#/components/schemas/AssetEntity"
},
"assetId": {
"type": "string"
},
"city": {
"nullable": true,
"type": "string"
},
"country": {
"nullable": true,
"type": "string"
},
"dateTimeOriginal": {
"format": "date-time",
"nullable": true,
"type": "string"
},
"description": {
"description": "General info",
"type": "string"
},
"exifImageHeight": {
"nullable": true,
"type": "number"
},
"exifImageWidth": {
"nullable": true,
"type": "number"
},
"exifTextSearchableColumn": {
"type": "string"
},
"exposureTime": {
"nullable": true,
"type": "string"
},
"fNumber": {
"nullable": true,
"type": "number"
},
"fileSizeInByte": {
"nullable": true,
"type": "number"
},
"focalLength": {
"nullable": true,
"type": "number"
},
"fps": {
"description": "Video info",
"nullable": true,
"type": "number"
},
"iso": {
"nullable": true,
"type": "number"
},
"latitude": {
"nullable": true,
"type": "number"
},
"lensModel": {
"nullable": true,
"type": "string"
},
"livePhotoCID": {
"nullable": true,
"type": "string"
},
"longitude": {
"nullable": true,
"type": "number"
},
"make": {
"description": "Image info",
"nullable": true,
"type": "string"
},
"model": {
"nullable": true,
"type": "string"
},
"modifyDate": {
"format": "date-time",
"nullable": true,
"type": "string"
},
"orientation": {
"nullable": true,
"type": "string"
},
"projectionType": {
"nullable": true,
"type": "string"
},
"state": {
"nullable": true,
"type": "string"
},
"timeZone": {
"nullable": true,
"type": "string"
}
},
"required": [
"assetId",
"description",
"exifImageWidth",
"exifImageHeight",
"fileSizeInByte",
"orientation",
"dateTimeOriginal",
"modifyDate",
"timeZone",
"latitude",
"longitude",
"projectionType",
"city",
"livePhotoCID",
"state",
"country",
"make",
"model",
"lensModel",
"fNumber",
"focalLength",
"iso",
"exposureTime",
"exifTextSearchableColumn"
],
"type": "object"
},
"ExifResponseDto": {
"properties": {
"city": {
@@ -6618,54 +6076,6 @@
],
"type": "object"
},
"PersonEntity": {
"properties": {
"createdAt": {
"format": "date-time",
"type": "string"
},
"faces": {
"items": {
"$ref": "#/components/schemas/AssetFaceEntity"
},
"type": "array"
},
"id": {
"type": "string"
},
"isHidden": {
"type": "boolean"
},
"name": {
"type": "string"
},
"owner": {
"$ref": "#/components/schemas/UserEntity"
},
"ownerId": {
"type": "string"
},
"thumbnailPath": {
"type": "string"
},
"updatedAt": {
"format": "date-time",
"type": "string"
}
},
"required": [
"id",
"createdAt",
"updatedAt",
"ownerId",
"owner",
"name",
"thumbnailPath",
"faces",
"isHidden"
],
"type": "object"
},
"PersonResponseDto": {
"properties": {
"id": {
@@ -6721,56 +6131,24 @@
],
"type": "object"
},
"RuleEntity": {
"properties": {
"album": {
"$ref": "#/components/schemas/AlbumEntity"
},
"albumId": {
"type": "string"
},
"id": {
"type": "string"
},
"key": {
"enum": [
"personId",
"exifInfo.city",
"asset.fileCreatedAt"
],
"type": "string"
},
"ownerId": {
"type": "string"
},
"user": {
"$ref": "#/components/schemas/UserEntity"
},
"value": {
"type": "string"
}
},
"required": [
"id",
"key",
"value",
"ownerId",
"user",
"albumId",
"album"
],
"type": "object"
},
"RuleKey": {
"enum": [
"personId",
"exifInfo.city",
"asset.fileCreatedAt"
"person",
"taken-after",
"city",
"state",
"country",
"make",
"model",
"location"
],
"type": "string"
},
"RuleResponseDto": {
"properties": {
"id": {
"type": "string"
},
"key": {
"$ref": "#/components/schemas/RuleKey"
},
@@ -6778,11 +6156,12 @@
"type": "string"
},
"value": {
"type": "string"
"type": "object"
}
},
"required": [
"key",
"id",
"value",
"ownerId"
],
@@ -7152,80 +6531,6 @@
},
"type": "object"
},
"SharedLinkEntity": {
"properties": {
"album": {
"$ref": "#/components/schemas/AlbumEntity"
},
"albumId": {
"nullable": true,
"type": "string"
},
"allowDownload": {
"type": "boolean"
},
"allowUpload": {
"type": "boolean"
},
"assets": {
"items": {
"$ref": "#/components/schemas/AssetEntity"
},
"type": "array"
},
"createdAt": {
"format": "date-time",
"type": "string"
},
"description": {
"nullable": true,
"type": "string"
},
"expiresAt": {
"format": "date-time",
"nullable": true,
"type": "string"
},
"id": {
"type": "string"
},
"key": {
"type": "object"
},
"showExif": {
"type": "boolean"
},
"type": {
"enum": [
"ALBUM",
"INDIVIDUAL"
],
"type": "string"
},
"user": {
"$ref": "#/components/schemas/UserEntity"
},
"userId": {
"type": "string"
}
},
"required": [
"id",
"description",
"userId",
"user",
"key",
"type",
"createdAt",
"expiresAt",
"allowUpload",
"allowDownload",
"showExif",
"assets",
"albumId"
],
"type": "object"
},
"SharedLinkResponseDto": {
"properties": {
"album": {
@@ -7321,44 +6626,6 @@
],
"type": "object"
},
"SmartInfoEntity": {
"properties": {
"asset": {
"$ref": "#/components/schemas/AssetEntity"
},
"assetId": {
"type": "string"
},
"clipEmbedding": {
"items": {
"type": "number"
},
"nullable": true,
"type": "array"
},
"objects": {
"items": {
"type": "string"
},
"nullable": true,
"type": "array"
},
"tags": {
"items": {
"type": "string"
},
"nullable": true,
"type": "array"
}
},
"required": [
"assetId",
"tags",
"objects",
"clipEmbedding"
],
"type": "object"
},
"SmartInfoResponseDto": {
"properties": {
"objects": {
@@ -7655,50 +6922,6 @@
],
"type": "object"
},
"TagEntity": {
"properties": {
"assets": {
"items": {
"$ref": "#/components/schemas/AssetEntity"
},
"type": "array"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"renameTagId": {
"nullable": true,
"type": "string"
},
"type": {
"enum": [
"OBJECT",
"FACE",
"CUSTOM"
],
"type": "string"
},
"user": {
"$ref": "#/components/schemas/UserEntity"
},
"userId": {
"type": "string"
}
},
"required": [
"id",
"type",
"name",
"user",
"userId",
"renameTagId",
"assets"
],
"type": "object"
},
"TagResponseDto": {
"properties": {
"id": {
@@ -7917,92 +7140,6 @@
],
"type": "object"
},
"UserEntity": {
"properties": {
"assets": {
"items": {
"$ref": "#/components/schemas/AssetEntity"
},
"type": "array"
},
"createdAt": {
"format": "date-time",
"type": "string"
},
"deletedAt": {
"format": "date-time",
"nullable": true,
"type": "string"
},
"email": {
"type": "string"
},
"externalPath": {
"nullable": true,
"type": "string"
},
"firstName": {
"type": "string"
},
"id": {
"type": "string"
},
"isAdmin": {
"type": "boolean"
},
"lastName": {
"type": "string"
},
"memoriesEnabled": {
"type": "boolean"
},
"oauthId": {
"type": "string"
},
"password": {
"type": "string"
},
"profileImagePath": {
"type": "string"
},
"shouldChangePassword": {
"type": "boolean"
},
"storageLabel": {
"nullable": true,
"type": "string"
},
"tags": {
"items": {
"$ref": "#/components/schemas/TagEntity"
},
"type": "array"
},
"updatedAt": {
"format": "date-time",
"type": "string"
}
},
"required": [
"id",
"firstName",
"lastName",
"isAdmin",
"email",
"storageLabel",
"externalPath",
"oauthId",
"profileImagePath",
"shouldChangePassword",
"createdAt",
"deletedAt",
"updatedAt",
"memoriesEnabled",
"tags",
"assets"
],
"type": "object"
},
"UserResponseDto": {
"properties": {
"createdAt": {
+17
View File
@@ -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));
@@ -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>;
};
@@ -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),
};
};
@@ -185,6 +185,7 @@ describe(AlbumService.name, () => {
endDate: undefined,
hasSharedLink: false,
updatedAt: expect.anything(),
rules: [],
});
expect(jobMock.queue).toHaveBeenCalledWith({
+2 -40
View File
@@ -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
View File
@@ -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';
-19
View File
@@ -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;
}
-1
View File
@@ -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
View File
@@ -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';
@@ -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
View File
@@ -0,0 +1,3 @@
export * from './rule.dto';
export * from './rule.repository';
export * from './rule.service';
+44
View File
@@ -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,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>;
}
@@ -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
View File
@@ -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;
}
}
@@ -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 },
-1
View File
@@ -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');
@@ -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
View File
@@ -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';
@@ -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
View File
@@ -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,
};
@@ -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
View File
@@ -0,0 +1 @@
export const JSON_TRANSFORMER = { to: JSON.stringify, from: JSON.parse };
@@ -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,
},
});
},
};
}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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: [],
},
}),
};
@@ -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
View File
@@ -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';
@@ -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(),
};
};