refactor: split integrity out of maintenance controller/service

This commit is contained in:
izzy
2025-12-17 14:55:38 +00:00
parent 7d71f99783
commit 21c26dd65f
14 changed files with 283 additions and 244 deletions
+1
View File
@@ -146,6 +146,7 @@ export const endpointTags: Record<ApiTag, string> = {
[ApiTag.Duplicates]: 'Endpoints for managing and identifying duplicate assets.',
[ApiTag.Faces]:
'A face is a detected human face within an asset, which can be associated with a person. Faces are normally detected via machine learning, but can also be created via manually.',
[ApiTag.Integrity]: 'Endpoints for viewing and managing integrity reports.',
[ApiTag.Jobs]:
'Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed.',
[ApiTag.Libraries]:
+2
View File
@@ -9,6 +9,7 @@ import { AuthController } from 'src/controllers/auth.controller';
import { DownloadController } from 'src/controllers/download.controller';
import { DuplicateController } from 'src/controllers/duplicate.controller';
import { FaceController } from 'src/controllers/face.controller';
import { IntegrityController } from 'src/controllers/integrity.controller';
import { JobController } from 'src/controllers/job.controller';
import { LibraryController } from 'src/controllers/library.controller';
import { MaintenanceController } from 'src/controllers/maintenance.controller';
@@ -49,6 +50,7 @@ export const controllers = [
DownloadController,
DuplicateController,
FaceController,
IntegrityController,
JobController,
LibraryController,
MaintenanceController,
@@ -0,0 +1,90 @@
import { Body, Controller, Delete, Get, Next, Param, Post, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import {
MaintenanceGetIntegrityReportDto,
MaintenanceIntegrityReportResponseDto,
MaintenanceIntegrityReportSummaryResponseDto,
} from 'src/dtos/maintenance.dto';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { IntegrityService } from 'src/services/integrity.service';
import { sendFile } from 'src/utils/file';
import { IntegrityReportTypeParamDto, UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.Maintenance)
@Controller('admin/integrity')
export class IntegrityController {
constructor(
private logger: LoggingRepository,
private service: IntegrityService,
) {}
@Get('summary')
@Endpoint({
summary: 'Get integrity report summary',
description: '...',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
})
@Authenticated({ permission: Permission.Maintenance, admin: true })
getIntegrityReportSummary(): Promise<MaintenanceIntegrityReportSummaryResponseDto> {
return this.service.getIntegrityReportSummary();
}
@Post('report')
@Endpoint({
summary: 'Get integrity report by type',
description: '...',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
})
@Authenticated({ permission: Permission.Maintenance, admin: true })
getIntegrityReport(@Body() dto: MaintenanceGetIntegrityReportDto): Promise<MaintenanceIntegrityReportResponseDto> {
return this.service.getIntegrityReport(dto);
}
@Delete('report/:id')
@Endpoint({
summary: 'Delete report entry and perform corresponding deletion action',
description: '...',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
})
@Authenticated({ permission: Permission.Maintenance, admin: true })
async deleteIntegrityReport(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
await this.service.deleteIntegrityReport(auth, id);
}
@Get('report/:type/csv')
@Endpoint({
summary: 'Export integrity report by type as CSV',
description: '...',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
})
@FileResponse()
@Authenticated({ permission: Permission.Maintenance, admin: true })
getIntegrityReportCsv(@Param() { type }: IntegrityReportTypeParamDto, @Res() res: Response): void {
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Cache-Control', 'private, no-cache, no-transform');
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(`${Date.now()}-${type}.csv`)}"`);
this.service.getIntegrityReportCsv(type).pipe(res);
}
@Get('report/:id/file')
@Endpoint({
summary: 'Download the orphan/broken file if one exists',
description: '...',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
})
@FileResponse()
@Authenticated({ permission: Permission.Maintenance, admin: true })
async getIntegrityReportFile(
@Param() { id }: UUIDParamDto,
@Res() res: Response,
@Next() next: NextFunction,
): Promise<void> {
await sendFile(res, next, () => this.service.getIntegrityReportFile(id), this.logger);
}
}
@@ -1,32 +1,19 @@
import { BadRequestException, Body, Controller, Delete, Get, Next, Param, Post, Res } from '@nestjs/common';
import { BadRequestException, Body, Controller, Post, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import {
MaintenanceAuthDto,
MaintenanceGetIntegrityReportDto,
MaintenanceIntegrityReportResponseDto,
MaintenanceIntegrityReportSummaryResponseDto,
MaintenanceLoginDto,
SetMaintenanceModeDto,
} from 'src/dtos/maintenance.dto';
import { MaintenanceAuthDto, MaintenanceLoginDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum';
import { Auth, Authenticated, FileResponse, GetLoginDetails } from 'src/middleware/auth.guard';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { LoginDetails } from 'src/services/auth.service';
import { MaintenanceService } from 'src/services/maintenance.service';
import { sendFile } from 'src/utils/file';
import { respondWithCookie } from 'src/utils/response';
import { IntegrityReportTypeParamDto, UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.Maintenance)
@Controller('admin/maintenance')
export class MaintenanceController {
constructor(
private logger: LoggingRepository,
private service: MaintenanceService,
) {}
constructor(private service: MaintenanceService) {}
@Post('login')
@Endpoint({
@@ -59,69 +46,4 @@ export class MaintenanceController {
});
}
}
@Get('integrity/summary')
@Endpoint({
summary: 'Get integrity report summary',
description: '...',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
})
@Authenticated({ permission: Permission.Maintenance, admin: true })
getIntegrityReportSummary(): Promise<MaintenanceIntegrityReportSummaryResponseDto> {
return this.service.getIntegrityReportSummary();
}
@Post('integrity/report')
@Endpoint({
summary: 'Get integrity report by type',
description: '...',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
})
@Authenticated({ permission: Permission.Maintenance, admin: true })
getIntegrityReport(@Body() dto: MaintenanceGetIntegrityReportDto): Promise<MaintenanceIntegrityReportResponseDto> {
return this.service.getIntegrityReport(dto);
}
@Delete('integrity/report/:id')
@Endpoint({
summary: 'Delete report entry and perform corresponding deletion action',
description: '...',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
})
@Authenticated({ permission: Permission.Maintenance, admin: true })
async deleteIntegrityReport(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
await this.service.deleteIntegrityReport(auth, id);
}
@Get('integrity/report/:type/csv')
@Endpoint({
summary: 'Export integrity report by type as CSV',
description: '...',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
})
@FileResponse()
@Authenticated({ permission: Permission.Maintenance, admin: true })
getIntegrityReportCsv(@Param() { type }: IntegrityReportTypeParamDto, @Res() res: Response): void {
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Cache-Control', 'private, no-cache, no-transform');
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(`${Date.now()}-${type}.csv`)}"`);
this.service.getIntegrityReportCsv(type).pipe(res);
}
@Get('integrity/report/:id/file')
@Endpoint({
summary: 'Download the orphan/broken file if one exists',
description: '...',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
})
@FileResponse()
@Authenticated({ permission: Permission.Maintenance, admin: true })
async getIntegrityReportFile(
@Param() { id }: UUIDParamDto,
@Res() res: Response,
@Next() next: NextFunction,
): Promise<void> {
await sendFile(res, next, () => this.service.getIntegrityReportFile(id), this.logger);
}
}
+1
View File
@@ -863,6 +863,7 @@ export enum ApiTag {
Download = 'Download',
Duplicates = 'Duplicates',
Faces = 'Faces',
Integrity = 'Integrity (admin)',
Jobs = 'Jobs',
Libraries = 'Libraries',
Maintenance = 'Maintenance (admin)',
@@ -0,0 +1,22 @@
import { IntegrityService } from 'src/services/integrity.service';
import { newTestService, ServiceMocks } from 'test/utils';
describe(IntegrityService.name, () => {
let sut: IntegrityService;
let mocks: ServiceMocks;
beforeEach(() => {
({ sut, mocks } = newTestService(IntegrityService));
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe.skip('getIntegrityReportSummary'); // just calls repository
describe.skip('getIntegrityReport'); // just calls repository
describe.skip('getIntegrityReportCsv'); // just calls repository
describe.todo('getIntegrityReportFile');
describe.todo('deleteIntegrityReport');
});
+58 -1
View File
@@ -2,13 +2,21 @@ import { Injectable } from '@nestjs/common';
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { Writable } from 'node:stream';
import { basename } from 'node:path';
import { Readable, Writable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent, OnJob } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import {
MaintenanceGetIntegrityReportDto,
MaintenanceIntegrityReportResponseDto,
MaintenanceIntegrityReportSummaryResponseDto,
} from 'src/dtos/maintenance.dto';
import {
AssetStatus,
CacheControl,
DatabaseLock,
ImmichWorker,
IntegrityReportType,
@@ -28,6 +36,7 @@ import {
IIntegrityPathWithChecksumJob,
IIntegrityPathWithReportJob,
} from 'src/types';
import { ImmichFileResponse } from 'src/utils/file';
import { handlePromiseError } from 'src/utils/misc';
/**
@@ -145,6 +154,54 @@ export class IntegrityService extends BaseService {
});
}
getIntegrityReportSummary(): Promise<MaintenanceIntegrityReportSummaryResponseDto> {
return this.integrityRepository.getIntegrityReportSummary();
}
async getIntegrityReport(dto: MaintenanceGetIntegrityReportDto): Promise<MaintenanceIntegrityReportResponseDto> {
return {
items: await this.integrityRepository.getIntegrityReports(dto.type),
};
}
getIntegrityReportCsv(type: IntegrityReportType): Readable {
return this.integrityRepository.streamIntegrityReportsCSV(type);
}
async getIntegrityReportFile(id: string): Promise<ImmichFileResponse> {
const { path } = await this.integrityRepository.getById(id);
return new ImmichFileResponse({
path,
fileName: basename(path),
contentType: 'application/octet-stream',
cacheControl: CacheControl.PrivateWithoutCache,
});
}
async deleteIntegrityReport(auth: AuthDto, id: string): Promise<void> {
const { path, assetId, fileAssetId } = await this.integrityRepository.getById(id);
if (assetId) {
await this.assetRepository.updateAll([assetId], {
deletedAt: new Date(),
status: AssetStatus.Trashed,
});
await this.eventRepository.emit('AssetTrashAll', {
assetIds: [assetId],
userId: auth.user.id,
});
await this.integrityRepository.deleteById(id);
} else if (fileAssetId) {
await this.assetRepository.deleteFiles([{ id: fileAssetId }]);
} else {
await this.storageRepository.unlink(path);
await this.integrityRepository.deleteById(id);
}
}
@OnJob({ name: JobName.IntegrityOrphanedFilesQueueAll, queue: QueueName.IntegrityCheck })
async handleOrphanedFilesQueueAll({ refreshOnly }: IIntegrityJob = {}): Promise<JobStatus> {
this.logger.log(`Checking for out of date orphaned file reports...`);
@@ -106,7 +106,4 @@ describe(MaintenanceService.name, () => {
expect(mocks.systemMetadata.get).toHaveBeenCalledTimes(1);
});
});
describe.skip('getIntegrityReportSummary'); // just calls repository
describe.skip('getIntegrityReport'); // just calls repository
});
+2 -59
View File
@@ -1,18 +1,9 @@
import { Injectable } from '@nestjs/common';
import { basename } from 'node:path';
import { Readable } from 'node:stream';
import { OnEvent } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import {
MaintenanceAuthDto,
MaintenanceGetIntegrityReportDto,
MaintenanceIntegrityReportResponseDto,
MaintenanceIntegrityReportSummaryResponseDto,
} from 'src/dtos/maintenance.dto';
import { AssetStatus, CacheControl, IntegrityReportType, SystemMetadataKey } from 'src/enum';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { MaintenanceModeState } from 'src/types';
import { ImmichFileResponse } from 'src/utils/file';
import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc';
@@ -59,52 +50,4 @@ export class MaintenanceService extends BaseService {
return await createMaintenanceLoginUrl(baseUrl, auth, secret);
}
getIntegrityReportSummary(): Promise<MaintenanceIntegrityReportSummaryResponseDto> {
return this.integrityRepository.getIntegrityReportSummary();
}
async getIntegrityReport(dto: MaintenanceGetIntegrityReportDto): Promise<MaintenanceIntegrityReportResponseDto> {
return {
items: await this.integrityRepository.getIntegrityReports(dto.type),
};
}
getIntegrityReportCsv(type: IntegrityReportType): Readable {
return this.integrityRepository.streamIntegrityReportsCSV(type);
}
async getIntegrityReportFile(id: string): Promise<ImmichFileResponse> {
const { path } = await this.integrityRepository.getById(id);
return new ImmichFileResponse({
path,
fileName: basename(path),
contentType: 'application/octet-stream',
cacheControl: CacheControl.PrivateWithoutCache,
});
}
async deleteIntegrityReport(auth: AuthDto, id: string): Promise<void> {
const { path, assetId, fileAssetId } = await this.integrityRepository.getById(id);
if (assetId) {
await this.assetRepository.updateAll([assetId], {
deletedAt: new Date(),
status: AssetStatus.Trashed,
});
await this.eventRepository.emit('AssetTrashAll', {
assetIds: [assetId],
userId: auth.user.id,
});
await this.integrityRepository.deleteById(id);
} else if (fileAssetId) {
await this.assetRepository.deleteFiles([{ id: fileAssetId }]);
} else {
await this.storageRepository.unlink(path);
await this.integrityRepository.deleteById(id);
}
}
}