mirror of https://github.com/immich-app/immich.git
Refactor API for albums feature (#155)
* Rename "shared" to "album" Prepare moving "SharedAlbums" to "Albums" * Update server album API endpoints * Update mobile app album endpoints Also add `putRequest` to mobile network.service * Add GET album collection filter - allow to filter by owner = 'mine' | 'their' - make sharedWithUserIds no longer required when creating an album * Rename remaining variables to "album" * Add ParseMeUUIDPipe to validate uuid or `me` * Add album params validation * Update todo in mobile album service. * Setup e2e testing * Add user e2e tests * Rename database host env variable to DB_HOST * Add some `Album` e2e tests Also fix issues found with the tests * Force push (try to recover DB_HOST env) * Rename db host env variable to `DB_HOSTNAME` * Remove unnecessary `initDb` from test-utils The current database.config is running the migrations: `migrationsRun: true` * Remove `initDb` usage from album e2e test * Update GET albums filter to `shared` - add filter by all / shared / not shared - add response DTOs - add GET albums e2e tests * Update album e2e tests for user.service changes * Update mobile app to use album response DTOs * Refactor album-service DB into album-registry - DB logic refactored into album-repository making it easier to test - add some album-service unit tests - add `clearMocks` to jest configuration * Finish implementing album.service unit tests * Rename response DTO Make them consistent with rest of the project naming * Update debug log messages in mobile network service * Rename table `shared_albums` to `albums` * Rename table `asset_shared_album` * Rename Albums `sharedAssets` to `assets` * Update tests to match updated "delete" response * Fixed asset cannot be compared in Set by adding Equatable package * Remove hero effect to fixed janky animation Co-authored-by: Alex <alex.tran1502@gmail.com>pull/231/head
parent
3511b69fc8
commit
517a3363d6
@ -1,50 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
|
||||||
|
|
||||||
class SharedAssets {
|
|
||||||
final ImmichAsset assetInfo;
|
|
||||||
|
|
||||||
SharedAssets({
|
|
||||||
required this.assetInfo,
|
|
||||||
});
|
|
||||||
|
|
||||||
SharedAssets copyWith({
|
|
||||||
ImmichAsset? assetInfo,
|
|
||||||
}) {
|
|
||||||
return SharedAssets(
|
|
||||||
assetInfo: assetInfo ?? this.assetInfo,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toMap() {
|
|
||||||
final result = <String, dynamic>{};
|
|
||||||
|
|
||||||
result.addAll({'assetInfo': assetInfo.toMap()});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
factory SharedAssets.fromMap(Map<String, dynamic> map) {
|
|
||||||
return SharedAssets(
|
|
||||||
assetInfo: ImmichAsset.fromMap(map['assetInfo']),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String toJson() => json.encode(toMap());
|
|
||||||
|
|
||||||
factory SharedAssets.fromJson(String source) => SharedAssets.fromMap(json.decode(source));
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => 'SharedAssets(assetInfo: $assetInfo)';
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) {
|
|
||||||
if (identical(this, other)) return true;
|
|
||||||
|
|
||||||
return other is SharedAssets && other.assetInfo == assetInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode => assetInfo.hashCode;
|
|
||||||
}
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:immich_mobile/shared/models/user_info.model.dart';
|
|
||||||
|
|
||||||
class SharedUsers {
|
|
||||||
final int id;
|
|
||||||
final String albumId;
|
|
||||||
final String sharedUserId;
|
|
||||||
final UserInfo userInfo;
|
|
||||||
|
|
||||||
SharedUsers({
|
|
||||||
required this.id,
|
|
||||||
required this.albumId,
|
|
||||||
required this.sharedUserId,
|
|
||||||
required this.userInfo,
|
|
||||||
});
|
|
||||||
|
|
||||||
SharedUsers copyWith({
|
|
||||||
int? id,
|
|
||||||
String? albumId,
|
|
||||||
String? sharedUserId,
|
|
||||||
UserInfo? userInfo,
|
|
||||||
}) {
|
|
||||||
return SharedUsers(
|
|
||||||
id: id ?? this.id,
|
|
||||||
albumId: albumId ?? this.albumId,
|
|
||||||
sharedUserId: sharedUserId ?? this.sharedUserId,
|
|
||||||
userInfo: userInfo ?? this.userInfo,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toMap() {
|
|
||||||
final result = <String, dynamic>{};
|
|
||||||
|
|
||||||
result.addAll({'id': id});
|
|
||||||
result.addAll({'albumId': albumId});
|
|
||||||
result.addAll({'sharedUserId': sharedUserId});
|
|
||||||
result.addAll({'userInfo': userInfo.toMap()});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
factory SharedUsers.fromMap(Map<String, dynamic> map) {
|
|
||||||
return SharedUsers(
|
|
||||||
id: map['id']?.toInt() ?? 0,
|
|
||||||
albumId: map['albumId'] ?? '',
|
|
||||||
sharedUserId: map['sharedUserId'] ?? '',
|
|
||||||
userInfo: UserInfo.fromMap(map['userInfo']),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String toJson() => json.encode(toMap());
|
|
||||||
|
|
||||||
factory SharedUsers.fromJson(String source) => SharedUsers.fromMap(json.decode(source));
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() {
|
|
||||||
return 'SharedUsers(id: $id, albumId: $albumId, sharedUserId: $sharedUserId, userInfo: $userInfo)';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) {
|
|
||||||
if (identical(this, other)) return true;
|
|
||||||
|
|
||||||
return other is SharedUsers &&
|
|
||||||
other.id == id &&
|
|
||||||
other.albumId == albumId &&
|
|
||||||
other.sharedUserId == sharedUserId &&
|
|
||||||
other.userInfo == userInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode {
|
|
||||||
return id.hashCode ^ albumId.hashCode ^ sharedUserId.hashCode ^ userInfo.hashCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,228 @@
|
|||||||
|
import { AlbumEntity } from '@app/database/entities/album.entity';
|
||||||
|
import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
|
||||||
|
import { UserAlbumEntity } from '@app/database/entities/user-album.entity';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { getConnection, Repository, SelectQueryBuilder } from 'typeorm';
|
||||||
|
import { AddAssetsDto } from './dto/add-assets.dto';
|
||||||
|
import { AddUsersDto } from './dto/add-users.dto';
|
||||||
|
import { CreateAlbumDto } from './dto/create-album.dto';
|
||||||
|
import { GetAlbumsDto } from './dto/get-albums.dto';
|
||||||
|
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
||||||
|
import { UpdateAlbumDto } from './dto/update-album.dto';
|
||||||
|
|
||||||
|
export interface IAlbumRepository {
|
||||||
|
create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity>;
|
||||||
|
getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise<AlbumEntity[]>;
|
||||||
|
get(albumId: string): Promise<AlbumEntity>;
|
||||||
|
delete(album: AlbumEntity): Promise<void>;
|
||||||
|
addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity>;
|
||||||
|
removeUser(album: AlbumEntity, userId: string): Promise<void>;
|
||||||
|
removeAssets(album: AlbumEntity, removeAssets: RemoveAssetsDto): Promise<boolean>;
|
||||||
|
addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AlbumEntity>;
|
||||||
|
updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ALBUM_REPOSITORY = 'ALBUM_REPOSITORY';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AlbumRepository implements IAlbumRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AlbumEntity)
|
||||||
|
private albumRepository: Repository<AlbumEntity>,
|
||||||
|
|
||||||
|
@InjectRepository(AssetAlbumEntity)
|
||||||
|
private assetAlbumRepository: Repository<AssetAlbumEntity>,
|
||||||
|
|
||||||
|
@InjectRepository(UserAlbumEntity)
|
||||||
|
private userAlbumRepository: Repository<UserAlbumEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity> {
|
||||||
|
return await getConnection().transaction(async (transactionalEntityManager) => {
|
||||||
|
// Create album entity
|
||||||
|
const newAlbum = new AlbumEntity();
|
||||||
|
newAlbum.ownerId = ownerId;
|
||||||
|
newAlbum.albumName = createAlbumDto.albumName;
|
||||||
|
|
||||||
|
const album = await transactionalEntityManager.save(newAlbum);
|
||||||
|
|
||||||
|
// Add shared users
|
||||||
|
if (createAlbumDto.sharedWithUserIds?.length) {
|
||||||
|
for (const sharedUserId of createAlbumDto.sharedWithUserIds) {
|
||||||
|
const newSharedUser = new UserAlbumEntity();
|
||||||
|
newSharedUser.albumId = album.id;
|
||||||
|
newSharedUser.sharedUserId = sharedUserId;
|
||||||
|
|
||||||
|
await transactionalEntityManager.save(newSharedUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add shared assets
|
||||||
|
const newRecords: AssetAlbumEntity[] = [];
|
||||||
|
|
||||||
|
if (createAlbumDto.assetIds?.length) {
|
||||||
|
for (const assetId of createAlbumDto.assetIds) {
|
||||||
|
const newAssetAlbum = new AssetAlbumEntity();
|
||||||
|
newAssetAlbum.assetId = assetId;
|
||||||
|
newAssetAlbum.albumId = album.id;
|
||||||
|
|
||||||
|
newRecords.push(newAssetAlbum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!album.albumThumbnailAssetId && newRecords.length > 0) {
|
||||||
|
album.albumThumbnailAssetId = newRecords[0].assetId;
|
||||||
|
await transactionalEntityManager.save(album);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transactionalEntityManager.save([...newRecords]);
|
||||||
|
|
||||||
|
return album;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise<AlbumEntity[]> {
|
||||||
|
const filteringByShared = typeof getAlbumsDto.shared == 'boolean';
|
||||||
|
const userId = ownerId;
|
||||||
|
let query = this.albumRepository.createQueryBuilder('album');
|
||||||
|
|
||||||
|
const getSharedAlbumIdsSubQuery = (qb: SelectQueryBuilder<AlbumEntity>) => {
|
||||||
|
return qb
|
||||||
|
.subQuery()
|
||||||
|
.select('albumSub.id')
|
||||||
|
.from(AlbumEntity, 'albumSub')
|
||||||
|
.innerJoin('albumSub.sharedUsers', 'userAlbumSub')
|
||||||
|
.where('albumSub.ownerId = :ownerId', { ownerId: userId })
|
||||||
|
.getQuery();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (filteringByShared) {
|
||||||
|
if (getAlbumsDto.shared) {
|
||||||
|
// shared albums
|
||||||
|
query = query
|
||||||
|
.innerJoinAndSelect('album.sharedUsers', 'sharedUser')
|
||||||
|
.innerJoinAndSelect('sharedUser.userInfo', 'userInfo')
|
||||||
|
.where((qb) => {
|
||||||
|
// owned and shared with other users
|
||||||
|
const subQuery = getSharedAlbumIdsSubQuery(qb);
|
||||||
|
return `album.id IN ${subQuery}`;
|
||||||
|
})
|
||||||
|
.orWhere((qb) => {
|
||||||
|
// shared with userId
|
||||||
|
const subQuery = qb
|
||||||
|
.subQuery()
|
||||||
|
.select('userAlbum.albumId')
|
||||||
|
.from(UserAlbumEntity, 'userAlbum')
|
||||||
|
.where('userAlbum.sharedUserId = :sharedUserId', { sharedUserId: userId })
|
||||||
|
.getQuery();
|
||||||
|
return `album.id IN ${subQuery}`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// owned, not shared albums
|
||||||
|
query = query.where('album.ownerId = :ownerId', { ownerId: userId }).andWhere((qb) => {
|
||||||
|
const subQuery = getSharedAlbumIdsSubQuery(qb);
|
||||||
|
return `album.id NOT IN ${subQuery}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// owned and shared with userId
|
||||||
|
query = query
|
||||||
|
.leftJoinAndSelect('album.sharedUsers', 'sharedUser')
|
||||||
|
.leftJoinAndSelect('sharedUser.userInfo', 'userInfo')
|
||||||
|
.where('album.ownerId = :ownerId', { ownerId: userId })
|
||||||
|
.orWhere((qb) => {
|
||||||
|
const subQuery = qb
|
||||||
|
.subQuery()
|
||||||
|
.select('userAlbum.albumId')
|
||||||
|
.from(UserAlbumEntity, 'userAlbum')
|
||||||
|
.where('userAlbum.sharedUserId = :sharedUserId', { sharedUserId: userId })
|
||||||
|
.getQuery();
|
||||||
|
return `album.id IN ${subQuery}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return query.orderBy('album.createdAt', 'DESC').getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(albumId: string): Promise<AlbumEntity | undefined> {
|
||||||
|
const album = await this.albumRepository.findOne({
|
||||||
|
where: { id: albumId },
|
||||||
|
relations: ['sharedUsers', 'sharedUsers.userInfo', 'assets', 'assets.assetInfo'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!album) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// TODO: sort in query
|
||||||
|
const sortedSharedAsset = album.assets.sort(
|
||||||
|
(a, b) => new Date(a.assetInfo.createdAt).valueOf() - new Date(b.assetInfo.createdAt).valueOf(),
|
||||||
|
);
|
||||||
|
|
||||||
|
album.assets = sortedSharedAsset;
|
||||||
|
|
||||||
|
return album;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(album: AlbumEntity): Promise<void> {
|
||||||
|
await this.albumRepository.delete({ id: album.id, ownerId: album.ownerId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity> {
|
||||||
|
const newRecords: UserAlbumEntity[] = [];
|
||||||
|
|
||||||
|
for (const sharedUserId of addUsersDto.sharedUserIds) {
|
||||||
|
const newEntity = new UserAlbumEntity();
|
||||||
|
newEntity.albumId = album.id;
|
||||||
|
newEntity.sharedUserId = sharedUserId;
|
||||||
|
|
||||||
|
newRecords.push(newEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.userAlbumRepository.save([...newRecords]);
|
||||||
|
return this.get(album.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeUser(album: AlbumEntity, userId: string): Promise<void> {
|
||||||
|
await this.userAlbumRepository.delete({ albumId: album.id, sharedUserId: userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeAssets(album: AlbumEntity, removeAssetsDto: RemoveAssetsDto): Promise<boolean> {
|
||||||
|
let deleteAssetCount = 0;
|
||||||
|
// TODO: should probably do a single delete query?
|
||||||
|
for (const assetId of removeAssetsDto.assetIds) {
|
||||||
|
const res = await this.assetAlbumRepository.delete({ albumId: album.id, assetId: assetId });
|
||||||
|
if (res.affected == 1) deleteAssetCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: No need to return boolean if using a singe delete query
|
||||||
|
return deleteAssetCount == removeAssetsDto.assetIds.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AlbumEntity> {
|
||||||
|
const newRecords: AssetAlbumEntity[] = [];
|
||||||
|
|
||||||
|
for (const assetId of addAssetsDto.assetIds) {
|
||||||
|
const newAssetAlbum = new AssetAlbumEntity();
|
||||||
|
newAssetAlbum.assetId = assetId;
|
||||||
|
newAssetAlbum.albumId = album.id;
|
||||||
|
|
||||||
|
newRecords.push(newAssetAlbum);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add album thumbnail if not exist.
|
||||||
|
if (!album.albumThumbnailAssetId && newRecords.length > 0) {
|
||||||
|
album.albumThumbnailAssetId = newRecords[0].assetId;
|
||||||
|
await this.albumRepository.save(album);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.assetAlbumRepository.save([...newRecords]);
|
||||||
|
return this.get(album.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity> {
|
||||||
|
album.albumName = updateAlbumDto.albumName;
|
||||||
|
|
||||||
|
return this.albumRepository.save(album);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,105 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Patch,
|
||||||
|
Param,
|
||||||
|
Delete,
|
||||||
|
UseGuards,
|
||||||
|
ValidationPipe,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ParseMeUUIDPipe } from '../validation/parse-me-uuid-pipe';
|
||||||
|
import { AlbumService } from './album.service';
|
||||||
|
import { CreateAlbumDto } from './dto/create-album.dto';
|
||||||
|
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
||||||
|
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 { UpdateAlbumDto } from './dto/update-album.dto';
|
||||||
|
import { GetAlbumsDto } from './dto/get-albums.dto';
|
||||||
|
|
||||||
|
// TODO might be worth creating a AlbumParamsDto that validates `albumId` instead of using the pipe.
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('album')
|
||||||
|
export class AlbumController {
|
||||||
|
constructor(private readonly albumService: AlbumService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) createAlbumDto: CreateAlbumDto) {
|
||||||
|
return this.albumService.create(authUser, createAlbumDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('/:albumId/users')
|
||||||
|
async addUsers(
|
||||||
|
@GetAuthUser() authUser: AuthUserDto,
|
||||||
|
@Body(ValidationPipe) addUsersDto: AddUsersDto,
|
||||||
|
@Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
|
||||||
|
) {
|
||||||
|
return this.albumService.addUsersToAlbum(authUser, addUsersDto, albumId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('/:albumId/assets')
|
||||||
|
async addAssets(
|
||||||
|
@GetAuthUser() authUser: AuthUserDto,
|
||||||
|
@Body(ValidationPipe) addAssetsDto: AddAssetsDto,
|
||||||
|
@Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
|
||||||
|
) {
|
||||||
|
return this.albumService.addAssetsToAlbum(authUser, addAssetsDto, albumId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getAllAlbums(
|
||||||
|
@GetAuthUser() authUser: AuthUserDto,
|
||||||
|
@Query(new ValidationPipe({ transform: true })) query: GetAlbumsDto,
|
||||||
|
) {
|
||||||
|
return this.albumService.getAllAlbums(authUser, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/:albumId')
|
||||||
|
async getAlbumInfo(
|
||||||
|
@GetAuthUser() authUser: AuthUserDto,
|
||||||
|
@Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
|
||||||
|
) {
|
||||||
|
return this.albumService.getAlbumInfo(authUser, albumId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('/:albumId/assets')
|
||||||
|
async removeAssetFromAlbum(
|
||||||
|
@GetAuthUser() authUser: AuthUserDto,
|
||||||
|
@Body(ValidationPipe) removeAssetsDto: RemoveAssetsDto,
|
||||||
|
@Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
|
||||||
|
) {
|
||||||
|
return this.albumService.removeAssetsFromAlbum(authUser, removeAssetsDto, albumId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('/:albumId')
|
||||||
|
async deleteAlbum(
|
||||||
|
@GetAuthUser() authUser: AuthUserDto,
|
||||||
|
@Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
|
||||||
|
) {
|
||||||
|
return this.albumService.deleteAlbum(authUser, albumId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('/:albumId/user/:userId')
|
||||||
|
async removeUserFromAlbum(
|
||||||
|
@GetAuthUser() authUser: AuthUserDto,
|
||||||
|
@Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
|
||||||
|
@Param('userId', new ParseMeUUIDPipe({ version: '4' })) userId: string,
|
||||||
|
) {
|
||||||
|
return this.albumService.removeUserFromAlbum(authUser, albumId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('/:albumId')
|
||||||
|
async updateAlbumInfo(
|
||||||
|
@GetAuthUser() authUser: AuthUserDto,
|
||||||
|
@Body(ValidationPipe) updateAlbumInfoDto: UpdateAlbumDto,
|
||||||
|
@Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
|
||||||
|
) {
|
||||||
|
return this.albumService.updateAlbumTitle(authUser, updateAlbumInfoDto, albumId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AlbumService } from './album.service';
|
||||||
|
import { AlbumController } from './album.controller';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||||
|
import { UserEntity } from '@app/database/entities/user.entity';
|
||||||
|
import { AlbumEntity } from '../../../../../libs/database/src/entities/album.entity';
|
||||||
|
import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
|
||||||
|
import { UserAlbumEntity } from '@app/database/entities/user-album.entity';
|
||||||
|
import { AlbumRepository, ALBUM_REPOSITORY } from './album-repository';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([AssetEntity, UserEntity, AlbumEntity, AssetAlbumEntity, UserAlbumEntity])],
|
||||||
|
controllers: [AlbumController],
|
||||||
|
providers: [
|
||||||
|
AlbumService,
|
||||||
|
{
|
||||||
|
provide: ALBUM_REPOSITORY,
|
||||||
|
useClass: AlbumRepository,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AlbumModule {}
|
||||||
@ -0,0 +1,414 @@
|
|||||||
|
import { AlbumService } from './album.service';
|
||||||
|
import { IAlbumRepository } from './album-repository';
|
||||||
|
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||||
|
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { AlbumEntity } from '@app/database/entities/album.entity';
|
||||||
|
import { AlbumResponseDto } from './response-dto/album-response.dto';
|
||||||
|
|
||||||
|
describe('Album service', () => {
|
||||||
|
let sut: AlbumService;
|
||||||
|
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
|
||||||
|
const authUser: AuthUserDto = Object.freeze({
|
||||||
|
id: '1111',
|
||||||
|
email: 'auth@test.com',
|
||||||
|
});
|
||||||
|
const albumId = '0001';
|
||||||
|
const sharedAlbumOwnerId = '2222';
|
||||||
|
const sharedAlbumSharedAlsoWithId = '3333';
|
||||||
|
const ownedAlbumSharedWithId = '4444';
|
||||||
|
|
||||||
|
const _getOwnedAlbum = () => {
|
||||||
|
const albumEntity = new AlbumEntity();
|
||||||
|
albumEntity.ownerId = authUser.id;
|
||||||
|
albumEntity.id = albumId;
|
||||||
|
albumEntity.albumName = 'name';
|
||||||
|
albumEntity.createdAt = 'date';
|
||||||
|
albumEntity.sharedUsers = [];
|
||||||
|
albumEntity.assets = [];
|
||||||
|
|
||||||
|
return albumEntity;
|
||||||
|
};
|
||||||
|
|
||||||
|
const _getOwnedSharedAlbum = () => {
|
||||||
|
const albumEntity = new AlbumEntity();
|
||||||
|
albumEntity.ownerId = authUser.id;
|
||||||
|
albumEntity.id = albumId;
|
||||||
|
albumEntity.albumName = 'name';
|
||||||
|
albumEntity.createdAt = 'date';
|
||||||
|
albumEntity.assets = [];
|
||||||
|
albumEntity.sharedUsers = [
|
||||||
|
{
|
||||||
|
id: '99',
|
||||||
|
albumId,
|
||||||
|
sharedUserId: ownedAlbumSharedWithId,
|
||||||
|
//@ts-expect-error Partial stub
|
||||||
|
albumInfo: {},
|
||||||
|
//@ts-expect-error Partial stub
|
||||||
|
userInfo: {
|
||||||
|
id: ownedAlbumSharedWithId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return albumEntity;
|
||||||
|
};
|
||||||
|
|
||||||
|
const _getSharedWithAuthUserAlbum = () => {
|
||||||
|
const albumEntity = new AlbumEntity();
|
||||||
|
albumEntity.ownerId = sharedAlbumOwnerId;
|
||||||
|
albumEntity.id = albumId;
|
||||||
|
albumEntity.albumName = 'name';
|
||||||
|
albumEntity.createdAt = 'date';
|
||||||
|
albumEntity.assets = [];
|
||||||
|
albumEntity.sharedUsers = [
|
||||||
|
{
|
||||||
|
id: '99',
|
||||||
|
albumId,
|
||||||
|
sharedUserId: authUser.id,
|
||||||
|
//@ts-expect-error Partial stub
|
||||||
|
albumInfo: {},
|
||||||
|
//@ts-expect-error Partial stub
|
||||||
|
userInfo: {
|
||||||
|
id: authUser.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '98',
|
||||||
|
albumId,
|
||||||
|
sharedUserId: sharedAlbumSharedAlsoWithId,
|
||||||
|
//@ts-expect-error Partial stub
|
||||||
|
albumInfo: {},
|
||||||
|
//@ts-expect-error Partial stub
|
||||||
|
userInfo: {
|
||||||
|
id: sharedAlbumSharedAlsoWithId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return albumEntity;
|
||||||
|
};
|
||||||
|
|
||||||
|
const _getNotOwnedNotSharedAlbum = () => {
|
||||||
|
const albumEntity = new AlbumEntity();
|
||||||
|
albumEntity.ownerId = '5555';
|
||||||
|
albumEntity.id = albumId;
|
||||||
|
albumEntity.albumName = 'name';
|
||||||
|
albumEntity.createdAt = 'date';
|
||||||
|
albumEntity.sharedUsers = [];
|
||||||
|
albumEntity.assets = [];
|
||||||
|
|
||||||
|
return albumEntity;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
albumRepositoryMock = {
|
||||||
|
addAssets: jest.fn(),
|
||||||
|
addSharedUsers: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
get: jest.fn(),
|
||||||
|
getList: jest.fn(),
|
||||||
|
removeAssets: jest.fn(),
|
||||||
|
removeUser: jest.fn(),
|
||||||
|
updateAlbum: jest.fn(),
|
||||||
|
};
|
||||||
|
sut = new AlbumService(albumRepositoryMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates album', async () => {
|
||||||
|
const albumEntity = _getOwnedAlbum();
|
||||||
|
albumRepositoryMock.create.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||||
|
|
||||||
|
const result = await sut.create(authUser, {
|
||||||
|
albumName: albumEntity.albumName,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.id).toEqual(albumEntity.id);
|
||||||
|
expect(result.albumName).toEqual(albumEntity.albumName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets list of albums for auth user', async () => {
|
||||||
|
const ownedAlbum = _getOwnedAlbum();
|
||||||
|
const ownedSharedAlbum = _getOwnedSharedAlbum();
|
||||||
|
const sharedWithMeAlbum = _getSharedWithAuthUserAlbum();
|
||||||
|
const albums: AlbumEntity[] = [ownedAlbum, ownedSharedAlbum, sharedWithMeAlbum];
|
||||||
|
|
||||||
|
albumRepositoryMock.getList.mockImplementation(() => Promise.resolve(albums));
|
||||||
|
|
||||||
|
const result = await sut.getAllAlbums(authUser, {});
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result[0].id).toEqual(ownedAlbum.id);
|
||||||
|
expect(result[1].id).toEqual(ownedSharedAlbum.id);
|
||||||
|
expect(result[2].id).toEqual(sharedWithMeAlbum.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets an owned album', async () => {
|
||||||
|
const ownerId = authUser.id;
|
||||||
|
const albumId = '0001';
|
||||||
|
|
||||||
|
const albumEntity = _getOwnedAlbum();
|
||||||
|
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||||
|
|
||||||
|
const expectedResult: AlbumResponseDto = {
|
||||||
|
albumName: 'name',
|
||||||
|
albumThumbnailAssetId: undefined,
|
||||||
|
createdAt: 'date',
|
||||||
|
id: '0001',
|
||||||
|
ownerId,
|
||||||
|
shared: false,
|
||||||
|
assets: [],
|
||||||
|
sharedUsers: [],
|
||||||
|
};
|
||||||
|
await expect(sut.getAlbumInfo(authUser, albumId)).resolves.toEqual(expectedResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets a shared album', async () => {
|
||||||
|
const albumEntity = _getSharedWithAuthUserAlbum();
|
||||||
|
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||||
|
|
||||||
|
const result = await sut.getAlbumInfo(authUser, albumId);
|
||||||
|
expect(result.id).toEqual(albumId);
|
||||||
|
expect(result.ownerId).toEqual(sharedAlbumOwnerId);
|
||||||
|
expect(result.shared).toEqual(true);
|
||||||
|
expect(result.sharedUsers).toHaveLength(2);
|
||||||
|
expect(result.sharedUsers[0].id).toEqual(authUser.id);
|
||||||
|
expect(result.sharedUsers[1].id).toEqual(sharedAlbumSharedAlsoWithId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents retrieving an album that is not owned or shared', async () => {
|
||||||
|
const albumEntity = _getNotOwnedNotSharedAlbum();
|
||||||
|
const albumId = albumEntity.id;
|
||||||
|
|
||||||
|
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||||
|
await expect(sut.getAlbumInfo(authUser, albumId)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws a not found exception if the album is not found', async () => {
|
||||||
|
albumRepositoryMock.get.mockImplementation(() => Promise.resolve(undefined));
|
||||||
|
await expect(sut.getAlbumInfo(authUser, '0002')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes an owned album', async () => {
|
||||||
|
const albumEntity = _getOwnedAlbum();
|
||||||
|
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||||
|
albumRepositoryMock.delete.mockImplementation(() => Promise.resolve());
|
||||||
|
await sut.deleteAlbum(authUser, albumId);
|
||||||
|
expect(albumRepositoryMock.delete).toHaveBeenCalledTimes(1);
|
||||||
|
expect(albumRepositoryMock.delete).toHaveBeenCalledWith(albumEntity);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents deleting a shared album (shared with auth user)', async () => {
|
||||||
|
const albumEntity = _getSharedWithAuthUserAlbum();
|
||||||
|
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||||
|
await expect(sut.deleteAlbum(authUser, albumId)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
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.removeUserFromAlbum(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.removeUserFromAlbum(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.removeUserFromAlbum(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.removeUserFromAlbum(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.removeUserFromAlbum(authUser, albumEntity.id, authUser.id)).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates a owned album', async () => {
|
||||||
|
const albumEntity = _getOwnedAlbum();
|
||||||
|
const albumId = albumEntity.id;
|
||||||
|
const updatedAlbumName = 'new album name';
|
||||||
|
|
||||||
|
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||||
|
albumRepositoryMock.updateAlbum.mockImplementation(() =>
|
||||||
|
Promise.resolve<AlbumEntity>({ ...albumEntity, albumName: updatedAlbumName }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await sut.updateAlbumTitle(
|
||||||
|
authUser,
|
||||||
|
{
|
||||||
|
albumName: updatedAlbumName,
|
||||||
|
ownerId: 'this is not used and will be removed',
|
||||||
|
},
|
||||||
|
albumId,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.id).toEqual(albumId);
|
||||||
|
expect(result.albumName).toEqual(updatedAlbumName);
|
||||||
|
expect(albumRepositoryMock.updateAlbum).toHaveBeenCalledTimes(1);
|
||||||
|
expect(albumRepositoryMock.updateAlbum).toHaveBeenCalledWith(albumEntity, {
|
||||||
|
albumName: updatedAlbumName,
|
||||||
|
ownerId: 'this is not used and will be removed',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents updating a not owned album (shared with auth user)', async () => {
|
||||||
|
const albumEntity = _getSharedWithAuthUserAlbum();
|
||||||
|
const albumId = albumEntity.id;
|
||||||
|
|
||||||
|
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sut.updateAlbumTitle(
|
||||||
|
authUser,
|
||||||
|
{
|
||||||
|
albumName: 'new album name',
|
||||||
|
ownerId: 'this is not used and will be removed',
|
||||||
|
},
|
||||||
|
albumId,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds assets to owned album', async () => {
|
||||||
|
const albumEntity = _getOwnedAlbum();
|
||||||
|
const albumId = albumEntity.id;
|
||||||
|
|
||||||
|
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||||
|
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||||
|
|
||||||
|
const result = await sut.addAssetsToAlbum(
|
||||||
|
authUser,
|
||||||
|
{
|
||||||
|
assetIds: ['1'],
|
||||||
|
},
|
||||||
|
albumId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: stub and expect album rendered
|
||||||
|
expect(result.id).toEqual(albumId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds assets to shared album (shared with auth user)', async () => {
|
||||||
|
const albumEntity = _getSharedWithAuthUserAlbum();
|
||||||
|
const albumId = albumEntity.id;
|
||||||
|
|
||||||
|
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||||
|
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||||
|
|
||||||
|
const result = await sut.addAssetsToAlbum(
|
||||||
|
authUser,
|
||||||
|
{
|
||||||
|
assetIds: ['1'],
|
||||||
|
},
|
||||||
|
albumId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: stub and expect album rendered
|
||||||
|
expect(result.id).toEqual(albumId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents adding assets to a not owned / shared album', async () => {
|
||||||
|
const albumEntity = _getNotOwnedNotSharedAlbum();
|
||||||
|
const albumId = albumEntity.id;
|
||||||
|
|
||||||
|
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||||
|
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
sut.addAssetsToAlbum(
|
||||||
|
authUser,
|
||||||
|
{
|
||||||
|
assetIds: ['1'],
|
||||||
|
},
|
||||||
|
albumId,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes assets from owned album', async () => {
|
||||||
|
const albumEntity = _getOwnedAlbum();
|
||||||
|
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||||
|
albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve(true));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sut.removeAssetsFromAlbum(
|
||||||
|
authUser,
|
||||||
|
{
|
||||||
|
assetIds: ['1'],
|
||||||
|
},
|
||||||
|
albumEntity.id,
|
||||||
|
),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
|
||||||
|
expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
|
||||||
|
assetIds: ['1'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes assets from shared album (shared with auth user)', async () => {
|
||||||
|
const albumEntity = _getOwnedSharedAlbum();
|
||||||
|
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||||
|
albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve(true));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sut.removeAssetsFromAlbum(
|
||||||
|
authUser,
|
||||||
|
{
|
||||||
|
assetIds: ['1'],
|
||||||
|
},
|
||||||
|
albumEntity.id,
|
||||||
|
),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
|
||||||
|
expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
|
||||||
|
assetIds: ['1'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents removing assets from a not owned / shared album', async () => {
|
||||||
|
const albumEntity = _getNotOwnedNotSharedAlbum();
|
||||||
|
const albumId = albumEntity.id;
|
||||||
|
|
||||||
|
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||||
|
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
sut.removeAssetsFromAlbum(
|
||||||
|
authUser,
|
||||||
|
{
|
||||||
|
assetIds: ['1'],
|
||||||
|
},
|
||||||
|
albumId,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
import { BadRequestException, Inject, Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||||
|
import { AddAssetsDto } from './dto/add-assets.dto';
|
||||||
|
import { CreateAlbumDto } from './dto/create-album.dto';
|
||||||
|
import { AlbumEntity } from '../../../../../libs/database/src/entities/album.entity';
|
||||||
|
import { AddUsersDto } from './dto/add-users.dto';
|
||||||
|
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
||||||
|
import { UpdateAlbumDto } from './dto/update-album.dto';
|
||||||
|
import { GetAlbumsDto } from './dto/get-albums.dto';
|
||||||
|
import { AlbumResponseDto, mapAlbum } from './response-dto/album-response.dto';
|
||||||
|
import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AlbumService {
|
||||||
|
constructor(@Inject(ALBUM_REPOSITORY) private _albumRepository: IAlbumRepository) {}
|
||||||
|
|
||||||
|
private async _getAlbum({
|
||||||
|
authUser,
|
||||||
|
albumId,
|
||||||
|
validateIsOwner = true,
|
||||||
|
}: {
|
||||||
|
authUser: AuthUserDto;
|
||||||
|
albumId: string;
|
||||||
|
validateIsOwner?: boolean;
|
||||||
|
}): Promise<AlbumEntity> {
|
||||||
|
const album = await this._albumRepository.get(albumId);
|
||||||
|
if (!album) {
|
||||||
|
throw new NotFoundException('Album Not Found');
|
||||||
|
}
|
||||||
|
const isOwner = album.ownerId == authUser.id;
|
||||||
|
|
||||||
|
if (validateIsOwner && !isOwner) {
|
||||||
|
throw new ForbiddenException('Unauthorized Album Access');
|
||||||
|
} else if (!isOwner && !album.sharedUsers.some((user) => user.sharedUserId == authUser.id)) {
|
||||||
|
throw new ForbiddenException('Unauthorized Album Access');
|
||||||
|
}
|
||||||
|
return album;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(authUser: AuthUserDto, createAlbumDto: CreateAlbumDto): Promise<AlbumResponseDto> {
|
||||||
|
const albumEntity = await this._albumRepository.create(authUser.id, createAlbumDto);
|
||||||
|
return mapAlbum(albumEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all shared album, including owned and shared one.
|
||||||
|
* @param authUser AuthUserDto
|
||||||
|
* @returns All Shared Album And Its Members
|
||||||
|
*/
|
||||||
|
async getAllAlbums(authUser: AuthUserDto, getAlbumsDto: GetAlbumsDto): Promise<AlbumResponseDto[]> {
|
||||||
|
const albums = await this._albumRepository.getList(authUser.id, getAlbumsDto);
|
||||||
|
return albums.map((album) => mapAlbum(album));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAlbumInfo(authUser: AuthUserDto, albumId: string): Promise<AlbumResponseDto> {
|
||||||
|
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
|
||||||
|
return mapAlbum(album);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addUsersToAlbum(authUser: AuthUserDto, addUsersDto: AddUsersDto, albumId: string): Promise<AlbumResponseDto> {
|
||||||
|
const album = await this._getAlbum({ authUser, albumId });
|
||||||
|
const updatedAlbum = await this._albumRepository.addSharedUsers(album, addUsersDto);
|
||||||
|
return mapAlbum(updatedAlbum);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAlbum(authUser: AuthUserDto, albumId: string): Promise<void> {
|
||||||
|
const album = await this._getAlbum({ authUser, albumId });
|
||||||
|
await this._albumRepository.delete(album);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeUserFromAlbum(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 removeUsersFromAlbum() {}
|
||||||
|
|
||||||
|
async removeAssetsFromAlbum(authUser: AuthUserDto, removeAssetsDto: RemoveAssetsDto, albumId: string): Promise<void> {
|
||||||
|
const album = await this._getAlbum({ authUser, albumId });
|
||||||
|
await this._albumRepository.removeAssets(album, removeAssetsDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addAssetsToAlbum(
|
||||||
|
authUser: AuthUserDto,
|
||||||
|
addAssetsDto: AddAssetsDto,
|
||||||
|
albumId: string,
|
||||||
|
): Promise<AlbumResponseDto> {
|
||||||
|
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
|
||||||
|
const updatedAlbum = await this._albumRepository.addAssets(album, addAssetsDto);
|
||||||
|
return mapAlbum(updatedAlbum);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAlbumTitle(
|
||||||
|
authUser: AuthUserDto,
|
||||||
|
updateAlbumDto: UpdateAlbumDto,
|
||||||
|
albumId: string,
|
||||||
|
): Promise<AlbumResponseDto> {
|
||||||
|
// TODO: this should not come from request DTO. To be removed from here and DTO
|
||||||
|
// if (authUser.id != updateAlbumDto.ownerId) {
|
||||||
|
// throw new BadRequestException('Unauthorized to change album info');
|
||||||
|
// }
|
||||||
|
const album = await this._getAlbum({ authUser, albumId });
|
||||||
|
const updatedAlbum = await this._albumRepository.updateAlbum(album, updateAlbumDto);
|
||||||
|
return mapAlbum(updatedAlbum);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,10 +1,6 @@
|
|||||||
import { IsNotEmpty } from 'class-validator';
|
import { IsNotEmpty } from 'class-validator';
|
||||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
|
||||||
|
|
||||||
export class AddAssetsDto {
|
export class AddAssetsDto {
|
||||||
@IsNotEmpty()
|
|
||||||
albumId: string;
|
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
assetIds: string[];
|
assetIds: string[];
|
||||||
}
|
}
|
||||||
@ -1,9 +1,6 @@
|
|||||||
import { IsNotEmpty } from 'class-validator';
|
import { IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
export class AddUsersDto {
|
export class AddUsersDto {
|
||||||
@IsNotEmpty()
|
|
||||||
albumId: string;
|
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
sharedUserIds: string[];
|
sharedUserIds: string[];
|
||||||
}
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
import { IsNotEmpty, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateAlbumDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
albumName: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
sharedWithUserIds?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
assetIds?: string[];
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsOptional, IsBoolean } from 'class-validator';
|
||||||
|
|
||||||
|
export class GetAlbumsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@Transform(({ value }) => {
|
||||||
|
if (value == 'true') {
|
||||||
|
return true;
|
||||||
|
} else if (value == 'false') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* true: only shared albums
|
||||||
|
* false: only non-shared own albums
|
||||||
|
* undefined: shared and owned albums
|
||||||
|
*/
|
||||||
|
shared?: boolean;
|
||||||
|
}
|
||||||
@ -1,9 +1,6 @@
|
|||||||
import { IsNotEmpty } from 'class-validator';
|
import { IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
export class RemoveAssetsDto {
|
export class RemoveAssetsDto {
|
||||||
@IsNotEmpty()
|
|
||||||
albumId: string;
|
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
assetIds: string[];
|
assetIds: string[];
|
||||||
}
|
}
|
||||||
@ -1,9 +1,6 @@
|
|||||||
import { IsNotEmpty } from 'class-validator';
|
import { IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateShareAlbumDto {
|
export class UpdateAlbumDto {
|
||||||
@IsNotEmpty()
|
|
||||||
albumId: string;
|
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
albumName: string;
|
albumName: string;
|
||||||
|
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
import { AlbumEntity } from '../../../../../../libs/database/src/entities/album.entity';
|
||||||
|
import { User, mapUser } from '../../user/response-dto/user';
|
||||||
|
import { AssetResponseDto, mapAsset } from '../../asset/response-dto/asset-response.dto';
|
||||||
|
|
||||||
|
export interface AlbumResponseDto {
|
||||||
|
id: string;
|
||||||
|
ownerId: string;
|
||||||
|
albumName: string;
|
||||||
|
createdAt: string;
|
||||||
|
albumThumbnailAssetId: string | null;
|
||||||
|
shared: boolean;
|
||||||
|
sharedUsers: User[];
|
||||||
|
assets: AssetResponseDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {
|
||||||
|
const sharedUsers = entity.sharedUsers?.map((userAlbum) => mapUser(userAlbum.userInfo)) || [];
|
||||||
|
return {
|
||||||
|
albumName: entity.albumName,
|
||||||
|
albumThumbnailAssetId: entity.albumThumbnailAssetId,
|
||||||
|
createdAt: entity.createdAt,
|
||||||
|
id: entity.id,
|
||||||
|
ownerId: entity.ownerId,
|
||||||
|
sharedUsers,
|
||||||
|
shared: sharedUsers.length > 0,
|
||||||
|
assets: entity.assets?.map((assetAlbum) => mapAsset(assetAlbum.assetInfo)) || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
|
||||||
|
import { ExifResponseDto, mapExif } from './exif-response.dto';
|
||||||
|
import { SmartInfoResponseDto, mapSmartInfo } from './smart-info-response.dto';
|
||||||
|
|
||||||
|
export interface AssetResponseDto {
|
||||||
|
id: string;
|
||||||
|
deviceAssetId: string;
|
||||||
|
ownerId: string;
|
||||||
|
deviceId: string;
|
||||||
|
type: AssetType;
|
||||||
|
originalPath: string;
|
||||||
|
resizePath: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
modifiedAt: string;
|
||||||
|
isFavorite: boolean;
|
||||||
|
mimeType: string | null;
|
||||||
|
duration: string | null;
|
||||||
|
exifInfo?: ExifResponseDto;
|
||||||
|
smartInfo?: SmartInfoResponseDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapAsset(entity: AssetEntity): AssetResponseDto {
|
||||||
|
return {
|
||||||
|
id: entity.id,
|
||||||
|
deviceAssetId: entity.deviceAssetId,
|
||||||
|
ownerId: entity.userId,
|
||||||
|
deviceId: entity.deviceId,
|
||||||
|
type: entity.type,
|
||||||
|
originalPath: entity.originalPath,
|
||||||
|
resizePath: entity.resizePath,
|
||||||
|
createdAt: entity.createdAt,
|
||||||
|
modifiedAt: entity.modifiedAt,
|
||||||
|
isFavorite: entity.isFavorite,
|
||||||
|
mimeType: entity.mimeType,
|
||||||
|
duration: entity.duration,
|
||||||
|
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
|
||||||
|
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
import { ExifEntity } from '@app/database/entities/exif.entity';
|
||||||
|
|
||||||
|
export interface ExifResponseDto {
|
||||||
|
id: string;
|
||||||
|
make: string | null;
|
||||||
|
model: string | null;
|
||||||
|
imageName: string | null;
|
||||||
|
exifImageWidth: number | null;
|
||||||
|
exifImageHeight: number | null;
|
||||||
|
fileSizeInByte: number | null;
|
||||||
|
orientation: string | null;
|
||||||
|
dateTimeOriginal: Date | null;
|
||||||
|
modifyDate: Date | null;
|
||||||
|
lensModel: string | null;
|
||||||
|
fNumber: number | null;
|
||||||
|
focalLength: number | null;
|
||||||
|
iso: number | null;
|
||||||
|
exposureTime: number | null;
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
city: string | null;
|
||||||
|
state: string | null;
|
||||||
|
country: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapExif(entity: ExifEntity): ExifResponseDto {
|
||||||
|
return {
|
||||||
|
id: entity.id,
|
||||||
|
make: entity.make,
|
||||||
|
model: entity.model,
|
||||||
|
imageName: entity.imageName,
|
||||||
|
exifImageWidth: entity.exifImageWidth,
|
||||||
|
exifImageHeight: entity.exifImageHeight,
|
||||||
|
fileSizeInByte: entity.fileSizeInByte,
|
||||||
|
orientation: entity.orientation,
|
||||||
|
dateTimeOriginal: entity.dateTimeOriginal,
|
||||||
|
modifyDate: entity.modifyDate,
|
||||||
|
lensModel: entity.lensModel,
|
||||||
|
fNumber: entity.fNumber,
|
||||||
|
focalLength: entity.focalLength,
|
||||||
|
iso: entity.iso,
|
||||||
|
exposureTime: entity.exposureTime,
|
||||||
|
latitude: entity.latitude,
|
||||||
|
longitude: entity.longitude,
|
||||||
|
city: entity.city,
|
||||||
|
state: entity.state,
|
||||||
|
country: entity.country,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
|
||||||
|
|
||||||
|
export interface SmartInfoResponseDto {
|
||||||
|
id: string;
|
||||||
|
tags: string[] | null;
|
||||||
|
objects: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapSmartInfo(entity: SmartInfoEntity): SmartInfoResponseDto {
|
||||||
|
return {
|
||||||
|
id: entity.id,
|
||||||
|
tags: entity.tags,
|
||||||
|
objects: entity.objects,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import { IsNotEmpty, IsOptional } from 'class-validator';
|
|
||||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
|
||||||
|
|
||||||
export class CreateSharedAlbumDto {
|
|
||||||
@IsNotEmpty()
|
|
||||||
albumName: string;
|
|
||||||
|
|
||||||
@IsNotEmpty()
|
|
||||||
sharedWithUserIds: string[];
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
assetIds: string[];
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, ValidationPipe, Query } from '@nestjs/common';
|
|
||||||
import { SharingService } from './sharing.service';
|
|
||||||
import { CreateSharedAlbumDto } from './dto/create-shared-album.dto';
|
|
||||||
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
|
||||||
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 { UpdateShareAlbumDto } from './dto/update-shared-album.dto';
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Controller('shared')
|
|
||||||
export class SharingController {
|
|
||||||
constructor(private readonly sharingService: SharingService) {}
|
|
||||||
|
|
||||||
@Post('/createAlbum')
|
|
||||||
async create(@GetAuthUser() authUser, @Body(ValidationPipe) createSharedAlbumDto: CreateSharedAlbumDto) {
|
|
||||||
return await this.sharingService.create(authUser, createSharedAlbumDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('/addUsers')
|
|
||||||
async addUsers(@Body(ValidationPipe) addUsersDto: AddUsersDto) {
|
|
||||||
return await this.sharingService.addUsersToAlbum(addUsersDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('/addAssets')
|
|
||||||
async addAssets(@Body(ValidationPipe) addAssetsDto: AddAssetsDto) {
|
|
||||||
return await this.sharingService.addAssetsToAlbum(addAssetsDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('/allSharedAlbums')
|
|
||||||
async getAllSharedAlbums(@GetAuthUser() authUser) {
|
|
||||||
return await this.sharingService.getAllSharedAlbums(authUser);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('/:albumId')
|
|
||||||
async getAlbumInfo(@GetAuthUser() authUser, @Param('albumId') albumId: string) {
|
|
||||||
return await this.sharingService.getAlbumInfo(authUser, albumId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete('/removeAssets')
|
|
||||||
async removeAssetFromAlbum(@GetAuthUser() authUser, @Body(ValidationPipe) removeAssetsDto: RemoveAssetsDto) {
|
|
||||||
console.log('removeAssets');
|
|
||||||
return await this.sharingService.removeAssetsFromAlbum(authUser, removeAssetsDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete('/:albumId')
|
|
||||||
async deleteAlbum(@GetAuthUser() authUser, @Param('albumId') albumId: string) {
|
|
||||||
return await this.sharingService.deleteAlbum(authUser, albumId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete('/leaveAlbum/:albumId')
|
|
||||||
async leaveAlbum(@GetAuthUser() authUser, @Param('albumId') albumId: string) {
|
|
||||||
return await this.sharingService.leaveAlbum(authUser, albumId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch('/updateInfo')
|
|
||||||
async updateAlbumInfo(@GetAuthUser() authUser, @Body(ValidationPipe) updateAlbumInfoDto: UpdateShareAlbumDto) {
|
|
||||||
return await this.sharingService.updateAlbumTitle(authUser, updateAlbumInfoDto);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { SharingService } from './sharing.service';
|
|
||||||
import { SharingController } from './sharing.controller';
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
|
||||||
import { UserEntity } from '@app/database/entities/user.entity';
|
|
||||||
import { AssetSharedAlbumEntity } from '@app/database/entities/asset-shared-album.entity';
|
|
||||||
import { SharedAlbumEntity } from '@app/database/entities/shared-album.entity';
|
|
||||||
import { UserSharedAlbumEntity } from '@app/database/entities/user-shared-album.entity';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
TypeOrmModule.forFeature([
|
|
||||||
AssetEntity,
|
|
||||||
UserEntity,
|
|
||||||
SharedAlbumEntity,
|
|
||||||
AssetSharedAlbumEntity,
|
|
||||||
UserSharedAlbumEntity,
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
controllers: [SharingController],
|
|
||||||
providers: [SharingService],
|
|
||||||
})
|
|
||||||
export class SharingModule {}
|
|
||||||
@ -1,199 +0,0 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { getConnection, Repository } from 'typeorm';
|
|
||||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
|
||||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
|
||||||
import { UserEntity } from '@app/database/entities/user.entity';
|
|
||||||
import { AddAssetsDto } from './dto/add-assets.dto';
|
|
||||||
import { CreateSharedAlbumDto } from './dto/create-shared-album.dto';
|
|
||||||
import { AssetSharedAlbumEntity } from '@app/database/entities/asset-shared-album.entity';
|
|
||||||
import { SharedAlbumEntity } from '@app/database/entities/shared-album.entity';
|
|
||||||
import { UserSharedAlbumEntity } from '@app/database/entities/user-shared-album.entity';
|
|
||||||
import _ from 'lodash';
|
|
||||||
import { AddUsersDto } from './dto/add-users.dto';
|
|
||||||
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
|
||||||
import { UpdateShareAlbumDto } from './dto/update-shared-album.dto';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SharingService {
|
|
||||||
constructor(
|
|
||||||
@InjectRepository(AssetEntity)
|
|
||||||
private assetRepository: Repository<AssetEntity>,
|
|
||||||
|
|
||||||
@InjectRepository(UserEntity)
|
|
||||||
private userRepository: Repository<UserEntity>,
|
|
||||||
|
|
||||||
@InjectRepository(SharedAlbumEntity)
|
|
||||||
private sharedAlbumRepository: Repository<SharedAlbumEntity>,
|
|
||||||
|
|
||||||
@InjectRepository(AssetSharedAlbumEntity)
|
|
||||||
private assetSharedAlbumRepository: Repository<AssetSharedAlbumEntity>,
|
|
||||||
|
|
||||||
@InjectRepository(UserSharedAlbumEntity)
|
|
||||||
private userSharedAlbumRepository: Repository<UserSharedAlbumEntity>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async create(authUser: AuthUserDto, createSharedAlbumDto: CreateSharedAlbumDto) {
|
|
||||||
return await getConnection().transaction(async (transactionalEntityManager) => {
|
|
||||||
// Create album entity
|
|
||||||
const newSharedAlbum = new SharedAlbumEntity();
|
|
||||||
newSharedAlbum.ownerId = authUser.id;
|
|
||||||
newSharedAlbum.albumName = createSharedAlbumDto.albumName;
|
|
||||||
|
|
||||||
const sharedAlbum = await transactionalEntityManager.save(newSharedAlbum);
|
|
||||||
|
|
||||||
// Add shared users
|
|
||||||
for (const sharedUserId of createSharedAlbumDto.sharedWithUserIds) {
|
|
||||||
const newSharedUser = new UserSharedAlbumEntity();
|
|
||||||
newSharedUser.albumId = sharedAlbum.id;
|
|
||||||
newSharedUser.sharedUserId = sharedUserId;
|
|
||||||
|
|
||||||
await transactionalEntityManager.save(newSharedUser);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add shared assets
|
|
||||||
const newRecords: AssetSharedAlbumEntity[] = [];
|
|
||||||
|
|
||||||
for (const assetId of createSharedAlbumDto.assetIds) {
|
|
||||||
const newAssetSharedAlbum = new AssetSharedAlbumEntity();
|
|
||||||
newAssetSharedAlbum.assetId = assetId;
|
|
||||||
newAssetSharedAlbum.albumId = sharedAlbum.id;
|
|
||||||
|
|
||||||
newRecords.push(newAssetSharedAlbum);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sharedAlbum.albumThumbnailAssetId && newRecords.length > 0) {
|
|
||||||
sharedAlbum.albumThumbnailAssetId = newRecords[0].assetId;
|
|
||||||
await transactionalEntityManager.save(sharedAlbum);
|
|
||||||
}
|
|
||||||
|
|
||||||
await transactionalEntityManager.save([...newRecords]);
|
|
||||||
|
|
||||||
return sharedAlbum;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all shared album, including owned and shared one.
|
|
||||||
* @param authUser AuthUserDto
|
|
||||||
* @returns All Shared Album And Its Members
|
|
||||||
*/
|
|
||||||
async getAllSharedAlbums(authUser: AuthUserDto) {
|
|
||||||
const ownedAlbums = await this.sharedAlbumRepository.find({
|
|
||||||
where: { ownerId: authUser.id },
|
|
||||||
relations: ['sharedUsers', 'sharedUsers.userInfo'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const isSharedWithAlbums = await this.userSharedAlbumRepository.find({
|
|
||||||
where: {
|
|
||||||
sharedUserId: authUser.id,
|
|
||||||
},
|
|
||||||
relations: ['albumInfo', 'albumInfo.sharedUsers', 'albumInfo.sharedUsers.userInfo'],
|
|
||||||
select: ['albumInfo'],
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...ownedAlbums, ...isSharedWithAlbums.map((o) => o.albumInfo)].sort(
|
|
||||||
(a, b) => new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAlbumInfo(authUser: AuthUserDto, albumId: string) {
|
|
||||||
const albumOwner = await this.sharedAlbumRepository.findOne({ where: { ownerId: authUser.id } });
|
|
||||||
const personShared = await this.userSharedAlbumRepository.findOne({
|
|
||||||
where: { albumId: albumId, sharedUserId: authUser.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!(albumOwner || personShared)) {
|
|
||||||
throw new UnauthorizedException('Unauthorized Album Access');
|
|
||||||
}
|
|
||||||
|
|
||||||
const albumInfo = await this.sharedAlbumRepository.findOne({
|
|
||||||
where: { id: albumId },
|
|
||||||
relations: ['sharedUsers', 'sharedUsers.userInfo', 'sharedAssets', 'sharedAssets.assetInfo'],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!albumInfo) {
|
|
||||||
throw new NotFoundException('Album Not Found');
|
|
||||||
}
|
|
||||||
const sortedSharedAsset = albumInfo.sharedAssets.sort(
|
|
||||||
(a, b) => new Date(a.assetInfo.createdAt).valueOf() - new Date(b.assetInfo.createdAt).valueOf(),
|
|
||||||
);
|
|
||||||
|
|
||||||
albumInfo.sharedAssets = sortedSharedAsset;
|
|
||||||
|
|
||||||
return albumInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
async addUsersToAlbum(addUsersDto: AddUsersDto) {
|
|
||||||
const newRecords: UserSharedAlbumEntity[] = [];
|
|
||||||
|
|
||||||
for (const sharedUserId of addUsersDto.sharedUserIds) {
|
|
||||||
const newEntity = new UserSharedAlbumEntity();
|
|
||||||
newEntity.albumId = addUsersDto.albumId;
|
|
||||||
newEntity.sharedUserId = sharedUserId;
|
|
||||||
|
|
||||||
newRecords.push(newEntity);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.userSharedAlbumRepository.save([...newRecords]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteAlbum(authUser: AuthUserDto, albumId: string) {
|
|
||||||
return await this.sharedAlbumRepository.delete({ id: albumId, ownerId: authUser.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
async leaveAlbum(authUser: AuthUserDto, albumId: string) {
|
|
||||||
return await this.userSharedAlbumRepository.delete({ albumId: albumId, sharedUserId: authUser.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeUsersFromAlbum() {}
|
|
||||||
|
|
||||||
async removeAssetsFromAlbum(authUser: AuthUserDto, removeAssetsDto: RemoveAssetsDto) {
|
|
||||||
let deleteAssetCount = 0;
|
|
||||||
const album = await this.sharedAlbumRepository.findOne({ id: removeAssetsDto.albumId });
|
|
||||||
|
|
||||||
if (album.ownerId != authUser.id) {
|
|
||||||
throw new BadRequestException("You don't have permission to remove assets in this album");
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const assetId of removeAssetsDto.assetIds) {
|
|
||||||
const res = await this.assetSharedAlbumRepository.delete({ albumId: removeAssetsDto.albumId, assetId: assetId });
|
|
||||||
if (res.affected == 1) deleteAssetCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return deleteAssetCount == removeAssetsDto.assetIds.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
async addAssetsToAlbum(addAssetsDto: AddAssetsDto) {
|
|
||||||
const newRecords: AssetSharedAlbumEntity[] = [];
|
|
||||||
|
|
||||||
for (const assetId of addAssetsDto.assetIds) {
|
|
||||||
const newAssetSharedAlbum = new AssetSharedAlbumEntity();
|
|
||||||
newAssetSharedAlbum.assetId = assetId;
|
|
||||||
newAssetSharedAlbum.albumId = addAssetsDto.albumId;
|
|
||||||
|
|
||||||
newRecords.push(newAssetSharedAlbum);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add album thumbnail if not exist.
|
|
||||||
const album = await this.sharedAlbumRepository.findOne({ id: addAssetsDto.albumId });
|
|
||||||
|
|
||||||
if (!album.albumThumbnailAssetId && newRecords.length > 0) {
|
|
||||||
album.albumThumbnailAssetId = newRecords[0].assetId;
|
|
||||||
await this.sharedAlbumRepository.save(album);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.assetSharedAlbumRepository.save([...newRecords]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateAlbumTitle(authUser: AuthUserDto, updateShareAlbumDto: UpdateShareAlbumDto) {
|
|
||||||
if (authUser.id != updateShareAlbumDto.ownerId) {
|
|
||||||
throw new BadRequestException('Unauthorized to change album info');
|
|
||||||
}
|
|
||||||
|
|
||||||
const sharedAlbum = await this.sharedAlbumRepository.findOne({ where: { id: updateShareAlbumDto.albumId } });
|
|
||||||
sharedAlbum.albumName = updateShareAlbumDto.albumName;
|
|
||||||
|
|
||||||
return await this.sharedAlbumRepository.save(sharedAlbum);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
import { ParseUUIDPipe, Injectable, ArgumentMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ParseMeUUIDPipe extends ParseUUIDPipe {
|
||||||
|
async transform(value: string, metadata: ArgumentMetadata) {
|
||||||
|
if (value == 'me') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return super.transform(value, metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,161 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { clearDb, getAuthUser, authCustom } from './test-utils';
|
||||||
|
import { databaseConfig } from '@app/database/config/database.config';
|
||||||
|
import { AlbumModule } from '../src/api-v1/album/album.module';
|
||||||
|
import { CreateAlbumDto } from '../src/api-v1/album/dto/create-album.dto';
|
||||||
|
import { ImmichJwtModule } from '../src/modules/immich-jwt/immich-jwt.module';
|
||||||
|
import { AuthUserDto } from '../src/decorators/auth-user.decorator';
|
||||||
|
import { UserService } from '../src/api-v1/user/user.service';
|
||||||
|
import { UserModule } from '../src/api-v1/user/user.module';
|
||||||
|
|
||||||
|
function _createAlbum(app: INestApplication, data: CreateAlbumDto) {
|
||||||
|
return request(app.getHttpServer()).post('/album').send(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Album', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await clearDb();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('without auth', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AlbumModule, ImmichJwtModule, TypeOrmModule.forRoot(databaseConfig)],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents fetching albums if not auth', async () => {
|
||||||
|
const { status } = await request(app.getHttpServer()).get('/album');
|
||||||
|
expect(status).toEqual(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with auth', () => {
|
||||||
|
let authUser: AuthUserDto;
|
||||||
|
let userService: UserService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const builder = Test.createTestingModule({
|
||||||
|
imports: [AlbumModule, UserModule, TypeOrmModule.forRoot(databaseConfig)],
|
||||||
|
});
|
||||||
|
authUser = getAuthUser(); // set default auth user
|
||||||
|
const moduleFixture: TestingModule = await authCustom(builder, () => authUser).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
userService = app.get(UserService);
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with empty DB', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await clearDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates an album', async () => {
|
||||||
|
const data: CreateAlbumDto = {
|
||||||
|
albumName: 'first albbum',
|
||||||
|
};
|
||||||
|
const { status, body } = await _createAlbum(app, data);
|
||||||
|
expect(status).toEqual(201);
|
||||||
|
expect(body).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
ownerId: authUser.id,
|
||||||
|
albumName: data.albumName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with albums in DB', () => {
|
||||||
|
const userOneShared = 'userOneShared';
|
||||||
|
const userOneNotShared = 'userOneNotShared';
|
||||||
|
const userTwoShared = 'userTwoShared';
|
||||||
|
const userTwoNotShared = 'userTwoNotShared';
|
||||||
|
let userOne: AuthUserDto;
|
||||||
|
let userTwo: AuthUserDto;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// setup users
|
||||||
|
const result = await Promise.all([
|
||||||
|
userService.createUser({
|
||||||
|
email: 'one@test.com',
|
||||||
|
password: '1234',
|
||||||
|
firstName: 'one',
|
||||||
|
lastName: 'test',
|
||||||
|
}),
|
||||||
|
userService.createUser({
|
||||||
|
email: 'two@test.com',
|
||||||
|
password: '1234',
|
||||||
|
firstName: 'two',
|
||||||
|
lastName: 'test',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
userOne = result[0];
|
||||||
|
userTwo = result[1];
|
||||||
|
// add user one albums
|
||||||
|
authUser = userOne;
|
||||||
|
await Promise.all([
|
||||||
|
_createAlbum(app, { albumName: userOneShared, sharedWithUserIds: [userTwo.id] }),
|
||||||
|
_createAlbum(app, { albumName: userOneNotShared }),
|
||||||
|
]);
|
||||||
|
// add user two albums
|
||||||
|
authUser = userTwo;
|
||||||
|
await Promise.all([
|
||||||
|
_createAlbum(app, { albumName: userTwoShared, sharedWithUserIds: [userOne.id] }),
|
||||||
|
_createAlbum(app, { albumName: userTwoNotShared }),
|
||||||
|
]);
|
||||||
|
// set user one as authed for next requests
|
||||||
|
authUser = userOne;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the album collection including owned and shared', async () => {
|
||||||
|
const { status, body } = await request(app.getHttpServer()).get('/album');
|
||||||
|
expect(status).toEqual(200);
|
||||||
|
expect(body).toHaveLength(3);
|
||||||
|
expect(body).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ ownerId: userOne.id, albumName: userOneShared, shared: true }),
|
||||||
|
expect.objectContaining({ ownerId: userOne.id, albumName: userOneNotShared, shared: false }),
|
||||||
|
expect.objectContaining({ ownerId: userTwo.id, albumName: userTwoShared, shared: true }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the album collection filtered by shared', async () => {
|
||||||
|
const { status, body } = await request(app.getHttpServer()).get('/album?shared=true');
|
||||||
|
expect(status).toEqual(200);
|
||||||
|
expect(body).toHaveLength(2);
|
||||||
|
expect(body).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ ownerId: userOne.id, albumName: userOneShared, shared: true }),
|
||||||
|
expect.objectContaining({ ownerId: userTwo.id, albumName: userTwoShared, shared: true }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the album collection filtered by NOT shared', async () => {
|
||||||
|
const { status, body } = await request(app.getHttpServer()).get('/album?shared=false');
|
||||||
|
expect(status).toEqual(200);
|
||||||
|
expect(body).toHaveLength(1);
|
||||||
|
expect(body).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ ownerId: userOne.id, albumName: userOneNotShared, shared: false }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
import { AssetAlbumEntity } from './asset-album.entity';
|
||||||
|
import { UserAlbumEntity } from './user-album.entity';
|
||||||
|
|
||||||
|
@Entity('albums')
|
||||||
|
export class AlbumEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
ownerId: string;
|
||||||
|
|
||||||
|
@Column({ default: 'Untitled Album' })
|
||||||
|
albumName: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: 'timestamptz' })
|
||||||
|
createdAt: string;
|
||||||
|
|
||||||
|
@Column({ comment: 'Asset ID to be used as thumbnail', nullable: true })
|
||||||
|
albumThumbnailAssetId: string;
|
||||||
|
|
||||||
|
@OneToMany(() => UserAlbumEntity, (userAlbums) => userAlbums.albumInfo)
|
||||||
|
sharedUsers: UserAlbumEntity[];
|
||||||
|
|
||||||
|
@OneToMany(() => AssetAlbumEntity, (assetAlbumEntity) => assetAlbumEntity.albumInfo)
|
||||||
|
assets: AssetAlbumEntity[];
|
||||||
|
}
|
||||||
@ -1,27 +0,0 @@
|
|||||||
import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
|
|
||||||
import { AssetSharedAlbumEntity } from './asset-shared-album.entity';
|
|
||||||
import { UserSharedAlbumEntity } from './user-shared-album.entity';
|
|
||||||
|
|
||||||
@Entity('shared_albums')
|
|
||||||
export class SharedAlbumEntity {
|
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@Column()
|
|
||||||
ownerId: string;
|
|
||||||
|
|
||||||
@Column({ default: 'Untitled Album' })
|
|
||||||
albumName: string;
|
|
||||||
|
|
||||||
@CreateDateColumn({ type: 'timestamptz' })
|
|
||||||
createdAt: string;
|
|
||||||
|
|
||||||
@Column({ comment: 'Asset ID to be used as thumbnail', nullable: true })
|
|
||||||
albumThumbnailAssetId: string;
|
|
||||||
|
|
||||||
@OneToMany(() => UserSharedAlbumEntity, (userSharedAlbums) => userSharedAlbums.albumInfo)
|
|
||||||
sharedUsers: UserSharedAlbumEntity[];
|
|
||||||
|
|
||||||
@OneToMany(() => AssetSharedAlbumEntity, (assetSharedAlbumEntity) => assetSharedAlbumEntity.albumInfo)
|
|
||||||
sharedAssets: AssetSharedAlbumEntity[];
|
|
||||||
}
|
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class RenameSharedAlbums1655401127251 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE shared_albums RENAME TO albums;
|
||||||
|
|
||||||
|
ALTER TABLE asset_shared_album RENAME TO asset_album;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE asset_album RENAME TO asset_shared_album;
|
||||||
|
|
||||||
|
ALTER TABLE albums RENAME TO shared_albums;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue