refactor(server,web): add/remove album users (#2681)

* refactor(server,web): add/remove album users

* fix(web): bug fixes for multiple users

* fix: linting
This commit is contained in:
Jason Rasmussen
2023-06-07 10:37:25 -04:00
committed by GitHub
parent 284edd97d6
commit eb1225a0a5
15 changed files with 521 additions and 329 deletions

View File

@@ -1,18 +1,15 @@
import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/entities';
import { AlbumEntity, AssetEntity } from '@app/infra/entities';
import { dataSource } from '@app/infra/database.config';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AddAssetsDto } from './dto/add-assets.dto';
import { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
export interface IAlbumRepository {
get(albumId: string): Promise<AlbumEntity | null>;
addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity>;
removeUser(album: AlbumEntity, userId: string): Promise<void>;
removeAssets(album: AlbumEntity, removeAssets: RemoveAssetsDto): Promise<number>;
addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AddAssetsResponseDto>;
updateThumbnails(): Promise<number | undefined>;
@@ -25,11 +22,8 @@ export const IAlbumRepository = 'IAlbumRepository';
@Injectable()
export class AlbumRepository implements IAlbumRepository {
constructor(
@InjectRepository(AlbumEntity)
private albumRepository: Repository<AlbumEntity>,
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
@InjectRepository(AlbumEntity) private albumRepository: Repository<AlbumEntity>,
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
) {}
async getCountByUserId(userId: string): Promise<AlbumCountResponseDto> {
@@ -59,22 +53,6 @@ export class AlbumRepository implements IAlbumRepository {
});
}
async addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity> {
album.sharedUsers.push(...addUsersDto.sharedUserIds.map((id) => ({ id } as UserEntity)));
album.updatedAt = new Date();
await this.albumRepository.save(album);
// need to re-load the shared user relation
return this.get(album.id) as Promise<AlbumEntity>;
}
async removeUser(album: AlbumEntity, userId: string): Promise<void> {
album.sharedUsers = album.sharedUsers.filter((user) => user.id !== userId);
album.updatedAt = new Date();
await this.albumRepository.save(album);
}
async removeAssets(album: AlbumEntity, removeAssetsDto: RemoveAssetsDto): Promise<number> {
const assetCount = album.assets.length;

View File

@@ -1,10 +1,8 @@
import { Controller, Get, Post, Body, Param, Delete, Put, Query, Response } from '@nestjs/common';
import { ParseMeUUIDPipe } from '../validation/parse-me-uuid-pipe';
import { AlbumService } from './album.service';
import { Authenticated, SharedLinkRoute } from '../../decorators/authenticated.decorator';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { AddAssetsDto } from './dto/add-assets.dto';
import { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { AlbumResponseDto } from '@app/domain';
@@ -29,12 +27,6 @@ export class AlbumController {
return this.service.getCountByUserId(authUser);
}
@Put(':id/users')
addUsersToAlbum(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: AddUsersDto) {
// TODO: Handle nonexistent sharedUserIds.
return this.service.addUsers(authUser, id, dto);
}
@SharedLinkRoute()
@Put(':id/assets')
addAssetsToAlbum(
@@ -62,15 +54,6 @@ export class AlbumController {
return this.service.removeAssets(authUser, id, dto);
}
@Delete(':id/user/:userId')
removeUserFromAlbum(
@GetAuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Param('userId', new ParseMeUUIDPipe({ version: '4' })) userId: string,
) {
return this.service.removeUser(authUser, id, userId);
}
@SharedLinkRoute()
@Get(':id/download')
@ApiOkResponse({ content: { 'application/zip': { schema: { type: 'string', format: 'binary' } } } })

View File

@@ -1,6 +1,6 @@
import { AlbumService } from './album.service';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
import { NotFoundException, ForbiddenException } from '@nestjs/common';
import { AlbumEntity, UserEntity } from '@app/infra/entities';
import { AlbumResponseDto, ICryptoRepository, mapUser } from '@app/domain';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
@@ -39,7 +39,6 @@ describe('Album service', () => {
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
const sharedAlbumOwnerId = '2222';
const sharedAlbumSharedAlsoWithId = '3333';
const ownedAlbumSharedWithId = '4444';
const _getOwnedAlbum = () => {
const albumEntity = new AlbumEntity();
@@ -56,25 +55,6 @@ describe('Album service', () => {
return albumEntity;
};
const _getOwnedSharedAlbum = () => {
const albumEntity = new AlbumEntity();
albumEntity.ownerId = albumOwner.id;
albumEntity.owner = albumOwner;
albumEntity.id = albumId;
albumEntity.albumName = 'name';
albumEntity.createdAt = new Date('2022-06-19T23:41:36.910Z');
albumEntity.assets = [];
albumEntity.albumThumbnailAssetId = null;
albumEntity.sharedUsers = [
{
...userEntityStub.user1,
id: ownedAlbumSharedWithId,
},
];
return albumEntity;
};
const _getSharedWithAuthUserAlbum = () => {
const albumEntity = new AlbumEntity();
albumEntity.ownerId = sharedAlbumOwnerId;
@@ -115,10 +95,8 @@ describe('Album service', () => {
beforeAll(() => {
albumRepositoryMock = {
addAssets: jest.fn(),
addSharedUsers: jest.fn(),
get: jest.fn(),
removeAssets: jest.fn(),
removeUser: jest.fn(),
updateThumbnails: jest.fn(),
getCountByUserId: jest.fn(),
getSharedWithUserAlbumCount: jest.fn(),
@@ -188,53 +166,6 @@ describe('Album service', () => {
await expect(sut.get(authUser, '0002')).rejects.toBeInstanceOf(NotFoundException);
});
it('removes a shared user from an owned album', async () => {
const albumEntity = _getOwnedSharedAlbum();
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.removeUser.mockImplementation(() => Promise.resolve());
await expect(sut.removeUser(authUser, albumEntity.id, ownedAlbumSharedWithId)).resolves.toBeUndefined();
expect(albumRepositoryMock.removeUser).toHaveBeenCalledTimes(1);
expect(albumRepositoryMock.removeUser).toHaveBeenCalledWith(albumEntity, ownedAlbumSharedWithId);
});
it('prevents removing a shared user from a not owned album (shared with auth user)', async () => {
const albumEntity = _getSharedWithAuthUserAlbum();
const albumId = albumEntity.id;
const userIdToRemove = sharedAlbumSharedAlsoWithId;
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
await expect(sut.removeUser(authUser, albumId, userIdToRemove)).rejects.toBeInstanceOf(ForbiddenException);
expect(albumRepositoryMock.removeUser).not.toHaveBeenCalled();
});
it('removes itself from a shared album', async () => {
const albumEntity = _getSharedWithAuthUserAlbum();
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.removeUser.mockImplementation(() => Promise.resolve());
await sut.removeUser(authUser, albumEntity.id, authUser.id);
expect(albumRepositoryMock.removeUser).toHaveReturnedTimes(1);
expect(albumRepositoryMock.removeUser).toHaveBeenCalledWith(albumEntity, authUser.id);
});
it('removes itself from a shared album using "me" as id', async () => {
const albumEntity = _getSharedWithAuthUserAlbum();
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.removeUser.mockImplementation(() => Promise.resolve());
await sut.removeUser(authUser, albumEntity.id, 'me');
expect(albumRepositoryMock.removeUser).toHaveReturnedTimes(1);
expect(albumRepositoryMock.removeUser).toHaveBeenCalledWith(albumEntity, authUser.id);
});
it('prevents removing itself from a owned album', async () => {
const albumEntity = _getOwnedAlbum();
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
await expect(sut.removeUser(authUser, albumEntity.id, authUser.id)).rejects.toBeInstanceOf(BadRequestException);
});
it('adds assets to owned album', async () => {
const albumEntity = _getOwnedAlbum();

View File

@@ -1,7 +1,6 @@
import { BadRequestException, Inject, Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { AlbumEntity, SharedLinkType } from '@app/infra/entities';
import { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { AlbumResponseDto, mapAlbum } from '@app/domain';
import { IAlbumRepository } from './album-repository';
@@ -63,24 +62,6 @@ export class AlbumService {
return mapAlbum(album);
}
async addUsers(authUser: AuthUserDto, albumId: string, dto: AddUsersDto): Promise<AlbumResponseDto> {
const album = await this._getAlbum({ authUser, albumId });
const updatedAlbum = await this.albumRepository.addSharedUsers(album, dto);
return mapAlbum(updatedAlbum);
}
async removeUser(authUser: AuthUserDto, albumId: string, userId: string | 'me'): Promise<void> {
const sharedUserId = userId == 'me' ? authUser.id : userId;
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
if (album.ownerId != authUser.id && authUser.id != sharedUserId) {
throw new ForbiddenException('Cannot remove a user from a album that is not owned');
}
if (album.ownerId == sharedUserId) {
throw new BadRequestException('The owner of the album cannot be removed');
}
await this.albumRepository.removeUser(album, sharedUserId);
}
async removeAssets(authUser: AuthUserDto, albumId: string, dto: RemoveAssetsDto): Promise<AlbumResponseDto> {
const album = await this._getAlbum({ authUser, albumId });
const deletedCount = await this.albumRepository.removeAssets(album, dto);

View File

@@ -1,4 +1,4 @@
import { ValidateUUID } from 'apps/immich/src/decorators/validate-uuid.decorator';
import { ValidateUUID } from '../../../../../../apps/immich/src/decorators/validate-uuid.decorator';
export class AddUsersDto {
@ValidateUUID({ each: true })

View File

@@ -1,7 +1,8 @@
/* */ import { AlbumService, AuthUserDto, CreateAlbumDto, UpdateAlbumDto } from '@app/domain';
import { AddUsersDto, AlbumService, AuthUserDto, CreateAlbumDto, UpdateAlbumDto } from '@app/domain';
import { GetAlbumsDto } from '@app/domain/album/dto/get-albums.dto';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ParseMeUUIDPipe } from '../api-v1/validation/parse-me-uuid-pipe';
import { GetAuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
@@ -33,4 +34,18 @@ export class AlbumController {
deleteAlbum(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
return this.service.delete(authUser, id);
}
@Put(':id/users')
addUsersToAlbum(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: AddUsersDto) {
return this.service.addUsers(authUser, id, dto);
}
@Delete(':id/user/:userId')
removeUserFromAlbum(
@GetAuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Param('userId', new ParseMeUUIDPipe({ version: '4' })) userId: string,
) {
return this.service.removeUser(authUser, id, userId);
}
}