feat (server, web): Share with partner (#2388)

* feat(server, web): implement share with partner

* chore: regenerate api

* chore: regenerate api

* Pass userId to getAssetCountByTimeBucket and getAssetByTimeBucket

* chore: regenerate api

* Use AssetGrid to view partner's assets

* Remove disableNavBarActions flag

* Check access to buckets

* Apply suggestions from code review

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>

* Remove exception rethrowing

* Simplify partner access check

* Create new PartnerController

* chore api:generate

* Use partnerApi

* Remove id from PartnerResponseDto

* Refactor PartnerEntity

* Rename args

* Remove duplicate code in getAll

* Create composite primary keys for partners table

* Move asset access check into PartnerCore

* Remove redundant getUserAssets call

* Remove unused getUserAssets method

* chore: regenerate api

* Simplify getAll

* Replace ?? with ||

* Simplify PartnerRepository.create

* Introduce PartnerIds interface

* Replace two database migrations with one

* Simplify getAll

* Change PartnerResponseDto to include UserResponseDto

* Move partner sharing endpoints to PartnerController

* Rename ShareController to SharedLinkController

* chore: regenerate api after rebase

* refactor: shared link remove return type

* refactor: return user response dto

* chore: regenerate open api

* refactor: partner getAll

* refactor: partner settings event typing

* chore: remove unused code

* refactor: add partners modal trigger

* refactor: update url for viewing partner photos

* feat: update partner sharing title

* refactor: rename service method names

* refactor: http exception logic to service, PartnerIds interface

* chore: regenerate open api

* test: coverage for domain code

* fix: addPartner => createPartner

* fix: missed rename

* refactor: more code cleanup

* chore: alphabetize settings order

* feat: stop sharing confirmation modal

* Enhance contrast of the email in dark mode

* Replace button with CircleIconButton

* Fix linter warning

* Fix date types for PartnerEntity

* Fix PartnerEntity creation

* Reset assetStore state

* Change layout of the partner's assets page

* Add bulk download action for partner's assets

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Sergey Kondrikov
2023-05-15 20:30:53 +03:00
committed by GitHub
parent 4524aa0d06
commit 7f2fa23179
55 changed files with 1669 additions and 92 deletions

View File

@@ -6,31 +6,33 @@ import { AuthService } from './auth';
import { JobService } from './job';
import { MediaService } from './media';
import { OAuthService } from './oauth';
import { PartnerService } from './partner';
import { SearchService } from './search';
import { ServerInfoService } from './server-info';
import { ShareService } from './share';
import { SmartInfoService } from './smart-info';
import { StorageService } from './storage';
import { StorageTemplateService } from './storage-template';
import { INITIAL_SYSTEM_CONFIG, SystemConfigService } from './system-config';
import { UserService } from './user';
import { INITIAL_SYSTEM_CONFIG, SystemConfigService } from './system-config';
const providers: Provider[] = [
AlbumService,
AssetService,
APIKeyService,
AssetService,
AuthService,
JobService,
MediaService,
OAuthService,
PartnerService,
SearchService,
ServerInfoService,
ShareService,
SmartInfoService,
StorageService,
StorageTemplateService,
SystemConfigService,
UserService,
ShareService,
SearchService,
{
provide: INITIAL_SYSTEM_CONFIG,
inject: [SystemConfigService],

View File

@@ -14,6 +14,7 @@ export * from './metadata';
export * from './oauth';
export * from './search';
export * from './server-info';
export * from './partner';
export * from './share';
export * from './smart-info';
export * from './storage';

View File

@@ -0,0 +1,3 @@
export * from './partner.core';
export * from './partner.repository';
export * from './partner.service';

View File

@@ -0,0 +1,33 @@
import { PartnerEntity } from '@app/infra/entities';
import { IPartnerRepository, PartnerIds } from './partner.repository';
export enum PartnerDirection {
SharedBy = 'shared-by',
SharedWith = 'shared-with',
}
export class PartnerCore {
constructor(private repository: IPartnerRepository) {}
async getAll(userId: string, direction: PartnerDirection): Promise<PartnerEntity[]> {
const partners = await this.repository.getAll(userId);
const key = direction === PartnerDirection.SharedBy ? 'sharedById' : 'sharedWithId';
return partners.filter((partner) => partner[key] === userId);
}
get(ids: PartnerIds): Promise<PartnerEntity | null> {
return this.repository.get(ids);
}
async create(ids: PartnerIds): Promise<PartnerEntity> {
return this.repository.create(ids);
}
async remove(ids: PartnerIds): Promise<void> {
await this.repository.remove(ids as PartnerEntity);
}
hasAssetAccess(assetId: string, userId: string): Promise<boolean> {
return this.repository.hasAssetAccess(assetId, userId);
}
}

View File

@@ -0,0 +1,16 @@
import { PartnerEntity } from '@app/infra/entities';
export interface PartnerIds {
sharedById: string;
sharedWithId: string;
}
export const IPartnerRepository = 'IPartnerRepository';
export interface IPartnerRepository {
getAll(userId: string): Promise<PartnerEntity[]>;
get(partner: PartnerIds): Promise<PartnerEntity | null>;
create(partner: PartnerIds): Promise<PartnerEntity>;
remove(entity: PartnerEntity): Promise<void>;
hasAssetAccess(assetId: string, userId: string): Promise<boolean>;
}

View File

@@ -0,0 +1,102 @@
import { BadRequestException } from '@nestjs/common';
import { authStub, newPartnerRepositoryMock, partnerStub } from '../../test';
import { PartnerDirection } from './partner.core';
import { IPartnerRepository } from './partner.repository';
import { PartnerService } from './partner.service';
const responseDto = {
admin: {
createdAt: '2021-01-01',
deletedAt: undefined,
email: 'admin@test.com',
firstName: 'admin_first_name',
id: 'admin_id',
isAdmin: true,
lastName: 'admin_last_name',
oauthId: '',
profileImagePath: '',
shouldChangePassword: false,
updatedAt: '2021-01-01',
},
user1: {
createdAt: '2021-01-01',
deletedAt: undefined,
email: 'immich@test.com',
firstName: 'immich_first_name',
id: 'immich_id',
isAdmin: false,
lastName: 'immich_last_name',
oauthId: '',
profileImagePath: '',
shouldChangePassword: false,
updatedAt: '2021-01-01',
},
};
describe(PartnerService.name, () => {
let sut: PartnerService;
let partnerMock: jest.Mocked<IPartnerRepository>;
beforeEach(async () => {
partnerMock = newPartnerRepositoryMock();
sut = new PartnerService(partnerMock);
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('getAll', () => {
it("should return a list of partners with whom I've shared my library", async () => {
partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]);
await expect(sut.getAll(authStub.user1, PartnerDirection.SharedBy)).resolves.toEqual([responseDto.admin]);
expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.id);
});
it('should return a list of partners who have shared their libraries with me', async () => {
partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]);
await expect(sut.getAll(authStub.user1, PartnerDirection.SharedWith)).resolves.toEqual([responseDto.admin]);
expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.id);
});
});
describe('create', () => {
it('should create a new partner', async () => {
partnerMock.get.mockResolvedValue(null);
partnerMock.create.mockResolvedValue(partnerStub.adminToUser1);
await expect(sut.create(authStub.admin, authStub.user1.id)).resolves.toEqual(responseDto.user1);
expect(partnerMock.create).toHaveBeenCalledWith({
sharedById: authStub.admin.id,
sharedWithId: authStub.user1.id,
});
});
it('should throw an error when the partner already exists', async () => {
partnerMock.get.mockResolvedValue(partnerStub.adminToUser1);
await expect(sut.create(authStub.admin, authStub.user1.id)).rejects.toBeInstanceOf(BadRequestException);
expect(partnerMock.create).not.toHaveBeenCalled();
});
});
describe('remove', () => {
it('should remove a partner', async () => {
partnerMock.get.mockResolvedValue(partnerStub.adminToUser1);
await sut.remove(authStub.admin, authStub.user1.id);
expect(partnerMock.remove).toHaveBeenCalledWith(partnerStub.adminToUser1);
});
it('should throw an error when the partner does not exist', async () => {
partnerMock.get.mockResolvedValue(null);
await expect(sut.remove(authStub.admin, authStub.user1.id)).rejects.toBeInstanceOf(BadRequestException);
expect(partnerMock.remove).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,45 @@
import { PartnerEntity } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { AuthUserDto } from '../auth';
import { IPartnerRepository, PartnerCore, PartnerDirection, PartnerIds } from '../partner';
import { mapUser, UserResponseDto } from '../user';
@Injectable()
export class PartnerService {
private partnerCore: PartnerCore;
constructor(@Inject(IPartnerRepository) partnerRepository: IPartnerRepository) {
this.partnerCore = new PartnerCore(partnerRepository);
}
async create(authUser: AuthUserDto, sharedWithId: string): Promise<UserResponseDto> {
const partnerId: PartnerIds = { sharedById: authUser.id, sharedWithId };
const exists = await this.partnerCore.get(partnerId);
if (exists) {
throw new BadRequestException(`Partner already exists`);
}
const partner = await this.partnerCore.create(partnerId);
return this.map(partner, PartnerDirection.SharedBy);
}
async remove(authUser: AuthUserDto, sharedWithId: string): Promise<void> {
const partnerId: PartnerIds = { sharedById: authUser.id, sharedWithId };
const partner = await this.partnerCore.get(partnerId);
if (!partner) {
throw new BadRequestException('Partner not found');
}
await this.partnerCore.remove(partner);
}
async getAll(authUser: AuthUserDto, direction: PartnerDirection): Promise<UserResponseDto[]> {
const partners = await this.partnerCore.getAll(authUser.id, direction);
return partners.map((partner) => this.map(partner, direction));
}
private map(partner: PartnerEntity, direction: PartnerDirection): UserResponseDto {
// this is opposite to return the non-me user of the "partner"
return mapUser(direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy);
}
}

View File

@@ -1,11 +1,5 @@
import { AssetEntity, SharedLinkEntity } from '@app/infra/entities';
import {
BadRequestException,
ForbiddenException,
InternalServerErrorException,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { BadRequestException, ForbiddenException, Logger, UnauthorizedException } from '@nestjs/common';
import { AuthUserDto } from '../auth';
import { ICryptoRepository } from '../crypto';
import { CreateSharedLinkDto } from './dto';
@@ -25,24 +19,19 @@ export class ShareCore {
}
create(userId: string, dto: CreateSharedLinkDto): Promise<SharedLinkEntity> {
try {
return this.repository.create({
key: Buffer.from(this.cryptoRepository.randomBytes(50)),
description: dto.description,
userId,
createdAt: new Date().toISOString(),
expiresAt: dto.expiresAt ?? null,
type: dto.type,
assets: dto.assets,
album: dto.album,
allowUpload: dto.allowUpload ?? false,
allowDownload: dto.allowDownload ?? true,
showExif: dto.showExif ?? true,
});
} catch (error: any) {
this.logger.error(error, error.stack);
throw new InternalServerErrorException('failed to create shared link');
}
return this.repository.create({
key: Buffer.from(this.cryptoRepository.randomBytes(50)),
description: dto.description,
userId,
createdAt: new Date().toISOString(),
expiresAt: dto.expiresAt ?? null,
type: dto.type,
assets: dto.assets,
album: dto.album,
allowUpload: dto.allowUpload ?? false,
allowDownload: dto.allowDownload ?? true,
showExif: dto.showExif ?? true,
});
}
async save(userId: string, id: string, entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity> {
@@ -54,13 +43,13 @@ export class ShareCore {
return this.repository.save({ ...entity, userId, id });
}
async remove(userId: string, id: string): Promise<SharedLinkEntity> {
async remove(userId: string, id: string): Promise<void> {
const link = await this.get(userId, id);
if (!link) {
throw new BadRequestException('Shared link not found');
}
return this.repository.remove(link);
await this.repository.remove(link);
}
async addAssets(userId: string, id: string, assets: AssetEntity[]) {

View File

@@ -7,7 +7,7 @@ export interface ISharedLinkRepository {
get(userId: string, id: string): Promise<SharedLinkEntity | null>;
getByKey(key: string): Promise<SharedLinkEntity | null>;
create(entity: Omit<SharedLinkEntity, 'id' | 'user'>): Promise<SharedLinkEntity>;
remove(entity: SharedLinkEntity): Promise<SharedLinkEntity>;
remove(entity: SharedLinkEntity): Promise<void>;
save(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity>;
hasAssetAccess(id: string, assetId: string): Promise<boolean>;
}

View File

@@ -3,6 +3,7 @@ import {
APIKeyEntity,
AssetEntity,
AssetType,
PartnerEntity,
SharedLinkEntity,
SharedLinkType,
SystemConfig,
@@ -824,3 +825,22 @@ export const probeStub = {
},
}),
};
export const partnerStub = {
adminToUser1: Object.freeze<PartnerEntity>({
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
sharedById: userEntityStub.admin.id,
sharedBy: userEntityStub.admin,
sharedWith: userEntityStub.user1,
sharedWithId: userEntityStub.user1.id,
}),
user1ToAdmin1: Object.freeze<PartnerEntity>({
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
sharedBy: userEntityStub.user1,
sharedById: userEntityStub.user1.id,
sharedWithId: userEntityStub.admin.id,
sharedWith: userEntityStub.admin,
}),
};

View File

@@ -7,6 +7,7 @@ export * from './fixtures';
export * from './job.repository.mock';
export * from './machine-learning.repository.mock';
export * from './media.repository.mock';
export * from './partner.repository.mock';
export * from './search.repository.mock';
export * from './shared-link.repository.mock';
export * from './smart-info.repository.mock';

View File

@@ -0,0 +1,11 @@
import { IPartnerRepository } from '../src';
export const newPartnerRepositoryMock = (): jest.Mocked<IPartnerRepository> => {
return {
create: jest.fn(),
remove: jest.fn(),
getAll: jest.fn(),
get: jest.fn(),
hasAssetAccess: jest.fn(),
};
};