feat(web): show partners assets on the main timeline (#4933)

This commit is contained in:
Alex
2023-11-11 15:06:19 -06:00
committed by GitHub
parent 3b11854702
commit 35767591d2
59 changed files with 1929 additions and 172 deletions
+5
View File
@@ -40,6 +40,8 @@ export enum Permission {
PERSON_READ = 'person.read',
PERSON_WRITE = 'person.write',
PERSON_MERGE = 'person.merge',
PARTNER_UPDATE = 'partner.update',
}
let instance: AccessCore | null;
@@ -242,6 +244,9 @@ export class AccessCore {
case Permission.PERSON_MERGE:
return this.repository.person.hasOwnerAccess(authUser.id, id);
case Permission.PARTNER_UPDATE:
return this.repository.partner.hasUpdateAccess(authUser.id, id);
default:
return false;
}
+74 -4
View File
@@ -10,6 +10,7 @@ import {
newCommunicationRepositoryMock,
newCryptoRepositoryMock,
newJobRepositoryMock,
newPartnerRepositoryMock,
newStorageRepositoryMock,
newSystemConfigRepositoryMock,
} from '@test';
@@ -23,6 +24,7 @@ import {
ICommunicationRepository,
ICryptoRepository,
IJobRepository,
IPartnerRepository,
IStorageRepository,
ISystemConfigRepository,
JobItem,
@@ -164,6 +166,7 @@ describe(AssetService.name, () => {
let storageMock: jest.Mocked<IStorageRepository>;
let communicationMock: jest.Mocked<ICommunicationRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>;
let partnerMock: jest.Mocked<IPartnerRepository>;
it('should work', () => {
expect(sut).toBeDefined();
@@ -177,7 +180,18 @@ describe(AssetService.name, () => {
jobMock = newJobRepositoryMock();
storageMock = newStorageRepositoryMock();
configMock = newSystemConfigRepositoryMock();
sut = new AssetService(accessMock, assetMock, cryptoMock, jobMock, configMock, storageMock, communicationMock);
partnerMock = newPartnerRepositoryMock();
sut = new AssetService(
accessMock,
assetMock,
cryptoMock,
jobMock,
configMock,
storageMock,
communicationMock,
partnerMock,
);
when(assetMock.getById)
.calledWith(assetStub.livePhotoStillAsset.id)
@@ -327,7 +341,7 @@ describe(AssetService.name, () => {
size: TimeBucketSize.DAY,
}),
).resolves.toEqual(expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }]));
expect(assetMock.getTimeBuckets).toBeCalledWith({ size: TimeBucketSize.DAY, userId: authStub.admin.id });
expect(assetMock.getTimeBuckets).toBeCalledWith({ size: TimeBucketSize.DAY, userIds: [authStub.admin.id] });
});
});
@@ -363,7 +377,7 @@ describe(AssetService.name, () => {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isArchived: true,
userId: authStub.admin.id,
userIds: [authStub.admin.id],
});
});
@@ -380,9 +394,65 @@ describe(AssetService.name, () => {
expect(assetMock.getTimeBucket).toBeCalledWith('bucket', {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
userId: authStub.admin.id,
userIds: [authStub.admin.id],
});
});
it('should throw an error if withParners is true and isArchived true or undefined', async () => {
await expect(
sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isArchived: true,
withPartners: true,
userId: authStub.admin.id,
}),
).rejects.toThrowError(BadRequestException);
await expect(
sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isArchived: undefined,
withPartners: true,
userId: authStub.admin.id,
}),
).rejects.toThrowError(BadRequestException);
});
it('should throw an error if withParners is true and isFavorite is either true or false', async () => {
await expect(
sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isFavorite: true,
withPartners: true,
userId: authStub.admin.id,
}),
).rejects.toThrowError(BadRequestException);
await expect(
sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isFavorite: false,
withPartners: true,
userId: authStub.admin.id,
}),
).rejects.toThrowError(BadRequestException);
});
it('should throw an error if withParners is true and isTrash is true', async () => {
await expect(
sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isTrashed: true,
withPartners: true,
userId: authStub.admin.id,
}),
).rejects.toThrowError(BadRequestException);
});
});
describe('downloadFile', () => {
+39 -2
View File
@@ -16,9 +16,11 @@ import {
ICommunicationRepository,
ICryptoRepository,
IJobRepository,
IPartnerRepository,
IStorageRepository,
ISystemConfigRepository,
ImmichReadStream,
TimeBucketOptions,
} from '../repositories';
import { StorageCore, StorageFolder } from '../storage';
import { SystemConfigCore } from '../system-config';
@@ -83,6 +85,7 @@ export class AssetService {
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
) {
this.access = AccessCore.create(accessRepository);
this.configCore = SystemConfigCore.create(configRepository);
@@ -187,11 +190,25 @@ export class AssetService {
await this.access.requirePermission(authUser, Permission.ARCHIVE_READ, [dto.userId]);
}
}
if (dto.withPartners) {
const requestedArchived = dto.isArchived === true || dto.isArchived === undefined;
const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false;
const requestedTrash = dto.isTrashed === true;
if (requestedArchived || requestedFavorite || requestedTrash) {
throw new BadRequestException(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
);
}
}
}
async getTimeBuckets(authUser: AuthUserDto, dto: TimeBucketDto): Promise<TimeBucketResponseDto[]> {
await this.timeBucketChecks(authUser, dto);
return this.assetRepository.getTimeBuckets(dto);
const timeBucketOptions = await this.buildTimeBucketOptions(authUser, dto);
return this.assetRepository.getTimeBuckets(timeBucketOptions);
}
async getTimeBucket(
@@ -199,7 +216,8 @@ export class AssetService {
dto: TimeBucketAssetDto,
): Promise<AssetResponseDto[] | SanitizedAssetResponseDto[]> {
await this.timeBucketChecks(authUser, dto);
const assets = await this.assetRepository.getTimeBucket(dto.timeBucket, dto);
const timeBucketOptions = await this.buildTimeBucketOptions(authUser, dto);
const assets = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions);
if (authUser.isShowMetadata) {
return assets.map((asset) => mapAsset(asset, { withStack: true }));
} else {
@@ -207,6 +225,25 @@ export class AssetService {
}
}
async buildTimeBucketOptions(authUser: AuthUserDto, dto: TimeBucketDto): Promise<TimeBucketOptions> {
const { userId, ...options } = dto;
let userIds: string[] | undefined = undefined;
if (userId) {
userIds = [userId];
if (dto.withPartners) {
const partners = await this.partnerRepository.getAll(authUser.id);
const partnersIds = partners
.filter((partner) => partner.sharedBy && partner.sharedWith && partner.inTimeline)
.map((partner) => partner.sharedById);
userIds.push(...partnersIds);
}
}
return { ...options, userIds };
}
async downloadFile(authUser: AuthUserDto, id: string): Promise<ImmichReadStream> {
await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, id);
@@ -38,6 +38,11 @@ export class TimeBucketDto {
@IsBoolean()
@Transform(toBoolean)
withStacked?: boolean;
@Optional()
@IsBoolean()
@Transform(toBoolean)
withPartners?: boolean;
}
export class TimeBucketAssetDto extends TimeBucketDto {
+11
View File
@@ -0,0 +1,11 @@
import { IsNotEmpty } from 'class-validator';
import { UserResponseDto } from '../user';
export class UpdatePartnerDto {
@IsNotEmpty()
inTimeline!: boolean;
}
export class PartnerResponseDto extends UserResponseDto {
inTimeline?: boolean;
}
@@ -1,11 +1,11 @@
import { BadRequestException } from '@nestjs/common';
import { authStub, newPartnerRepositoryMock, partnerStub } from '@test';
import { UserResponseDto } from '../index';
import { IPartnerRepository, PartnerDirection } from '../repositories';
import { IAccessRepository, IPartnerRepository, PartnerDirection } from '../repositories';
import { PartnerResponseDto } from './partner.dto';
import { PartnerService } from './partner.service';
const responseDto = {
admin: <UserResponseDto>{
admin: <PartnerResponseDto>{
email: 'admin@test.com',
firstName: 'admin_first_name',
id: 'admin_id',
@@ -20,8 +20,9 @@ const responseDto = {
updatedAt: new Date('2021-01-01'),
externalPath: null,
memoriesEnabled: true,
inTimeline: true,
},
user1: <UserResponseDto>{
user1: <PartnerResponseDto>{
email: 'immich@test.com',
firstName: 'immich_first_name',
id: 'user-id',
@@ -36,16 +37,18 @@ const responseDto = {
updatedAt: new Date('2021-01-01'),
externalPath: null,
memoriesEnabled: true,
inTimeline: true,
},
};
describe(PartnerService.name, () => {
let sut: PartnerService;
let partnerMock: jest.Mocked<IPartnerRepository>;
let accessMock: jest.Mocked<IAccessRepository>;
beforeEach(async () => {
partnerMock = newPartnerRepositoryMock();
sut = new PartnerService(partnerMock);
sut = new PartnerService(partnerMock, accessMock);
});
it('should work', () => {
+29 -7
View File
@@ -1,14 +1,22 @@
import { PartnerEntity } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { AccessCore, Permission } from '../access';
import { AuthUserDto } from '../auth';
import { IPartnerRepository, PartnerDirection, PartnerIds } from '../repositories';
import { UserResponseDto, mapUser } from '../user';
import { IAccessRepository, IPartnerRepository, PartnerDirection, PartnerIds } from '../repositories';
import { mapUser } from '../user';
import { PartnerResponseDto, UpdatePartnerDto } from './partner.dto';
@Injectable()
export class PartnerService {
constructor(@Inject(IPartnerRepository) private repository: IPartnerRepository) {}
private access: AccessCore;
constructor(
@Inject(IPartnerRepository) private repository: IPartnerRepository,
@Inject(IAccessRepository) accessRepository: IAccessRepository,
) {
this.access = AccessCore.create(accessRepository);
}
async create(authUser: AuthUserDto, sharedWithId: string): Promise<UserResponseDto> {
async create(authUser: AuthUserDto, sharedWithId: string): Promise<PartnerResponseDto> {
const partnerId: PartnerIds = { sharedById: authUser.id, sharedWithId };
const exists = await this.repository.get(partnerId);
if (exists) {
@@ -29,7 +37,7 @@ export class PartnerService {
await this.repository.remove(partner);
}
async getAll(authUser: AuthUserDto, direction: PartnerDirection): Promise<UserResponseDto[]> {
async getAll(authUser: AuthUserDto, direction: PartnerDirection): Promise<PartnerResponseDto[]> {
const partners = await this.repository.getAll(authUser.id);
const key = direction === PartnerDirection.SharedBy ? 'sharedById' : 'sharedWithId';
return partners
@@ -38,8 +46,22 @@ export class PartnerService {
.map((partner) => this.map(partner, direction));
}
private map(partner: PartnerEntity, direction: PartnerDirection): UserResponseDto {
async update(authUser: AuthUserDto, sharedById: string, dto: UpdatePartnerDto): Promise<PartnerResponseDto> {
await this.access.requirePermission(authUser, Permission.PARTNER_UPDATE, sharedById);
const partnerId: PartnerIds = { sharedById, sharedWithId: authUser.id };
const entity = await this.repository.update({ ...partnerId, inTimeline: dto.inTimeline });
return this.map(entity, PartnerDirection.SharedWith);
}
private map(partner: PartnerEntity, direction: PartnerDirection): PartnerResponseDto {
// this is opposite to return the non-me user of the "partner"
return mapUser(direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy);
const user = mapUser(
direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy,
) as PartnerResponseDto;
user.inTimeline = partner.inTimeline;
return user;
}
}
@@ -35,4 +35,8 @@ export interface IAccessRepository {
person: {
hasOwnerAccess(userId: string, personId: string): Promise<boolean>;
};
partner: {
hasUpdateAccess(userId: string, partnerId: string): Promise<boolean>;
};
}
@@ -65,7 +65,7 @@ export interface TimeBucketOptions {
isTrashed?: boolean;
albumId?: string;
personId?: string;
userId?: string;
userIds?: string[];
withStacked?: boolean;
}
@@ -17,4 +17,5 @@ export interface IPartnerRepository {
get(partner: PartnerIds): Promise<PartnerEntity | null>;
create(partner: PartnerIds): Promise<PartnerEntity>;
remove(entity: PartnerEntity): Promise<void>;
update(entity: Partial<PartnerEntity>): Promise<PartnerEntity>;
}