diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f3c5165f5..03f5370b2 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -332,15 +332,15 @@ ] } }, - "/album/sub-album": { + "/album/nested-album": { "post": { - "operationId": "createSubAlbum", + "operationId": "createNestedAlbum", "parameters": [], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateSubAlbumDto" + "$ref": "#/components/schemas/CreateNestedAlbumDto" } } }, @@ -348,6 +348,13 @@ }, "responses": { "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlbumResponseDto" + } + } + }, "description": "" } }, @@ -624,9 +631,9 @@ ] } }, - "/album/{id}/sub-album": { + "/album/{id}/nested-album": { "get": { - "operationId": "getAlbumTree", + "operationId": "getNestedAlbums", "parameters": [ { "name": "id", @@ -640,6 +647,13 @@ ], "responses": { "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NestedAlbumResponseDto" + } + } + }, "description": "" } }, @@ -659,9 +673,9 @@ ] } }, - "/album/{id}/sub-album/{childAlbumId}": { + "/album/{id}/nested-album/{childAlbumId}": { "delete": { - "operationId": "removeSubAlbum", + "operationId": "removeNestedAlbum", "parameters": [ { "name": "childAlbumId", @@ -683,6 +697,13 @@ ], "responses": { "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlbumResponseDto" + } + } + }, "description": "" } }, @@ -7196,12 +7217,6 @@ }, "type": "array" }, - "childAlbums": { - "items": { - "$ref": "#/components/schemas/SubAlbumResponseDto" - }, - "type": "array" - }, "createdAt": { "format": "date-time", "type": "string" @@ -7235,12 +7250,6 @@ "ownerId": { "type": "string" }, - "parentAlbums": { - "items": { - "$ref": "#/components/schemas/SubAlbumResponseDto" - }, - "type": "array" - }, "shared": { "type": "boolean" }, @@ -8145,6 +8154,22 @@ ], "type": "object" }, + "CreateNestedAlbumDto": { + "properties": { + "childId": { + "format": "uuid", + "type": "string" + }, + "parentId": { + "type": "string" + } + }, + "required": [ + "childId", + "parentId" + ], + "type": "object" + }, "CreateProfileImageDto": { "properties": { "file": { @@ -8172,22 +8197,6 @@ ], "type": "object" }, - "CreateSubAlbumDto": { - "properties": { - "childrenId": { - "format": "uuid", - "type": "string" - }, - "parentId": { - "type": "string" - } - }, - "required": [ - "childrenId", - "parentId" - ], - "type": "object" - }, "CreateTagDto": { "properties": { "name": { @@ -9218,6 +9227,27 @@ ], "type": "string" }, + "NestedAlbumResponseDto": { + "properties": { + "children": { + "items": { + "$ref": "#/components/schemas/AlbumResponseDto" + }, + "type": "array" + }, + "parents": { + "items": { + "$ref": "#/components/schemas/AlbumResponseDto" + }, + "type": "array" + } + }, + "required": [ + "children", + "parents" + ], + "type": "object" + }, "OAuthAuthorizeResponseDto": { "properties": { "url": { @@ -10347,26 +10377,6 @@ ], "type": "object" }, - "SubAlbumResponseDto": { - "properties": { - "albumName": { - "type": "string" - }, - "albumThumbnailAssetId": { - "nullable": true, - "type": "string" - }, - "id": { - "type": "string" - } - }, - "required": [ - "albumName", - "albumThumbnailAssetId", - "id" - ], - "type": "object" - }, "SystemConfigDto": { "properties": { "ffmpeg": { diff --git a/server/src/controllers/album.controller.ts b/server/src/controllers/album.controller.ts index dcf78f4c8..15edc5e1e 100644 --- a/server/src/controllers/album.controller.ts +++ b/server/src/controllers/album.controller.ts @@ -6,8 +6,9 @@ import { AlbumInfoDto, AlbumResponseDto, CreateAlbumDto, - CreateSubAlbumDto, + CreateNestedAlbumDto, GetAlbumsDto, + NestedAlbumResponseDto, UpdateAlbumDto, } from 'src/dtos/album.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; @@ -98,22 +99,22 @@ export class AlbumController { return this.service.removeUser(auth, id, userId); } - @Post('sub-album') - createSubAlbum(@Auth() auth: AuthDto, @Body() dto: CreateSubAlbumDto) { - // TODO + @Post('nested-album') + createNestedAlbum(@Auth() auth: AuthDto, @Body() dto: CreateNestedAlbumDto): Promise { + return this.service.createNestedAlbum(auth, dto.parentId, dto.childId); } - @Delete(':id/sub-album/:childAlbumId') - removeSubAlbum( + @Delete(':id/nested-album/:childAlbumId') + removeNestedAlbum( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Param('childAlbumId', new ParseMeUUIDPipe({ version: '4' })) childAlbumId: string, - ) { - // TODO + ): Promise { + return this.service.removeNestedAlbum(auth, id, childAlbumId); } - @Get(':id/sub-album') - getAlbumTree(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) { - // TODO + @Get(':id/nested-album') + getNestedAlbums(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.getNestedAlbums(auth, id); } } diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 9e0ed2887..a05b44cc6 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -83,12 +83,6 @@ export class AlbumCountResponseDto { notShared!: number; } -export class SubAlbumResponseDto { - id!: string; - albumName!: string; - albumThumbnailAssetId!: string | null; -} - export class AlbumResponseDto { id!: string; ownerId!: string; @@ -111,17 +105,22 @@ export class AlbumResponseDto { @Optional() @ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder }) order?: AssetOrder; - - parentAlbums?: SubAlbumResponseDto[]; - childAlbums?: SubAlbumResponseDto[]; } -export class CreateSubAlbumDto { +export class CreateNestedAlbumDto { @IsString() parentId!: string; @ValidateUUID() - childrenId!: string; + childId!: string; +} + +export class NestedAlbumResponseDto { + @ApiProperty() + parents!: AlbumResponseDto[]; + + @ApiProperty() + children!: AlbumResponseDto[]; } export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => { diff --git a/server/src/entities/album.entity.ts b/server/src/entities/album.entity.ts index 80c4ec2e6..0e55279e9 100644 --- a/server/src/entities/album.entity.ts +++ b/server/src/entities/album.entity.ts @@ -73,9 +73,9 @@ export class AlbumEntity { @Entity('nested_albums') export class NestedAlbumEntity { - @PrimaryColumn() + @PrimaryColumn('uuid') parentId!: string; - @PrimaryColumn() + @PrimaryColumn('uuid') childId!: string; } diff --git a/server/src/interfaces/album.interface.ts b/server/src/interfaces/album.interface.ts index 48c728feb..8052e4841 100644 --- a/server/src/interfaces/album.interface.ts +++ b/server/src/interfaces/album.interface.ts @@ -24,6 +24,11 @@ export interface AlbumAssets { assetIds: string[]; } +export interface NestedAlbums { + parents: AlbumEntity[]; + children: AlbumEntity[]; +} + export interface IAlbumRepository extends IBulkAsset { getById(id: string, options: AlbumInfoOptions): Promise; getByIds(ids: string[]): Promise; @@ -45,4 +50,8 @@ export interface IAlbumRepository extends IBulkAsset { update(album: Partial): Promise; delete(album: AlbumEntity): Promise; updateThumbnails(): Promise; + + createNestedAlbum(parentId: string, childId: string): Promise; + removeNestedAlbum(parentId: string, childId: string): Promise; + getNestedAlbums(id: string): Promise; } diff --git a/server/src/migrations/1712155807366-AddNestedAlbumTable.ts b/server/src/migrations/1712161154542-AddNestedAlbumTable.ts similarity index 56% rename from server/src/migrations/1712155807366-AddNestedAlbumTable.ts rename to server/src/migrations/1712161154542-AddNestedAlbumTable.ts index 7a85da01a..353c97ffd 100644 --- a/server/src/migrations/1712155807366-AddNestedAlbumTable.ts +++ b/server/src/migrations/1712161154542-AddNestedAlbumTable.ts @@ -1,10 +1,10 @@ import { MigrationInterface, QueryRunner } from "typeorm"; -export class AddNestedAlbumTable1712155807366 implements MigrationInterface { - name = 'AddNestedAlbumTable1712155807366' +export class AddNestedAlbumTable1712161154542 implements MigrationInterface { + name = 'AddNestedAlbumTable1712161154542' public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`CREATE TABLE "nested_albums" ("parentId" character varying NOT NULL, "childId" character varying NOT NULL, CONSTRAINT "PK_702e1e5d9ed4b85d3bdffc934bd" PRIMARY KEY ("parentId", "childId"))`); + await queryRunner.query(`CREATE TABLE "nested_albums" ("parentId" uuid NOT NULL, "childId" uuid NOT NULL, CONSTRAINT "PK_702e1e5d9ed4b85d3bdffc934bd" PRIMARY KEY ("parentId", "childId"))`); } public async down(queryRunner: QueryRunner): Promise { diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index bbaab2a12..96cadfd6c 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -3,9 +3,15 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import _ from 'lodash'; import { dataSource } from 'src/database.config'; import { Chunked, ChunkedArray, DATABASE_PARAMETER_CHUNK_SIZE, DummyValue, GenerateSql } from 'src/decorators'; -import { AlbumEntity } from 'src/entities/album.entity'; +import { AlbumEntity, NestedAlbumEntity } from 'src/entities/album.entity'; import { AssetEntity } from 'src/entities/asset.entity'; -import { AlbumAsset, AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface'; +import { + AlbumAsset, + AlbumAssetCount, + AlbumInfoOptions, + IAlbumRepository, + NestedAlbums, +} from 'src/interfaces/album.interface'; import { Instrumentation } from 'src/utils/instrumentation'; import { setUnion } from 'src/utils/set'; import { DataSource, FindOptionsOrder, FindOptionsRelations, In, IsNull, Not, Repository } from 'typeorm'; @@ -16,6 +22,7 @@ export class AlbumRepository implements IAlbumRepository { constructor( @InjectRepository(AssetEntity) private assetRepository: Repository, @InjectRepository(AlbumEntity) private repository: Repository, + @InjectRepository(NestedAlbumEntity) private nestedAlbumRepository: Repository, @InjectDataSource() private dataSource: DataSource, ) {} @@ -331,4 +338,48 @@ export class AlbumRepository implements IAlbumRepository { return result.affected; } + + @GenerateSql() + async createNestedAlbum(parentId: string, childId: string): Promise { + const nestedAlbum = new NestedAlbumEntity(); + nestedAlbum.parentId = parentId; + nestedAlbum.childId = childId; + + await this.nestedAlbumRepository.save(nestedAlbum); + + return this.repository.findOneOrFail({ + where: { id: childId }, + }); + } + + @GenerateSql() + async removeNestedAlbum(parentId: string, childId: string): Promise { + await this.nestedAlbumRepository.delete({ parentId, childId }); + + return this.repository.findOneOrFail({ + where: { id: childId }, + }); + } + + @GenerateSql() + async getNestedAlbums(id: string): Promise { + const children = await this.repository + .createQueryBuilder('albums') + .innerJoin('nested_albums', 'nested', 'nested.childId = albums.id') + .leftJoinAndSelect('albums.owner', 'owner') + .where('nested.parentId = :id', { id }) + .getMany(); + + const parents = await this.repository + .createQueryBuilder('albums') + .innerJoin('nested_albums', 'nested', 'nested.parentId = albums.id') + .leftJoinAndSelect('albums.owner', 'owner') + .where('nested.childId = :id', { id }) + .getMany(); + + return { + parents, + children, + }; + } } diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index df6c6b814..5f1e95a07 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -7,6 +7,7 @@ import { AlbumResponseDto, CreateAlbumDto, GetAlbumsDto, + NestedAlbumResponseDto as NestedAlbumsResponseDto, UpdateAlbumDto, mapAlbum, mapAlbumWithAssets, @@ -266,6 +267,33 @@ export class AlbumService { }); } + async createNestedAlbum(auth: AuthDto, parentId: string, childId: string): Promise { + await this.access.requirePermission(auth, Permission.ALBUM_UPDATE, parentId); + + const nestedAlbum = await this.albumRepository.createNestedAlbum(parentId, childId); + + return mapAlbumWithoutAssets(nestedAlbum); + } + + async removeNestedAlbum(auth: AuthDto, parentId: string, childId: string): Promise { + await this.access.requirePermission(auth, Permission.ALBUM_UPDATE, parentId); + + const deletedNestedAlbum = await this.albumRepository.removeNestedAlbum(parentId, childId); + + return mapAlbumWithoutAssets(deletedNestedAlbum); + } + + async getNestedAlbums(auth: AuthDto, id: string): Promise { + await this.access.requirePermission(auth, Permission.ALBUM_READ, id); + + const { parents, children } = await this.albumRepository.getNestedAlbums(id); + + return { + parents: parents.length > 0 ? parents.map(mapAlbumWithoutAssets) : [], + children: children.length > 0 ? children.map(mapAlbumWithoutAssets) : [], + }; + } + private async findOrFail(id: string, options: AlbumInfoOptions) { const album = await this.albumRepository.getById(id, options); if (!album) {