From 000d0a08f4fe1a4df3a434135c134efcbd863c30 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Sat, 18 Feb 2023 20:58:55 +0000 Subject: [PATCH] infra(server): fix Album TypeORM relations and change ids to uuids (#1582) * infra: make api-key primary key column a UUID * infra: move ManyToMany relations in album entity, make ownerId ManyToOne --------- Co-authored-by: Alex Tran --- .../src/api-v1/album/album-repository.ts | 286 ++++++------------ .../immich/src/api-v1/album/album.module.ts | 11 +- .../src/api-v1/album/album.service.spec.ts | 61 +--- .../immich/src/api-v1/album/album.service.ts | 36 +-- .../immich/src/api-v1/asset/asset.module.ts | 4 +- .../src/controllers/api-key.controller.ts | 8 +- server/immich-openapi-specs.json | 8 +- .../album/response-dto/album-response.dto.ts | 18 +- .../domain/src/api-key/api-key.repository.ts | 6 +- .../src/api-key/api-key.service.spec.ts | 26 +- .../domain/src/api-key/api-key.service.ts | 6 +- .../response-dto/api-key-response.dto.ts | 4 +- .../response-dto/shared-link-response.dto.ts | 4 +- server/libs/domain/test/fixtures.ts | 137 +++++---- .../infra/src/db/entities/album.entity.ts | 21 +- .../infra/src/db/entities/api-key.entity.ts | 10 +- .../src/db/entities/asset-album.entity.ts | 31 -- server/libs/infra/src/db/entities/index.ts | 2 - .../src/db/entities/user-album.entity.ts | 27 -- .../1675808874445-APIKeyUUIDPrimaryKey.ts | 20 ++ .../1675812532822-FixAlbumEntityTypeORM.ts | 82 +++++ .../src/db/repository/api-key.repository.ts | 6 +- .../db/repository/shared-link.repository.ts | 14 +- server/package.json | 1 + 24 files changed, 368 insertions(+), 461 deletions(-) delete mode 100644 server/libs/infra/src/db/entities/asset-album.entity.ts delete mode 100644 server/libs/infra/src/db/entities/user-album.entity.ts create mode 100644 server/libs/infra/src/db/migrations/1675808874445-APIKeyUUIDPrimaryKey.ts create mode 100644 server/libs/infra/src/db/migrations/1675812532822-FixAlbumEntityTypeORM.ts diff --git a/server/apps/immich/src/api-v1/album/album-repository.ts b/server/apps/immich/src/api-v1/album/album-repository.ts index 38a2b4406a..11d0260fde 100644 --- a/server/apps/immich/src/api-v1/album/album-repository.ts +++ b/server/apps/immich/src/api-v1/album/album-repository.ts @@ -1,7 +1,7 @@ -import { AlbumEntity, AssetAlbumEntity, UserAlbumEntity } from '@app/infra'; +import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { In, Repository, SelectQueryBuilder, DataSource, Brackets, Not, IsNull } from 'typeorm'; +import { Repository, Not, IsNull, FindManyOptions } from 'typeorm'; import { AddAssetsDto } from './dto/add-assets.dto'; import { AddUsersDto } from './dto/add-users.dto'; import { CreateAlbumDto } from './dto/create-album.dto'; @@ -15,7 +15,7 @@ export interface IAlbumRepository { create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise; getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise; getPublicSharingList(ownerId: string): Promise; - get(albumId: string): Promise; + get(albumId: string): Promise; delete(album: AlbumEntity): Promise; addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise; removeUser(album: AlbumEntity, userId: string): Promise; @@ -34,14 +34,6 @@ export class AlbumRepository implements IAlbumRepository { constructor( @InjectRepository(AlbumEntity) private albumRepository: Repository, - - @InjectRepository(AssetAlbumEntity) - private assetAlbumRepository: Repository, - - @InjectRepository(UserAlbumEntity) - private userAlbumRepository: Repository, - - private dataSource: DataSource, ) {} async getPublicSharingList(ownerId: string): Promise { @@ -62,194 +54,98 @@ export class AlbumRepository implements IAlbumRepository { async getCountByUserId(userId: string): Promise { const ownedAlbums = await this.albumRepository.find({ where: { ownerId: userId }, relations: ['sharedUsers'] }); - - const sharedAlbums = await this.userAlbumRepository.count({ - where: { sharedUserId: userId }, - }); - - let sharedAlbumCount = 0; - ownedAlbums.map((album) => { - if (album.sharedUsers?.length) { - sharedAlbumCount += 1; - } - }); + const sharedAlbums = await this.albumRepository.count({ where: { sharedUsers: { id: userId } } }); + const sharedAlbumCount = ownedAlbums.filter((album) => album.sharedUsers?.length > 0).length; return new AlbumCountResponseDto(ownedAlbums.length, sharedAlbums, sharedAlbumCount); } - async create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise { - return this.dataSource.transaction(async (transactionalEntityManager) => { - // Create album entity - const newAlbum = new AlbumEntity(); - newAlbum.ownerId = ownerId; - newAlbum.albumName = createAlbumDto.albumName; - - let album = await transactionalEntityManager.save(newAlbum); - album = await transactionalEntityManager.findOneOrFail(AlbumEntity, { - where: { id: album.id }, - relations: ['owner'], - }); - - // 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; + async create(ownerId: string, dto: CreateAlbumDto): Promise { + const album = await this.albumRepository.save({ + ownerId, + albumName: dto.albumName, + sharedUsers: dto.sharedWithUserIds?.map((value) => ({ id: value } as UserEntity)) ?? [], + assets: dto.assetIds?.map((value) => ({ id: value } as AssetEntity)) ?? [], + albumThumbnailAssetId: dto.assetIds?.[0] || null, }); + + // need to re-load the relations + return this.get(album.id) as Promise; } async getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise { const filteringByShared = typeof getAlbumsDto.shared == 'boolean'; const userId = ownerId; - let query = this.albumRepository.createQueryBuilder('album'); - const getSharedAlbumIdsSubQuery = (qb: SelectQueryBuilder) => { - return qb - .subQuery() - .select('albumSub.id') - .from(AlbumEntity, 'albumSub') - .innerJoin('albumSub.sharedUsers', 'userAlbumSub') - .where('albumSub.ownerId = :ownerId', { ownerId: userId }) - .getQuery(); + const queryProperties: FindManyOptions = { + relations: { sharedUsers: true, assets: true, sharedLinks: true, owner: true }, + order: { assets: { createdAt: 'ASC' }, createdAt: 'ASC' }, }; + let albumsQuery: Promise; + + /** + * `shared` boolean usage + * true = shared with me, and my albums that are shared + * false = my albums that are not shared + * undefined = all my albums + */ 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}`; - }); + albumsQuery = this.albumRepository.find({ + where: [{ sharedUsers: { id: userId } }, { ownerId: userId, sharedUsers: { id: Not(IsNull()) } }], + ...queryProperties, + }); } 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}`; + albumsQuery = this.albumRepository.find({ + where: { ownerId: userId, sharedUsers: { id: IsNull() } }, + ...queryProperties, }); } } else { - // owned and shared with userId - query = query - .leftJoinAndSelect('album.sharedUsers', 'sharedUser') - .leftJoinAndSelect('sharedUser.userInfo', 'userInfo') - .where('album.ownerId = :ownerId', { ownerId: userId }); + // owned + albumsQuery = this.albumRepository.find({ + where: { ownerId: userId }, + ...queryProperties, + }); } - // Get information of assets in albums - query = query - .leftJoinAndSelect('album.assets', 'assets') - .leftJoinAndSelect('assets.assetInfo', 'assetInfo') - .orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC'); - - // Get information of shared links in albums - query = query.leftJoinAndSelect('album.sharedLinks', 'sharedLink'); - - // get information of owner of albums - query = query.leftJoinAndSelect('album.owner', 'owner'); - - const albums = await query.getMany(); + const albums = await albumsQuery; albums.sort((a, b) => new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf()); - return albums; + return albumsQuery; } async getListByAssetId(userId: string, assetId: string): Promise { - const query = this.albumRepository.createQueryBuilder('album'); - - const albums = await query - .where('album.ownerId = :ownerId', { ownerId: userId }) - .andWhere((qb) => { - // shared with userId - const subQuery = qb - .subQuery() - .select('assetAlbum.albumId') - .from(AssetAlbumEntity, 'assetAlbum') - .where('assetAlbum.assetId = :assetId', { assetId: assetId }) - .getQuery(); - return `album.id IN ${subQuery}`; - }) - .leftJoinAndSelect('album.owner', 'owner') - .leftJoinAndSelect('album.assets', 'assets') - .leftJoinAndSelect('assets.assetInfo', 'assetInfo') - .leftJoinAndSelect('album.sharedUsers', 'sharedUser') - .leftJoinAndSelect('sharedUser.userInfo', 'userInfo') - .orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC') - .getMany(); + const albums = await this.albumRepository.find({ + where: { ownerId: userId, assets: { id: assetId } }, + relations: { owner: true, assets: true, sharedUsers: true }, + order: { assets: { createdAt: 'ASC' } }, + }); return albums; } - async get(albumId: string): Promise { - const album = await this.albumRepository.findOne({ + async get(albumId: string): Promise { + return this.albumRepository.findOne({ where: { id: albumId }, relations: { owner: true, - sharedUsers: { - userInfo: true, - }, + sharedUsers: true, assets: { - assetInfo: { - exifInfo: true, - }, + exifInfo: true, }, sharedLinks: true, }, order: { assets: { - assetInfo: { - createdAt: 'ASC', - }, + createdAt: 'ASC', }, }, }); - - if (!album) { - return; - } - - return album; } async delete(album: AlbumEntity): Promise { @@ -257,67 +153,53 @@ export class AlbumRepository implements IAlbumRepository { } async addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise { - const newRecords: UserAlbumEntity[] = []; + album.sharedUsers.push(...addUsersDto.sharedUserIds.map((id) => ({ id } as UserEntity))); - for (const sharedUserId of addUsersDto.sharedUserIds) { - const newEntity = new UserAlbumEntity(); - newEntity.albumId = album.id; - newEntity.sharedUserId = sharedUserId; + await this.albumRepository.save(album); - newRecords.push(newEntity); - } - - await this.userAlbumRepository.save([...newRecords]); - await this.albumRepository.update({ id: album.id }, { updatedAt: new Date().toISOString() }); - - return this.get(album.id) as Promise; // There is an album for sure + // need to re-load the shared user relation + return this.get(album.id) as Promise; } async removeUser(album: AlbumEntity, userId: string): Promise { - await this.userAlbumRepository.delete({ albumId: album.id, sharedUserId: userId }); - await this.albumRepository.update({ id: album.id }, { updatedAt: new Date().toISOString() }); + album.sharedUsers = album.sharedUsers.filter((user) => user.id !== userId); + await this.albumRepository.save(album); } async removeAssets(album: AlbumEntity, removeAssetsDto: RemoveAssetsDto): Promise { - const res = await this.assetAlbumRepository.delete({ - albumId: album.id, - assetId: In(removeAssetsDto.assetIds), + const assetCount = album.assets.length; + + album.assets = album.assets.filter((asset) => { + return !removeAssetsDto.assetIds.includes(asset.id); }); - await this.albumRepository.update({ id: album.id }, { updatedAt: new Date().toISOString() }); + await this.albumRepository.save(album, {}); - return res.affected || 0; + return assetCount - album.assets.length; } async addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise { - const newRecords: AssetAlbumEntity[] = []; const alreadyExisting: string[] = []; for (const assetId of addAssetsDto.assetIds) { // Album already contains that asset - if (album.assets?.some((a) => a.assetId === assetId)) { + if (album.assets?.some((a) => a.id === assetId)) { alreadyExisting.push(assetId); continue; } - const newAssetAlbum = new AssetAlbumEntity(); - newAssetAlbum.assetId = assetId; - newAssetAlbum.albumId = album.id; - newRecords.push(newAssetAlbum); + album.assets.push({ id: assetId } as AssetEntity); } // Add album thumbnail if not exist. - if (!album.albumThumbnailAssetId && newRecords.length > 0) { - album.albumThumbnailAssetId = newRecords[0].assetId; - await this.albumRepository.save(album); + if (!album.albumThumbnailAssetId && album.assets.length > 0) { + album.albumThumbnailAssetId = album.assets[0].id; } - await this.assetAlbumRepository.save([...newRecords]); - - await this.albumRepository.update({ id: album.id }, { updatedAt: new Date().toISOString() }); + await this.albumRepository.save(album); return { - successfullyAdded: newRecords.length, + successfullyAdded: addAssetsDto.assetIds.length - alreadyExisting.length, alreadyInAlbum: alreadyExisting, }; } @@ -330,19 +212,23 @@ export class AlbumRepository implements IAlbumRepository { } async getSharedWithUserAlbumCount(userId: string, assetId: string): Promise { - const result = await this.userAlbumRepository - .createQueryBuilder('usa') - .select('count(aa)', 'count') - .innerJoin('asset_album', 'aa', 'aa.albumId = usa.albumId') - .innerJoin('albums', 'a', 'a.id = usa.albumId') - .where('aa.assetId = :assetId', { assetId }) - .andWhere( - new Brackets((qb) => { - qb.where('a.ownerId = :userId', { userId }).orWhere('usa.sharedUserId = :userId', { userId }); - }), - ) - .getRawOne(); - - return result.count; + return this.albumRepository.count({ + where: [ + { + ownerId: userId, + assets: { + id: assetId, + }, + }, + { + sharedUsers: { + id: userId, + }, + assets: { + id: assetId, + }, + }, + ], + }); } } diff --git a/server/apps/immich/src/api-v1/album/album.module.ts b/server/apps/immich/src/api-v1/album/album.module.ts index 8c8733a8c3..5b152d38d7 100644 --- a/server/apps/immich/src/api-v1/album/album.module.ts +++ b/server/apps/immich/src/api-v1/album/album.module.ts @@ -1,11 +1,10 @@ -import { forwardRef, Module } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { AlbumService } from './album.service'; import { AlbumController } from './album.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { AlbumEntity, AssetAlbumEntity, UserAlbumEntity } from '@app/infra'; +import { AlbumEntity } from '@app/infra'; import { AlbumRepository, IAlbumRepository } from './album-repository'; import { DownloadModule } from '../../modules/download/download.module'; -import { AssetModule } from '../asset/asset.module'; const ALBUM_REPOSITORY_PROVIDER = { provide: IAlbumRepository, @@ -13,11 +12,7 @@ const ALBUM_REPOSITORY_PROVIDER = { }; @Module({ - imports: [ - TypeOrmModule.forFeature([AlbumEntity, AssetAlbumEntity, UserAlbumEntity]), - DownloadModule, - forwardRef(() => AssetModule), - ], + imports: [TypeOrmModule.forFeature([AlbumEntity]), DownloadModule], controllers: [AlbumController], providers: [AlbumService, ALBUM_REPOSITORY_PROVIDER], exports: [ALBUM_REPOSITORY_PROVIDER], diff --git a/server/apps/immich/src/api-v1/album/album.service.spec.ts b/server/apps/immich/src/api-v1/album/album.service.spec.ts index 150ddb2506..acb74ab44c 100644 --- a/server/apps/immich/src/api-v1/album/album.service.spec.ts +++ b/server/apps/immich/src/api-v1/album/album.service.spec.ts @@ -7,7 +7,12 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; import { IAlbumRepository } from './album-repository'; import { DownloadService } from '../../modules/download/download.service'; import { ISharedLinkRepository } from '@app/domain'; -import { newCryptoRepositoryMock, newSharedLinkRepositoryMock } from '@app/domain/../test'; +import { + assetEntityStub, + newCryptoRepositoryMock, + newSharedLinkRepositoryMock, + userEntityStub, +} from '@app/domain/../test'; describe('Album service', () => { let sut: AlbumService; @@ -64,15 +69,8 @@ describe('Album service', () => { albumEntity.albumThumbnailAssetId = null; albumEntity.sharedUsers = [ { - id: '99', - albumId, - sharedUserId: ownedAlbumSharedWithId, - //@ts-expect-error Partial stub - albumInfo: {}, - //@ts-expect-error Partial stub - userInfo: { - id: ownedAlbumSharedWithId, - }, + ...userEntityStub.user1, + id: ownedAlbumSharedWithId, }, ]; @@ -90,26 +88,12 @@ describe('Album service', () => { albumEntity.albumThumbnailAssetId = null; albumEntity.sharedUsers = [ { - id: '99', - albumId, - sharedUserId: authUser.id, - //@ts-expect-error Partial stub - albumInfo: {}, - //@ts-expect-error Partial stub - userInfo: { - id: authUser.id, - }, + ...userEntityStub.user1, + id: authUser.id, }, { - id: '98', - albumId, - sharedUserId: sharedAlbumSharedAlsoWithId, - //@ts-expect-error Partial stub - albumInfo: {}, - //@ts-expect-error Partial stub - userInfo: { - id: sharedAlbumSharedAlsoWithId, - }, + ...userEntityStub.user1, + id: sharedAlbumSharedAlsoWithId, }, ]; albumEntity.sharedLinks = []; @@ -232,7 +216,7 @@ describe('Album service', () => { }); it('throws a not found exception if the album is not found', async () => { - albumRepositoryMock.get.mockImplementation(() => Promise.resolve(undefined)); + albumRepositoryMock.get.mockImplementation(() => Promise.resolve(null)); await expect(sut.getAlbumInfo(authUser, '0002')).rejects.toBeInstanceOf(NotFoundException); }); @@ -495,13 +479,8 @@ describe('Album service', () => { albumEntity.sharedUsers = []; albumEntity.assets = [ { - id: '1', - albumId: '2', - assetId: '3', - //@ts-expect-error Partial stub - albumInfo: {}, - //@ts-expect-error Partial stub - assetInfo: {}, + ...assetEntityStub.image, + id: '3', }, ]; albumEntity.albumThumbnailAssetId = null; @@ -521,15 +500,7 @@ describe('Album service', () => { albumEntity.albumThumbnailAssetId = 'nonexistent'; assetEntity.id = newThumbnailAssetId; - albumEntity.assets = [ - { - id: '760841c1-f7c4-42b1-96af-c7d007a26126', - assetId: assetEntity.id, - albumId: albumEntity.id, - albumInfo: albumEntity, - assetInfo: assetEntity, - }, - ]; + albumEntity.assets = [assetEntity]; albumRepositoryMock.getList.mockImplementation(async () => [albumEntity]); albumRepositoryMock.updateAlbum.mockImplementation(async () => ({ ...albumEntity, diff --git a/server/apps/immich/src/api-v1/album/album.service.ts b/server/apps/immich/src/api-v1/album/album.service.ts index 1f8def394d..58d44b45f7 100644 --- a/server/apps/immich/src/api-v1/album/album.service.ts +++ b/server/apps/immich/src/api-v1/album/album.service.ts @@ -23,7 +23,7 @@ export class AlbumService { private shareCore: ShareCore; constructor( - @Inject(IAlbumRepository) private _albumRepository: IAlbumRepository, + @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, @Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository, private downloadService: DownloadService, @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, @@ -40,7 +40,7 @@ export class AlbumService { albumId: string; validateIsOwner?: boolean; }): Promise { - const album = await this._albumRepository.get(albumId); + const album = await this.albumRepository.get(albumId); if (!album) { throw new NotFoundException('Album Not Found'); } @@ -48,14 +48,14 @@ export class AlbumService { if (validateIsOwner && !isOwner) { throw new ForbiddenException('Unauthorized Album Access'); - } else if (!isOwner && !album.sharedUsers?.some((user) => user.sharedUserId == authUser.id)) { + } else if (!isOwner && !album.sharedUsers?.some((user) => user.id == authUser.id)) { throw new ForbiddenException('Unauthorized Album Access'); } return album; } async create(authUser: AuthUserDto, createAlbumDto: CreateAlbumDto): Promise { - const albumEntity = await this._albumRepository.create(authUser.id, createAlbumDto); + const albumEntity = await this.albumRepository.create(authUser.id, createAlbumDto); return mapAlbum(albumEntity); } @@ -68,11 +68,11 @@ export class AlbumService { let albums: AlbumEntity[]; if (typeof getAlbumsDto.assetId === 'string') { - albums = await this._albumRepository.getListByAssetId(authUser.id, getAlbumsDto.assetId); + albums = await this.albumRepository.getListByAssetId(authUser.id, getAlbumsDto.assetId); } else { - albums = await this._albumRepository.getList(authUser.id, getAlbumsDto); + albums = await this.albumRepository.getList(authUser.id, getAlbumsDto); if (getAlbumsDto.shared) { - const publicSharingAlbums = await this._albumRepository.getPublicSharingList(authUser.id); + const publicSharingAlbums = await this.albumRepository.getPublicSharingList(authUser.id); albums = [...albums, ...publicSharingAlbums]; } } @@ -93,7 +93,7 @@ export class AlbumService { async addUsersToAlbum(authUser: AuthUserDto, addUsersDto: AddUsersDto, albumId: string): Promise { const album = await this._getAlbum({ authUser, albumId }); - const updatedAlbum = await this._albumRepository.addSharedUsers(album, addUsersDto); + const updatedAlbum = await this.albumRepository.addSharedUsers(album, addUsersDto); return mapAlbum(updatedAlbum); } @@ -104,7 +104,7 @@ export class AlbumService { await this.shareCore.remove(authUser.id, sharedLink.id); } - await this._albumRepository.delete(album); + await this.albumRepository.delete(album); } async removeUserFromAlbum(authUser: AuthUserDto, albumId: string, userId: string | 'me'): Promise { @@ -116,7 +116,7 @@ export class AlbumService { if (album.ownerId == sharedUserId) { throw new BadRequestException('The owner of the album cannot be removed'); } - await this._albumRepository.removeUser(album, sharedUserId); + await this.albumRepository.removeUser(album, sharedUserId); } async removeAssetsFromAlbum( @@ -125,7 +125,7 @@ export class AlbumService { albumId: string, ): Promise { const album = await this._getAlbum({ authUser, albumId }); - const deletedCount = await this._albumRepository.removeAssets(album, removeAssetsDto); + const deletedCount = await this.albumRepository.removeAssets(album, removeAssetsDto); const newAlbum = await this._getAlbum({ authUser, albumId }); if (newAlbum) { @@ -150,7 +150,7 @@ export class AlbumService { } const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); - const result = await this._albumRepository.addAssets(album, addAssetsDto); + const result = await this.albumRepository.addAssets(album, addAssetsDto); const newAlbum = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); return { @@ -170,17 +170,17 @@ export class AlbumService { throw new BadRequestException('Unauthorized to change album info'); } - const updatedAlbum = await this._albumRepository.updateAlbum(album, updateAlbumDto); + const updatedAlbum = await this.albumRepository.updateAlbum(album, updateAlbumDto); return mapAlbum(updatedAlbum); } async getAlbumCountByUserId(authUser: AuthUserDto): Promise { - return this._albumRepository.getCountByUserId(authUser.id); + return this.albumRepository.getCountByUserId(authUser.id); } async downloadArchive(authUser: AuthUserDto, albumId: string, dto: DownloadDto) { const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); - const assets = (album.assets || []).map((asset) => asset.assetInfo).slice(dto.skip || 0); + const assets = (album.assets || []).map((asset) => asset).slice(dto.skip || 0); return this.downloadService.downloadArchive(album.albumName, assets); } @@ -190,16 +190,16 @@ export class AlbumService { // Check if the album's thumbnail is invalid by referencing // an asset outside the album. - const invalid = assets.length > 0 && !assets.some((asset) => asset.assetId === album.albumThumbnailAssetId); + const invalid = assets.length > 0 && !assets.some((asset) => asset.id === album.albumThumbnailAssetId); // Check if an empty album still has a thumbnail. const isEmptyWithThumbnail = assets.length === 0 && album.albumThumbnailAssetId !== null; if (invalid || isEmptyWithThumbnail) { - const albumThumbnailAssetId = assets[0]?.assetId; + const albumThumbnailAssetId = assets[0]?.id; album.albumThumbnailAssetId = albumThumbnailAssetId || null; - await this._albumRepository.updateAlbum(album, { albumThumbnailAssetId }); + await this.albumRepository.updateAlbum(album, { albumThumbnailAssetId }); } } diff --git a/server/apps/immich/src/api-v1/asset/asset.module.ts b/server/apps/immich/src/api-v1/asset/asset.module.ts index 037a12b2b6..d73542034f 100644 --- a/server/apps/immich/src/api-v1/asset/asset.module.ts +++ b/server/apps/immich/src/api-v1/asset/asset.module.ts @@ -1,4 +1,4 @@ -import { forwardRef, Module } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { AssetService } from './asset.service'; import { AssetController } from './asset.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -22,7 +22,7 @@ const ASSET_REPOSITORY_PROVIDER = { DownloadModule, TagModule, StorageModule, - forwardRef(() => AlbumModule), + AlbumModule, ], controllers: [AssetController], providers: [AssetService, ASSET_REPOSITORY_PROVIDER], diff --git a/server/apps/immich/src/controllers/api-key.controller.ts b/server/apps/immich/src/controllers/api-key.controller.ts index 178248b7c8..5f8e59a7ba 100644 --- a/server/apps/immich/src/controllers/api-key.controller.ts +++ b/server/apps/immich/src/controllers/api-key.controller.ts @@ -6,7 +6,7 @@ import { APIKeyUpdateDto, AuthUserDto, } from '@app/domain'; -import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, ValidationPipe } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Put, ValidationPipe } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { GetAuthUser } from '../decorators/auth-user.decorator'; import { Authenticated } from '../decorators/authenticated.decorator'; @@ -31,21 +31,21 @@ export class APIKeyController { } @Get(':id') - getKey(@GetAuthUser() authUser: AuthUserDto, @Param('id', ParseIntPipe) id: number): Promise { + getKey(@GetAuthUser() authUser: AuthUserDto, @Param('id') id: string): Promise { return this.service.getById(authUser, id); } @Put(':id') updateKey( @GetAuthUser() authUser: AuthUserDto, - @Param('id', ParseIntPipe) id: number, + @Param('id') id: string, @Body(ValidationPipe) dto: APIKeyUpdateDto, ): Promise { return this.service.update(authUser, id, dto); } @Delete(':id') - deleteKey(@GetAuthUser() authUser: AuthUserDto, @Param('id', ParseIntPipe) id: number): Promise { + deleteKey(@GetAuthUser() authUser: AuthUserDto, @Param('id') id: string): Promise { return this.service.delete(authUser, id); } } diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 3dcf8c6eba..680003da50 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -66,7 +66,7 @@ "required": true, "in": "path", "schema": { - "type": "number" + "type": "string" } } ], @@ -95,7 +95,7 @@ "required": true, "in": "path", "schema": { - "type": "number" + "type": "string" } } ], @@ -134,7 +134,7 @@ "required": true, "in": "path", "schema": { - "type": "number" + "type": "string" } } ], @@ -2759,7 +2759,7 @@ "type": "object", "properties": { "id": { - "type": "integer" + "type": "string" }, "name": { "type": "string" diff --git a/server/libs/domain/src/album/response-dto/album-response.dto.ts b/server/libs/domain/src/album/response-dto/album-response.dto.ts index b7a52af8c3..1199609497 100644 --- a/server/libs/domain/src/album/response-dto/album-response.dto.ts +++ b/server/libs/domain/src/album/response-dto/album-response.dto.ts @@ -21,11 +21,9 @@ export class AlbumResponseDto { export function mapAlbum(entity: AlbumEntity): AlbumResponseDto { const sharedUsers: UserResponseDto[] = []; - entity.sharedUsers?.forEach((userAlbum) => { - if (userAlbum.userInfo) { - const user = mapUser(userAlbum.userInfo); - sharedUsers.push(user); - } + entity.sharedUsers?.forEach((user) => { + const userDto = mapUser(user); + sharedUsers.push(userDto); }); return { @@ -38,7 +36,7 @@ export function mapAlbum(entity: AlbumEntity): AlbumResponseDto { owner: mapUser(entity.owner), sharedUsers, shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0, - assets: entity.assets?.map((assetAlbum) => mapAsset(assetAlbum.assetInfo)) || [], + assets: entity.assets?.map((asset) => mapAsset(asset)) || [], assetCount: entity.assets?.length || 0, }; } @@ -46,11 +44,9 @@ export function mapAlbum(entity: AlbumEntity): AlbumResponseDto { export function mapAlbumExcludeAssetInfo(entity: AlbumEntity): AlbumResponseDto { const sharedUsers: UserResponseDto[] = []; - entity.sharedUsers?.forEach((userAlbum) => { - if (userAlbum.userInfo) { - const user = mapUser(userAlbum.userInfo); - sharedUsers.push(user); - } + entity.sharedUsers?.forEach((user) => { + const userDto = mapUser(user); + sharedUsers.push(userDto); }); return { diff --git a/server/libs/domain/src/api-key/api-key.repository.ts b/server/libs/domain/src/api-key/api-key.repository.ts index 76182fe1da..9eb9897fc5 100644 --- a/server/libs/domain/src/api-key/api-key.repository.ts +++ b/server/libs/domain/src/api-key/api-key.repository.ts @@ -4,13 +4,13 @@ export const IKeyRepository = 'IKeyRepository'; export interface IKeyRepository { create(dto: Partial): Promise; - update(userId: string, id: number, dto: Partial): Promise; - delete(userId: string, id: number): Promise; + update(userId: string, id: string, dto: Partial): Promise; + delete(userId: string, id: string): Promise; /** * Includes the hashed `key` for verification * @param id */ getKey(hashedToken: string): Promise; - getById(userId: string, id: number): Promise; + getById(userId: string, id: string): Promise; getByUserId(userId: string): Promise; } diff --git a/server/libs/domain/src/api-key/api-key.service.spec.ts b/server/libs/domain/src/api-key/api-key.service.spec.ts index 89d4c1ea8b..cfae7c2cc9 100644 --- a/server/libs/domain/src/api-key/api-key.service.spec.ts +++ b/server/libs/domain/src/api-key/api-key.service.spec.ts @@ -47,17 +47,19 @@ describe(APIKeyService.name, () => { it('should throw an error if the key is not found', async () => { keyMock.getById.mockResolvedValue(null); - await expect(sut.update(authStub.admin, 1, { name: 'New Name' })).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.update(authStub.admin, 'random-guid', { name: 'New Name' })).rejects.toBeInstanceOf( + BadRequestException, + ); - expect(keyMock.update).not.toHaveBeenCalledWith(1); + expect(keyMock.update).not.toHaveBeenCalledWith('random-guid'); }); it('should update a key', async () => { keyMock.getById.mockResolvedValue(keyStub.admin); - await sut.update(authStub.admin, 1, { name: 'New Name' }); + await sut.update(authStub.admin, 'random-guid', { name: 'New Name' }); - expect(keyMock.update).toHaveBeenCalledWith(authStub.admin.id, 1, { name: 'New Name' }); + expect(keyMock.update).toHaveBeenCalledWith(authStub.admin.id, 'random-guid', { name: 'New Name' }); }); }); @@ -65,17 +67,17 @@ describe(APIKeyService.name, () => { it('should throw an error if the key is not found', async () => { keyMock.getById.mockResolvedValue(null); - await expect(sut.delete(authStub.admin, 1)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.delete(authStub.admin, 'random-guid')).rejects.toBeInstanceOf(BadRequestException); - expect(keyMock.delete).not.toHaveBeenCalledWith(1); + expect(keyMock.delete).not.toHaveBeenCalledWith('random-guid'); }); it('should delete a key', async () => { keyMock.getById.mockResolvedValue(keyStub.admin); - await sut.delete(authStub.admin, 1); + await sut.delete(authStub.admin, 'random-guid'); - expect(keyMock.delete).toHaveBeenCalledWith(authStub.admin.id, 1); + expect(keyMock.delete).toHaveBeenCalledWith(authStub.admin.id, 'random-guid'); }); }); @@ -83,17 +85,17 @@ describe(APIKeyService.name, () => { it('should throw an error if the key is not found', async () => { keyMock.getById.mockResolvedValue(null); - await expect(sut.getById(authStub.admin, 1)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.getById(authStub.admin, 'random-guid')).rejects.toBeInstanceOf(BadRequestException); - expect(keyMock.getById).toHaveBeenCalledWith(authStub.admin.id, 1); + expect(keyMock.getById).toHaveBeenCalledWith(authStub.admin.id, 'random-guid'); }); it('should get a key by id', async () => { keyMock.getById.mockResolvedValue(keyStub.admin); - await sut.getById(authStub.admin, 1); + await sut.getById(authStub.admin, 'random-guid'); - expect(keyMock.getById).toHaveBeenCalledWith(authStub.admin.id, 1); + expect(keyMock.getById).toHaveBeenCalledWith(authStub.admin.id, 'random-guid'); }); }); diff --git a/server/libs/domain/src/api-key/api-key.service.ts b/server/libs/domain/src/api-key/api-key.service.ts index cc08e1fe98..a503f92809 100644 --- a/server/libs/domain/src/api-key/api-key.service.ts +++ b/server/libs/domain/src/api-key/api-key.service.ts @@ -24,7 +24,7 @@ export class APIKeyService { return { secret, apiKey: mapKey(entity) }; } - async update(authUser: AuthUserDto, id: number, dto: APIKeyCreateDto): Promise { + async update(authUser: AuthUserDto, id: string, dto: APIKeyCreateDto): Promise { const exists = await this.repository.getById(authUser.id, id); if (!exists) { throw new BadRequestException('API Key not found'); @@ -35,7 +35,7 @@ export class APIKeyService { }); } - async delete(authUser: AuthUserDto, id: number): Promise { + async delete(authUser: AuthUserDto, id: string): Promise { const exists = await this.repository.getById(authUser.id, id); if (!exists) { throw new BadRequestException('API Key not found'); @@ -44,7 +44,7 @@ export class APIKeyService { await this.repository.delete(authUser.id, id); } - async getById(authUser: AuthUserDto, id: number): Promise { + async getById(authUser: AuthUserDto, id: string): Promise { const key = await this.repository.getById(authUser.id, id); if (!key) { throw new BadRequestException('API Key not found'); diff --git a/server/libs/domain/src/api-key/response-dto/api-key-response.dto.ts b/server/libs/domain/src/api-key/response-dto/api-key-response.dto.ts index 400712dfd3..05cd717aaf 100644 --- a/server/libs/domain/src/api-key/response-dto/api-key-response.dto.ts +++ b/server/libs/domain/src/api-key/response-dto/api-key-response.dto.ts @@ -1,9 +1,7 @@ import { APIKeyEntity } from '@app/infra/db/entities'; -import { ApiProperty } from '@nestjs/swagger'; export class APIKeyResponseDto { - @ApiProperty({ type: 'integer' }) - id!: number; + id!: string; name!: string; createdAt!: string; updatedAt!: string; diff --git a/server/libs/domain/src/share/response-dto/shared-link-response.dto.ts b/server/libs/domain/src/share/response-dto/shared-link-response.dto.ts index 6e9b2fda92..bce9901697 100644 --- a/server/libs/domain/src/share/response-dto/shared-link-response.dto.ts +++ b/server/libs/domain/src/share/response-dto/shared-link-response.dto.ts @@ -23,7 +23,7 @@ export class SharedLinkResponseDto { export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseDto { const linkAssets = sharedLink.assets || []; - const albumAssets = (sharedLink?.album?.assets || []).map((albumAsset) => albumAsset.assetInfo); + const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset); const assets = _.uniqBy([...linkAssets, ...albumAssets], (asset) => asset.id); @@ -45,7 +45,7 @@ export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseD export function mapSharedLinkWithNoExif(sharedLink: SharedLinkEntity): SharedLinkResponseDto { const linkAssets = sharedLink.assets || []; - const albumAssets = (sharedLink?.album?.assets || []).map((albumAsset) => albumAsset.assetInfo); + const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset); const assets = _.uniqBy([...linkAssets, ...albumAssets], (asset) => asset.id); diff --git a/server/libs/domain/test/fixtures.ts b/server/libs/domain/test/fixtures.ts index 3d305aa857..85062402d4 100644 --- a/server/libs/domain/test/fixtures.ts +++ b/server/libs/domain/test/fixtures.ts @@ -1,5 +1,6 @@ import { APIKeyEntity, + AssetEntity, AssetType, SharedLinkEntity, SharedLinkType, @@ -90,6 +91,30 @@ export const userEntityStub = { }), }; +export const assetEntityStub = { + image: Object.freeze({ + id: 'asset-id', + deviceAssetId: 'device-asset-id', + modifiedAt: today.toISOString(), + createdAt: today.toISOString(), + userId: 'user-id', + deviceId: 'device-id', + originalPath: '/original/path', + resizePath: null, + type: AssetType.IMAGE, + webpPath: null, + encodedVideoPath: null, + updatedAt: today.toISOString(), + mimeType: null, + isFavorite: true, + duration: null, + isVisible: true, + livePhotoVideoId: null, + tags: [], + sharedLinks: [], + }), +}; + const assetInfo: ExifResponseDto = { id: 1, make: 'camera-make', @@ -165,7 +190,7 @@ export const userTokenEntityStub = { export const keyStub = { admin: Object.freeze({ - id: 1, + id: 'my-random-guid', name: 'My Key', key: 'my-api-key (hashed)', userId: authStub.admin.id, @@ -348,66 +373,60 @@ export const sharedLinkStub = { sharedLinks: [], assets: [ { - id: 'album-asset-123', - albumId: 'album-123', - assetId: 'asset-123', - albumInfo: {} as any, - assetInfo: { - id: 'id_1', - userId: 'user_id_1', - deviceAssetId: 'device_asset_id_1', - deviceId: 'device_id_1', - type: AssetType.VIDEO, - originalPath: 'fake_path/jpeg', - resizePath: '', - createdAt: today.toISOString(), - modifiedAt: today.toISOString(), - updatedAt: today.toISOString(), - isFavorite: false, - mimeType: 'image/jpeg', - smartInfo: { - id: 'should-be-a-number', - assetId: 'id_1', - tags: [], - objects: ['a', 'b', 'c'], - asset: null as any, - }, - webpPath: '', - encodedVideoPath: '', - duration: null, - isVisible: true, - livePhotoVideoId: null, - exifInfo: { - livePhotoCID: null, - id: 1, - assetId: 'id_1', - description: 'description', - exifImageWidth: 500, - exifImageHeight: 500, - fileSizeInByte: 100, - orientation: 'orientation', - dateTimeOriginal: today, - modifyDate: today, - latitude: 100, - longitude: 100, - city: 'city', - state: 'state', - country: 'country', - make: 'camera-make', - model: 'camera-model', - imageName: 'fancy-image', - lensModel: 'fancy', - fNumber: 100, - focalLength: 100, - iso: 100, - exposureTime: '1/16', - fps: 100, - asset: null as any, - exifTextSearchableColumn: '', - }, + id: 'id_1', + userId: 'user_id_1', + deviceAssetId: 'device_asset_id_1', + deviceId: 'device_id_1', + type: AssetType.VIDEO, + originalPath: 'fake_path/jpeg', + resizePath: '', + createdAt: today.toISOString(), + modifiedAt: today.toISOString(), + updatedAt: today.toISOString(), + isFavorite: false, + mimeType: 'image/jpeg', + smartInfo: { + id: 'should-be-a-number', + assetId: 'id_1', tags: [], - sharedLinks: [], + objects: ['a', 'b', 'c'], + asset: null as any, }, + webpPath: '', + encodedVideoPath: '', + duration: null, + isVisible: true, + livePhotoVideoId: null, + exifInfo: { + livePhotoCID: null, + id: 1, + assetId: 'id_1', + description: 'description', + exifImageWidth: 500, + exifImageHeight: 500, + fileSizeInByte: 100, + orientation: 'orientation', + dateTimeOriginal: today, + modifyDate: today, + latitude: 100, + longitude: 100, + city: 'city', + state: 'state', + country: 'country', + make: 'camera-make', + model: 'camera-model', + imageName: 'fancy-image', + lensModel: 'fancy', + fNumber: 100, + focalLength: 100, + iso: 100, + exposureTime: '1/16', + fps: 100, + asset: null as any, + exifTextSearchableColumn: '', + }, + tags: [], + sharedLinks: [], }, ], }, diff --git a/server/libs/infra/src/db/entities/album.entity.ts b/server/libs/infra/src/db/entities/album.entity.ts index c700a9d934..1bc481f135 100644 --- a/server/libs/infra/src/db/entities/album.entity.ts +++ b/server/libs/infra/src/db/entities/album.entity.ts @@ -2,14 +2,15 @@ import { Column, CreateDateColumn, Entity, + JoinTable, + ManyToMany, ManyToOne, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; -import { AssetAlbumEntity } from './asset-album.entity'; import { SharedLinkEntity } from './shared-link.entity'; -import { UserAlbumEntity } from './user-album.entity'; +import { AssetEntity } from './asset.entity'; import { UserEntity } from './user.entity'; @Entity('albums') @@ -17,12 +18,12 @@ export class AlbumEntity { @PrimaryGeneratedColumn('uuid') id!: string; + @ManyToOne(() => UserEntity, { eager: true, onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) + owner!: UserEntity; + @Column() ownerId!: string; - @ManyToOne(() => UserEntity, { eager: true }) - owner!: UserEntity; - @Column({ default: 'Untitled Album' }) albumName!: string; @@ -35,11 +36,13 @@ export class AlbumEntity { @Column({ comment: 'Asset ID to be used as thumbnail', type: 'varchar', nullable: true }) albumThumbnailAssetId!: string | null; - @OneToMany(() => UserAlbumEntity, (userAlbums) => userAlbums.albumInfo) - sharedUsers?: UserAlbumEntity[]; + @ManyToMany(() => UserEntity, { eager: true }) + @JoinTable() + sharedUsers!: UserEntity[]; - @OneToMany(() => AssetAlbumEntity, (assetAlbumEntity) => assetAlbumEntity.albumInfo) - assets?: AssetAlbumEntity[]; + @ManyToMany(() => AssetEntity, { eager: true }) + @JoinTable() + assets!: AssetEntity[]; @OneToMany(() => SharedLinkEntity, (link) => link.album) sharedLinks!: SharedLinkEntity[]; diff --git a/server/libs/infra/src/db/entities/api-key.entity.ts b/server/libs/infra/src/db/entities/api-key.entity.ts index 3b80480f6b..77c8a570c0 100644 --- a/server/libs/infra/src/db/entities/api-key.entity.ts +++ b/server/libs/infra/src/db/entities/api-key.entity.ts @@ -3,8 +3,8 @@ import { UserEntity } from './user.entity'; @Entity('api_keys') export class APIKeyEntity { - @PrimaryGeneratedColumn() - id!: number; + @PrimaryGeneratedColumn('uuid') + id!: string; @Column() name!: string; @@ -12,12 +12,12 @@ export class APIKeyEntity { @Column({ select: false }) key?: string; - @Column() - userId!: string; - @ManyToOne(() => UserEntity) user?: UserEntity; + @Column() + userId!: string; + @CreateDateColumn({ type: 'timestamptz' }) createdAt!: string; diff --git a/server/libs/infra/src/db/entities/asset-album.entity.ts b/server/libs/infra/src/db/entities/asset-album.entity.ts deleted file mode 100644 index 37dd7f7fea..0000000000 --- a/server/libs/infra/src/db/entities/asset-album.entity.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm'; -import { AlbumEntity } from './album.entity'; -import { AssetEntity } from './asset.entity'; - -@Entity('asset_album') -@Unique('UQ_unique_asset_in_album', ['albumId', 'assetId']) -export class AssetAlbumEntity { - @PrimaryGeneratedColumn() - id!: string; - - @Column() - albumId!: string; - - @Column() - @OneToOne(() => AssetEntity, (entity) => entity.id) - assetId!: string; - - @ManyToOne(() => AlbumEntity, (album) => album.assets, { - onDelete: 'CASCADE', - nullable: true, - }) - @JoinColumn({ name: 'albumId' }) - albumInfo!: AlbumEntity; - - @ManyToOne(() => AssetEntity, { - onDelete: 'CASCADE', - nullable: true, - }) - @JoinColumn({ name: 'assetId' }) - assetInfo!: AssetEntity; -} diff --git a/server/libs/infra/src/db/entities/index.ts b/server/libs/infra/src/db/entities/index.ts index 3ea8abcb15..971700dbd1 100644 --- a/server/libs/infra/src/db/entities/index.ts +++ b/server/libs/infra/src/db/entities/index.ts @@ -1,13 +1,11 @@ export * from './album.entity'; export * from './api-key.entity'; -export * from './asset-album.entity'; export * from './asset.entity'; export * from './device-info.entity'; export * from './exif.entity'; export * from './smart-info.entity'; export * from './system-config.entity'; export * from './tag.entity'; -export * from './user-album.entity'; export * from './user.entity'; export * from './user-token.entity'; export * from './shared-link.entity'; diff --git a/server/libs/infra/src/db/entities/user-album.entity.ts b/server/libs/infra/src/db/entities/user-album.entity.ts deleted file mode 100644 index fe08ff5a86..0000000000 --- a/server/libs/infra/src/db/entities/user-album.entity.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm'; -import { UserEntity } from './user.entity'; -import { AlbumEntity } from './album.entity'; - -@Entity('user_shared_album') -@Unique('PK_unique_user_in_album', ['albumId', 'sharedUserId']) -export class UserAlbumEntity { - @PrimaryGeneratedColumn() - id!: string; - - @Column() - albumId!: string; - - @Column() - sharedUserId!: string; - - @ManyToOne(() => AlbumEntity, (album) => album.sharedUsers, { - onDelete: 'CASCADE', - nullable: true, - }) - @JoinColumn({ name: 'albumId' }) - albumInfo!: AlbumEntity; - - @ManyToOne(() => UserEntity) - @JoinColumn({ name: 'sharedUserId' }) - userInfo!: UserEntity; -} diff --git a/server/libs/infra/src/db/migrations/1675808874445-APIKeyUUIDPrimaryKey.ts b/server/libs/infra/src/db/migrations/1675808874445-APIKeyUUIDPrimaryKey.ts new file mode 100644 index 0000000000..368a9ca9c5 --- /dev/null +++ b/server/libs/infra/src/db/migrations/1675808874445-APIKeyUUIDPrimaryKey.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class APIKeyUUIDPrimaryKey1675808874445 implements MigrationInterface { + name = 'APIKeyUUIDPrimaryKey1675808874445' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "api_keys" DROP CONSTRAINT "PK_5c8a79801b44bd27b79228e1dad"`); + await queryRunner.query(`ALTER TABLE "api_keys" DROP COLUMN "id"`); + await queryRunner.query(`ALTER TABLE "api_keys" ADD "id" uuid NOT NULL DEFAULT uuid_generate_v4()`); + await queryRunner.query(`ALTER TABLE "api_keys" ADD CONSTRAINT "PK_5c8a79801b44bd27b79228e1dad" PRIMARY KEY ("id")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "api_keys" DROP CONSTRAINT "PK_5c8a79801b44bd27b79228e1dad"`); + await queryRunner.query(`ALTER TABLE "api_keys" DROP COLUMN "id"`); + await queryRunner.query(`ALTER TABLE "api_keys" ADD "id" SERIAL NOT NULL`); + await queryRunner.query(`ALTER TABLE "api_keys" ADD CONSTRAINT "PK_5c8a79801b44bd27b79228e1dad" PRIMARY KEY ("id")`); + } + +} diff --git a/server/libs/infra/src/db/migrations/1675812532822-FixAlbumEntityTypeORM.ts b/server/libs/infra/src/db/migrations/1675812532822-FixAlbumEntityTypeORM.ts new file mode 100644 index 0000000000..3be6a2aa1d --- /dev/null +++ b/server/libs/infra/src/db/migrations/1675812532822-FixAlbumEntityTypeORM.ts @@ -0,0 +1,82 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class FixAlbumEntityTypeORM1675812532822 implements MigrationInterface { + name = 'FixAlbumEntityTypeORM1675812532822' + + public async up(queryRunner: QueryRunner): Promise { + + await queryRunner.query(`ALTER TABLE "asset_album" RENAME TO "albums_assets_assets"`); + await queryRunner.query(`ALTER TABLE "albums_assets_assets" DROP CONSTRAINT "FK_7ae4e03729895bf87e056d7b598"`); + await queryRunner.query(`ALTER TABLE "albums_assets_assets" DROP CONSTRAINT "FK_256a30a03a4a0aff0394051397d"`); + await queryRunner.query(`ALTER TABLE "albums_assets_assets" DROP CONSTRAINT "UQ_unique_asset_in_album"`); + await queryRunner.query(`ALTER TABLE "albums_assets_assets" DROP CONSTRAINT "PK_a34e076afbc601d81938e2c2277"`); + await queryRunner.query(`ALTER TABLE "albums_assets_assets" DROP COLUMN "id"`); + await queryRunner.query(`ALTER TABLE "albums_assets_assets" RENAME COLUMN "albumId" TO "albumsId"`); + await queryRunner.query(`ALTER TABLE "albums_assets_assets" RENAME COLUMN "assetId" TO "assetsId"`); + await queryRunner.query(`ALTER TABLE "albums_assets_assets" ADD CONSTRAINT "PK_c67bc36fa845fb7b18e0e398180" PRIMARY KEY ("albumsId", "assetsId")`); + await queryRunner.query(`CREATE INDEX "IDX_e590fa396c6898fcd4a50e4092" ON "albums_assets_assets" ("albumsId") `); + await queryRunner.query(`CREATE INDEX "IDX_4bd1303d199f4e72ccdf998c62" ON "albums_assets_assets" ("assetsId") `); + await queryRunner.query(`ALTER TABLE "albums_assets_assets" ADD CONSTRAINT "FK_e590fa396c6898fcd4a50e40927" FOREIGN KEY ("albumsId") REFERENCES "albums"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "albums_assets_assets" ADD CONSTRAINT "FK_4bd1303d199f4e72ccdf998c621" FOREIGN KEY ("assetsId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + + await queryRunner.query(`ALTER TABLE "user_shared_album" RENAME TO "albums_shared_users_users"`); + await queryRunner.query(`ALTER TABLE "albums_shared_users_users" DROP CONSTRAINT "FK_543c31211653e63e080ba882eb5"`); + await queryRunner.query(`ALTER TABLE "albums_shared_users_users" DROP CONSTRAINT "FK_7b3bf0f5f8da59af30519c25f18"`); + await queryRunner.query(`ALTER TABLE "albums_shared_users_users" DROP CONSTRAINT "PK_unique_user_in_album"`); + await queryRunner.query(`ALTER TABLE "albums_shared_users_users" DROP CONSTRAINT "PK_b6562316a98845a7b3e9a25cdd0"`); + await queryRunner.query(`ALTER TABLE "albums_shared_users_users" DROP COLUMN "id"`); + await queryRunner.query(`ALTER TABLE "albums_shared_users_users" RENAME COLUMN "albumId" TO "albumsId"`); + await queryRunner.query(`ALTER TABLE "albums_shared_users_users" RENAME COLUMN "sharedUserId" TO "usersId"`); + await queryRunner.query(`ALTER TABLE "albums_shared_users_users" ADD CONSTRAINT "PK_7df55657e0b2e8b626330a0ebc8" PRIMARY KEY ("albumsId", "usersId")`); + await queryRunner.query(`CREATE INDEX "IDX_427c350ad49bd3935a50baab73" ON "albums_shared_users_users" ("albumsId") `); + await queryRunner.query(`CREATE INDEX "IDX_f48513bf9bccefd6ff3ad30bd0" ON "albums_shared_users_users" ("usersId") `); + await queryRunner.query(`ALTER TABLE "albums_shared_users_users" ADD CONSTRAINT "FK_427c350ad49bd3935a50baab737" FOREIGN KEY ("albumsId") REFERENCES "albums"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "albums_shared_users_users" ADD CONSTRAINT "FK_f48513bf9bccefd6ff3ad30bd06" FOREIGN KEY ("usersId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + + await queryRunner.query(`ALTER TABLE "albums" DROP CONSTRAINT "FK_b22c53f35ef20c28c21637c85f4"`) + await queryRunner.query(`ALTER TABLE "albums" ADD CONSTRAINT "FK_b22c53f35ef20c28c21637c85f4" FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "albums_assets_assets" RENAME TO "asset_album"`); + await queryRunner.query(`ALTER TABLE "albums_shared_users_users" RENAME TO "user_shared_album"`); + await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_e590fa396c6898fcd4a50e40927"`); + await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_4bd1303d199f4e72ccdf998c621"`); + await queryRunner.query(`ALTER TABLE "user_shared_album" DROP CONSTRAINT "FK_427c350ad49bd3935a50baab737"`); + await queryRunner.query(`ALTER TABLE "user_shared_album" DROP CONSTRAINT "FK_f48513bf9bccefd6ff3ad30bd06"`); + await queryRunner.query(`DROP INDEX "public"."IDX_427c350ad49bd3935a50baab73"`); + await queryRunner.query(`DROP INDEX "public"."IDX_f48513bf9bccefd6ff3ad30bd0"`); + await queryRunner.query(`DROP INDEX "public"."IDX_e590fa396c6898fcd4a50e4092"`); + await queryRunner.query(`DROP INDEX "public"."IDX_4bd1303d199f4e72ccdf998c62"`); + + await queryRunner.query(`ALTER TABLE "albums" DROP CONSTRAINT "FK_b22c53f35ef20c28c21637c85f4"`); + await queryRunner.query( + `ALTER TABLE "albums" ADD CONSTRAINT "FK_b22c53f35ef20c28c21637c85f4" FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + + await queryRunner.query(`ALTER TABLE "user_shared_album" DROP CONSTRAINT "PK_7df55657e0b2e8b626330a0ebc8"`); + await queryRunner.query(`ALTER TABLE "user_shared_album" ADD CONSTRAINT "PK_323f8dcbe85373722886940f143" PRIMARY KEY ("albumsId")`); + await queryRunner.query(`ALTER TABLE "user_shared_album" DROP COLUMN "usersId"`); + await queryRunner.query(`ALTER TABLE "user_shared_album" DROP CONSTRAINT "PK_323f8dcbe85373722886940f143"`); + await queryRunner.query(`ALTER TABLE "user_shared_album" DROP COLUMN "albumsId"`); + await queryRunner.query(`ALTER TABLE "user_shared_album" ADD "sharedUserId" uuid NOT NULL`); + await queryRunner.query(`ALTER TABLE "user_shared_album" ADD "albumId" uuid NOT NULL`); + await queryRunner.query(`ALTER TABLE "user_shared_album" ADD "id" SERIAL NOT NULL`); + await queryRunner.query(`ALTER TABLE "user_shared_album" ADD CONSTRAINT "PK_b6562316a98845a7b3e9a25cdd0" PRIMARY KEY ("id")`); + await queryRunner.query(`ALTER TABLE "user_shared_album" ADD CONSTRAINT "PK_unique_user_in_album" UNIQUE ("albumId", "sharedUserId")`); + await queryRunner.query(`ALTER TABLE "user_shared_album" ADD CONSTRAINT "FK_7b3bf0f5f8da59af30519c25f18" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_shared_album" ADD CONSTRAINT "FK_543c31211653e63e080ba882eb5" FOREIGN KEY ("sharedUserId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + + await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "PK_c67bc36fa845fb7b18e0e398180"`); + await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "PK_b4f2e5b96efc25cbccd80a04f7a" PRIMARY KEY ("albumsId")`); + await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "PK_b4f2e5b96efc25cbccd80a04f7a"`); + await queryRunner.query(`ALTER TABLE "asset_album" RENAME COLUMN "albumsId" TO "albumId"`); + await queryRunner.query(`ALTER TABLE "asset_album" RENAME COLUMN "assetsId" TO "assetId"`); + await queryRunner.query(`ALTER TABLE "asset_album" ADD "id" SERIAL NOT NULL`); + await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "PK_a34e076afbc601d81938e2c2277" PRIMARY KEY ("id")`); + await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "UQ_unique_asset_in_album" UNIQUE ("albumId", "assetId")`); + await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_256a30a03a4a0aff0394051397d" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_7ae4e03729895bf87e056d7b598" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + +} diff --git a/server/libs/infra/src/db/repository/api-key.repository.ts b/server/libs/infra/src/db/repository/api-key.repository.ts index 35119d2d7c..2484b0d561 100644 --- a/server/libs/infra/src/db/repository/api-key.repository.ts +++ b/server/libs/infra/src/db/repository/api-key.repository.ts @@ -12,12 +12,12 @@ export class APIKeyRepository implements IKeyRepository { return this.repository.save(dto); } - async update(userId: string, id: number, dto: Partial): Promise { + async update(userId: string, id: string, dto: Partial): Promise { await this.repository.update({ userId, id }, dto); return this.repository.findOneOrFail({ where: { id: dto.id } }); } - async delete(userId: string, id: number): Promise { + async delete(userId: string, id: string): Promise { await this.repository.delete({ userId, id }); } @@ -35,7 +35,7 @@ export class APIKeyRepository implements IKeyRepository { }); } - getById(userId: string, id: number): Promise { + getById(userId: string, id: string): Promise { return this.repository.findOne({ where: { userId, id } }); } diff --git a/server/libs/infra/src/db/repository/shared-link.repository.ts b/server/libs/infra/src/db/repository/shared-link.repository.ts index bc54ae7e7c..09e706d0a2 100644 --- a/server/libs/infra/src/db/repository/shared-link.repository.ts +++ b/server/libs/infra/src/db/repository/shared-link.repository.ts @@ -24,9 +24,7 @@ export class SharedLinkRepository implements ISharedLinkRepository { }, album: { assets: { - assetInfo: { - exifInfo: true, - }, + exifInfo: true, }, }, }, @@ -37,9 +35,7 @@ export class SharedLinkRepository implements ISharedLinkRepository { }, album: { assets: { - assetInfo: { - createdAt: 'ASC', - }, + createdAt: 'ASC', }, }, }, @@ -69,9 +65,7 @@ export class SharedLinkRepository implements ISharedLinkRepository { relations: { assets: true, album: { - assets: { - assetInfo: true, - }, + assets: true, }, user: true, }, @@ -109,7 +103,7 @@ export class SharedLinkRepository implements ISharedLinkRepository { id, album: { assets: { - assetId, + id: assetId, }, }, }, diff --git a/server/package.json b/server/package.json index 7100ade0e2..0e9b92f800 100644 --- a/server/package.json +++ b/server/package.json @@ -33,6 +33,7 @@ "typeorm:migrations:run": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:run -d ./libs/infra/src/db/config/database.config.ts", "typeorm:migrations:revert": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:revert -d ./libs/infra/src/db/config/database.config.ts", "typeorm:schema:drop": "node --require ts-node/register ./node_modules/typeorm/cli.js schema:drop -d ./libs/infra/src/db/config/database.config.ts", + "typeorm:schema:reset": "npm run typeorm:schema:drop && npm run typeorm:migrations:run", "api:typescript": "bash ./bin/generate-open-api.sh web", "api:dart": "bash ./bin/generate-open-api.sh mobile", "api:generate": "bash ./bin/generate-open-api.sh"