mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
refactor(server): trash endpoints (#6652)
* refactor(server): trash endpoints * chore: open api * chore: fix wrong rename
This commit is contained in:
parent
33757689fe
commit
96b7885583
27 changed files with 967 additions and 137 deletions
|
|
@ -679,25 +679,6 @@ describe(AssetService.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('restoreAll', () => {
|
||||
it('should require asset restore access for all ids', async () => {
|
||||
await expect(
|
||||
sut.deleteAll(authStub.user1, {
|
||||
ids: ['asset-1'],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should restore a batch of assets', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2']));
|
||||
|
||||
await sut.restoreAll(authStub.user1, { ids: ['asset1', 'asset2'] });
|
||||
|
||||
expect(assetMock.restoreAll).toHaveBeenCalledWith(['asset1', 'asset2']);
|
||||
expect(jobMock.queue.mock.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAssetDeletion', () => {
|
||||
beforeEach(() => {
|
||||
when(jobMock.queue)
|
||||
|
|
|
|||
|
|
@ -37,14 +37,12 @@ import {
|
|||
MemoryLaneDto,
|
||||
TimeBucketAssetDto,
|
||||
TimeBucketDto,
|
||||
TrashAction,
|
||||
UpdateAssetDto,
|
||||
UpdateStackParentDto,
|
||||
mapStats,
|
||||
} from './dto';
|
||||
import {
|
||||
AssetResponseDto,
|
||||
BulkIdsDto,
|
||||
MapMarkerResponseDto,
|
||||
MemoryLaneResponseDto,
|
||||
SanitizedAssetResponseDto,
|
||||
|
|
@ -451,37 +449,6 @@ export class AssetService {
|
|||
}
|
||||
}
|
||||
|
||||
async handleTrashAction(auth: AuthDto, action: TrashAction): Promise<void> {
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
||||
this.assetRepository.getByUserId(pagination, auth.user.id, { trashedBefore: DateTime.now().toJSDate() }),
|
||||
);
|
||||
|
||||
if (action == TrashAction.RESTORE_ALL) {
|
||||
for await (const assets of assetPagination) {
|
||||
const ids = assets.map((a) => a.id);
|
||||
await this.assetRepository.restoreAll(ids);
|
||||
this.communicationRepository.send(ClientEvent.ASSET_RESTORE, auth.user.id, ids);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action == TrashAction.EMPTY_ALL) {
|
||||
for await (const assets of assetPagination) {
|
||||
await this.jobRepository.queueAll(
|
||||
assets.map((asset) => ({ name: JobName.ASSET_DELETION, data: { id: asset.id } })),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async restoreAll(auth: AuthDto, dto: BulkIdsDto): Promise<void> {
|
||||
const { ids } = dto;
|
||||
await this.access.requirePermission(auth, Permission.ASSET_RESTORE, ids);
|
||||
await this.assetRepository.restoreAll(ids);
|
||||
this.communicationRepository.send(ClientEvent.ASSET_RESTORE, auth.user.id, ids);
|
||||
}
|
||||
|
||||
async updateStackParent(auth: AuthDto, dto: UpdateStackParentDto): Promise<void> {
|
||||
const { oldParentId, newParentId } = dto;
|
||||
await this.access.requirePermission(auth, Permission.ASSET_READ, oldParentId);
|
||||
|
|
|
|||
|
|
@ -246,11 +246,6 @@ export class RandomAssetsDto {
|
|||
count?: number;
|
||||
}
|
||||
|
||||
export enum TrashAction {
|
||||
EMPTY_ALL = 'empty-all',
|
||||
RESTORE_ALL = 'restore-all',
|
||||
}
|
||||
|
||||
export class AssetBulkDeleteDto extends BulkIdsDto {
|
||||
@Optional()
|
||||
@IsBoolean()
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { StorageService } from './storage';
|
|||
import { StorageTemplateService } from './storage-template';
|
||||
import { SystemConfigService } from './system-config';
|
||||
import { TagService } from './tag';
|
||||
import { TrashService } from './trash';
|
||||
import { UserService } from './user';
|
||||
|
||||
const providers: Provider[] = [
|
||||
|
|
@ -48,6 +49,7 @@ const providers: Provider[] = [
|
|||
StorageTemplateService,
|
||||
SystemConfigService,
|
||||
TagService,
|
||||
TrashService,
|
||||
UserService,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -26,4 +26,5 @@ export * from './storage';
|
|||
export * from './storage-template';
|
||||
export * from './system-config';
|
||||
export * from './tag';
|
||||
export * from './trash';
|
||||
export * from './user';
|
||||
|
|
|
|||
1
server/src/domain/trash/index.ts
Normal file
1
server/src/domain/trash/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './trash.service';
|
||||
87
server/src/domain/trash/trash.service.spec.ts
Normal file
87
server/src/domain/trash/trash.service.spec.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
IAccessRepositoryMock,
|
||||
assetStub,
|
||||
authStub,
|
||||
newAccessRepositoryMock,
|
||||
newAssetRepositoryMock,
|
||||
newCommunicationRepositoryMock,
|
||||
newJobRepositoryMock,
|
||||
} from '@test';
|
||||
import { JobName } from '..';
|
||||
import { ClientEvent, IAssetRepository, ICommunicationRepository, IJobRepository } from '../repositories';
|
||||
import { TrashService } from './trash.service';
|
||||
|
||||
describe(TrashService.name, () => {
|
||||
let sut: TrashService;
|
||||
let accessMock: IAccessRepositoryMock;
|
||||
let assetMock: jest.Mocked<IAssetRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
let communicationMock: jest.Mocked<ICommunicationRepository>;
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
accessMock = newAccessRepositoryMock();
|
||||
assetMock = newAssetRepositoryMock();
|
||||
communicationMock = newCommunicationRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
|
||||
sut = new TrashService(accessMock, assetMock, jobMock, communicationMock);
|
||||
});
|
||||
|
||||
describe('restoreAssets', () => {
|
||||
it('should require asset restore access for all ids', async () => {
|
||||
await expect(
|
||||
sut.restoreAssets(authStub.user1, {
|
||||
ids: ['asset-1'],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should restore a batch of assets', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2']));
|
||||
|
||||
await sut.restoreAssets(authStub.user1, { ids: ['asset1', 'asset2'] });
|
||||
|
||||
expect(assetMock.restoreAll).toHaveBeenCalledWith(['asset1', 'asset2']);
|
||||
expect(jobMock.queue.mock.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restore', () => {
|
||||
it('should handle an empty trash', async () => {
|
||||
assetMock.getByUserId.mockResolvedValue({ items: [], hasNextPage: false });
|
||||
await expect(sut.restore(authStub.user1)).resolves.toBeUndefined();
|
||||
expect(assetMock.restoreAll).not.toHaveBeenCalled();
|
||||
expect(communicationMock.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should restore and notify', async () => {
|
||||
assetMock.getByUserId.mockResolvedValue({ items: [assetStub.image], hasNextPage: false });
|
||||
await expect(sut.restore(authStub.user1)).resolves.toBeUndefined();
|
||||
expect(assetMock.restoreAll).toHaveBeenCalledWith([assetStub.image.id]);
|
||||
expect(communicationMock.send).toHaveBeenCalledWith(ClientEvent.ASSET_RESTORE, authStub.user1.user.id, [
|
||||
assetStub.image.id,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty', () => {
|
||||
it('should handle an empty trash', async () => {
|
||||
assetMock.getByUserId.mockResolvedValue({ items: [], hasNextPage: false });
|
||||
await expect(sut.empty(authStub.user1)).resolves.toBeUndefined();
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('should empty the trash', async () => {
|
||||
assetMock.getByUserId.mockResolvedValue({ items: [assetStub.image], hasNextPage: false });
|
||||
await expect(sut.empty(authStub.user1)).resolves.toBeUndefined();
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{ name: JobName.ASSET_DELETION, data: { id: assetStub.image.id } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
65
server/src/domain/trash/trash.service.ts
Normal file
65
server/src/domain/trash/trash.service.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { Inject } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { AccessCore, Permission } from '../access';
|
||||
import { BulkIdsDto } from '../asset';
|
||||
import { AuthDto } from '../auth';
|
||||
import { usePagination } from '../domain.util';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
|
||||
import {
|
||||
ClientEvent,
|
||||
IAccessRepository,
|
||||
IAssetRepository,
|
||||
ICommunicationRepository,
|
||||
IJobRepository,
|
||||
} from '../repositories';
|
||||
|
||||
export class TrashService {
|
||||
private access: AccessCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IAccessRepository) accessRepository: IAccessRepository,
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
|
||||
) {
|
||||
this.access = AccessCore.create(accessRepository);
|
||||
}
|
||||
|
||||
async restoreAssets(auth: AuthDto, dto: BulkIdsDto): Promise<void> {
|
||||
const { ids } = dto;
|
||||
await this.access.requirePermission(auth, Permission.ASSET_RESTORE, ids);
|
||||
await this.restoreAndSend(auth, ids);
|
||||
}
|
||||
|
||||
async restore(auth: AuthDto): Promise<void> {
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
||||
this.assetRepository.getByUserId(pagination, auth.user.id, { trashedBefore: DateTime.now().toJSDate() }),
|
||||
);
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
const ids = assets.map((a) => a.id);
|
||||
await this.restoreAndSend(auth, ids);
|
||||
}
|
||||
}
|
||||
|
||||
async empty(auth: AuthDto): Promise<void> {
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
||||
this.assetRepository.getByUserId(pagination, auth.user.id, { trashedBefore: DateTime.now().toJSDate() }),
|
||||
);
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
await this.jobRepository.queueAll(
|
||||
assets.map((asset) => ({ name: JobName.ASSET_DELETION, data: { id: asset.id } })),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async restoreAndSend(auth: AuthDto, ids: string[]) {
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.assetRepository.restoreAll(ids);
|
||||
this.communicationRepository.send(ClientEvent.ASSET_RESTORE, auth.user.id, ids);
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ import {
|
|||
SharedLinkController,
|
||||
SystemConfigController,
|
||||
TagController,
|
||||
TrashController,
|
||||
UserController,
|
||||
} from './controllers';
|
||||
import { ErrorInterceptor, FileUploadInterceptor } from './interceptors';
|
||||
|
|
@ -64,6 +65,7 @@ import { ErrorInterceptor, FileUploadInterceptor } from './interceptors';
|
|||
SharedLinkController,
|
||||
SystemConfigController,
|
||||
TagController,
|
||||
TrashController,
|
||||
UserController,
|
||||
PersonController,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import {
|
|||
TimeBucketAssetDto,
|
||||
TimeBucketDto,
|
||||
TimeBucketResponseDto,
|
||||
TrashAction,
|
||||
TrashService,
|
||||
UpdateAssetDto as UpdateDto,
|
||||
UpdateStackParentDto,
|
||||
} from '@app/domain';
|
||||
|
|
@ -69,6 +69,7 @@ export class AssetController {
|
|||
constructor(
|
||||
private service: AssetService,
|
||||
private downloadService: DownloadService,
|
||||
private trashService: TrashService,
|
||||
) {}
|
||||
|
||||
@Get('map-marker')
|
||||
|
|
@ -165,22 +166,31 @@ export class AssetController {
|
|||
return this.service.deleteAll(auth, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use `POST /trash/restore/assets`
|
||||
*/
|
||||
@Post('restore')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
restoreAssets(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<void> {
|
||||
return this.service.restoreAll(auth, dto);
|
||||
restoreAssetsOld(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<void> {
|
||||
return this.trashService.restoreAssets(auth, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use `POST /trash/empty`
|
||||
*/
|
||||
@Post('trash/empty')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
emptyTrash(@Auth() auth: AuthDto): Promise<void> {
|
||||
return this.service.handleTrashAction(auth, TrashAction.EMPTY_ALL);
|
||||
emptyTrashOld(@Auth() auth: AuthDto): Promise<void> {
|
||||
return this.trashService.empty(auth);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use `POST /trash/restore`
|
||||
*/
|
||||
@Post('trash/restore')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
restoreTrash(@Auth() auth: AuthDto): Promise<void> {
|
||||
return this.service.handleTrashAction(auth, TrashAction.RESTORE_ALL);
|
||||
restoreTrashOld(@Auth() auth: AuthDto): Promise<void> {
|
||||
return this.trashService.restore(auth);
|
||||
}
|
||||
|
||||
@Put('stack/parent')
|
||||
|
|
|
|||
|
|
@ -17,4 +17,5 @@ export * from './server-info.controller';
|
|||
export * from './shared-link.controller';
|
||||
export * from './system-config.controller';
|
||||
export * from './tag.controller';
|
||||
export * from './trash.controller';
|
||||
export * from './user.controller';
|
||||
|
|
|
|||
31
server/src/immich/controllers/trash.controller.ts
Normal file
31
server/src/immich/controllers/trash.controller.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { AuthDto, BulkIdsDto, TrashService } from '@app/domain';
|
||||
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Auth, Authenticated } from '../app.guard';
|
||||
import { UseValidation } from '../app.utils';
|
||||
|
||||
@ApiTags('Trash')
|
||||
@Controller('trash')
|
||||
@Authenticated()
|
||||
@UseValidation()
|
||||
export class TrashController {
|
||||
constructor(private service: TrashService) {}
|
||||
|
||||
@Post('empty')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
emptyTrash(@Auth() auth: AuthDto): Promise<void> {
|
||||
return this.service.empty(auth);
|
||||
}
|
||||
|
||||
@Post('restore')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
restoreTrash(@Auth() auth: AuthDto): Promise<void> {
|
||||
return this.service.restore(auth);
|
||||
}
|
||||
|
||||
@Post('restore/assets')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
restoreAssets(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<void> {
|
||||
return this.service.restoreAssets(auth, dto);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue