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:
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user