feat: tags (#11980)

* feat: tags

* fix: folder tree icons

* navigate to tag from detail panel

* delete tag

* Tag position and add tag button

* Tag asset in detail panel

* refactor form

* feat: navigate to tag page from clicking on a tag

* feat: delete tags from the tag page

* refactor: moving tag section in detail panel and add + tag button

* feat: tag asset action in detail panel

* refactor add tag form

* fdisable add tag button when there is no selection

* feat: tag bulk endpoint

* feat: tag colors

* chore: clean up

* chore: unit tests

* feat: write tags to sidecar

* Remove tag and auto focus on tag creation form opened

* chore: regenerate migration

* chore: linting

* add color picker to tag edit form

* fix: force render tags timeline on navigating back from asset viewer

* feat: read tags from keywords

* chore: clean up

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Jason Rasmussen
2024-08-29 12:14:03 -04:00
committed by GitHub
parent 682adaa334
commit d08a20bd57
68 changed files with 3032 additions and 814 deletions

View File

@@ -1,10 +1,15 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AssetIdsDto } from 'src/dtos/asset.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { CreateTagDto, TagResponseDto, UpdateTagDto } from 'src/dtos/tag.dto';
import {
TagBulkAssetsDto,
TagBulkAssetsResponseDto,
TagCreateDto,
TagResponseDto,
TagUpdateDto,
TagUpsertDto,
} from 'src/dtos/tag.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { TagService } from 'src/services/tag.service';
@@ -17,7 +22,7 @@ export class TagController {
@Post()
@Authenticated({ permission: Permission.TAG_CREATE })
createTag(@Auth() auth: AuthDto, @Body() dto: CreateTagDto): Promise<TagResponseDto> {
createTag(@Auth() auth: AuthDto, @Body() dto: TagCreateDto): Promise<TagResponseDto> {
return this.service.create(auth, dto);
}
@@ -27,47 +32,54 @@ export class TagController {
return this.service.getAll(auth);
}
@Put()
@Authenticated({ permission: Permission.TAG_CREATE })
upsertTags(@Auth() auth: AuthDto, @Body() dto: TagUpsertDto): Promise<TagResponseDto[]> {
return this.service.upsert(auth, dto);
}
@Put('assets')
@Authenticated({ permission: Permission.TAG_ASSET })
bulkTagAssets(@Auth() auth: AuthDto, @Body() dto: TagBulkAssetsDto): Promise<TagBulkAssetsResponseDto> {
return this.service.bulkTagAssets(auth, dto);
}
@Get(':id')
@Authenticated({ permission: Permission.TAG_READ })
getTagById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<TagResponseDto> {
return this.service.getById(auth, id);
return this.service.get(auth, id);
}
@Patch(':id')
@Put(':id')
@Authenticated({ permission: Permission.TAG_UPDATE })
updateTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateTagDto): Promise<TagResponseDto> {
updateTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: TagUpdateDto): Promise<TagResponseDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.TAG_DELETE })
deleteTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}
@Get(':id/assets')
@Authenticated()
getTagAssets(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto[]> {
return this.service.getAssets(auth, id);
}
@Put(':id/assets')
@Authenticated()
@Authenticated({ permission: Permission.TAG_ASSET })
tagAssets(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: AssetIdsDto,
): Promise<AssetIdsResponseDto[]> {
@Body() dto: BulkIdsDto,
): Promise<BulkIdResponseDto[]> {
return this.service.addAssets(auth, id, dto);
}
@Delete(':id/assets')
@Authenticated()
@Authenticated({ permission: Permission.TAG_ASSET })
untagAssets(
@Auth() auth: AuthDto,
@Body() dto: AssetIdsDto,
@Body() dto: BulkIdsDto,
@Param() { id }: UUIDParamDto,
): Promise<AssetIdsResponseDto[]> {
): Promise<BulkIdResponseDto[]> {
return this.service.removeAssets(auth, id, dto);
}
}

View File

@@ -140,7 +140,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
livePhotoVideoId: entity.livePhotoVideoId,
tags: entity.tags?.map(mapTag),
tags: entity.tags?.map((tag) => mapTag(tag)),
people: peopleWithFaces(entity.faces),
unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)),
checksum: entity.checksum.toString('base64'),

View File

@@ -1,38 +1,64 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { TagEntity, TagType } from 'src/entities/tag.entity';
import { Optional } from 'src/validation';
import { Transform } from 'class-transformer';
import { IsHexColor, IsNotEmpty, IsString } from 'class-validator';
import { TagEntity } from 'src/entities/tag.entity';
import { Optional, ValidateUUID } from 'src/validation';
export class CreateTagDto {
export class TagCreateDto {
@IsString()
@IsNotEmpty()
name!: string;
@IsEnum(TagType)
@IsNotEmpty()
@ApiProperty({ enumName: 'TagTypeEnum', enum: TagType })
type!: TagType;
@ValidateUUID({ optional: true, nullable: true })
parentId?: string | null;
@IsHexColor()
@Optional({ nullable: true, emptyToNull: true })
color?: string;
}
export class UpdateTagDto {
@IsString()
@Optional()
name?: string;
export class TagUpdateDto {
@Optional({ nullable: true, emptyToNull: true })
@IsHexColor()
@Transform(({ value }) => (typeof value === 'string' && value[0] !== '#' ? `#${value}` : value))
color?: string | null;
}
export class TagUpsertDto {
@IsString({ each: true })
@IsNotEmpty({ each: true })
tags!: string[];
}
export class TagBulkAssetsDto {
@ValidateUUID({ each: true })
tagIds!: string[];
@ValidateUUID({ each: true })
assetIds!: string[];
}
export class TagBulkAssetsResponseDto {
@ApiProperty({ type: 'integer' })
count!: number;
}
export class TagResponseDto {
id!: string;
@ApiProperty({ enumName: 'TagTypeEnum', enum: TagType })
type!: string;
name!: string;
userId!: string;
value!: string;
createdAt!: Date;
updatedAt!: Date;
color?: string;
}
export function mapTag(entity: TagEntity): TagResponseDto {
return {
id: entity.id,
type: entity.type,
name: entity.name,
userId: entity.userId,
name: entity.value.split('/').at(-1) as string,
value: entity.value,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
color: entity.color ?? undefined,
};
}

View File

@@ -19,6 +19,9 @@ export class TimeBucketDto {
@ValidateUUID({ optional: true })
personId?: string;
@ValidateUUID({ optional: true })
tagId?: string;
@ValidateBoolean({ optional: true })
isArchived?: boolean;

View File

@@ -1,45 +1,48 @@
import { AssetEntity } from 'src/entities/asset.entity';
import { UserEntity } from 'src/entities/user.entity';
import { Column, Entity, ManyToMany, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
import {
Column,
CreateDateColumn,
Entity,
ManyToMany,
ManyToOne,
PrimaryGeneratedColumn,
Tree,
TreeChildren,
TreeParent,
UpdateDateColumn,
} from 'typeorm';
@Entity('tags')
@Unique('UQ_tag_name_userId', ['name', 'userId'])
@Tree('closure-table')
export class TagEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
type!: TagType;
@Column({ unique: true })
value!: string;
@Column()
name!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@ManyToOne(() => UserEntity, (user) => user.tags)
user!: UserEntity;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@Column({ type: 'varchar', nullable: true, default: null })
color!: string | null;
@TreeParent({ onDelete: 'CASCADE' })
parent?: TagEntity;
@TreeChildren()
children?: TagEntity[];
@ManyToOne(() => UserEntity, (user) => user.tags, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
user?: UserEntity;
@Column()
userId!: string;
@Column({ type: 'uuid', comment: 'The new renamed tagId', nullable: true })
renameTagId!: string | null;
@ManyToMany(() => AssetEntity, (asset) => asset.tags)
assets!: AssetEntity[];
}
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',
@ManyToMany(() => AssetEntity, (asset) => asset.tags, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
assets?: AssetEntity[];
}

View File

@@ -130,6 +130,7 @@ export enum Permission {
TAG_READ = 'tag.read',
TAG_UPDATE = 'tag.update',
TAG_DELETE = 'tag.delete',
TAG_ASSET = 'tag.asset',
ADMIN_USER_CREATE = 'admin.user.create',
ADMIN_USER_READ = 'admin.user.read',

View File

@@ -46,4 +46,8 @@ export interface IAccessRepository {
stack: {
checkOwnerAccess(userId: string, stackIds: Set<string>): Promise<Set<string>>;
};
tag: {
checkOwnerAccess(userId: string, tagIds: Set<string>): Promise<Set<string>>;
};
}

View File

@@ -51,6 +51,7 @@ export interface AssetBuilderOptions {
isTrashed?: boolean;
isDuplicate?: boolean;
albumId?: string;
tagId?: string;
personId?: string;
userIds?: string[];
withStacked?: boolean;

View File

@@ -17,6 +17,10 @@ type EmitEventMap = {
'album.update': [{ id: string; updatedBy: string }];
'album.invite': [{ id: string; userId: string }];
// tag events
'asset.tag': [{ assetId: string }];
'asset.untag': [{ assetId: string }];
// user events
'user.signup': [{ notify: boolean; id: string; tempPassword?: string }];
};

View File

@@ -155,6 +155,7 @@ export interface ISidecarWriteJob extends IEntityJob {
latitude?: number;
longitude?: number;
rating?: number;
tags?: true;
}
export interface IDeferrableJob extends IEntityJob {

View File

@@ -1,17 +1,19 @@
import { AssetEntity } from 'src/entities/asset.entity';
import { TagEntity } from 'src/entities/tag.entity';
import { IBulkAsset } from 'src/utils/asset.util';
export const ITagRepository = 'ITagRepository';
export interface ITagRepository {
getById(userId: string, tagId: string): Promise<TagEntity | null>;
export type AssetTagItem = { assetId: string; tagId: string };
export interface ITagRepository extends IBulkAsset {
getAll(userId: string): Promise<TagEntity[]>;
getByValue(userId: string, value: string): Promise<TagEntity | null>;
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>;
get(id: string): Promise<TagEntity | null>;
update(tag: { id: string } & Partial<TagEntity>): Promise<TagEntity>;
delete(id: string): Promise<void>;
upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }): Promise<void>;
upsertAssetIds(items: AssetTagItem[]): Promise<AssetTagItem[]>;
}

View File

@@ -0,0 +1,57 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class NestedTagTable1724790460210 implements MigrationInterface {
name = 'NestedTagTable1724790460210'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('TRUNCATE TABLE "tags" CASCADE');
await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "FK_92e67dc508c705dd66c94615576"`);
await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "UQ_tag_name_userId"`);
await queryRunner.query(`CREATE TABLE "tags_closure" ("id_ancestor" uuid NOT NULL, "id_descendant" uuid NOT NULL, CONSTRAINT "PK_eab38eb12a3ec6df8376c95477c" PRIMARY KEY ("id_ancestor", "id_descendant"))`);
await queryRunner.query(`CREATE INDEX "IDX_15fbcbc67663c6bfc07b354c22" ON "tags_closure" ("id_ancestor") `);
await queryRunner.query(`CREATE INDEX "IDX_b1a2a7ed45c29179b5ad51548a" ON "tags_closure" ("id_descendant") `);
await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "renameTagId"`);
await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "type"`);
await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "name"`);
await queryRunner.query(`ALTER TABLE "tags" ADD "value" character varying NOT NULL`);
await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "UQ_d090e09fe86ebe2ec0aec27b451" UNIQUE ("value")`);
await queryRunner.query(`ALTER TABLE "tags" ADD "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`);
await queryRunner.query(`ALTER TABLE "tags" ADD "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`);
await queryRunner.query(`ALTER TABLE "tags" ADD "color" character varying`);
await queryRunner.query(`ALTER TABLE "tags" ADD "parentId" uuid`);
await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "FK_9f9590cc11561f1f48ff034ef99" FOREIGN KEY ("parentId") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "FK_92e67dc508c705dd66c94615576" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
await queryRunner.query(`ALTER TABLE "tags_closure" ADD CONSTRAINT "FK_15fbcbc67663c6bfc07b354c22c" FOREIGN KEY ("id_ancestor") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "tags_closure" ADD CONSTRAINT "FK_b1a2a7ed45c29179b5ad51548a1" FOREIGN KEY ("id_descendant") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
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 "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 CASCADE ON UPDATE CASCADE`);
}
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 "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`);
await queryRunner.query(`ALTER TABLE "tags_closure" DROP CONSTRAINT "FK_b1a2a7ed45c29179b5ad51548a1"`);
await queryRunner.query(`ALTER TABLE "tags_closure" DROP CONSTRAINT "FK_15fbcbc67663c6bfc07b354c22c"`);
await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "FK_92e67dc508c705dd66c94615576"`);
await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "FK_9f9590cc11561f1f48ff034ef99"`);
await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "parentId"`);
await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "color"`);
await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "updatedAt"`);
await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "createdAt"`);
await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "UQ_d090e09fe86ebe2ec0aec27b451"`);
await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "value"`);
await queryRunner.query(`ALTER TABLE "tags" ADD "name" character varying NOT NULL`);
await queryRunner.query(`ALTER TABLE "tags" ADD "type" character varying NOT NULL`);
await queryRunner.query(`ALTER TABLE "tags" ADD "renameTagId" uuid`);
await queryRunner.query(`DROP INDEX "public"."IDX_b1a2a7ed45c29179b5ad51548a"`);
await queryRunner.query(`DROP INDEX "public"."IDX_15fbcbc67663c6bfc07b354c22"`);
await queryRunner.query(`DROP TABLE "tags_closure"`);
await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "UQ_tag_name_userId" UNIQUE ("name", "userId")`);
await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "FK_92e67dc508c705dd66c94615576" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}
}

View File

@@ -259,6 +259,17 @@ WHERE
AND ("StackEntity"."ownerId" = $2)
)
-- AccessRepository.tag.checkOwnerAccess
SELECT
"TagEntity"."id" AS "TagEntity_id"
FROM
"tags" "TagEntity"
WHERE
(
("TagEntity"."id" IN ($1))
AND ("TagEntity"."userId" = $2)
)
-- AccessRepository.timeline.checkPartnerAccess
SELECT
"partner"."sharedById" AS "partner_sharedById",

View File

@@ -184,10 +184,12 @@ SELECT
"AssetEntity__AssetEntity_smartInfo"."tags" AS "AssetEntity__AssetEntity_smartInfo_tags",
"AssetEntity__AssetEntity_smartInfo"."objects" AS "AssetEntity__AssetEntity_smartInfo_objects",
"AssetEntity__AssetEntity_tags"."id" AS "AssetEntity__AssetEntity_tags_id",
"AssetEntity__AssetEntity_tags"."type" AS "AssetEntity__AssetEntity_tags_type",
"AssetEntity__AssetEntity_tags"."name" AS "AssetEntity__AssetEntity_tags_name",
"AssetEntity__AssetEntity_tags"."value" AS "AssetEntity__AssetEntity_tags_value",
"AssetEntity__AssetEntity_tags"."createdAt" AS "AssetEntity__AssetEntity_tags_createdAt",
"AssetEntity__AssetEntity_tags"."updatedAt" AS "AssetEntity__AssetEntity_tags_updatedAt",
"AssetEntity__AssetEntity_tags"."color" AS "AssetEntity__AssetEntity_tags_color",
"AssetEntity__AssetEntity_tags"."userId" AS "AssetEntity__AssetEntity_tags_userId",
"AssetEntity__AssetEntity_tags"."renameTagId" AS "AssetEntity__AssetEntity_tags_renameTagId",
"AssetEntity__AssetEntity_tags"."parentId" AS "AssetEntity__AssetEntity_tags_parentId",
"AssetEntity__AssetEntity_faces"."id" AS "AssetEntity__AssetEntity_faces_id",
"AssetEntity__AssetEntity_faces"."assetId" AS "AssetEntity__AssetEntity_faces_assetId",
"AssetEntity__AssetEntity_faces"."personId" AS "AssetEntity__AssetEntity_faces_personId",

View File

@@ -0,0 +1,30 @@
-- NOTE: This file is auto generated by ./sql-generator
-- TagRepository.getAssetIds
SELECT
"tag_asset"."assetsId" AS "assetId"
FROM
"tag_asset" "tag_asset"
WHERE
"tag_asset"."tagsId" = $1
AND "tag_asset"."assetsId" IN ($2)
-- TagRepository.addAssetIds
INSERT INTO
"tag_asset" ("assetsId", "tagsId")
VALUES
($1, $2)
-- TagRepository.removeAssetIds
DELETE FROM "tag_asset"
WHERE
(
"tagsId" = $1
AND "assetsId" IN ($2)
)
-- TagRepository.upsertAssetIds
INSERT INTO
"tag_asset" ("assetsId", "tagsId")
VALUES
($1, $2)

View File

@@ -12,6 +12,7 @@ import { PersonEntity } from 'src/entities/person.entity';
import { SessionEntity } from 'src/entities/session.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { StackEntity } from 'src/entities/stack.entity';
import { TagEntity } from 'src/entities/tag.entity';
import { AlbumUserRole } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { Instrumentation } from 'src/utils/instrumentation';
@@ -25,6 +26,7 @@ type IMemoryAccess = IAccessRepository['memory'];
type IPersonAccess = IAccessRepository['person'];
type IPartnerAccess = IAccessRepository['partner'];
type IStackAccess = IAccessRepository['stack'];
type ITagAccess = IAccessRepository['tag'];
type ITimelineAccess = IAccessRepository['timeline'];
@Instrumentation()
@@ -444,6 +446,28 @@ class PartnerAccess implements IPartnerAccess {
}
}
class TagAccess implements ITagAccess {
constructor(private tagRepository: Repository<TagEntity>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, tagIds: Set<string>): Promise<Set<string>> {
if (tagIds.size === 0) {
return new Set();
}
return this.tagRepository
.find({
select: { id: true },
where: {
id: In([...tagIds]),
userId,
},
})
.then((tags) => new Set(tags.map((tag) => tag.id)));
}
}
export class AccessRepository implements IAccessRepository {
activity: IActivityAccess;
album: IAlbumAccess;
@@ -453,6 +477,7 @@ export class AccessRepository implements IAccessRepository {
person: IPersonAccess;
partner: IPartnerAccess;
stack: IStackAccess;
tag: ITagAccess;
timeline: ITimelineAccess;
constructor(
@@ -467,6 +492,7 @@ export class AccessRepository implements IAccessRepository {
@InjectRepository(SharedLinkEntity) sharedLinkRepository: Repository<SharedLinkEntity>,
@InjectRepository(SessionEntity) sessionRepository: Repository<SessionEntity>,
@InjectRepository(StackEntity) stackRepository: Repository<StackEntity>,
@InjectRepository(TagEntity) tagRepository: Repository<TagEntity>,
) {
this.activity = new ActivityAccess(activityRepository, albumRepository);
this.album = new AlbumAccess(albumRepository, sharedLinkRepository);
@@ -476,6 +502,7 @@ export class AccessRepository implements IAccessRepository {
this.person = new PersonAccess(assetFaceRepository, personRepository);
this.partner = new PartnerAccess(partnerRepository);
this.stack = new StackAccess(stackRepository);
this.tag = new TagAccess(tagRepository);
this.timeline = new TimelineAccess(partnerRepository);
}
}

View File

@@ -723,6 +723,15 @@ export class AssetRepository implements IAssetRepository {
builder.andWhere('asset.type = :assetType', { assetType: options.assetType });
}
if (options.tagId) {
builder.innerJoin(
'asset.tags',
'asset_tags',
'asset_tags.id IN (SELECT id_descendant FROM tags_closure WHERE id_ancestor = :tagId)',
{ tagId: options.tagId },
);
}
let stackJoined = false;
if (options.exifInfo !== false) {

View File

@@ -1,33 +1,36 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { AssetEntity } from 'src/entities/asset.entity';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { TagEntity } from 'src/entities/tag.entity';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface';
import { Instrumentation } from 'src/utils/instrumentation';
import { Repository } from 'typeorm';
import { DataSource, In, Repository } from 'typeorm';
@Instrumentation()
@Injectable()
export class TagRepository implements ITagRepository {
constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectDataSource() private dataSource: DataSource,
@InjectRepository(TagEntity) private repository: Repository<TagEntity>,
) {}
getById(userId: string, id: string): Promise<TagEntity | null> {
return this.repository.findOne({
where: {
id,
userId,
},
relations: {
user: true,
},
});
get(id: string): Promise<TagEntity | null> {
return this.repository.findOne({ where: { id } });
}
getAll(userId: string): Promise<TagEntity[]> {
return this.repository.find({ where: { userId } });
getByValue(userId: string, value: string): Promise<TagEntity | null> {
return this.repository.findOne({ where: { userId, value } });
}
async getAll(userId: string): Promise<TagEntity[]> {
const tags = await this.repository.find({
where: { userId },
order: {
value: 'ASC',
},
});
return tags;
}
create(tag: Partial<TagEntity>): Promise<TagEntity> {
@@ -38,89 +41,99 @@ export class TagRepository implements ITagRepository {
return this.save(tag);
}
async remove(tag: TagEntity): Promise<void> {
await this.repository.remove(tag);
async delete(id: string): Promise<void> {
await this.repository.delete(id);
}
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);
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
@ChunkedSet({ paramIndex: 1 })
async getAssetIds(tagId: string, assetIds: string[]): Promise<Set<string>> {
if (assetIds.length === 0) {
return new Set();
}
const results = await this.dataSource
.createQueryBuilder()
.select('tag_asset.assetsId', 'assetId')
.from('tag_asset', 'tag_asset')
.where('"tag_asset"."tagsId" = :tagId', { tagId })
.andWhere('"tag_asset"."assetsId" IN (:...assetIds)', { assetIds })
.getRawMany<{ assetId: string }>();
return new Set(results.map(({ assetId }) => assetId));
}
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);
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
async addAssetIds(tagId: string, assetIds: string[]): Promise<void> {
if (assetIds.length === 0) {
return;
}
await this.dataSource.manager
.createQueryBuilder()
.insert()
.into('tag_asset', ['tagsId', 'assetsId'])
.values(assetIds.map((assetId) => ({ tagsId: tagId, assetsId: assetId })))
.execute();
}
hasAsset(userId: string, tagId: string, assetId: string): Promise<boolean> {
return this.repository.exists({
where: {
id: tagId,
userId,
assets: {
id: assetId,
},
},
relations: {
assets: true,
},
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
@Chunked({ paramIndex: 1 })
async removeAssetIds(tagId: string, assetIds: string[]): Promise<void> {
if (assetIds.length === 0) {
return;
}
await this.dataSource
.createQueryBuilder()
.delete()
.from('tag_asset')
.where({
tagsId: tagId,
assetsId: In(assetIds),
})
.execute();
}
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, tagId: DummyValue.UUID }]] })
@Chunked()
async upsertAssetIds(items: AssetTagItem[]): Promise<AssetTagItem[]> {
if (items.length === 0) {
return [];
}
const { identifiers } = await this.dataSource
.createQueryBuilder()
.insert()
.into('tag_asset', ['assetsId', 'tagsId'])
.values(items.map(({ assetId, tagId }) => ({ assetsId: assetId, tagsId: tagId })))
.execute();
return (identifiers as Array<{ assetsId: string; tagsId: string }>).map(({ assetsId, tagsId }) => ({
assetId: assetsId,
tagId: tagsId,
}));
}
async upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }) {
await this.dataSource.transaction(async (manager) => {
await manager.createQueryBuilder().delete().from('tag_asset').where({ assetsId: assetId }).execute();
if (tagIds.length === 0) {
return;
}
await manager
.createQueryBuilder()
.insert()
.into('tag_asset', ['tagsId', 'assetsId'])
.values(tagIds.map((tagId) => ({ tagsId: tagId, assetsId: assetId })))
.execute();
});
}
hasName(userId: string, name: string): Promise<boolean> {
return this.repository.exists({
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 } });
private async save(partial: Partial<TagEntity>): Promise<TagEntity> {
const { id } = await this.repository.save(partial);
return this.repository.findOneOrFail({ where: { id } });
}
}

View File

@@ -18,11 +18,13 @@ import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { MetadataService, Orientation } from 'src/services/metadata.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { fileStub } from 'test/fixtures/file.stub';
import { probeStub } from 'test/fixtures/media.stub';
import { tagStub } from 'test/fixtures/tag.stub';
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
@@ -37,6 +39,7 @@ import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked } from 'vitest';
@@ -56,6 +59,7 @@ describe(MetadataService.name, () => {
let databaseMock: Mocked<IDatabaseRepository>;
let userMock: Mocked<IUserRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let tagMock: Mocked<ITagRepository>;
let sut: MetadataService;
beforeEach(() => {
@@ -74,6 +78,7 @@ describe(MetadataService.name, () => {
databaseMock = newDatabaseRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
tagMock = newTagRepositoryMock();
sut = new MetadataService(
albumMock,
@@ -89,6 +94,7 @@ describe(MetadataService.name, () => {
personMock,
storageMock,
systemMock,
tagMock,
userMock,
loggerMock,
);
@@ -356,6 +362,72 @@ describe(MetadataService.name, () => {
expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ latitude: null, longitude: null }));
});
it('should extract tags from TagsList', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.readTags.mockResolvedValue({ TagsList: ['Parent'] });
tagMock.getByValue.mockResolvedValue(null);
tagMock.create.mockResolvedValue(tagStub.parent);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(tagMock.create).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
});
it('should extract hierarchy from TagsList', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.readTags.mockResolvedValue({ TagsList: ['Parent/Child'] });
tagMock.getByValue.mockResolvedValue(null);
tagMock.create.mockResolvedValueOnce(tagStub.parent);
tagMock.create.mockResolvedValueOnce(tagStub.child);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(tagMock.create).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined });
expect(tagMock.create).toHaveBeenNthCalledWith(2, {
userId: 'user-id',
value: 'Parent/Child',
parent: tagStub.parent,
});
});
it('should extract tags from Keywords as a string', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.readTags.mockResolvedValue({ Keywords: 'Parent' });
tagMock.getByValue.mockResolvedValue(null);
tagMock.create.mockResolvedValue(tagStub.parent);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(tagMock.create).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
});
it('should extract tags from Keywords as a list', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.readTags.mockResolvedValue({ Keywords: ['Parent'] });
tagMock.getByValue.mockResolvedValue(null);
tagMock.create.mockResolvedValue(tagStub.parent);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(tagMock.create).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
});
it('should extract hierarchal tags from Keywords', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.readTags.mockResolvedValue({ Keywords: 'Parent/Child' });
tagMock.getByValue.mockResolvedValue(null);
tagMock.create.mockResolvedValue(tagStub.parent);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(tagMock.create).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined });
expect(tagMock.create).toHaveBeenNthCalledWith(2, {
userId: 'user-id',
value: 'Parent/Child',
parent: tagStub.parent,
});
});
it('should not apply motion photos if asset is video', async () => {
assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoMotionAsset, isVisible: true }]);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);

View File

@@ -22,8 +22,8 @@ import {
IEntityJob,
IJobRepository,
ISidecarWriteJob,
JOBS_ASSET_PAGINATION_SIZE,
JobName,
JOBS_ASSET_PAGINATION_SIZE,
JobStatus,
QueueName,
} from 'src/interfaces/job.interface';
@@ -35,8 +35,10 @@ import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { usePagination } from 'src/utils/pagination';
import { upsertTags } from 'src/utils/tag';
/** look for a date from these tags (in order) */
const EXIF_DATE_TAGS: Array<keyof Tags> = [
@@ -105,6 +107,7 @@ export class MetadataService {
@Inject(IPersonRepository) personRepository: IPersonRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ITagRepository) private tagRepository: ITagRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
@@ -217,24 +220,27 @@ export class MetadataService {
return JobStatus.FAILED;
}
const { exifData, tags } = await this.exifData(asset);
const { exifData, exifTags } = await this.exifData(asset);
if (asset.type === AssetType.VIDEO) {
await this.applyVideoMetadata(asset, exifData);
}
await this.applyMotionPhotos(asset, tags);
await this.applyMotionPhotos(asset, exifTags);
await this.applyReverseGeocoding(asset, exifData);
await this.applyTagList(asset, exifTags);
await this.assetRepository.upsertExif(exifData);
const dateTimeOriginal = exifData.dateTimeOriginal;
let localDateTime = dateTimeOriginal ?? undefined;
const timeZoneOffset = tzOffset(firstDateTime(tags as Tags)) ?? 0;
const timeZoneOffset = tzOffset(firstDateTime(exifTags as Tags)) ?? 0;
if (dateTimeOriginal && timeZoneOffset) {
localDateTime = new Date(dateTimeOriginal.getTime() + timeZoneOffset * 60_000);
}
await this.assetRepository.update({
id: asset.id,
duration: asset.duration,
@@ -278,22 +284,35 @@ export class MetadataService {
return this.processSidecar(id, false);
}
@OnEmit({ event: 'asset.tag' })
async handleTagAsset({ assetId }: ArgOf<'asset.tag'>) {
await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } });
}
@OnEmit({ event: 'asset.untag' })
async handleUntagAsset({ assetId }: ArgOf<'asset.untag'>) {
await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } });
}
async handleSidecarWrite(job: ISidecarWriteJob): Promise<JobStatus> {
const { id, description, dateTimeOriginal, latitude, longitude, rating } = job;
const [asset] = await this.assetRepository.getByIds([id]);
const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job;
const [asset] = await this.assetRepository.getByIds([id], { tags: true });
if (!asset) {
return JobStatus.FAILED;
}
const tagsList = (asset.tags || []).map((tag) => tag.value);
const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`;
const exif = _.omitBy<Tags>(
{
const exif = _.omitBy(
<Tags>{
Description: description,
ImageDescription: description,
DateTimeOriginal: dateTimeOriginal,
GPSLatitude: latitude,
GPSLongitude: longitude,
Rating: rating,
TagsList: tags ? tagsList : undefined,
},
_.isUndefined,
);
@@ -332,6 +351,28 @@ export class MetadataService {
}
}
private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) {
const tags: string[] = [];
if (exifTags.TagsList) {
tags.push(...exifTags.TagsList);
}
if (exifTags.Keywords) {
let keywords = exifTags.Keywords;
if (typeof keywords === 'string') {
keywords = [keywords];
}
tags.push(...keywords);
}
if (tags.length > 0) {
const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags });
const tagIds = results.map((tag) => tag.id);
await this.tagRepository.upsertAssetTags({ assetId: asset.id, tagIds });
}
}
private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags) {
if (asset.type !== AssetType.IMAGE) {
return;
@@ -466,7 +507,7 @@ export class MetadataService {
private async exifData(
asset: AssetEntity,
): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; tags: ImmichTags }> {
): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; exifTags: ImmichTags }> {
const stats = await this.storageRepository.stat(asset.originalPath);
const mediaTags = await this.repository.readTags(asset.originalPath);
const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : null;
@@ -479,38 +520,38 @@ export class MetadataService {
}
}
const tags = { ...mediaTags, ...sidecarTags };
const exifTags = { ...mediaTags, ...sidecarTags };
this.logger.verbose('Exif Tags', tags);
this.logger.verbose('Exif Tags', exifTags);
const exifData = {
// altitude: tags.GPSAltitude ?? null,
assetId: asset.id,
bitsPerSample: this.getBitsPerSample(tags),
colorspace: tags.ColorSpace ?? null,
dateTimeOriginal: this.getDateTimeOriginal(tags) ?? asset.fileCreatedAt,
description: String(tags.ImageDescription || tags.Description || '').trim(),
exifImageHeight: validate(tags.ImageHeight),
exifImageWidth: validate(tags.ImageWidth),
exposureTime: tags.ExposureTime ?? null,
bitsPerSample: this.getBitsPerSample(exifTags),
colorspace: exifTags.ColorSpace ?? null,
dateTimeOriginal: this.getDateTimeOriginal(exifTags) ?? asset.fileCreatedAt,
description: String(exifTags.ImageDescription || exifTags.Description || '').trim(),
exifImageHeight: validate(exifTags.ImageHeight),
exifImageWidth: validate(exifTags.ImageWidth),
exposureTime: exifTags.ExposureTime ?? null,
fileSizeInByte: stats.size,
fNumber: validate(tags.FNumber),
focalLength: validate(tags.FocalLength),
fps: validate(Number.parseFloat(tags.VideoFrameRate!)),
iso: validate(tags.ISO),
latitude: validate(tags.GPSLatitude),
lensModel: tags.LensModel ?? null,
livePhotoCID: (tags.ContentIdentifier || tags.MediaGroupUUID) ?? null,
autoStackId: this.getAutoStackId(tags),
longitude: validate(tags.GPSLongitude),
make: tags.Make ?? null,
model: tags.Model ?? null,
modifyDate: exifDate(tags.ModifyDate) ?? asset.fileModifiedAt,
orientation: validate(tags.Orientation)?.toString() ?? null,
profileDescription: tags.ProfileDescription || null,
projectionType: tags.ProjectionType ? String(tags.ProjectionType).toUpperCase() : null,
timeZone: tags.tz ?? null,
rating: tags.Rating ?? null,
fNumber: validate(exifTags.FNumber),
focalLength: validate(exifTags.FocalLength),
fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)),
iso: validate(exifTags.ISO),
latitude: validate(exifTags.GPSLatitude),
lensModel: exifTags.LensModel ?? null,
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
autoStackId: this.getAutoStackId(exifTags),
longitude: validate(exifTags.GPSLongitude),
make: exifTags.Make ?? null,
model: exifTags.Model ?? null,
modifyDate: exifDate(exifTags.ModifyDate) ?? asset.fileModifiedAt,
orientation: validate(exifTags.Orientation)?.toString() ?? null,
profileDescription: exifTags.ProfileDescription || null,
projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null,
timeZone: exifTags.tz ?? null,
rating: exifTags.Rating ?? null,
};
if (exifData.latitude === 0 && exifData.longitude === 0) {
@@ -519,7 +560,7 @@ export class MetadataService {
exifData.longitude = null;
}
return { exifData, tags };
return { exifData, exifTags };
}
private getAutoStackId(tags: ImmichTags | null): string | null {

View File

@@ -1,21 +1,28 @@
import { BadRequestException } from '@nestjs/common';
import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { TagType } from 'src/entities/tag.entity';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { IEventRepository } from 'src/interfaces/event.interface';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { TagService } from 'src/services/tag.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock';
import { Mocked } from 'vitest';
describe(TagService.name, () => {
let sut: TagService;
let accessMock: IAccessRepositoryMock;
let eventMock: Mocked<IEventRepository>;
let tagMock: Mocked<ITagRepository>;
beforeEach(() => {
accessMock = newAccessRepositoryMock();
eventMock = newEventRepositoryMock();
tagMock = newTagRepositoryMock();
sut = new TagService(tagMock);
sut = new TagService(accessMock, eventMock, tagMock);
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1']));
});
it('should work', () => {
@@ -30,148 +37,216 @@ describe(TagService.name, () => {
});
});
describe('getById', () => {
describe('get', () => {
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.user.id, 'tag-1');
tagMock.get.mockResolvedValue(null);
await expect(sut.get(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
expect(tagMock.get).toHaveBeenCalledWith('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.user.id, 'tag-1');
tagMock.get.mockResolvedValue(tagStub.tag1);
await expect(sut.get(authStub.admin, 'tag-1')).resolves.toEqual(tagResponseStub.tag1);
expect(tagMock.get).toHaveBeenCalledWith('tag-1');
});
});
describe('create', () => {
it('should throw an error for no parent tag access', async () => {
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set());
await expect(sut.create(authStub.admin, { name: 'tag', parentId: 'tag-parent' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(tagMock.create).not.toHaveBeenCalled();
});
it('should create a tag with a parent', async () => {
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent']));
tagMock.create.mockResolvedValue(tagStub.tag1);
tagMock.get.mockResolvedValueOnce(tagStub.parent);
tagMock.get.mockResolvedValueOnce(tagStub.child);
await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).resolves.toBeDefined();
expect(tagMock.create).toHaveBeenCalledWith(expect.objectContaining({ value: 'Parent/tagA' }));
});
it('should handle invalid parent ids', async () => {
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent']));
await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(tagMock.create).not.toHaveBeenCalled();
});
});
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.user.id, 'tag-1');
tagMock.getByValue.mockResolvedValue(tagStub.tag1);
await expect(sut.create(authStub.admin, { name: 'tag-1' })).rejects.toBeInstanceOf(BadRequestException);
expect(tagMock.getByValue).toHaveBeenCalledWith(authStub.admin.user.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,
);
await expect(sut.create(authStub.admin, { name: 'tag-1' })).resolves.toEqual(tagResponseStub.tag1);
expect(tagMock.create).toHaveBeenCalledWith({
userId: authStub.admin.user.id,
name: 'tag-1',
type: TagType.CUSTOM,
value: 'tag-1',
});
});
});
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.user.id, 'tag-1');
expect(tagMock.remove).not.toHaveBeenCalled();
it('should throw an error for no update permission', async () => {
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set());
await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(tagMock.update).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.user.id, 'tag-1');
expect(tagMock.update).toHaveBeenCalledWith({ id: 'tag-1', name: 'tag-2' });
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1']));
tagMock.update.mockResolvedValue(tagStub.color1);
await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).resolves.toEqual(tagResponseStub.color1);
expect(tagMock.update).toHaveBeenCalledWith({ id: 'tag-1', color: '#000000' });
});
});
describe('upsert', () => {
it('should upsert a new tag', async () => {
tagMock.create.mockResolvedValue(tagStub.parent);
await expect(sut.upsert(authStub.admin, { tags: ['Parent'] })).resolves.toBeDefined();
expect(tagMock.create).toHaveBeenCalledWith({
value: 'Parent',
userId: 'admin_id',
parentId: undefined,
});
});
it('should upsert a nested tag', async () => {
tagMock.getByValue.mockResolvedValueOnce(null);
tagMock.create.mockResolvedValueOnce(tagStub.parent);
tagMock.create.mockResolvedValueOnce(tagStub.child);
await expect(sut.upsert(authStub.admin, { tags: ['Parent/Child'] })).resolves.toBeDefined();
expect(tagMock.create).toHaveBeenNthCalledWith(1, {
value: 'Parent',
userId: 'admin_id',
parentId: undefined,
});
expect(tagMock.create).toHaveBeenNthCalledWith(2, {
value: 'Parent/Child',
userId: 'admin_id',
parent: expect.objectContaining({ id: 'tag-parent' }),
});
});
});
describe('remove', () => {
it('should throw an error for an invalid id', async () => {
tagMock.getById.mockResolvedValue(null);
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set());
await expect(sut.remove(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.remove).not.toHaveBeenCalled();
expect(tagMock.delete).not.toHaveBeenCalled();
});
it('should remove a tag', async () => {
tagMock.getById.mockResolvedValue(tagStub.tag1);
tagMock.get.mockResolvedValue(tagStub.tag1);
await sut.remove(authStub.admin, 'tag-1');
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.remove).toHaveBeenCalledWith(tagStub.tag1);
expect(tagMock.delete).toHaveBeenCalledWith('tag-1');
});
});
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.user.id, 'tag-1');
expect(tagMock.remove).not.toHaveBeenCalled();
describe('bulkTagAssets', () => {
it('should handle invalid requests', async () => {
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set());
tagMock.upsertAssetIds.mockResolvedValue([]);
await expect(sut.bulkTagAssets(authStub.admin, { tagIds: ['tag-1'], assetIds: ['asset-1'] })).resolves.toEqual({
count: 0,
});
expect(tagMock.upsertAssetIds).toHaveBeenCalledWith([]);
});
it('should get the assets for a tag', async () => {
tagMock.getById.mockResolvedValue(tagStub.tag1);
tagMock.getAssets.mockResolvedValue([assetStub.image]);
await sut.getAssets(authStub.admin, 'tag-1');
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.getAssets).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
it('should upsert records', async () => {
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2']));
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
tagMock.upsertAssetIds.mockResolvedValue([
{ tagId: 'tag-1', assetId: 'asset-1' },
{ tagId: 'tag-1', assetId: 'asset-2' },
{ tagId: 'tag-1', assetId: 'asset-3' },
{ tagId: 'tag-2', assetId: 'asset-1' },
{ tagId: 'tag-2', assetId: 'asset-2' },
{ tagId: 'tag-2', assetId: 'asset-3' },
]);
await expect(
sut.bulkTagAssets(authStub.admin, { tagIds: ['tag-1', 'tag-2'], assetIds: ['asset-1', 'asset-2', 'asset-3'] }),
).resolves.toEqual({
count: 6,
});
expect(tagMock.upsertAssetIds).toHaveBeenCalledWith([
{ tagId: 'tag-1', assetId: 'asset-1' },
{ tagId: 'tag-1', assetId: 'asset-2' },
{ tagId: 'tag-1', assetId: 'asset-3' },
{ tagId: 'tag-2', assetId: 'asset-1' },
{ tagId: 'tag-2', assetId: 'asset-2' },
{ tagId: 'tag-2', assetId: 'asset-3' },
]);
});
});
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.user.id, 'tag-1');
expect(tagMock.addAssets).not.toHaveBeenCalled();
it('should handle invalid ids', async () => {
tagMock.get.mockResolvedValue(null);
tagMock.getAssetIds.mockResolvedValue(new Set([]));
await expect(sut.addAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([
{ id: 'asset-1', success: false, error: 'no_permission' },
]);
expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']);
expect(tagMock.addAssetIds).not.toHaveBeenCalled();
});
it('should reject duplicate asset ids and accept new ones', async () => {
tagMock.getById.mockResolvedValue(tagStub.tag1);
tagMock.hasAsset.mockImplementation((userId, tagId, assetId) => Promise.resolve(assetId === 'asset-1'));
it('should accept accept ids that are new and reject the rest', async () => {
tagMock.get.mockResolvedValue(tagStub.tag1);
tagMock.getAssetIds.mockResolvedValue(new Set(['asset-1']));
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2']));
await expect(
sut.addAssets(authStub.admin, 'tag-1', {
assetIds: ['asset-1', 'asset-2'],
ids: ['asset-1', 'asset-2'],
}),
).resolves.toEqual([
{ assetId: 'asset-1', success: false, error: AssetIdErrorReason.DUPLICATE },
{ assetId: 'asset-2', success: true },
{ id: 'asset-1', success: false, error: BulkIdErrorReason.DUPLICATE },
{ id: 'asset-2', success: true },
]);
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.hasAsset).toHaveBeenCalledTimes(2);
expect(tagMock.addAssets).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1', ['asset-2']);
expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
expect(tagMock.addAssetIds).toHaveBeenCalledWith('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.user.id, 'tag-1');
expect(tagMock.removeAssets).not.toHaveBeenCalled();
tagMock.get.mockResolvedValue(null);
tagMock.getAssetIds.mockResolvedValue(new Set());
await expect(sut.removeAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([
{ id: 'asset-1', success: false, error: 'not_found' },
]);
});
it('should accept accept ids that are tagged and reject the rest', async () => {
tagMock.getById.mockResolvedValue(tagStub.tag1);
tagMock.hasAsset.mockImplementation((userId, tagId, assetId) => Promise.resolve(assetId === 'asset-1'));
tagMock.get.mockResolvedValue(tagStub.tag1);
tagMock.getAssetIds.mockResolvedValue(new Set(['asset-1']));
await expect(
sut.removeAssets(authStub.admin, 'tag-1', {
assetIds: ['asset-1', 'asset-2'],
ids: ['asset-1', 'asset-2'],
}),
).resolves.toEqual([
{ assetId: 'asset-1', success: true },
{ assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND },
{ id: 'asset-1', success: true },
{ id: 'asset-2', success: false, error: BulkIdErrorReason.NOT_FOUND },
]);
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.hasAsset).toHaveBeenCalledTimes(2);
expect(tagMock.removeAssets).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1', ['asset-1']);
expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
expect(tagMock.removeAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']);
});
});
});

View File

@@ -1,102 +1,145 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AssetIdsDto } from 'src/dtos/asset.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { CreateTagDto, TagResponseDto, UpdateTagDto, mapTag } from 'src/dtos/tag.dto';
import { ITagRepository } from 'src/interfaces/tag.interface';
import {
TagBulkAssetsDto,
TagBulkAssetsResponseDto,
TagCreateDto,
TagResponseDto,
TagUpdateDto,
TagUpsertDto,
mapTag,
} from 'src/dtos/tag.dto';
import { TagEntity } from 'src/entities/tag.entity';
import { Permission } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface';
import { checkAccess, requireAccess } from 'src/utils/access';
import { addAssets, removeAssets } from 'src/utils/asset.util';
import { upsertTags } from 'src/utils/tag';
@Injectable()
export class TagService {
constructor(@Inject(ITagRepository) private repository: ITagRepository) {}
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ITagRepository) private repository: ITagRepository,
) {}
getAll(auth: AuthDto) {
return this.repository.getAll(auth.user.id).then((tags) => tags.map((tag) => mapTag(tag)));
async getAll(auth: AuthDto) {
const tags = await this.repository.getAll(auth.user.id);
return tags.map((tag) => mapTag(tag));
}
async getById(auth: AuthDto, id: string): Promise<TagResponseDto> {
const tag = await this.findOrFail(auth, id);
async get(auth: AuthDto, id: string): Promise<TagResponseDto> {
await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [id] });
const tag = await this.findOrFail(id);
return mapTag(tag);
}
async create(auth: AuthDto, dto: CreateTagDto) {
const duplicate = await this.repository.hasName(auth.user.id, dto.name);
async create(auth: AuthDto, dto: TagCreateDto) {
let parent: TagEntity | undefined;
if (dto.parentId) {
await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [dto.parentId] });
parent = (await this.repository.get(dto.parentId)) || undefined;
if (!parent) {
throw new BadRequestException('Tag not found');
}
}
const userId = auth.user.id;
const value = parent ? `${parent.value}/${dto.name}` : dto.name;
const duplicate = await this.repository.getByValue(userId, value);
if (duplicate) {
throw new BadRequestException(`A tag with that name already exists`);
}
const tag = await this.repository.create({
userId: auth.user.id,
name: dto.name,
type: dto.type,
});
const tag = await this.repository.create({ userId, value, parent });
return mapTag(tag);
}
async update(auth: AuthDto, id: string, dto: UpdateTagDto): Promise<TagResponseDto> {
await this.findOrFail(auth, id);
const tag = await this.repository.update({ id, name: dto.name });
async update(auth: AuthDto, id: string, dto: TagUpdateDto): Promise<TagResponseDto> {
await requireAccess(this.access, { auth, permission: Permission.TAG_UPDATE, ids: [id] });
const { color } = dto;
const tag = await this.repository.update({ id, color });
return mapTag(tag);
}
async upsert(auth: AuthDto, dto: TagUpsertDto) {
const tags = await upsertTags(this.repository, { userId: auth.user.id, tags: dto.tags });
return tags.map((tag) => mapTag(tag));
}
async remove(auth: AuthDto, id: string): Promise<void> {
const tag = await this.findOrFail(auth, id);
await this.repository.remove(tag);
await requireAccess(this.access, { auth, permission: Permission.TAG_DELETE, ids: [id] });
// TODO sync tag changes for affected assets
await this.repository.delete(id);
}
async getAssets(auth: AuthDto, id: string): Promise<AssetResponseDto[]> {
await this.findOrFail(auth, id);
const assets = await this.repository.getAssets(auth.user.id, id);
return assets.map((asset) => mapAsset(asset));
}
async bulkTagAssets(auth: AuthDto, dto: TagBulkAssetsDto): Promise<TagBulkAssetsResponseDto> {
const [tagIds, assetIds] = await Promise.all([
checkAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: dto.tagIds }),
checkAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }),
]);
async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
await this.findOrFail(auth, id);
const results: AssetIdsResponseDto[] = [];
for (const assetId of dto.assetIds) {
const hasAsset = await this.repository.hasAsset(auth.user.id, id, assetId);
if (hasAsset) {
results.push({ assetId, success: false, error: AssetIdErrorReason.DUPLICATE });
} else {
results.push({ assetId, success: true });
const items: AssetTagItem[] = [];
for (const tagId of tagIds) {
for (const assetId of assetIds) {
items.push({ tagId, assetId });
}
}
await this.repository.addAssets(
auth.user.id,
id,
results.filter((result) => result.success).map((result) => result.assetId),
const results = await this.repository.upsertAssetIds(items);
for (const assetId of new Set(results.map((item) => item.assetId))) {
await this.eventRepository.emit('asset.tag', { assetId });
}
return { count: results.length };
}
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await requireAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: [id] });
const results = await addAssets(
auth,
{ access: this.access, bulk: this.repository },
{ parentId: id, assetIds: dto.ids },
);
for (const { id: assetId, success } of results) {
if (success) {
await this.eventRepository.emit('asset.tag', { assetId });
}
}
return results;
}
async removeAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
await this.findOrFail(auth, id);
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await requireAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: [id] });
const results: AssetIdsResponseDto[] = [];
for (const assetId of dto.assetIds) {
const hasAsset = await this.repository.hasAsset(auth.user.id, id, assetId);
if (hasAsset) {
results.push({ assetId, success: true });
} else {
results.push({ assetId, success: false, error: AssetIdErrorReason.NOT_FOUND });
const results = await removeAssets(
auth,
{ access: this.access, bulk: this.repository },
{ parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.TAG_DELETE },
);
for (const { id: assetId, success } of results) {
if (success) {
await this.eventRepository.emit('asset.untag', { assetId });
}
}
await this.repository.removeAssets(
auth.user.id,
id,
results.filter((result) => result.success).map((result) => result.assetId),
);
return results;
}
private async findOrFail(auth: AuthDto, id: string) {
const tag = await this.repository.getById(auth.user.id, id);
private async findOrFail(id: string) {
const tag = await this.repository.get(id);
if (!tag) {
throw new BadRequestException('Tag not found');
}

View File

@@ -68,6 +68,10 @@ export class TimelineService {
}
}
if (dto.tagId) {
await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [dto.tagId] });
}
if (dto.withPartners) {
const requestedArchived = dto.isArchived === true || dto.isArchived === undefined;
const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false;

View File

@@ -41,7 +41,10 @@ export const requireAccess = async (access: IAccessRepository, request: AccessRe
}
};
export const checkAccess = async (access: IAccessRepository, { ids, auth, permission }: AccessRequest) => {
export const checkAccess = async (
access: IAccessRepository,
{ ids, auth, permission }: AccessRequest,
): Promise<Set<string>> => {
const idSet = Array.isArray(ids) ? new Set(ids) : ids;
if (idSet.size === 0) {
return new Set<string>();
@@ -52,7 +55,10 @@ export const checkAccess = async (access: IAccessRepository, { ids, auth, permis
: checkOtherAccess(access, { auth, permission, ids: idSet });
};
const checkSharedLinkAccess = async (access: IAccessRepository, request: SharedLinkAccessRequest) => {
const checkSharedLinkAccess = async (
access: IAccessRepository,
request: SharedLinkAccessRequest,
): Promise<Set<string>> => {
const { sharedLink, permission, ids } = request;
const sharedLinkId = sharedLink.id;
@@ -96,7 +102,7 @@ const checkSharedLinkAccess = async (access: IAccessRepository, request: SharedL
}
};
const checkOtherAccess = async (access: IAccessRepository, request: OtherAccessRequest) => {
const checkOtherAccess = async (access: IAccessRepository, request: OtherAccessRequest): Promise<Set<string>> => {
const { auth, permission, ids } = request;
switch (permission) {
@@ -211,6 +217,13 @@ const checkOtherAccess = async (access: IAccessRepository, request: OtherAccessR
return await access.authDevice.checkOwnerAccess(auth.user.id, ids);
}
case Permission.TAG_ASSET:
case Permission.TAG_READ:
case Permission.TAG_UPDATE:
case Permission.TAG_DELETE: {
return await access.tag.checkOwnerAccess(auth.user.id, ids);
}
case Permission.TIMELINE_READ: {
const isOwner = ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set<string>();
const isPartner = await access.timeline.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner));

View File

@@ -2,4 +2,4 @@ export const fromChecksum = (checksum: string): Buffer => {
return Buffer.from(checksum, checksum.length === 28 ? 'base64' : 'hex');
};
export const fromMaybeArray = (param: string | string[] | undefined) => (Array.isArray(param) ? param[0] : param);
export const fromMaybeArray = <T>(param: T | T[]) => (Array.isArray(param) ? param[0] : param);

30
server/src/utils/tag.ts Normal file
View File

@@ -0,0 +1,30 @@
import { TagEntity } from 'src/entities/tag.entity';
import { ITagRepository } from 'src/interfaces/tag.interface';
type UpsertRequest = { userId: string; tags: string[] };
export const upsertTags = async (repository: ITagRepository, { userId, tags }: UpsertRequest) => {
tags = [...new Set(tags)];
const results: TagEntity[] = [];
for (const tag of tags) {
const parts = tag.split('/');
let parent: TagEntity | undefined;
for (const part of parts) {
const value = parent ? `${parent.value}/${part}` : part;
let tag = await repository.getByValue(userId, value);
if (!tag) {
tag = await repository.create({ userId, value, parent });
}
parent = tag;
}
if (parent) {
results.push(parent);
}
}
return results;
};