mirror of
https://github.com/immich-app/immich.git
synced 2024-12-26 10:50:29 +02:00
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:
parent
284edd97d6
commit
eb1225a0a5
@ -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 { dataSource } from '@app/infra/database.config';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { AddAssetsDto } from './dto/add-assets.dto';
|
import { AddAssetsDto } from './dto/add-assets.dto';
|
||||||
import { AddUsersDto } from './dto/add-users.dto';
|
|
||||||
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
||||||
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
|
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
|
||||||
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
|
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
|
||||||
|
|
||||||
export interface IAlbumRepository {
|
export interface IAlbumRepository {
|
||||||
get(albumId: string): Promise<AlbumEntity | null>;
|
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>;
|
removeAssets(album: AlbumEntity, removeAssets: RemoveAssetsDto): Promise<number>;
|
||||||
addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AddAssetsResponseDto>;
|
addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AddAssetsResponseDto>;
|
||||||
updateThumbnails(): Promise<number | undefined>;
|
updateThumbnails(): Promise<number | undefined>;
|
||||||
@ -25,11 +22,8 @@ export const IAlbumRepository = 'IAlbumRepository';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class AlbumRepository implements IAlbumRepository {
|
export class AlbumRepository implements IAlbumRepository {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(AlbumEntity)
|
@InjectRepository(AlbumEntity) private albumRepository: Repository<AlbumEntity>,
|
||||||
private albumRepository: Repository<AlbumEntity>,
|
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
||||||
|
|
||||||
@InjectRepository(AssetEntity)
|
|
||||||
private assetRepository: Repository<AssetEntity>,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getCountByUserId(userId: string): Promise<AlbumCountResponseDto> {
|
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> {
|
async removeAssets(album: AlbumEntity, removeAssetsDto: RemoveAssetsDto): Promise<number> {
|
||||||
const assetCount = album.assets.length;
|
const assetCount = album.assets.length;
|
||||||
|
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
import { Controller, Get, Post, Body, Param, Delete, Put, Query, Response } from '@nestjs/common';
|
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 { AlbumService } from './album.service';
|
||||||
import { Authenticated, SharedLinkRoute } from '../../decorators/authenticated.decorator';
|
import { Authenticated, SharedLinkRoute } from '../../decorators/authenticated.decorator';
|
||||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||||
import { AddAssetsDto } from './dto/add-assets.dto';
|
import { AddAssetsDto } from './dto/add-assets.dto';
|
||||||
import { AddUsersDto } from './dto/add-users.dto';
|
|
||||||
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
||||||
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { AlbumResponseDto } from '@app/domain';
|
import { AlbumResponseDto } from '@app/domain';
|
||||||
@ -29,12 +27,6 @@ export class AlbumController {
|
|||||||
return this.service.getCountByUserId(authUser);
|
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()
|
@SharedLinkRoute()
|
||||||
@Put(':id/assets')
|
@Put(':id/assets')
|
||||||
addAssetsToAlbum(
|
addAssetsToAlbum(
|
||||||
@ -62,15 +54,6 @@ export class AlbumController {
|
|||||||
return this.service.removeAssets(authUser, id, dto);
|
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()
|
@SharedLinkRoute()
|
||||||
@Get(':id/download')
|
@Get(':id/download')
|
||||||
@ApiOkResponse({ content: { 'application/zip': { schema: { type: 'string', format: 'binary' } } } })
|
@ApiOkResponse({ content: { 'application/zip': { schema: { type: 'string', format: 'binary' } } } })
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { AlbumService } from './album.service';
|
import { AlbumService } from './album.service';
|
||||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
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 { AlbumEntity, UserEntity } from '@app/infra/entities';
|
||||||
import { AlbumResponseDto, ICryptoRepository, mapUser } from '@app/domain';
|
import { AlbumResponseDto, ICryptoRepository, mapUser } from '@app/domain';
|
||||||
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
|
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
|
||||||
@ -39,7 +39,6 @@ describe('Album service', () => {
|
|||||||
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
|
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
|
||||||
const sharedAlbumOwnerId = '2222';
|
const sharedAlbumOwnerId = '2222';
|
||||||
const sharedAlbumSharedAlsoWithId = '3333';
|
const sharedAlbumSharedAlsoWithId = '3333';
|
||||||
const ownedAlbumSharedWithId = '4444';
|
|
||||||
|
|
||||||
const _getOwnedAlbum = () => {
|
const _getOwnedAlbum = () => {
|
||||||
const albumEntity = new AlbumEntity();
|
const albumEntity = new AlbumEntity();
|
||||||
@ -56,25 +55,6 @@ describe('Album service', () => {
|
|||||||
return albumEntity;
|
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 _getSharedWithAuthUserAlbum = () => {
|
||||||
const albumEntity = new AlbumEntity();
|
const albumEntity = new AlbumEntity();
|
||||||
albumEntity.ownerId = sharedAlbumOwnerId;
|
albumEntity.ownerId = sharedAlbumOwnerId;
|
||||||
@ -115,10 +95,8 @@ describe('Album service', () => {
|
|||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
albumRepositoryMock = {
|
albumRepositoryMock = {
|
||||||
addAssets: jest.fn(),
|
addAssets: jest.fn(),
|
||||||
addSharedUsers: jest.fn(),
|
|
||||||
get: jest.fn(),
|
get: jest.fn(),
|
||||||
removeAssets: jest.fn(),
|
removeAssets: jest.fn(),
|
||||||
removeUser: jest.fn(),
|
|
||||||
updateThumbnails: jest.fn(),
|
updateThumbnails: jest.fn(),
|
||||||
getCountByUserId: jest.fn(),
|
getCountByUserId: jest.fn(),
|
||||||
getSharedWithUserAlbumCount: jest.fn(),
|
getSharedWithUserAlbumCount: jest.fn(),
|
||||||
@ -188,53 +166,6 @@ describe('Album service', () => {
|
|||||||
await expect(sut.get(authUser, '0002')).rejects.toBeInstanceOf(NotFoundException);
|
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 () => {
|
it('adds assets to owned album', async () => {
|
||||||
const albumEntity = _getOwnedAlbum();
|
const albumEntity = _getOwnedAlbum();
|
||||||
|
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { BadRequestException, Inject, Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
|
||||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||||
import { AlbumEntity, SharedLinkType } from '@app/infra/entities';
|
import { AlbumEntity, SharedLinkType } from '@app/infra/entities';
|
||||||
import { AddUsersDto } from './dto/add-users.dto';
|
|
||||||
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
||||||
import { AlbumResponseDto, mapAlbum } from '@app/domain';
|
import { AlbumResponseDto, mapAlbum } from '@app/domain';
|
||||||
import { IAlbumRepository } from './album-repository';
|
import { IAlbumRepository } from './album-repository';
|
||||||
@ -63,24 +62,6 @@ export class AlbumService {
|
|||||||
return mapAlbum(album);
|
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> {
|
async removeAssets(authUser: AuthUserDto, albumId: string, dto: RemoveAssetsDto): Promise<AlbumResponseDto> {
|
||||||
const album = await this._getAlbum({ authUser, albumId });
|
const album = await this._getAlbum({ authUser, albumId });
|
||||||
const deletedCount = await this.albumRepository.removeAssets(album, dto);
|
const deletedCount = await this.albumRepository.removeAssets(album, dto);
|
||||||
|
@ -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 {
|
export class AddUsersDto {
|
||||||
@ValidateUUID({ each: true })
|
@ValidateUUID({ each: true })
|
||||||
|
@ -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 { 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 { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { ParseMeUUIDPipe } from '../api-v1/validation/parse-me-uuid-pipe';
|
||||||
import { GetAuthUser } from '../decorators/auth-user.decorator';
|
import { GetAuthUser } from '../decorators/auth-user.decorator';
|
||||||
import { Authenticated } from '../decorators/authenticated.decorator';
|
import { Authenticated } from '../decorators/authenticated.decorator';
|
||||||
import { UseValidation } from '../decorators/use-validation.decorator';
|
import { UseValidation } from '../decorators/use-validation.decorator';
|
||||||
@ -33,4 +34,18 @@ export class AlbumController {
|
|||||||
deleteAlbum(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
|
deleteAlbum(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
|
||||||
return this.service.delete(authUser, id);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -228,6 +228,101 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/album/{id}/users": {
|
||||||
|
"put": {
|
||||||
|
"operationId": "addUsersToAlbum",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AddUsersDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AlbumResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Album"
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/album/{id}/user/{userId}": {
|
||||||
|
"delete": {
|
||||||
|
"operationId": "removeUserFromAlbum",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "userId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Album"
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api-key": {
|
"/api-key": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "createKey",
|
"operationId": "createKey",
|
||||||
@ -3990,58 +4085,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/album/{id}/users": {
|
|
||||||
"put": {
|
|
||||||
"operationId": "addUsersToAlbum",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "id",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"schema": {
|
|
||||||
"format": "uuid",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"requestBody": {
|
|
||||||
"required": true,
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/AddUsersDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/AlbumResponseDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tags": [
|
|
||||||
"Album"
|
|
||||||
],
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"bearer": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cookie": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"api_key": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/album/{id}/assets": {
|
"/album/{id}/assets": {
|
||||||
"put": {
|
"put": {
|
||||||
"operationId": "addAssetsToAlbum",
|
"operationId": "addAssetsToAlbum",
|
||||||
@ -4152,49 +4195,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/album/{id}/user/{userId}": {
|
|
||||||
"delete": {
|
|
||||||
"operationId": "removeUserFromAlbum",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "id",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"schema": {
|
|
||||||
"format": "uuid",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "userId",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tags": [
|
|
||||||
"Album"
|
|
||||||
],
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"bearer": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cookie": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"api_key": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/album/{id}/download": {
|
"/album/{id}/download": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "downloadArchive",
|
"operationId": "downloadArchive",
|
||||||
@ -4778,6 +4778,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"AddUsersDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"sharedUserIds": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"sharedUserIds"
|
||||||
|
]
|
||||||
|
},
|
||||||
"APIKeyCreateDto": {
|
"APIKeyCreateDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -6620,21 +6635,6 @@
|
|||||||
"sharing"
|
"sharing"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"AddUsersDto": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"sharedUserIds": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "uuid"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"sharedUserIds"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"AddAssetsResponseDto": {
|
"AddAssetsResponseDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
@ -1,7 +1,17 @@
|
|||||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||||
import { albumStub, authStub, newAlbumRepositoryMock, newAssetRepositoryMock, newJobRepositoryMock } from '../../test';
|
import _ from 'lodash';
|
||||||
|
import {
|
||||||
|
albumStub,
|
||||||
|
authStub,
|
||||||
|
newAlbumRepositoryMock,
|
||||||
|
newAssetRepositoryMock,
|
||||||
|
newJobRepositoryMock,
|
||||||
|
newUserRepositoryMock,
|
||||||
|
userEntityStub,
|
||||||
|
} from '../../test';
|
||||||
import { IAssetRepository } from '../asset';
|
import { IAssetRepository } from '../asset';
|
||||||
import { IJobRepository, JobName } from '../job';
|
import { IJobRepository, JobName } from '../job';
|
||||||
|
import { IUserRepository } from '../user';
|
||||||
import { IAlbumRepository } from './album.repository';
|
import { IAlbumRepository } from './album.repository';
|
||||||
import { AlbumService } from './album.service';
|
import { AlbumService } from './album.service';
|
||||||
|
|
||||||
@ -10,13 +20,15 @@ describe(AlbumService.name, () => {
|
|||||||
let albumMock: jest.Mocked<IAlbumRepository>;
|
let albumMock: jest.Mocked<IAlbumRepository>;
|
||||||
let assetMock: jest.Mocked<IAssetRepository>;
|
let assetMock: jest.Mocked<IAssetRepository>;
|
||||||
let jobMock: jest.Mocked<IJobRepository>;
|
let jobMock: jest.Mocked<IJobRepository>;
|
||||||
|
let userMock: jest.Mocked<IUserRepository>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
albumMock = newAlbumRepositoryMock();
|
albumMock = newAlbumRepositoryMock();
|
||||||
assetMock = newAssetRepositoryMock();
|
assetMock = newAssetRepositoryMock();
|
||||||
jobMock = newJobRepositoryMock();
|
jobMock = newJobRepositoryMock();
|
||||||
|
userMock = newUserRepositoryMock();
|
||||||
|
|
||||||
sut = new AlbumService(albumMock, assetMock, jobMock);
|
sut = new AlbumService(albumMock, assetMock, jobMock, userMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should work', () => {
|
it('should work', () => {
|
||||||
@ -152,6 +164,18 @@ describe(AlbumService.name, () => {
|
|||||||
data: { ids: [albumStub.empty.id] },
|
data: { ids: [albumStub.empty.id] },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should require valid userIds', async () => {
|
||||||
|
userMock.get.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
sut.create(authStub.admin, {
|
||||||
|
albumName: 'Empty album',
|
||||||
|
sharedWithUserIds: ['user-3'],
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(userMock.get).toHaveBeenCalledWith('user-3');
|
||||||
|
expect(albumMock.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('update', () => {
|
describe('update', () => {
|
||||||
@ -240,4 +264,130 @@ describe(AlbumService.name, () => {
|
|||||||
expect(albumMock.delete).toHaveBeenCalledWith(albumStub.empty);
|
expect(albumMock.delete).toHaveBeenCalledWith(albumStub.empty);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('addUsers', () => {
|
||||||
|
it('should require a valid album id', async () => {
|
||||||
|
albumMock.getByIds.mockResolvedValue([]);
|
||||||
|
await expect(sut.addUsers(authStub.admin, 'album-1', { sharedUserIds: ['user-1'] })).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(albumMock.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require the user to be the owner', async () => {
|
||||||
|
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
|
||||||
|
await expect(
|
||||||
|
sut.addUsers(authStub.admin, albumStub.sharedWithAdmin.id, { sharedUserIds: ['user-1'] }),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(albumMock.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error if the userId is already added', async () => {
|
||||||
|
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
|
||||||
|
await expect(
|
||||||
|
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.admin.id] }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(albumMock.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error if the userId does not exist', async () => {
|
||||||
|
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
|
||||||
|
userMock.get.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: ['user-3'] }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(albumMock.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add valid shared users', async () => {
|
||||||
|
albumMock.getByIds.mockResolvedValue([_.cloneDeep(albumStub.sharedWithAdmin)]);
|
||||||
|
albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||||
|
userMock.get.mockResolvedValue(userEntityStub.user2);
|
||||||
|
await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.user2.id] });
|
||||||
|
expect(albumMock.update).toHaveBeenCalledWith({
|
||||||
|
id: albumStub.sharedWithAdmin.id,
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
sharedUsers: [userEntityStub.admin, { id: authStub.user2.id }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeUser', () => {
|
||||||
|
it('should require a valid album id', async () => {
|
||||||
|
albumMock.getByIds.mockResolvedValue([]);
|
||||||
|
await expect(sut.removeUser(authStub.admin, 'album-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(albumMock.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove a shared user from an owned album', async () => {
|
||||||
|
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sut.removeUser(authStub.admin, albumStub.sharedWithUser.id, userEntityStub.user1.id),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(albumMock.update).toHaveBeenCalledTimes(1);
|
||||||
|
expect(albumMock.update).toHaveBeenCalledWith({
|
||||||
|
id: albumStub.sharedWithUser.id,
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
sharedUsers: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => {
|
||||||
|
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithMultiple]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sut.removeUser(authStub.user1, albumStub.sharedWithMultiple.id, authStub.user2.id),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
|
||||||
|
expect(albumMock.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow a shared user to remove themselves', async () => {
|
||||||
|
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]);
|
||||||
|
|
||||||
|
await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, authStub.user1.id);
|
||||||
|
|
||||||
|
expect(albumMock.update).toHaveBeenCalledTimes(1);
|
||||||
|
expect(albumMock.update).toHaveBeenCalledWith({
|
||||||
|
id: albumStub.sharedWithUser.id,
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
sharedUsers: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow a shared user to remove themselves using "me"', async () => {
|
||||||
|
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]);
|
||||||
|
|
||||||
|
await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, 'me');
|
||||||
|
|
||||||
|
expect(albumMock.update).toHaveBeenCalledTimes(1);
|
||||||
|
expect(albumMock.update).toHaveBeenCalledWith({
|
||||||
|
id: albumStub.sharedWithUser.id,
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
sharedUsers: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow the owner to be removed', async () => {
|
||||||
|
albumMock.getByIds.mockResolvedValue([albumStub.empty]);
|
||||||
|
|
||||||
|
await expect(sut.removeUser(authStub.admin, albumStub.empty.id, authStub.admin.id)).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(albumMock.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error for a user not in the album', async () => {
|
||||||
|
albumMock.getByIds.mockResolvedValue([albumStub.empty]);
|
||||||
|
|
||||||
|
await expect(sut.removeUser(authStub.admin, albumStub.empty.id, 'user-3')).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(albumMock.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -3,8 +3,9 @@ import { BadRequestException, ForbiddenException, Inject, Injectable } from '@ne
|
|||||||
import { IAssetRepository, mapAsset } from '../asset';
|
import { IAssetRepository, mapAsset } from '../asset';
|
||||||
import { AuthUserDto } from '../auth';
|
import { AuthUserDto } from '../auth';
|
||||||
import { IJobRepository, JobName } from '../job';
|
import { IJobRepository, JobName } from '../job';
|
||||||
|
import { IUserRepository } from '../user';
|
||||||
import { IAlbumRepository } from './album.repository';
|
import { IAlbumRepository } from './album.repository';
|
||||||
import { CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto } from './dto';
|
import { AddUsersDto, CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto } from './dto';
|
||||||
import { AlbumResponseDto, mapAlbum } from './response-dto';
|
import { AlbumResponseDto, mapAlbum } from './response-dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -13,6 +14,7 @@ export class AlbumService {
|
|||||||
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
|
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
|
||||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||||
|
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getAll({ id: ownerId }: AuthUserDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
|
async getAll({ id: ownerId }: AuthUserDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
|
||||||
@ -48,7 +50,7 @@ export class AlbumService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateInvalidThumbnails(): Promise<number> {
|
private async updateInvalidThumbnails(): Promise<number> {
|
||||||
const invalidAlbumIds = await this.albumRepository.getInvalidThumbnail();
|
const invalidAlbumIds = await this.albumRepository.getInvalidThumbnail();
|
||||||
|
|
||||||
for (const albumId of invalidAlbumIds) {
|
for (const albumId of invalidAlbumIds) {
|
||||||
@ -60,7 +62,13 @@ export class AlbumService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(authUser: AuthUserDto, dto: CreateAlbumDto): Promise<AlbumResponseDto> {
|
async create(authUser: AuthUserDto, dto: CreateAlbumDto): Promise<AlbumResponseDto> {
|
||||||
// TODO: Handle nonexistent sharedWithUserIds and assetIds.
|
for (const userId of dto.sharedWithUserIds || []) {
|
||||||
|
const exists = await this.userRepository.get(userId);
|
||||||
|
if (!exists) {
|
||||||
|
throw new BadRequestException('User not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const album = await this.albumRepository.create({
|
const album = await this.albumRepository.create({
|
||||||
ownerId: authUser.id,
|
ownerId: authUser.id,
|
||||||
albumName: dto.albumName,
|
albumName: dto.albumName,
|
||||||
@ -68,19 +76,14 @@ export class AlbumService {
|
|||||||
assets: (dto.assetIds || []).map((id) => ({ id } as AssetEntity)),
|
assets: (dto.assetIds || []).map((id) => ({ id } as AssetEntity)),
|
||||||
albumThumbnailAssetId: dto.assetIds?.[0] || null,
|
albumThumbnailAssetId: dto.assetIds?.[0] || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [album.id] } });
|
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [album.id] } });
|
||||||
return mapAlbum(album);
|
return mapAlbum(album);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(authUser: AuthUserDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
|
async update(authUser: AuthUserDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
|
||||||
const [album] = await this.albumRepository.getByIds([id]);
|
const album = await this.get(id);
|
||||||
if (!album) {
|
this.assertOwner(authUser, album);
|
||||||
throw new BadRequestException('Album not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (album.ownerId !== authUser.id) {
|
|
||||||
throw new ForbiddenException('Album not owned by user');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto.albumThumbnailAssetId) {
|
if (dto.albumThumbnailAssetId) {
|
||||||
const valid = await this.albumRepository.hasAsset(id, dto.albumThumbnailAssetId);
|
const valid = await this.albumRepository.hasAsset(id, dto.albumThumbnailAssetId);
|
||||||
@ -113,4 +116,73 @@ export class AlbumService {
|
|||||||
await this.albumRepository.delete(album);
|
await this.albumRepository.delete(album);
|
||||||
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { ids: [id] } });
|
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { ids: [id] } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addUsers(authUser: AuthUserDto, id: string, dto: AddUsersDto) {
|
||||||
|
const album = await this.get(id);
|
||||||
|
this.assertOwner(authUser, album);
|
||||||
|
|
||||||
|
for (const userId of dto.sharedUserIds) {
|
||||||
|
const exists = album.sharedUsers.find((user) => user.id === userId);
|
||||||
|
if (exists) {
|
||||||
|
throw new BadRequestException('User already added');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.userRepository.get(userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new BadRequestException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
album.sharedUsers.push({ id: userId } as UserEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.albumRepository
|
||||||
|
.update({
|
||||||
|
id: album.id,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
sharedUsers: album.sharedUsers,
|
||||||
|
})
|
||||||
|
.then(mapAlbum);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeUser(authUser: AuthUserDto, id: string, userId: string | 'me'): Promise<void> {
|
||||||
|
if (userId === 'me') {
|
||||||
|
userId = authUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const album = await this.get(id);
|
||||||
|
|
||||||
|
if (album.ownerId === userId) {
|
||||||
|
throw new BadRequestException('Cannot remove album owner');
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = album.sharedUsers.find((user) => user.id === userId);
|
||||||
|
if (!exists) {
|
||||||
|
throw new BadRequestException('Album not shared with user');
|
||||||
|
}
|
||||||
|
|
||||||
|
// non-admin can remove themselves
|
||||||
|
if (authUser.id !== userId) {
|
||||||
|
this.assertOwner(authUser, album);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.albumRepository.update({
|
||||||
|
id: album.id,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
sharedUsers: album.sharedUsers.filter((user) => user.id !== userId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async get(id: string) {
|
||||||
|
const [album] = await this.albumRepository.getByIds([id]);
|
||||||
|
if (!album) {
|
||||||
|
throw new BadRequestException('Album not found');
|
||||||
|
}
|
||||||
|
return album;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertOwner(authUser: AuthUserDto, album: AlbumEntity) {
|
||||||
|
if (album.ownerId !== authUser.id) {
|
||||||
|
throw new ForbiddenException('Album not owned by user');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
8
server/libs/domain/src/album/dto/album-add-users.dto.ts
Normal file
8
server/libs/domain/src/album/dto/album-add-users.dto.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { ArrayNotEmpty } from 'class-validator';
|
||||||
|
import { ValidateUUID } from '../../../../../apps/immich/src/decorators/validate-uuid.decorator';
|
||||||
|
|
||||||
|
export class AddUsersDto {
|
||||||
|
@ValidateUUID({ each: true })
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
sharedUserIds!: string[];
|
||||||
|
}
|
@ -1,3 +1,4 @@
|
|||||||
|
export * from './album-add-users.dto';
|
||||||
export * from './album-create.dto';
|
export * from './album-create.dto';
|
||||||
export * from './album-update.dto';
|
export * from './album-update.dto';
|
||||||
export * from './get-albums.dto';
|
export * from './get-albums.dto';
|
||||||
|
@ -61,6 +61,16 @@ export const authStub = {
|
|||||||
isShowExif: true,
|
isShowExif: true,
|
||||||
accessTokenId: 'token-id',
|
accessTokenId: 'token-id',
|
||||||
}),
|
}),
|
||||||
|
user2: Object.freeze<AuthUserDto>({
|
||||||
|
id: 'user-2',
|
||||||
|
email: 'user2@immich.app',
|
||||||
|
isAdmin: false,
|
||||||
|
isPublicUser: false,
|
||||||
|
isAllowUpload: true,
|
||||||
|
isAllowDownload: true,
|
||||||
|
isShowExif: true,
|
||||||
|
accessTokenId: 'token-id',
|
||||||
|
}),
|
||||||
adminSharedLink: Object.freeze<AuthUserDto>({
|
adminSharedLink: Object.freeze<AuthUserDto>({
|
||||||
id: 'admin_id',
|
id: 'admin_id',
|
||||||
email: 'admin@test.com',
|
email: 'admin@test.com',
|
||||||
@ -125,6 +135,21 @@ export const userEntityStub = {
|
|||||||
tags: [],
|
tags: [],
|
||||||
assets: [],
|
assets: [],
|
||||||
}),
|
}),
|
||||||
|
user2: Object.freeze<UserEntity>({
|
||||||
|
...authStub.user2,
|
||||||
|
password: 'immich_password',
|
||||||
|
firstName: 'immich_first_name',
|
||||||
|
lastName: 'immich_last_name',
|
||||||
|
storageLabel: null,
|
||||||
|
oauthId: '',
|
||||||
|
shouldChangePassword: false,
|
||||||
|
profileImagePath: '',
|
||||||
|
createdAt: new Date('2021-01-01'),
|
||||||
|
deletedAt: null,
|
||||||
|
updatedAt: new Date('2021-01-01'),
|
||||||
|
tags: [],
|
||||||
|
assets: [],
|
||||||
|
}),
|
||||||
storageLabel: Object.freeze<UserEntity>({
|
storageLabel: Object.freeze<UserEntity>({
|
||||||
...authStub.user1,
|
...authStub.user1,
|
||||||
password: 'immich_password',
|
password: 'immich_password',
|
||||||
@ -357,6 +382,19 @@ export const albumStub = {
|
|||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
sharedUsers: [userEntityStub.user1],
|
sharedUsers: [userEntityStub.user1],
|
||||||
}),
|
}),
|
||||||
|
sharedWithMultiple: Object.freeze<AlbumEntity>({
|
||||||
|
id: 'album-3',
|
||||||
|
albumName: 'Empty album shared with users',
|
||||||
|
ownerId: authStub.admin.id,
|
||||||
|
owner: userEntityStub.admin,
|
||||||
|
assets: [],
|
||||||
|
albumThumbnailAsset: null,
|
||||||
|
albumThumbnailAssetId: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
sharedLinks: [],
|
||||||
|
sharedUsers: [userEntityStub.user1, userEntityStub.user2],
|
||||||
|
}),
|
||||||
sharedWithAdmin: Object.freeze<AlbumEntity>({
|
sharedWithAdmin: Object.freeze<AlbumEntity>({
|
||||||
id: 'album-3',
|
id: 'album-3',
|
||||||
albumName: 'Empty album shared with admin',
|
albumName: 'Empty album shared with admin',
|
||||||
|
@ -16,6 +16,7 @@ export class AlbumRepository implements IAlbumRepository {
|
|||||||
},
|
},
|
||||||
relations: {
|
relations: {
|
||||||
owner: true,
|
owner: true,
|
||||||
|
sharedUsers: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -153,6 +154,12 @@ export class AlbumRepository implements IAlbumRepository {
|
|||||||
|
|
||||||
private async save(album: Partial<AlbumEntity>) {
|
private async save(album: Partial<AlbumEntity>) {
|
||||||
const { id } = await this.repository.save(album);
|
const { id } = await this.repository.save(album);
|
||||||
return this.repository.findOneOrFail({ where: { id }, relations: { owner: true } });
|
return this.repository.findOneOrFail({
|
||||||
|
where: { id },
|
||||||
|
relations: {
|
||||||
|
owner: true,
|
||||||
|
sharedUsers: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -42,6 +42,7 @@
|
|||||||
import ShareInfoModal from './share-info-modal.svelte';
|
import ShareInfoModal from './share-info-modal.svelte';
|
||||||
import ThumbnailSelection from './thumbnail-selection.svelte';
|
import ThumbnailSelection from './thumbnail-selection.svelte';
|
||||||
import UserSelectionModal from './user-selection-modal.svelte';
|
import UserSelectionModal from './user-selection-modal.svelte';
|
||||||
|
import { handleError } from '../../utils/handle-error';
|
||||||
|
|
||||||
export let album: AlbumResponseDto;
|
export let album: AlbumResponseDto;
|
||||||
export let sharedLink: SharedLinkResponseDto | undefined = undefined;
|
export let sharedLink: SharedLinkResponseDto | undefined = undefined;
|
||||||
@ -195,19 +196,16 @@
|
|||||||
if (userId == 'me') {
|
if (userId == 'me') {
|
||||||
isShowShareInfoModal = false;
|
isShowShareInfoModal = false;
|
||||||
goto(backUrl);
|
goto(backUrl);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await api.albumApi.getAlbumInfo({ id: album.id });
|
const { data } = await api.albumApi.getAlbumInfo({ id: album.id });
|
||||||
|
|
||||||
album = data;
|
album = data;
|
||||||
isShowShareInfoModal = false;
|
isShowShareInfoModal = data.sharedUsers.length >= 1;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error [sharedUserDeletedHandler] ', e);
|
handleError(e, 'Error deleting share users');
|
||||||
notificationController.show({
|
|
||||||
type: NotificationType.Error,
|
|
||||||
message: 'Error deleting share users, check console for more details'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher, onMount } from 'svelte';
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
import { AlbumResponseDto, api, UserResponseDto } from '@api';
|
import { AlbumResponseDto, api, UserResponseDto } from '@api';
|
||||||
import { clickOutside } from '$lib/utils/click-outside';
|
|
||||||
import BaseModal from '../shared-components/base-modal.svelte';
|
import BaseModal from '../shared-components/base-modal.svelte';
|
||||||
import UserAvatar from '../shared-components/user-avatar.svelte';
|
import UserAvatar from '../shared-components/user-avatar.svelte';
|
||||||
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
|
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
|
||||||
@ -12,15 +11,18 @@
|
|||||||
notificationController,
|
notificationController,
|
||||||
NotificationType
|
NotificationType
|
||||||
} from '../shared-components/notification/notification';
|
} from '../shared-components/notification/notification';
|
||||||
|
import { handleError } from '../../utils/handle-error';
|
||||||
|
import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
|
||||||
|
|
||||||
export let album: AlbumResponseDto;
|
export let album: AlbumResponseDto;
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
let currentUser: UserResponseDto;
|
let currentUser: UserResponseDto;
|
||||||
let isShowMenu = false;
|
|
||||||
let position = { x: 0, y: 0 };
|
let position = { x: 0, y: 0 };
|
||||||
let targetUserId: string;
|
let selectedMenuUser: UserResponseDto | null = null;
|
||||||
|
let selectedRemoveUser: UserResponseDto | null = null;
|
||||||
|
|
||||||
$: isOwned = currentUser?.id == album.ownerId;
|
$: isOwned = currentUser?.id == album.ownerId;
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@ -28,16 +30,12 @@
|
|||||||
const { data } = await api.userApi.getMyUserInfo();
|
const { data } = await api.userApi.getMyUserInfo();
|
||||||
currentUser = data;
|
currentUser = data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error [share-info-modal] [getAllUsers]', e);
|
handleError(e, 'Unable to refresh user');
|
||||||
notificationController.show({
|
|
||||||
message: 'Error getting user info, check console for more details',
|
|
||||||
type: NotificationType.Error
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const showContextMenu = (userId: string) => {
|
const showContextMenu = (user: UserResponseDto) => {
|
||||||
const iconButton = document.getElementById('icon-' + userId);
|
const iconButton = document.getElementById('icon-' + user.id);
|
||||||
|
|
||||||
if (iconButton) {
|
if (iconButton) {
|
||||||
position = {
|
position = {
|
||||||
@ -46,69 +44,101 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
targetUserId = userId;
|
selectedMenuUser = user;
|
||||||
isShowMenu = !isShowMenu;
|
selectedRemoveUser = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeUser = async (userId: string) => {
|
const handleMenuRemove = () => {
|
||||||
if (window.confirm('Do you want to remove selected user from the album?')) {
|
selectedRemoveUser = selectedMenuUser;
|
||||||
try {
|
selectedMenuUser = null;
|
||||||
await api.albumApi.removeUserFromAlbum({ id: album.id, userId });
|
};
|
||||||
dispatch('user-deleted', { userId });
|
|
||||||
} catch (e) {
|
const handleRemoveUser = async () => {
|
||||||
console.error('Error [share-info-modal] [removeUser]', e);
|
if (!selectedRemoveUser) {
|
||||||
notificationController.show({
|
return;
|
||||||
message: 'Error removing user, check console for more details',
|
}
|
||||||
type: NotificationType.Error
|
|
||||||
});
|
const userId = selectedRemoveUser.id === currentUser?.id ? 'me' : selectedRemoveUser.id;
|
||||||
}
|
|
||||||
|
try {
|
||||||
|
await api.albumApi.removeUserFromAlbum({ id: album.id, userId });
|
||||||
|
dispatch('user-deleted', { userId });
|
||||||
|
const message =
|
||||||
|
userId === 'me' ? `Left ${album.albumName}` : `Removed ${selectedRemoveUser.firstName}`;
|
||||||
|
notificationController.show({ type: NotificationType.Info, message });
|
||||||
|
} catch (e) {
|
||||||
|
handleError(e, 'Unable to remove user');
|
||||||
|
} finally {
|
||||||
|
selectedRemoveUser = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<BaseModal on:close={() => dispatch('close')}>
|
{#if !selectedRemoveUser}
|
||||||
<svelte:fragment slot="title">
|
<BaseModal on:close={() => dispatch('close')}>
|
||||||
<span class="flex gap-2 place-items-center">
|
<svelte:fragment slot="title">
|
||||||
<p class="font-medium text-immich-fg dark:text-immich-dark-fg">Options</p>
|
<span class="flex gap-2 place-items-center">
|
||||||
</span>
|
<p class="font-medium text-immich-fg dark:text-immich-dark-fg">Options</p>
|
||||||
</svelte:fragment>
|
</span>
|
||||||
|
</svelte:fragment>
|
||||||
|
|
||||||
<section class="max-h-[400px] overflow-y-auto immich-scrollbar pb-4">
|
<section class="max-h-[400px] overflow-y-auto immich-scrollbar pb-4">
|
||||||
{#each album.sharedUsers as user}
|
{#each album.sharedUsers as user}
|
||||||
<div
|
<div
|
||||||
class="flex gap-4 p-5 place-items-center justify-between w-full transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
|
class="flex gap-4 p-5 place-items-center justify-between w-full transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
<div class="flex gap-4 place-items-center">
|
<div class="flex gap-4 place-items-center">
|
||||||
<UserAvatar {user} size="md" autoColor />
|
<UserAvatar {user} size="md" autoColor />
|
||||||
<p class="font-medium text-sm">{user.firstName} {user.lastName}</p>
|
<p class="font-medium text-sm">{user.firstName} {user.lastName}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id={`icon-${user.id}`} class="flex place-items-center">
|
<div id={`icon-${user.id}`} class="flex place-items-center">
|
||||||
{#if isOwned}
|
{#if isOwned}
|
||||||
<div use:clickOutside on:outclick={() => (isShowMenu = false)}>
|
<div>
|
||||||
<CircleIconButton
|
<CircleIconButton
|
||||||
on:click={() => showContextMenu(user.id)}
|
on:click={() => showContextMenu(user)}
|
||||||
logo={DotsVertical}
|
logo={DotsVertical}
|
||||||
backgroundColor={'transparent'}
|
backgroundColor="transparent"
|
||||||
hoverColor={'#e2e7e9'}
|
hoverColor="#e2e7e9"
|
||||||
size={'20'}
|
size="20"
|
||||||
>
|
/>
|
||||||
{#if isShowMenu}
|
|
||||||
<ContextMenu {...position}>
|
{#if selectedMenuUser === user}
|
||||||
<MenuOption on:click={() => removeUser(targetUserId)} text="Remove" />
|
<ContextMenu {...position} on:outclick={() => (selectedMenuUser = null)}>
|
||||||
|
<MenuOption on:click={handleMenuRemove} text="Remove" />
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
{/if}
|
{/if}
|
||||||
</CircleIconButton>
|
</div>
|
||||||
</div>
|
{:else if user.id == currentUser?.id}
|
||||||
{:else if user.id == currentUser?.id}
|
<button
|
||||||
<button
|
on:click={() => (selectedRemoveUser = user)}
|
||||||
on:click={() => removeUser('me')}
|
class="text-sm text-immich-primary dark:text-immich-dark-primary font-medium transition-colors hover:text-immich-primary/75"
|
||||||
class="text-sm text-immich-primary dark:text-immich-dark-primary font-medium transition-colors hover:text-immich-primary/75"
|
>Leave</button
|
||||||
>Leave</button
|
>
|
||||||
>
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/each}
|
||||||
{/each}
|
</section>
|
||||||
</section>
|
</BaseModal>
|
||||||
</BaseModal>
|
{/if}
|
||||||
|
|
||||||
|
{#if selectedRemoveUser && selectedRemoveUser?.id === currentUser?.id}
|
||||||
|
<ConfirmDialogue
|
||||||
|
title="Leave Album?"
|
||||||
|
prompt="Are you sure you want to leave {album.albumName}?"
|
||||||
|
confirmText="Leave"
|
||||||
|
on:confirm={handleRemoveUser}
|
||||||
|
on:cancel={() => (selectedRemoveUser = null)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selectedRemoveUser && selectedRemoveUser?.id !== currentUser?.id}
|
||||||
|
<ConfirmDialogue
|
||||||
|
title="Remove User?"
|
||||||
|
prompt="Are you sure you want to remove {selectedRemoveUser.firstName} {selectedRemoveUser.lastName}"
|
||||||
|
confirmText="Remove"
|
||||||
|
on:confirm={handleRemoveUser}
|
||||||
|
on:cancel={() => (selectedRemoveUser = null)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
Loading…
Reference in New Issue
Block a user