mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	feat(server/web): album description (#3558)
* feat(server): add album description * chore: open api * fix: tests * show and edit description on the web * fix test * remove unused code * type event * format fix --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
		| @@ -4754,6 +4754,9 @@ | ||||
|             "format": "date-time", | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "description": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "id": { | ||||
|             "type": "string" | ||||
|           }, | ||||
| @@ -4786,6 +4789,7 @@ | ||||
|           "id", | ||||
|           "ownerId", | ||||
|           "albumName", | ||||
|           "description", | ||||
|           "createdAt", | ||||
|           "updatedAt", | ||||
|           "albumThumbnailAssetId", | ||||
| @@ -5264,6 +5268,9 @@ | ||||
|             }, | ||||
|             "type": "array" | ||||
|           }, | ||||
|           "description": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "sharedWithUserIds": { | ||||
|             "items": { | ||||
|               "format": "uuid", | ||||
| @@ -6903,6 +6910,9 @@ | ||||
|           "albumThumbnailAssetId": { | ||||
|             "format": "uuid", | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "description": { | ||||
|             "type": "string" | ||||
|           } | ||||
|         }, | ||||
|         "type": "object" | ||||
|   | ||||
| @@ -7,6 +7,7 @@ export class AlbumResponseDto { | ||||
|   id!: string; | ||||
|   ownerId!: string; | ||||
|   albumName!: string; | ||||
|   description!: string; | ||||
|   createdAt!: Date; | ||||
|   updatedAt!: Date; | ||||
|   albumThumbnailAssetId!: string | null; | ||||
| @@ -19,7 +20,7 @@ export class AlbumResponseDto { | ||||
|   lastModifiedAssetTimestamp?: Date; | ||||
| } | ||||
|  | ||||
| export function mapAlbum(entity: AlbumEntity): AlbumResponseDto { | ||||
| const _map = (entity: AlbumEntity, withAssets: boolean): AlbumResponseDto => { | ||||
|   const sharedUsers: UserResponseDto[] = []; | ||||
|  | ||||
|   entity.sharedUsers?.forEach((user) => { | ||||
| @@ -29,6 +30,7 @@ export function mapAlbum(entity: AlbumEntity): AlbumResponseDto { | ||||
|  | ||||
|   return { | ||||
|     albumName: entity.albumName, | ||||
|     description: entity.description, | ||||
|     albumThumbnailAssetId: entity.albumThumbnailAssetId, | ||||
|     createdAt: entity.createdAt, | ||||
|     updatedAt: entity.updatedAt, | ||||
| @@ -37,33 +39,13 @@ export function mapAlbum(entity: AlbumEntity): AlbumResponseDto { | ||||
|     owner: mapUser(entity.owner), | ||||
|     sharedUsers, | ||||
|     shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0, | ||||
|     assets: entity.assets?.map((asset) => mapAsset(asset)) || [], | ||||
|     assets: withAssets ? entity.assets?.map((asset) => mapAsset(asset)) || [] : [], | ||||
|     assetCount: entity.assets?.length || 0, | ||||
|   }; | ||||
| } | ||||
| }; | ||||
|  | ||||
| export function mapAlbumExcludeAssetInfo(entity: AlbumEntity): AlbumResponseDto { | ||||
|   const sharedUsers: UserResponseDto[] = []; | ||||
|  | ||||
|   entity.sharedUsers?.forEach((user) => { | ||||
|     const userDto = mapUser(user); | ||||
|     sharedUsers.push(userDto); | ||||
|   }); | ||||
|  | ||||
|   return { | ||||
|     albumName: entity.albumName, | ||||
|     albumThumbnailAssetId: entity.albumThumbnailAssetId, | ||||
|     createdAt: entity.createdAt, | ||||
|     updatedAt: entity.updatedAt, | ||||
|     id: entity.id, | ||||
|     ownerId: entity.ownerId, | ||||
|     owner: mapUser(entity.owner), | ||||
|     sharedUsers, | ||||
|     shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0, | ||||
|     assets: [], | ||||
|     assetCount: entity.assets?.length || 0, | ||||
|   }; | ||||
| } | ||||
| export const mapAlbum = (entity: AlbumEntity) => _map(entity, true); | ||||
| export const mapAlbumExcludeAssetInfo = (entity: AlbumEntity) => _map(entity, false); | ||||
|  | ||||
| export class AlbumCountResponseDto { | ||||
|   @ApiProperty({ type: 'integer' }) | ||||
|   | ||||
| @@ -156,6 +156,7 @@ describe(AlbumService.name, () => { | ||||
|  | ||||
|       await expect(sut.create(authStub.admin, { albumName: 'Empty album' })).resolves.toEqual({ | ||||
|         albumName: 'Empty album', | ||||
|         description: '', | ||||
|         albumThumbnailAssetId: null, | ||||
|         assetCount: 0, | ||||
|         assets: [], | ||||
|   | ||||
| @@ -94,6 +94,7 @@ export class AlbumService { | ||||
|     const album = await this.albumRepository.create({ | ||||
|       ownerId: authUser.id, | ||||
|       albumName: dto.albumName, | ||||
|       description: dto.description, | ||||
|       sharedUsers: dto.sharedWithUserIds?.map((value) => ({ id: value } as UserEntity)) ?? [], | ||||
|       assets: (dto.assetIds || []).map((id) => ({ id } as AssetEntity)), | ||||
|       albumThumbnailAssetId: dto.assetIds?.[0] || null, | ||||
| @@ -118,6 +119,7 @@ export class AlbumService { | ||||
|     const updatedAlbum = await this.albumRepository.update({ | ||||
|       id: album.id, | ||||
|       albumName: dto.albumName, | ||||
|       description: dto.description, | ||||
|       albumThumbnailAssetId: dto.albumThumbnailAssetId, | ||||
|     }); | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { ApiProperty } from '@nestjs/swagger'; | ||||
| import { IsNotEmpty, IsString } from 'class-validator'; | ||||
| import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; | ||||
| import { ValidateUUID } from '../../domain.util'; | ||||
|  | ||||
| export class CreateAlbumDto { | ||||
| @@ -8,6 +8,10 @@ export class CreateAlbumDto { | ||||
|   @ApiProperty() | ||||
|   albumName!: string; | ||||
|  | ||||
|   @IsString() | ||||
|   @IsOptional() | ||||
|   description?: string; | ||||
|  | ||||
|   @ValidateUUID({ optional: true, each: true }) | ||||
|   sharedWithUserIds?: string[]; | ||||
|  | ||||
|   | ||||
| @@ -1,12 +1,15 @@ | ||||
| import { ApiProperty } from '@nestjs/swagger'; | ||||
| import { IsOptional } from 'class-validator'; | ||||
| import { IsOptional, IsString } from 'class-validator'; | ||||
| import { ValidateUUID } from '../../domain.util'; | ||||
|  | ||||
| export class UpdateAlbumDto { | ||||
|   @IsOptional() | ||||
|   @ApiProperty() | ||||
|   @IsString() | ||||
|   albumName?: string; | ||||
|  | ||||
|   @IsOptional() | ||||
|   @IsString() | ||||
|   description?: string; | ||||
|  | ||||
|   @ValidateUUID({ optional: true }) | ||||
|   albumThumbnailAssetId?: string; | ||||
| } | ||||
|   | ||||
| @@ -5,8 +5,8 @@ import { | ||||
|   AuthUserDto, | ||||
|   BulkIdResponseDto, | ||||
|   BulkIdsDto, | ||||
|   CreateAlbumDto, | ||||
|   UpdateAlbumDto, | ||||
|   CreateAlbumDto as CreateDto, | ||||
|   UpdateAlbumDto as UpdateDto, | ||||
| } from '@app/domain'; | ||||
| import { GetAlbumsDto } from '@app/domain/album/dto/get-albums.dto'; | ||||
| import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; | ||||
| @@ -34,7 +34,7 @@ export class AlbumController { | ||||
|   } | ||||
|  | ||||
|   @Post() | ||||
|   createAlbum(@AuthUser() authUser: AuthUserDto, @Body() dto: CreateAlbumDto) { | ||||
|   createAlbum(@AuthUser() authUser: AuthUserDto, @Body() dto: CreateDto) { | ||||
|     return this.service.create(authUser, dto); | ||||
|   } | ||||
|  | ||||
| @@ -45,7 +45,7 @@ export class AlbumController { | ||||
|   } | ||||
|  | ||||
|   @Patch(':id') | ||||
|   updateAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateAlbumDto) { | ||||
|   updateAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateDto) { | ||||
|     return this.service.update(authUser, id, dto); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -27,6 +27,9 @@ export class AlbumEntity { | ||||
|   @Column({ default: 'Untitled Album' }) | ||||
|   albumName!: string; | ||||
|  | ||||
|   @Column({ type: 'text', default: '' }) | ||||
|   description!: string; | ||||
|  | ||||
|   @CreateDateColumn({ type: 'timestamptz' }) | ||||
|   createdAt!: Date; | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,13 @@ | ||||
| import { MigrationInterface, QueryRunner } from 'typeorm'; | ||||
|  | ||||
| export class AddAlbumDescription1691209138541 implements MigrationInterface { | ||||
|   name = 'AddAlbumDescription1691209138541'; | ||||
|  | ||||
|   public async up(queryRunner: QueryRunner): Promise<void> { | ||||
|     await queryRunner.query(`ALTER TABLE "albums" ADD "description" text NOT NULL DEFAULT ''`); | ||||
|   } | ||||
|  | ||||
|   public async down(queryRunner: QueryRunner): Promise<void> { | ||||
|     await queryRunner.query(`ALTER TABLE "albums" DROP COLUMN "description"`); | ||||
|   } | ||||
| } | ||||
| @@ -234,7 +234,7 @@ export class TypesenseRepository implements ISearchRepository { | ||||
|       .documents() | ||||
|       .search({ | ||||
|         q: query, | ||||
|         query_by: 'albumName', | ||||
|         query_by: ['albumName', 'description'].join(','), | ||||
|         filter_by: this.getAlbumFilters(filters), | ||||
|       }); | ||||
|  | ||||
|   | ||||
| @@ -1,11 +1,12 @@ | ||||
| import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; | ||||
|  | ||||
| export const albumSchemaVersion = 1; | ||||
| export const albumSchemaVersion = 2; | ||||
| export const albumSchema: CollectionCreateSchema = { | ||||
|   name: `albums-v${albumSchemaVersion}`, | ||||
|   fields: [ | ||||
|     { name: 'ownerId', type: 'string', facet: false }, | ||||
|     { name: 'albumName', type: 'string', facet: false, sort: true }, | ||||
|     { name: 'description', type: 'string', facet: false }, | ||||
|     { name: 'createdAt', type: 'string', facet: false, sort: true }, | ||||
|     { name: 'updatedAt', type: 'string', facet: false, sort: true }, | ||||
|   ], | ||||
|   | ||||
| @@ -4,7 +4,7 @@ import { SharedLinkType } from '@app/infra/entities'; | ||||
| import { INestApplication } from '@nestjs/common'; | ||||
| import { Test, TestingModule } from '@nestjs/testing'; | ||||
| import request from 'supertest'; | ||||
| import { errorStub } from '../fixtures'; | ||||
| import { errorStub, uuidStub } from '../fixtures'; | ||||
| import { api, db } from '../test-utils'; | ||||
|  | ||||
| const user1SharedUser = 'user1SharedUser'; | ||||
| @@ -193,6 +193,7 @@ describe(`${AlbumController.name} (e2e)`, () => { | ||||
|         updatedAt: expect.any(String), | ||||
|         ownerId: user1.userId, | ||||
|         albumName: 'New album', | ||||
|         description: '', | ||||
|         albumThumbnailAssetId: null, | ||||
|         shared: false, | ||||
|         sharedUsers: [], | ||||
| @@ -202,4 +203,32 @@ describe(`${AlbumController.name} (e2e)`, () => { | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('PATCH /album/:id', () => { | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .patch(`/album/${uuidStub.notFound}`) | ||||
|         .send({ albumName: 'New album name' }); | ||||
|       expect(status).toBe(401); | ||||
|       expect(body).toEqual(errorStub.unauthorized); | ||||
|     }); | ||||
|  | ||||
|     it('should update an album', async () => { | ||||
|       const album = await api.albumApi.create(server, user1.accessToken, { albumName: 'New album' }); | ||||
|       const { status, body } = await request(server) | ||||
|         .patch(`/album/${album.id}`) | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .send({ | ||||
|           albumName: 'New album name', | ||||
|           description: 'An album description', | ||||
|         }); | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual({ | ||||
|         ...album, | ||||
|         updatedAt: expect.any(String), | ||||
|         albumName: 'New album name', | ||||
|         description: 'An album description', | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
							
								
								
									
										10
									
								
								server/test/fixtures/album.stub.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								server/test/fixtures/album.stub.ts
									
									
									
									
										vendored
									
									
								
							| @@ -7,6 +7,7 @@ export const albumStub = { | ||||
|   empty: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-1', | ||||
|     albumName: 'Empty album', | ||||
|     description: '', | ||||
|     ownerId: authStub.admin.id, | ||||
|     owner: userStub.admin, | ||||
|     assets: [], | ||||
| @@ -20,6 +21,7 @@ export const albumStub = { | ||||
|   sharedWithUser: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-2', | ||||
|     albumName: 'Empty album shared with user', | ||||
|     description: '', | ||||
|     ownerId: authStub.admin.id, | ||||
|     owner: userStub.admin, | ||||
|     assets: [], | ||||
| @@ -33,6 +35,7 @@ export const albumStub = { | ||||
|   sharedWithMultiple: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-3', | ||||
|     albumName: 'Empty album shared with users', | ||||
|     description: '', | ||||
|     ownerId: authStub.admin.id, | ||||
|     owner: userStub.admin, | ||||
|     assets: [], | ||||
| @@ -46,6 +49,7 @@ export const albumStub = { | ||||
|   sharedWithAdmin: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-3', | ||||
|     albumName: 'Empty album shared with admin', | ||||
|     description: '', | ||||
|     ownerId: authStub.user1.id, | ||||
|     owner: userStub.user1, | ||||
|     assets: [], | ||||
| @@ -59,6 +63,7 @@ export const albumStub = { | ||||
|   oneAsset: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-4', | ||||
|     albumName: 'Album with one asset', | ||||
|     description: '', | ||||
|     ownerId: authStub.admin.id, | ||||
|     owner: userStub.admin, | ||||
|     assets: [assetStub.image], | ||||
| @@ -72,6 +77,7 @@ export const albumStub = { | ||||
|   twoAssets: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-4a', | ||||
|     albumName: 'Album with two assets', | ||||
|     description: '', | ||||
|     ownerId: authStub.admin.id, | ||||
|     owner: userStub.admin, | ||||
|     assets: [assetStub.image, assetStub.withLocation], | ||||
| @@ -85,6 +91,7 @@ export const albumStub = { | ||||
|   emptyWithInvalidThumbnail: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-5', | ||||
|     albumName: 'Empty album with invalid thumbnail', | ||||
|     description: '', | ||||
|     ownerId: authStub.admin.id, | ||||
|     owner: userStub.admin, | ||||
|     assets: [], | ||||
| @@ -98,6 +105,7 @@ export const albumStub = { | ||||
|   emptyWithValidThumbnail: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-5', | ||||
|     albumName: 'Empty album with invalid thumbnail', | ||||
|     description: '', | ||||
|     ownerId: authStub.admin.id, | ||||
|     owner: userStub.admin, | ||||
|     assets: [], | ||||
| @@ -111,6 +119,7 @@ export const albumStub = { | ||||
|   oneAssetInvalidThumbnail: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-6', | ||||
|     albumName: 'Album with one asset and invalid thumbnail', | ||||
|     description: '', | ||||
|     ownerId: authStub.admin.id, | ||||
|     owner: userStub.admin, | ||||
|     assets: [assetStub.image], | ||||
| @@ -124,6 +133,7 @@ export const albumStub = { | ||||
|   oneAssetValidThumbnail: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-6', | ||||
|     albumName: 'Album with one asset and invalid thumbnail', | ||||
|     description: '', | ||||
|     ownerId: authStub.admin.id, | ||||
|     owner: userStub.admin, | ||||
|     assets: [assetStub.image], | ||||
|   | ||||
							
								
								
									
										2
									
								
								server/test/fixtures/shared-link.stub.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								server/test/fixtures/shared-link.stub.ts
									
									
									
									
										vendored
									
									
								
							| @@ -68,6 +68,7 @@ const assetResponse: AssetResponseDto = { | ||||
|  | ||||
| const albumResponse: AlbumResponseDto = { | ||||
|   albumName: 'Test Album', | ||||
|   description: '', | ||||
|   albumThumbnailAssetId: null, | ||||
|   createdAt: today, | ||||
|   updatedAt: today, | ||||
| @@ -146,6 +147,7 @@ export const sharedLinkStub = { | ||||
|       ownerId: authStub.admin.id, | ||||
|       owner: userStub.admin, | ||||
|       albumName: 'Test Album', | ||||
|       description: '', | ||||
|       createdAt: today, | ||||
|       updatedAt: today, | ||||
|       albumThumbnailAsset: null, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user