mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
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:
@@ -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],
|
||||
|
||||
@@ -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';
|
||||
|
||||
3
server/libs/domain/src/partner/index.ts
Normal file
3
server/libs/domain/src/partner/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './partner.core';
|
||||
export * from './partner.repository';
|
||||
export * from './partner.service';
|
||||
33
server/libs/domain/src/partner/partner.core.ts
Normal file
33
server/libs/domain/src/partner/partner.core.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
16
server/libs/domain/src/partner/partner.repository.ts
Normal file
16
server/libs/domain/src/partner/partner.repository.ts
Normal 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>;
|
||||
}
|
||||
102
server/libs/domain/src/partner/partner.service.spec.ts
Normal file
102
server/libs/domain/src/partner/partner.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
45
server/libs/domain/src/partner/partner.service.ts
Normal file
45
server/libs/domain/src/partner/partner.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
11
server/libs/domain/test/partner.repository.mock.ts
Normal file
11
server/libs/domain/test/partner.repository.mock.ts
Normal 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(),
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user