mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
refactor(server): tags (#2589)
* refactor: tags * chore: open api * chore: unused import * feat: add/remove/get tag assets * chore: open api * chore: finish tag tests for add/remove assets
This commit is contained in:
6
server/libs/domain/src/asset/dto/asset-ids.dto.ts
Normal file
6
server/libs/domain/src/asset/dto/asset-ids.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ValidateUUID } from '../../../../../apps/immich/src/decorators/validate-uuid.decorator';
|
||||
|
||||
export class AssetIdsDto {
|
||||
@ValidateUUID({ each: true })
|
||||
assetIds!: string[];
|
||||
}
|
||||
2
server/libs/domain/src/asset/dto/index.ts
Normal file
2
server/libs/domain/src/asset/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './asset-ids.dto';
|
||||
export * from './map-marker.dto';
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { toBoolean } from 'apps/immich/src/utils/transform.util';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsBoolean, IsDate, IsOptional } from 'class-validator';
|
||||
import { toBoolean } from '../../../../../apps/immich/src/utils/transform.util';
|
||||
|
||||
export class MapMarkerDto {
|
||||
@ApiProperty()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './asset.repository';
|
||||
export * from './asset.service';
|
||||
export * from './dto';
|
||||
export * from './response-dto';
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export enum AssetIdErrorReason {
|
||||
DUPLICATE = 'duplicate',
|
||||
NO_PERMISSION = 'no_permission',
|
||||
NOT_FOUND = 'not_found',
|
||||
}
|
||||
|
||||
export class AssetIdsResponseDto {
|
||||
assetId!: string;
|
||||
success!: boolean;
|
||||
error?: AssetIdErrorReason;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './asset-ids-response.dto';
|
||||
export * from './asset-response.dto';
|
||||
export * from './exif-response.dto';
|
||||
export * from './smart-info-response.dto';
|
||||
export * from './map-marker-response.dto';
|
||||
export * from './smart-info-response.dto';
|
||||
|
||||
@@ -17,6 +17,7 @@ import { SmartInfoService } from './smart-info';
|
||||
import { StorageService } from './storage';
|
||||
import { StorageTemplateService } from './storage-template';
|
||||
import { INITIAL_SYSTEM_CONFIG, SystemConfigService } from './system-config';
|
||||
import { TagService } from './tag';
|
||||
import { UserService } from './user';
|
||||
|
||||
const providers: Provider[] = [
|
||||
@@ -38,6 +39,7 @@ const providers: Provider[] = [
|
||||
StorageService,
|
||||
StorageTemplateService,
|
||||
SystemConfigService,
|
||||
TagService,
|
||||
UserService,
|
||||
{
|
||||
provide: INITIAL_SYSTEM_CONFIG,
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
export * from './response-dto';
|
||||
export * from './tag-response.dto';
|
||||
export * from './tag.dto';
|
||||
export * from './tag.repository';
|
||||
export * from './tag.service';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './tag-response.dto';
|
||||
@@ -2,17 +2,11 @@ import { TagEntity, TagType } from '@app/infra/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class TagResponseDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ enumName: 'TagTypeEnum', enum: TagType })
|
||||
type!: string;
|
||||
|
||||
name!: string;
|
||||
|
||||
userId!: string;
|
||||
|
||||
renameTagId?: string | null;
|
||||
}
|
||||
|
||||
export function mapTag(entity: TagEntity): TagResponseDto {
|
||||
@@ -21,6 +15,5 @@ export function mapTag(entity: TagEntity): TagResponseDto {
|
||||
type: entity.type,
|
||||
name: entity.name,
|
||||
userId: entity.userId,
|
||||
renameTagId: entity.renameTagId,
|
||||
};
|
||||
}
|
||||
20
server/libs/domain/src/tag/tag.dto.ts
Normal file
20
server/libs/domain/src/tag/tag.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { TagType } from '@app/infra/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateTagDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
|
||||
@IsEnum(TagType)
|
||||
@IsNotEmpty()
|
||||
@ApiProperty({ enumName: 'TagTypeEnum', enum: TagType })
|
||||
type!: TagType;
|
||||
}
|
||||
|
||||
export class UpdateTagDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
}
|
||||
16
server/libs/domain/src/tag/tag.repository.ts
Normal file
16
server/libs/domain/src/tag/tag.repository.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { AssetEntity, TagEntity } from '@app/infra/entities';
|
||||
|
||||
export const ITagRepository = 'ITagRepository';
|
||||
|
||||
export interface ITagRepository {
|
||||
getById(userId: string, tagId: string): Promise<TagEntity | null>;
|
||||
getAll(userId: string): Promise<TagEntity[]>;
|
||||
create(tag: Partial<TagEntity>): Promise<TagEntity>;
|
||||
update(tag: Partial<TagEntity>): Promise<TagEntity>;
|
||||
remove(tag: TagEntity): Promise<void>;
|
||||
hasName(userId: string, name: string): Promise<boolean>;
|
||||
hasAsset(userId: string, tagId: string, assetId: string): Promise<boolean>;
|
||||
getAssets(userId: string, tagId: string): Promise<AssetEntity[]>;
|
||||
addAssets(userId: string, tagId: string, assetIds: string[]): Promise<void>;
|
||||
removeAssets(userId: string, tagId: string, assetIds: string[]): Promise<void>;
|
||||
}
|
||||
178
server/libs/domain/src/tag/tag.service.spec.ts
Normal file
178
server/libs/domain/src/tag/tag.service.spec.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { TagType } from '@app/infra/entities';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { when } from 'jest-when';
|
||||
import { assetEntityStub, authStub, newTagRepositoryMock, tagResponseStub, tagStub } from '../../test';
|
||||
import { AssetIdErrorReason } from '../asset';
|
||||
import { ITagRepository } from './tag.repository';
|
||||
import { TagService } from './tag.service';
|
||||
|
||||
describe(TagService.name, () => {
|
||||
let sut: TagService;
|
||||
let tagMock: jest.Mocked<ITagRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
tagMock = newTagRepositoryMock();
|
||||
sut = new TagService(tagMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should return all tags for a user', async () => {
|
||||
tagMock.getAll.mockResolvedValue([tagStub.tag1]);
|
||||
await expect(sut.getAll(authStub.admin)).resolves.toEqual([tagResponseStub.tag1]);
|
||||
expect(tagMock.getAll).toHaveBeenCalledWith(authStub.admin.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('should throw an error for an invalid id', async () => {
|
||||
tagMock.getById.mockResolvedValue(null);
|
||||
await expect(sut.getById(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.id, 'tag-1');
|
||||
});
|
||||
|
||||
it('should return a tag for a user', async () => {
|
||||
tagMock.getById.mockResolvedValue(tagStub.tag1);
|
||||
await expect(sut.getById(authStub.admin, 'tag-1')).resolves.toEqual(tagResponseStub.tag1);
|
||||
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.id, 'tag-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should throw an error for a duplicate tag', async () => {
|
||||
tagMock.hasName.mockResolvedValue(true);
|
||||
await expect(sut.create(authStub.admin, { name: 'tag-1', type: TagType.CUSTOM })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(tagMock.hasName).toHaveBeenCalledWith(authStub.admin.id, 'tag-1');
|
||||
expect(tagMock.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create a new tag', async () => {
|
||||
tagMock.create.mockResolvedValue(tagStub.tag1);
|
||||
await expect(sut.create(authStub.admin, { name: 'tag-1', type: TagType.CUSTOM })).resolves.toEqual(
|
||||
tagResponseStub.tag1,
|
||||
);
|
||||
expect(tagMock.create).toHaveBeenCalledWith({
|
||||
userId: authStub.admin.id,
|
||||
name: 'tag-1',
|
||||
type: TagType.CUSTOM,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should throw an error for an invalid id', async () => {
|
||||
tagMock.getById.mockResolvedValue(null);
|
||||
await expect(sut.update(authStub.admin, 'tag-1', { name: 'tag-2' })).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.id, 'tag-1');
|
||||
expect(tagMock.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update a tag', async () => {
|
||||
tagMock.getById.mockResolvedValue(tagStub.tag1);
|
||||
tagMock.update.mockResolvedValue(tagStub.tag1);
|
||||
await expect(sut.update(authStub.admin, 'tag-1', { name: 'tag-2' })).resolves.toEqual(tagResponseStub.tag1);
|
||||
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.id, 'tag-1');
|
||||
expect(tagMock.update).toHaveBeenCalledWith({ id: 'tag-1', name: 'tag-2' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should throw an error for an invalid id', async () => {
|
||||
tagMock.getById.mockResolvedValue(null);
|
||||
await expect(sut.remove(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.id, 'tag-1');
|
||||
expect(tagMock.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove a tag', async () => {
|
||||
tagMock.getById.mockResolvedValue(tagStub.tag1);
|
||||
await sut.remove(authStub.admin, 'tag-1');
|
||||
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.id, 'tag-1');
|
||||
expect(tagMock.remove).toHaveBeenCalledWith(tagStub.tag1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAssets', () => {
|
||||
it('should throw an error for an invalid id', async () => {
|
||||
tagMock.getById.mockResolvedValue(null);
|
||||
await expect(sut.remove(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.id, 'tag-1');
|
||||
expect(tagMock.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should get the assets for a tag', async () => {
|
||||
tagMock.getById.mockResolvedValue(tagStub.tag1);
|
||||
tagMock.getAssets.mockResolvedValue([assetEntityStub.image]);
|
||||
await sut.getAssets(authStub.admin, 'tag-1');
|
||||
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.id, 'tag-1');
|
||||
expect(tagMock.getAssets).toHaveBeenCalledWith(authStub.admin.id, 'tag-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAssets', () => {
|
||||
it('should throw an error for an invalid id', async () => {
|
||||
tagMock.getById.mockResolvedValue(null);
|
||||
await expect(sut.addAssets(authStub.admin, 'tag-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.id, 'tag-1');
|
||||
expect(tagMock.addAssets).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject duplicate asset ids and accept new ones', async () => {
|
||||
tagMock.getById.mockResolvedValue(tagStub.tag1);
|
||||
|
||||
when(tagMock.hasAsset).calledWith(authStub.admin.id, 'tag-1', 'asset-1').mockResolvedValue(true);
|
||||
when(tagMock.hasAsset).calledWith(authStub.admin.id, 'tag-1', 'asset-2').mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
sut.addAssets(authStub.admin, 'tag-1', {
|
||||
assetIds: ['asset-1', 'asset-2'],
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{ assetId: 'asset-1', success: false, error: AssetIdErrorReason.DUPLICATE },
|
||||
{ assetId: 'asset-2', success: true },
|
||||
]);
|
||||
|
||||
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.id, 'tag-1');
|
||||
expect(tagMock.hasAsset).toHaveBeenCalledTimes(2);
|
||||
expect(tagMock.addAssets).toHaveBeenCalledWith(authStub.admin.id, 'tag-1', ['asset-2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAssets', () => {
|
||||
it('should throw an error for an invalid id', async () => {
|
||||
tagMock.getById.mockResolvedValue(null);
|
||||
await expect(sut.removeAssets(authStub.admin, 'tag-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.id, 'tag-1');
|
||||
expect(tagMock.removeAssets).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept accept ids that are tagged and reject the rest', async () => {
|
||||
tagMock.getById.mockResolvedValue(tagStub.tag1);
|
||||
|
||||
when(tagMock.hasAsset).calledWith(authStub.admin.id, 'tag-1', 'asset-1').mockResolvedValue(true);
|
||||
when(tagMock.hasAsset).calledWith(authStub.admin.id, 'tag-1', 'asset-2').mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
sut.removeAssets(authStub.admin, 'tag-1', {
|
||||
assetIds: ['asset-1', 'asset-2'],
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{ assetId: 'asset-1', success: true },
|
||||
{ assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND },
|
||||
]);
|
||||
|
||||
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.id, 'tag-1');
|
||||
expect(tagMock.hasAsset).toHaveBeenCalledTimes(2);
|
||||
expect(tagMock.removeAssets).toHaveBeenCalledWith(authStub.admin.id, 'tag-1', ['asset-1']);
|
||||
});
|
||||
});
|
||||
});
|
||||
104
server/libs/domain/src/tag/tag.service.ts
Normal file
104
server/libs/domain/src/tag/tag.service.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { AssetIdErrorReason, AssetIdsDto, AssetIdsResponseDto, AssetResponseDto, mapAsset } from '../asset';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { mapTag, TagResponseDto } from './tag-response.dto';
|
||||
import { CreateTagDto, UpdateTagDto } from './tag.dto';
|
||||
import { ITagRepository } from './tag.repository';
|
||||
|
||||
@Injectable()
|
||||
export class TagService {
|
||||
constructor(@Inject(ITagRepository) private repository: ITagRepository) {}
|
||||
|
||||
getAll(authUser: AuthUserDto) {
|
||||
return this.repository.getAll(authUser.id).then((tags) => tags.map(mapTag));
|
||||
}
|
||||
|
||||
async getById(authUser: AuthUserDto, id: string): Promise<TagResponseDto> {
|
||||
const tag = await this.findOrFail(authUser, id);
|
||||
return mapTag(tag);
|
||||
}
|
||||
|
||||
async create(authUser: AuthUserDto, dto: CreateTagDto) {
|
||||
const duplicate = await this.repository.hasName(authUser.id, dto.name);
|
||||
if (duplicate) {
|
||||
throw new BadRequestException(`A tag with that name already exists`);
|
||||
}
|
||||
|
||||
const tag = await this.repository.create({
|
||||
userId: authUser.id,
|
||||
name: dto.name,
|
||||
type: dto.type,
|
||||
});
|
||||
|
||||
return mapTag(tag);
|
||||
}
|
||||
|
||||
async update(authUser: AuthUserDto, id: string, dto: UpdateTagDto): Promise<TagResponseDto> {
|
||||
await this.findOrFail(authUser, id);
|
||||
const tag = await this.repository.update({ id, name: dto.name });
|
||||
return mapTag(tag);
|
||||
}
|
||||
|
||||
async remove(authUser: AuthUserDto, id: string): Promise<void> {
|
||||
const tag = await this.findOrFail(authUser, id);
|
||||
await this.repository.remove(tag);
|
||||
}
|
||||
|
||||
async getAssets(authUser: AuthUserDto, id: string): Promise<AssetResponseDto[]> {
|
||||
await this.findOrFail(authUser, id);
|
||||
const assets = await this.repository.getAssets(authUser.id, id);
|
||||
return assets.map(mapAsset);
|
||||
}
|
||||
|
||||
async addAssets(authUser: AuthUserDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
|
||||
await this.findOrFail(authUser, id);
|
||||
|
||||
const results: AssetIdsResponseDto[] = [];
|
||||
for (const assetId of dto.assetIds) {
|
||||
const hasAsset = await this.repository.hasAsset(authUser.id, id, assetId);
|
||||
if (hasAsset) {
|
||||
results.push({ assetId, success: false, error: AssetIdErrorReason.DUPLICATE });
|
||||
} else {
|
||||
results.push({ assetId, success: true });
|
||||
}
|
||||
}
|
||||
|
||||
await this.repository.addAssets(
|
||||
authUser.id,
|
||||
id,
|
||||
results.filter((result) => result.success).map((result) => result.assetId),
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async removeAssets(authUser: AuthUserDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
|
||||
await this.findOrFail(authUser, id);
|
||||
|
||||
const results: AssetIdsResponseDto[] = [];
|
||||
for (const assetId of dto.assetIds) {
|
||||
const hasAsset = await this.repository.hasAsset(authUser.id, id, assetId);
|
||||
if (!hasAsset) {
|
||||
results.push({ assetId, success: false, error: AssetIdErrorReason.NOT_FOUND });
|
||||
} else {
|
||||
results.push({ assetId, success: true });
|
||||
}
|
||||
}
|
||||
|
||||
await this.repository.removeAssets(
|
||||
authUser.id,
|
||||
id,
|
||||
results.filter((result) => result.success).map((result) => result.assetId),
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async findOrFail(authUser: AuthUserDto, id: string) {
|
||||
const tag = await this.repository.getById(authUser.id, id);
|
||||
if (!tag) {
|
||||
throw new BadRequestException('Tag not found');
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,19 @@ import {
|
||||
AlbumEntity,
|
||||
APIKeyEntity,
|
||||
AssetEntity,
|
||||
AssetFaceEntity,
|
||||
AssetType,
|
||||
PersonEntity,
|
||||
ExifEntity,
|
||||
PartnerEntity,
|
||||
PersonEntity,
|
||||
SharedLinkEntity,
|
||||
SharedLinkType,
|
||||
SystemConfig,
|
||||
TagEntity,
|
||||
TagType,
|
||||
TranscodePreset,
|
||||
UserEntity,
|
||||
UserTokenEntity,
|
||||
AssetFaceEntity,
|
||||
ExifEntity,
|
||||
} from '@app/infra/entities';
|
||||
import {
|
||||
AlbumResponseDto,
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
mapUser,
|
||||
SearchResult,
|
||||
SharedLinkResponseDto,
|
||||
TagResponseDto,
|
||||
VideoFormat,
|
||||
VideoInfo,
|
||||
VideoStreamInfo,
|
||||
@@ -988,3 +991,24 @@ export const faceStub = {
|
||||
embedding: [1, 2, 3, 4],
|
||||
}),
|
||||
};
|
||||
|
||||
export const tagStub = {
|
||||
tag1: Object.freeze<TagEntity>({
|
||||
id: 'tag-1',
|
||||
name: 'Tag1',
|
||||
type: TagType.CUSTOM,
|
||||
userId: userEntityStub.admin.id,
|
||||
user: userEntityStub.admin,
|
||||
renameTagId: null,
|
||||
assets: [],
|
||||
}),
|
||||
};
|
||||
|
||||
export const tagResponseStub = {
|
||||
tag1: Object.freeze<TagResponseDto>({
|
||||
id: 'tag-1',
|
||||
name: 'Tag1',
|
||||
type: 'CUSTOM',
|
||||
userId: 'admin_id',
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ export * from './shared-link.repository.mock';
|
||||
export * from './smart-info.repository.mock';
|
||||
export * from './storage.repository.mock';
|
||||
export * from './system-config.repository.mock';
|
||||
export * from './tag.repository.mock';
|
||||
export * from './user-token.repository.mock';
|
||||
export * from './user.repository.mock';
|
||||
|
||||
|
||||
16
server/libs/domain/test/tag.repository.mock.ts
Normal file
16
server/libs/domain/test/tag.repository.mock.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ITagRepository } from '../src';
|
||||
|
||||
export const newTagRepositoryMock = (): jest.Mocked<ITagRepository> => {
|
||||
return {
|
||||
getAll: jest.fn(),
|
||||
getById: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
hasAsset: jest.fn(),
|
||||
hasName: jest.fn(),
|
||||
getAssets: jest.fn(),
|
||||
addAssets: jest.fn(),
|
||||
removeAssets: jest.fn(),
|
||||
};
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { PersonEntity } from './person.entity';
|
||||
import { SharedLinkEntity } from './shared-link.entity';
|
||||
import { SmartInfoEntity } from './smart-info.entity';
|
||||
import { SystemConfigEntity } from './system-config.entity';
|
||||
import { TagEntity } from './tag.entity';
|
||||
import { UserTokenEntity } from './user-token.entity';
|
||||
import { UserEntity } from './user.entity';
|
||||
|
||||
@@ -34,6 +35,7 @@ export const databaseEntities = [
|
||||
SharedLinkEntity,
|
||||
SmartInfoEntity,
|
||||
SystemConfigEntity,
|
||||
TagEntity,
|
||||
UserEntity,
|
||||
UserTokenEntity,
|
||||
];
|
||||
|
||||
@@ -21,7 +21,7 @@ export class TagEntity {
|
||||
userId!: string;
|
||||
|
||||
@Column({ type: 'uuid', comment: 'The new renamed tagId', nullable: true })
|
||||
renameTagId!: string;
|
||||
renameTagId!: string | null;
|
||||
|
||||
@ManyToMany(() => AssetEntity, (asset) => asset.tags)
|
||||
assets!: AssetEntity[];
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ISmartInfoRepository,
|
||||
IStorageRepository,
|
||||
ISystemConfigRepository,
|
||||
ITagRepository,
|
||||
IUserRepository,
|
||||
IUserTokenRepository,
|
||||
} from '@app/domain';
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
SharedLinkRepository,
|
||||
SmartInfoRepository,
|
||||
SystemConfigRepository,
|
||||
TagRepository,
|
||||
TypesenseRepository,
|
||||
UserRepository,
|
||||
UserTokenRepository,
|
||||
@@ -68,6 +70,7 @@ const providers: Provider[] = [
|
||||
{ provide: ISmartInfoRepository, useClass: SmartInfoRepository },
|
||||
{ provide: IStorageRepository, useClass: FilesystemProvider },
|
||||
{ provide: ISystemConfigRepository, useClass: SystemConfigRepository },
|
||||
{ provide: ITagRepository, useClass: TagRepository },
|
||||
{ provide: IUserRepository, useClass: UserRepository },
|
||||
{ provide: IUserTokenRepository, useClass: UserTokenRepository },
|
||||
];
|
||||
|
||||
@@ -14,6 +14,7 @@ export * from './person.repository';
|
||||
export * from './shared-link.repository';
|
||||
export * from './smart-info.repository';
|
||||
export * from './system-config.repository';
|
||||
export * from './tag.repository';
|
||||
export * from './typesense.repository';
|
||||
export * from './user-token.repository';
|
||||
export * from './user.repository';
|
||||
|
||||
123
server/libs/infra/src/repositories/tag.repository.ts
Normal file
123
server/libs/infra/src/repositories/tag.repository.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { ITagRepository } from '@app/domain';
|
||||
import { AssetEntity, TagEntity } from '@app/infra/entities';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class TagRepository implements ITagRepository {
|
||||
constructor(
|
||||
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
||||
@InjectRepository(TagEntity) private repository: Repository<TagEntity>,
|
||||
) {}
|
||||
|
||||
getById(userId: string, id: string): Promise<TagEntity | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
id,
|
||||
userId,
|
||||
},
|
||||
relations: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getAll(userId: string): Promise<TagEntity[]> {
|
||||
return this.repository.find({ where: { userId } });
|
||||
}
|
||||
|
||||
create(tag: Partial<TagEntity>): Promise<TagEntity> {
|
||||
return this.save(tag);
|
||||
}
|
||||
|
||||
update(tag: Partial<TagEntity>): Promise<TagEntity> {
|
||||
return this.save(tag);
|
||||
}
|
||||
|
||||
async remove(tag: TagEntity): Promise<void> {
|
||||
await this.repository.remove(tag);
|
||||
}
|
||||
|
||||
async getAssets(userId: string, tagId: string): Promise<AssetEntity[]> {
|
||||
return this.assetRepository.find({
|
||||
where: {
|
||||
tags: {
|
||||
userId,
|
||||
id: tagId,
|
||||
},
|
||||
},
|
||||
relations: {
|
||||
exifInfo: true,
|
||||
tags: true,
|
||||
faces: {
|
||||
person: true,
|
||||
},
|
||||
},
|
||||
order: {
|
||||
createdAt: 'ASC',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async addAssets(userId: string, id: string, assetIds: string[]): Promise<void> {
|
||||
for (const assetId of assetIds) {
|
||||
const asset = await this.assetRepository.findOneOrFail({
|
||||
where: {
|
||||
ownerId: userId,
|
||||
id: assetId,
|
||||
},
|
||||
relations: {
|
||||
tags: true,
|
||||
},
|
||||
});
|
||||
asset.tags.push({ id } as TagEntity);
|
||||
await this.assetRepository.save(asset);
|
||||
}
|
||||
}
|
||||
|
||||
async removeAssets(userId: string, id: string, assetIds: string[]): Promise<void> {
|
||||
for (const assetId of assetIds) {
|
||||
const asset = await this.assetRepository.findOneOrFail({
|
||||
where: {
|
||||
ownerId: userId,
|
||||
id: assetId,
|
||||
},
|
||||
relations: {
|
||||
tags: true,
|
||||
},
|
||||
});
|
||||
asset.tags = asset.tags.filter((tag) => tag.id !== id);
|
||||
await this.assetRepository.save(asset);
|
||||
}
|
||||
}
|
||||
|
||||
hasAsset(userId: string, tagId: string, assetId: string): Promise<boolean> {
|
||||
return this.repository.exist({
|
||||
where: {
|
||||
id: tagId,
|
||||
userId,
|
||||
assets: {
|
||||
id: assetId,
|
||||
},
|
||||
},
|
||||
relations: {
|
||||
assets: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
hasName(userId: string, name: string): Promise<boolean> {
|
||||
return this.repository.exist({
|
||||
where: {
|
||||
name,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async save(tag: Partial<TagEntity>): Promise<TagEntity> {
|
||||
const { id } = await this.repository.save(tag);
|
||||
return this.repository.findOneOrFail({ where: { id }, relations: { user: true } });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user