refactor(server): trash endpoints (#6652)

* refactor(server): trash endpoints

* chore: open api

* chore: fix wrong rename
This commit is contained in:
Jason Rasmussen 2024-01-26 11:48:37 -05:00 committed by GitHub
parent 33757689fe
commit 96b7885583
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 967 additions and 137 deletions

View file

@ -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)

View file

@ -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);

View file

@ -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()

View file

@ -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,
];

View file

@ -26,4 +26,5 @@ export * from './storage';
export * from './storage-template';
export * from './system-config';
export * from './tag';
export * from './trash';
export * from './user';

View file

@ -0,0 +1 @@
export * from './trash.service';

View 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 } },
]);
});
});
});

View 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);
}
}

View file

@ -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,
],

View file

@ -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')

View file

@ -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';

View 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);
}
}