feat(server) Tagging system (#1046)

This commit is contained in:
Alex
2022-12-05 11:56:44 -06:00
committed by GitHub
parent 6e2763b72c
commit 5de8ea162d
74 changed files with 8768 additions and 167 deletions

View File

@@ -1,32 +1,29 @@
import { Module } from '@nestjs/common';
import { forwardRef, Module } from '@nestjs/common';
import { AlbumService } from './album.service';
import { AlbumController } from './album.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { UserEntity } from '@app/database/entities/user.entity';
import { AlbumEntity } from '../../../../../libs/database/src/entities/album.entity';
import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
import { UserAlbumEntity } from '@app/database/entities/user-album.entity';
import { AlbumRepository, ALBUM_REPOSITORY } from './album-repository';
import { AssetRepository, ASSET_REPOSITORY } from '../asset/asset-repository';
import { DownloadModule } from '../../modules/download/download.module';
import { AssetModule } from '../asset/asset.module';
import { UserModule } from '../user/user.module';
const ALBUM_REPOSITORY_PROVIDER = {
provide: ALBUM_REPOSITORY,
useClass: AlbumRepository,
};
@Module({
imports: [
TypeOrmModule.forFeature([AssetEntity, UserEntity, AlbumEntity, AssetAlbumEntity, UserAlbumEntity]),
TypeOrmModule.forFeature([AlbumEntity, AssetAlbumEntity, UserAlbumEntity]),
DownloadModule,
UserModule,
forwardRef(() => AssetModule),
],
controllers: [AlbumController],
providers: [
AlbumService,
{
provide: ALBUM_REPOSITORY,
useClass: AlbumRepository,
},
{
provide: ASSET_REPOSITORY,
useClass: AssetRepository,
},
],
providers: [AlbumService, ALBUM_REPOSITORY_PROVIDER],
exports: [ALBUM_REPOSITORY_PROVIDER],
})
export class AlbumModule {}

View File

@@ -1,7 +1,7 @@
import { SearchPropertiesDto } from './dto/search-properties.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm/repository/Repository';
import { CreateAssetDto } from './dto/create-asset.dto';
@@ -14,6 +14,7 @@ import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
import { In } from 'typeorm/find-options/operator/In';
import { UpdateAssetDto } from './dto/update-asset.dto';
import { ITagRepository, TAG_REPOSITORY } from '../tag/tag.repository';
export interface IAssetRepository {
create(
@@ -25,7 +26,7 @@ export interface IAssetRepository {
checksum?: Buffer,
livePhotoAssetEntity?: AssetEntity,
): Promise<AssetEntity>;
update(asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity>;
update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity>;
getAllByUserId(userId: string, skip?: number): Promise<AssetEntity[]>;
getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
getById(assetId: string): Promise<AssetEntity>;
@@ -53,6 +54,8 @@ export class AssetRepository implements IAssetRepository {
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
@Inject(TAG_REPOSITORY) private _tagRepository: ITagRepository,
) {}
async getAssetWithNoSmartInfo(): Promise<AssetEntity[]> {
@@ -222,7 +225,7 @@ export class AssetRepository implements IAssetRepository {
where: {
id: assetId,
},
relations: ['exifInfo'],
relations: ['exifInfo', 'tags'],
});
}
@@ -237,9 +240,9 @@ export class AssetRepository implements IAssetRepository {
.andWhere('asset.resizePath is not NULL')
.andWhere('asset.isVisible = true')
.leftJoinAndSelect('asset.exifInfo', 'exifInfo')
.leftJoinAndSelect('asset.tags', 'tags')
.skip(skip || 0)
.orderBy('asset.createdAt', 'DESC');
return await query.getMany();
}
@@ -286,9 +289,14 @@ export class AssetRepository implements IAssetRepository {
/**
* Update asset
*/
async update(asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity> {
async update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity> {
asset.isFavorite = dto.isFavorite ?? asset.isFavorite;
if (dto.tagIds) {
const tags = await this._tagRepository.getByIds(userId, dto.tagIds);
asset.tags = tags;
}
return await this.assetRepository.save(asset);
}
@@ -347,10 +355,10 @@ export class AssetRepository implements IAssetRepository {
async countByIdAndUser(assetId: string, userId: string): Promise<number> {
return await this.assetRepository.count({
where: {
id: assetId,
userId
}
where: {
id: assetId,
userId,
},
});
}
}

View File

@@ -216,14 +216,14 @@ export class AssetController {
/**
* Update an asset
*/
@Put('/assetById/:assetId')
async updateAssetById(
@Put('/:assetId')
async updateAsset(
@GetAuthUser() authUser: AuthUserDto,
@Param('assetId') assetId: string,
@Body() dto: UpdateAssetDto,
@Body(ValidationPipe) dto: UpdateAssetDto,
): Promise<AssetResponseDto> {
await this.assetService.checkAssetsAccess(authUser, [assetId], true);
return await this.assetService.updateAssetById(assetId, dto);
return await this.assetService.updateAsset(authUser, assetId, dto);
}
@Delete('/')

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { forwardRef, Module } from '@nestjs/common';
import { AssetService } from './asset.service';
import { AssetController } from './asset.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
@@ -10,18 +10,25 @@ import { CommunicationModule } from '../communication/communication.module';
import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
import { AssetRepository, ASSET_REPOSITORY } from './asset-repository';
import { DownloadModule } from '../../modules/download/download.module';
import { ALBUM_REPOSITORY, AlbumRepository } from '../album/album-repository';
import { AlbumEntity } from '@app/database/entities/album.entity';
import { UserAlbumEntity } from '@app/database/entities/user-album.entity';
import { UserEntity } from '@app/database/entities/user.entity';
import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
import { TagModule } from '../tag/tag.module';
import { AlbumModule } from '../album/album.module';
import { UserModule } from '../user/user.module';
const ASSET_REPOSITORY_PROVIDER = {
provide: ASSET_REPOSITORY,
useClass: AssetRepository,
};
@Module({
imports: [
TypeOrmModule.forFeature([AssetEntity]),
CommunicationModule,
BackgroundTaskModule,
DownloadModule,
TypeOrmModule.forFeature([AssetEntity, AlbumEntity, UserAlbumEntity, UserEntity, AssetAlbumEntity]),
UserModule,
AlbumModule,
TagModule,
forwardRef(() => AlbumModule),
BullModule.registerQueue({
name: QueueNameEnum.ASSET_UPLOADED,
defaultJobOptions: {
@@ -40,18 +47,7 @@ import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
}),
],
controllers: [AssetController],
providers: [
AssetService,
BackgroundTaskService,
{
provide: ASSET_REPOSITORY,
useClass: AssetRepository,
},
{
provide: ALBUM_REPOSITORY,
useClass: AlbumRepository,
},
],
exports: [AssetService],
providers: [AssetService, BackgroundTaskService, ASSET_REPOSITORY_PROVIDER],
exports: [ASSET_REPOSITORY_PROVIDER],
})
export class AssetModule {}

View File

@@ -231,13 +231,13 @@ export class AssetService {
return mapAsset(asset);
}
public async updateAssetById(assetId: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
public async updateAsset(authUser: AuthUserDto, assetId: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
const asset = await this._assetRepository.getById(assetId);
if (!asset) {
throw new BadRequestException('Asset not found');
}
const updatedAsset = await this._assetRepository.update(asset, dto);
const updatedAsset = await this._assetRepository.update(authUser.id, asset, dto);
return mapAsset(updatedAsset);
}

View File

@@ -1,6 +1,24 @@
import { IsBoolean } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class UpdateAssetDto {
@IsOptional()
@IsBoolean()
isFavorite!: boolean;
isFavorite?: boolean;
@IsOptional()
@IsArray()
@IsString({ each: true })
@IsNotEmpty({ each: true })
@ApiProperty({
isArray: true,
type: String,
title: 'Array of tag IDs to add to the asset',
example: [
'bf973405-3f2a-48d2-a687-2ed4167164be',
'dd41870b-5d00-46d2-924e-1d8489a0aa0f',
'fad77c3f-deef-4e7e-9608-14c1aa4e559a',
],
})
tagIds?: string[];
}

View File

@@ -1,5 +1,6 @@
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
import { ApiProperty } from '@nestjs/swagger';
import { mapTag, TagResponseDto } from '../../tag/response-dto/tag-response.dto';
import { ExifResponseDto, mapExif } from './exif-response.dto';
import { SmartInfoResponseDto, mapSmartInfo } from './smart-info-response.dto';
@@ -23,6 +24,7 @@ export class AssetResponseDto {
exifInfo?: ExifResponseDto;
smartInfo?: SmartInfoResponseDto;
livePhotoVideoId?: string | null;
tags!: TagResponseDto[];
}
export function mapAsset(entity: AssetEntity): AssetResponseDto {
@@ -44,5 +46,6 @@ export function mapAsset(entity: AssetEntity): AssetResponseDto {
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
livePhotoVideoId: entity.livePhotoVideoId,
tags: entity.tags?.map(mapTag),
};
}

View File

@@ -5,18 +5,21 @@ import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
import { JwtModule } from '@nestjs/jwt';
import { jwtConfig } from '../../config/jwt.config';
import { UserEntity } from '@app/database/entities/user.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bull';
import { QueueNameEnum } from '@app/job';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { ExifEntity } from '@app/database/entities/exif.entity';
import { AssetRepository, ASSET_REPOSITORY } from '../asset/asset-repository';
import { TagModule } from '../tag/tag.module';
import { AssetModule } from '../asset/asset.module';
import { UserModule } from '../user/user.module';
@Module({
imports: [
TypeOrmModule.forFeature([UserEntity, AssetEntity, ExifEntity]),
TypeOrmModule.forFeature([ExifEntity]),
ImmichJwtModule,
TagModule,
AssetModule,
UserModule,
JwtModule.register(jwtConfig),
BullModule.registerQueue(
{
@@ -70,13 +73,6 @@ import { AssetRepository, ASSET_REPOSITORY } from '../asset/asset-repository';
),
],
controllers: [JobController],
providers: [
JobService,
ImmichJwtService,
{
provide: ASSET_REPOSITORY,
useClass: AssetRepository,
},
],
providers: [JobService, ImmichJwtService],
})
export class JobModule {}

View File

@@ -0,0 +1,14 @@
import { TagType } from '@app/database/entities/tag.entity';
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
export class CreateTagDto {
@IsString()
@IsNotEmpty()
name!: string;
@IsEnum(TagType)
@IsNotEmpty()
@ApiProperty({ enumName: 'TagTypeEnum', enum: TagType })
type!: TagType;
}

View File

@@ -0,0 +1,11 @@
import { IsOptional, IsString } from 'class-validator';
export class UpdateTagDto {
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsOptional()
renameTagId?: string;
}

View File

@@ -0,0 +1,20 @@
import { TagEntity, TagType } from '@app/database/entities/tag.entity';
import { ApiProperty } from '@nestjs/swagger';
export class TagResponseDto {
@ApiProperty()
id!: string;
@ApiProperty({ enumName: 'TagTypeEnum', enum: TagType })
type!: string;
name!: string;
}
export function mapTag(entity: TagEntity): TagResponseDto {
return {
id: entity.id,
type: entity.type,
name: entity.name,
};
}

View File

@@ -0,0 +1,44 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, ValidationPipe } from '@nestjs/common';
import { TagService } from './tag.service';
import { CreateTagDto } from './dto/create-tag.dto';
import { UpdateTagDto } from './dto/update-tag.dto';
import { Authenticated } from '../../decorators/authenticated.decorator';
import { ApiTags } from '@nestjs/swagger';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { TagEntity } from '@app/database/entities/tag.entity';
@Authenticated()
@ApiTags('Tag')
@Controller('tag')
export class TagController {
constructor(private readonly tagService: TagService) {}
@Post()
create(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) createTagDto: CreateTagDto): Promise<TagEntity> {
return this.tagService.create(authUser, createTagDto);
}
@Get()
findAll(@GetAuthUser() authUser: AuthUserDto) {
return this.tagService.findAll(authUser);
}
@Get(':id')
findOne(@GetAuthUser() authUser: AuthUserDto, @Param('id') id: string) {
return this.tagService.findOne(authUser, id);
}
@Patch(':id')
update(
@GetAuthUser() authUser: AuthUserDto,
@Param('id') id: string,
@Body(ValidationPipe) updateTagDto: UpdateTagDto,
) {
return this.tagService.update(authUser, id, updateTagDto);
}
@Delete(':id')
delete(@GetAuthUser() authUser: AuthUserDto, @Param('id') id: string) {
return this.tagService.remove(authUser, id);
}
}

View File

@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TagService } from './tag.service';
import { TagController } from './tag.controller';
import { TagEntity } from '@app/database/entities/tag.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TagRepository, TAG_REPOSITORY } from './tag.repository';
const TAG_REPOSITORY_PROVIDER = {
provide: TAG_REPOSITORY,
useClass: TagRepository,
};
@Module({
imports: [TypeOrmModule.forFeature([TagEntity])],
controllers: [TagController],
providers: [TagService, TAG_REPOSITORY_PROVIDER],
exports: [TAG_REPOSITORY_PROVIDER],
})
export class TagModule {}

View File

@@ -0,0 +1,61 @@
import { TagEntity, TagType } from '@app/database/entities/tag.entity';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { UpdateTagDto } from './dto/update-tag.dto';
export interface ITagRepository {
create(userId: string, tagType: TagType, tagName: string): Promise<TagEntity>;
getByIds(userId: string, tagIds: string[]): Promise<TagEntity[]>;
getById(tagId: string, userId: string): Promise<TagEntity | null>;
getByUserId(userId: string): Promise<TagEntity[]>;
update(tag: TagEntity, updateTagDto: UpdateTagDto): Promise<TagEntity | null>;
remove(tag: TagEntity): Promise<TagEntity>;
}
export const TAG_REPOSITORY = 'TAG_REPOSITORY';
@Injectable()
export class TagRepository implements ITagRepository {
constructor(
@InjectRepository(TagEntity)
private tagRepository: Repository<TagEntity>,
) {}
async create(userId: string, tagType: TagType, tagName: string): Promise<TagEntity> {
const tag = new TagEntity();
tag.name = tagName;
tag.type = tagType;
tag.userId = userId;
return this.tagRepository.save(tag);
}
async getById(tagId: string, userId: string): Promise<TagEntity | null> {
return await this.tagRepository.findOne({ where: { id: tagId, userId }, relations: ['user'] });
}
async getByIds(userId: string, tagIds: string[]): Promise<TagEntity[]> {
return await this.tagRepository.find({
where: { id: In(tagIds), userId },
relations: {
user: true,
},
});
}
async getByUserId(userId: string): Promise<TagEntity[]> {
return await this.tagRepository.find({ where: { userId } });
}
async update(tag: TagEntity, updateTagDto: UpdateTagDto): Promise<TagEntity> {
tag.name = updateTagDto.name ?? tag.name;
tag.renameTagId = updateTagDto.renameTagId ?? tag.renameTagId;
return this.tagRepository.save(tag);
}
async remove(tag: TagEntity): Promise<TagEntity> {
return await this.tagRepository.remove(tag);
}
}

View File

@@ -0,0 +1,91 @@
import { TagEntity, TagType } from '@app/database/entities/tag.entity';
import { UserEntity } from '@app/database/entities/user.entity';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { ITagRepository } from './tag.repository';
import { TagService } from './tag.service';
describe('TagService', () => {
let sut: TagService;
let tagRepositoryMock: jest.Mocked<ITagRepository>;
const user1AuthUser: AuthUserDto = Object.freeze({
id: '1111',
email: 'testuser@email.com',
});
const user1: UserEntity = Object.freeze({
id: '1111',
firstName: 'Alex',
lastName: 'Tran',
isAdmin: true,
email: 'testuser@email.com',
profileImagePath: '',
shouldChangePassword: true,
createdAt: '2022-12-02T19:29:23.603Z',
deletedAt: undefined,
tags: [],
oauthId: 'oauth-id-1',
});
// const user2: UserEntity = Object.freeze({
// id: '2222',
// firstName: 'Alex',
// lastName: 'Tran',
// isAdmin: true,
// email: 'testuser2@email.com',
// profileImagePath: '',
// shouldChangePassword: true,
// createdAt: '2022-12-02T19:29:23.603Z',
// deletedAt: undefined,
// tags: [],
// oauthId: 'oauth-id-2',
// });
const user1Tag1: TagEntity = Object.freeze({
name: 'user 1 tag 1',
type: TagType.CUSTOM,
userId: user1.id,
user: user1,
renameTagId: '',
id: 'user1-tag-1-id',
assets: [],
});
// const user1Tag2: TagEntity = Object.freeze({
// name: 'user 1 tag 2',
// type: TagType.CUSTOM,
// userId: user1.id,
// user: user1,
// renameTagId: '',
// id: 'user1-tag-2-id',
// assets: [],
// });
beforeAll(() => {
tagRepositoryMock = {
create: jest.fn(),
getByIds: jest.fn(),
getById: jest.fn(),
getByUserId: jest.fn(),
remove: jest.fn(),
update: jest.fn(),
};
sut = new TagService(tagRepositoryMock);
});
it('creates tag', async () => {
const createTagDto = {
name: 'user 1 tag 1',
type: TagType.CUSTOM,
};
tagRepositoryMock.create.mockResolvedValue(user1Tag1);
const result = await sut.create(user1AuthUser, createTagDto);
expect(result.userId).toEqual(user1AuthUser.id);
expect(result.name).toEqual(createTagDto.name);
expect(result.type).toEqual(createTagDto.type);
});
});

View File

@@ -0,0 +1,48 @@
import { TagEntity } from '@app/database/entities/tag.entity';
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateTagDto } from './dto/create-tag.dto';
import { UpdateTagDto } from './dto/update-tag.dto';
import { ITagRepository, TAG_REPOSITORY } from './tag.repository';
@Injectable()
export class TagService {
readonly logger = new Logger(TagService.name);
constructor(@Inject(TAG_REPOSITORY) private _tagRepository: ITagRepository) {}
async create(authUser: AuthUserDto, createTagDto: CreateTagDto) {
try {
return await this._tagRepository.create(authUser.id, createTagDto.type, createTagDto.name);
} catch (e: any) {
this.logger.error(e, e.stack);
throw new BadRequestException(`Failed to create tag: ${e.detail}`);
}
}
async findAll(authUser: AuthUserDto) {
return await this._tagRepository.getByUserId(authUser.id);
}
async findOne(authUser: AuthUserDto, id: string): Promise<TagEntity> {
const tag = await this._tagRepository.getById(id, authUser.id);
if (!tag) {
throw new BadRequestException('Tag not found');
}
return tag;
}
async update(authUser: AuthUserDto, id: string, updateTagDto: UpdateTagDto) {
const tag = await this.findOne(authUser, id);
return this._tagRepository.update(tag, updateTagDto);
}
async remove(authUser: AuthUserDto, id: string) {
const tag = await this.findOne(authUser, id);
return this._tagRepository.remove(tag);
}
}

View File

@@ -31,6 +31,7 @@ describe('UserService', () => {
shouldChangePassword: false,
profileImagePath: '',
createdAt: '2021-01-01',
tags: [],
});
const immichUser: UserEntity = Object.freeze({
@@ -45,6 +46,7 @@ describe('UserService', () => {
shouldChangePassword: false,
profileImagePath: '',
createdAt: '2021-01-01',
tags: [],
});
const updatedImmichUser: UserEntity = Object.freeze({
@@ -59,6 +61,7 @@ describe('UserService', () => {
shouldChangePassword: true,
profileImagePath: '',
createdAt: '2021-01-01',
tags: [],
});
beforeAll(() => {

View File

@@ -18,6 +18,7 @@ import { DatabaseModule } from '@app/database';
import { JobModule } from './api-v1/job/job.module';
import { SystemConfigModule } from './api-v1/system-config/system-config.module';
import { OAuthModule } from './api-v1/oauth/oauth.module';
import { TagModule } from './api-v1/tag/tag.module';
@Module({
imports: [
@@ -63,6 +64,8 @@ import { OAuthModule } from './api-v1/oauth/oauth.module';
JobModule,
SystemConfigModule,
TagModule,
],
controllers: [AppController],
providers: [],

View File

@@ -55,7 +55,7 @@ async function bootstrap() {
if (process.env.NODE_ENV == 'development') {
// Generate API Documentation only in development mode
const outputPath = path.resolve(process.cwd(), 'immich-openapi-specs.json');
writeFileSync(outputPath, JSON.stringify(apiDocument), { encoding: 'utf8' });
writeFileSync(outputPath, JSON.stringify(apiDocument, null, 2), { encoding: 'utf8' });
Logger.log(
`Running Immich Server in DEVELOPMENT environment - version ${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`,
'ImmichServer',

View File

@@ -56,6 +56,7 @@ describe('ImmichJwtService', () => {
profileImagePath: '',
shouldChangePassword: false,
createdAt: 'today',
tags: [],
};
const dto: LoginResponseDto = {

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,7 @@
import { Column, Entity, Index, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
import { Column, Entity, Index, JoinTable, ManyToMany, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
import { ExifEntity } from './exif.entity';
import { SmartInfoEntity } from './smart-info.entity';
import { TagEntity } from './tag.entity';
@Entity('assets')
@Unique('UQ_userid_checksum', ['userId', 'checksum'])
@@ -62,6 +63,11 @@ export class AssetEntity {
@OneToOne(() => SmartInfoEntity, (smartInfoEntity) => smartInfoEntity.asset)
smartInfo?: SmartInfoEntity;
// https://github.com/typeorm/typeorm/blob/master/docs/many-to-many-relations.md
@ManyToMany(() => TagEntity, (tag) => tag.assets, { cascade: true })
@JoinTable({ name: 'tag_asset' })
tags!: TagEntity[];
}
export enum AssetType {

View File

@@ -0,0 +1,45 @@
import { Column, Entity, ManyToMany, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
import { AssetEntity } from './asset.entity';
import { UserEntity } from './user.entity';
@Entity('tags')
@Unique('UQ_tag_name_userId', ['name', 'userId'])
export class TagEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
type!: TagType;
@Column()
name!: string;
@Column()
userId!: string;
@Column({ type: 'uuid', comment: 'The new renamed tagId', nullable: true })
renameTagId!: string;
@ManyToMany(() => AssetEntity, (asset) => asset.tags)
assets!: AssetEntity[];
@ManyToOne(() => UserEntity, (user) => user.tags)
user!: UserEntity;
}
export enum TagType {
/**
* Tag that is detected by the ML model for object detection will use this type
*/
OBJECT = 'OBJECT',
/**
* Face that is detected by the ML model for facial detection (TBD/NOT YET IMPLEMENTED) will use this type
*/
FACE = 'FACE',
/**
* Tag that is created by the user will use this type
*/
CUSTOM = 'CUSTOM',
}

View File

@@ -1,4 +1,5 @@
import { Column, CreateDateColumn, DeleteDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { Column, CreateDateColumn, DeleteDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { TagEntity } from './tag.entity';
@Entity('users')
export class UserEntity {
@@ -37,4 +38,7 @@ export class UserEntity {
@DeleteDateColumn()
deletedAt?: Date;
@OneToMany(() => TagEntity, (tag) => tag.user)
tags!: TagEntity[];
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreateTagsTable1670257571385 implements MigrationInterface {
name = 'CreateTagsTable1670257571385'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "tags" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "type" character varying NOT NULL, "name" character varying NOT NULL, "userId" uuid NOT NULL, "renameTagId" uuid, CONSTRAINT "UQ_tag_name_userId" UNIQUE ("name", "userId"), CONSTRAINT "PK_e7dc17249a1148a1970748eda99" PRIMARY KEY ("id")); COMMENT ON COLUMN "tags"."renameTagId" IS 'The new renamed tagId'`);
await queryRunner.query(`CREATE TABLE "tag_asset" ("assetsId" uuid NOT NULL, "tagsId" uuid NOT NULL, CONSTRAINT "PK_ef5346fe522b5fb3bc96454747e" PRIMARY KEY ("assetsId", "tagsId"))`);
await queryRunner.query(`CREATE INDEX "IDX_f8e8a9e893cb5c54907f1b798e" ON "tag_asset" ("assetsId") `);
await queryRunner.query(`CREATE INDEX "IDX_e99f31ea4cdf3a2c35c7287eb4" ON "tag_asset" ("tagsId") `);
await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "FK_92e67dc508c705dd66c94615576" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9" FOREIGN KEY ("assetsId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42" FOREIGN KEY ("tagsId") REFERENCES "tags"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42"`);
await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9"`);
await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "FK_92e67dc508c705dd66c94615576"`);
await queryRunner.query(`DROP INDEX "public"."IDX_e99f31ea4cdf3a2c35c7287eb4"`);
await queryRunner.query(`DROP INDEX "public"."IDX_f8e8a9e893cb5c54907f1b798e"`);
await queryRunner.query(`DROP TABLE "tag_asset"`);
await queryRunner.query(`DROP TABLE "tags"`);
}
}