1
0
mirror of https://github.com/immich-app/immich.git synced 2025-07-03 05:46:58 +02:00

feat(server): person delete (#19511)

feat(api): person delete
This commit is contained in:
Jason Rasmussen
2025-06-25 11:12:36 -04:00
committed by GitHub
parent 5b0575b956
commit eca9b56847
14 changed files with 380 additions and 60 deletions

View File

@ -169,6 +169,8 @@ Class | Method | HTTP request | Description
*PartnersApi* | [**removePartner**](doc//PartnersApi.md#removepartner) | **DELETE** /partners/{id} |
*PartnersApi* | [**updatePartner**](doc//PartnersApi.md#updatepartner) | **PUT** /partners/{id} |
*PeopleApi* | [**createPerson**](doc//PeopleApi.md#createperson) | **POST** /people |
*PeopleApi* | [**deletePeople**](doc//PeopleApi.md#deletepeople) | **DELETE** /people |
*PeopleApi* | [**deletePerson**](doc//PeopleApi.md#deleteperson) | **DELETE** /people/{id} |
*PeopleApi* | [**getAllPeople**](doc//PeopleApi.md#getallpeople) | **GET** /people |
*PeopleApi* | [**getPerson**](doc//PeopleApi.md#getperson) | **GET** /people/{id} |
*PeopleApi* | [**getPersonStatistics**](doc//PeopleApi.md#getpersonstatistics) | **GET** /people/{id}/statistics |

View File

@ -63,6 +63,85 @@ class PeopleApi {
return null;
}
/// Performs an HTTP 'DELETE /people' operation and returns the [Response].
/// Parameters:
///
/// * [BulkIdsDto] bulkIdsDto (required):
Future<Response> deletePeopleWithHttpInfo(BulkIdsDto bulkIdsDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/people';
// ignore: prefer_final_locals
Object? postBody = bulkIdsDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [BulkIdsDto] bulkIdsDto (required):
Future<void> deletePeople(BulkIdsDto bulkIdsDto,) async {
final response = await deletePeopleWithHttpInfo(bulkIdsDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Performs an HTTP 'DELETE /people/{id}' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
Future<Response> deletePersonWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final apiPath = r'/people/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
Future<void> deletePerson(String id,) async {
final response = await deletePersonWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Performs an HTTP 'GET /people' operation and returns the [Response].
/// Parameters:
///

View File

@ -4546,6 +4546,39 @@
}
},
"/people": {
"delete": {
"operationId": "deletePeople",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BulkIdsDto"
}
}
},
"required": true
},
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"People"
]
},
"get": {
"operationId": "getAllPeople",
"parameters": [
@ -4711,6 +4744,39 @@
}
},
"/people/{id}": {
"delete": {
"operationId": "deletePerson",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"People"
]
},
"get": {
"operationId": "getPerson",
"parameters": [

View File

@ -2769,6 +2769,15 @@ export function updatePartner({ id, updatePartnerDto }: {
body: updatePartnerDto
})));
}
export function deletePeople({ bulkIdsDto }: {
bulkIdsDto: BulkIdsDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/people", oazapfts.json({
...opts,
method: "DELETE",
body: bulkIdsDto
})));
}
export function getAllPeople({ closestAssetId, closestPersonId, page, size, withHidden }: {
closestAssetId?: string;
closestPersonId?: string;
@ -2813,6 +2822,14 @@ export function updatePeople({ peopleUpdateDto }: {
body: peopleUpdateDto
})));
}
export function deletePerson({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/people/${encodeURIComponent(id)}`, {
...opts,
method: "DELETE"
}));
}
export function getPerson({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {

View File

@ -60,6 +60,29 @@ describe(PersonController.name, () => {
});
});
describe('DELETE /people', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).delete('/people');
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require uuids in the body', async () => {
const { status, body } = await request(ctx.getHttpServer())
.delete('/people')
.send({ ids: ['invalid'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
});
it('should respond with 204', async () => {
const { status } = await request(ctx.getHttpServer())
.delete(`/people`)
.send({ ids: [factory.uuid()] });
expect(status).toBe(204);
expect(service.deleteAll).toHaveBeenCalled();
});
});
describe('GET /people/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/people/${factory.uuid()}`);
@ -156,6 +179,25 @@ describe(PersonController.name, () => {
});
});
describe('DELETE /people/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).delete(`/people/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/people/invalid`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
});
it('should respond with 204', async () => {
const { status } = await request(ctx.getHttpServer()).delete(`/people/${factory.uuid()}`);
expect(status).toBe(204);
expect(service.delete).toHaveBeenCalled();
});
});
describe('POST /people/:id/merge', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post(`/people/${factory.uuid()}/merge`);

View File

@ -1,7 +1,20 @@
import { Body, Controller, Get, Next, Param, Post, Put, Query, Res } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Next,
Param,
Post,
Put,
Query,
Res,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
AssetFaceUpdateDto,
@ -49,6 +62,13 @@ export class PersonController {
return this.service.updateAll(auth, dto);
}
@Delete()
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.PERSON_DELETE })
deletePeople(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<void> {
return this.service.deleteAll(auth, dto);
}
@Get(':id')
@Authenticated({ permission: Permission.PERSON_READ })
getPerson(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PersonResponseDto> {
@ -65,6 +85,13 @@ export class PersonController {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.PERSON_DELETE })
deletePerson(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
@Get(':id/statistics')
@Authenticated({ permission: Permission.PERSON_STATISTICS })
getPersonStatistics(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PersonStatisticsResponseDto> {

View File

@ -328,3 +328,12 @@ set
"deletedAt" = $1
where
"asset_faces"."id" = $2
-- PersonRepository.getForPeopleDelete
select
"id",
"thumbnailPath"
from
"person"
where
"id" in ($1)

View File

@ -3,7 +3,7 @@ import { ExpressionBuilder, Insertable, Kysely, Selectable, sql, Updateable } fr
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { AssetFaces, DB, FaceSearch, Person } from 'src/db';
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AssetFileType, AssetVisibility, SourceType } from 'src/enum';
import { removeUndefinedKeys } from 'src/utils/database';
import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
@ -102,6 +102,7 @@ export class PersonRepository {
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@Chunked()
async delete(ids: string[]): Promise<void> {
if (ids.length === 0) {
return;
@ -517,4 +518,13 @@ export class PersonRepository {
await sql`REINDEX TABLE face_search`.execute(this.db);
}
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@Chunked()
getForPeopleDelete(ids: string[]) {
if (ids.length === 0) {
return Promise.resolve([]);
}
return this.db.selectFrom('person').select(['id', 'thumbnailPath']).where('id', 'in', ids).execute();
}
}

View File

@ -4,7 +4,7 @@ import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { Person } from 'src/database';
import { AssetFaces, FaceSearch } from 'src/db';
import { Chunked, OnJob } from 'src/decorators';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
AssetFaceCreateDto,
@ -216,6 +216,10 @@ export class PersonService extends BaseService {
return mapPerson(person);
}
delete(auth: AuthDto, id: string): Promise<void> {
return this.deleteAll(auth, { ids: [id] });
}
async updateAll(auth: AuthDto, dto: PeopleUpdateDto): Promise<BulkIdResponseDto[]> {
const results: BulkIdResponseDto[] = [];
for (const person of dto.people) {
@ -236,8 +240,14 @@ export class PersonService extends BaseService {
return results;
}
async deleteAll(auth: AuthDto, { ids }: BulkIdsDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.PERSON_DELETE, ids });
const people = await this.personRepository.getForPeopleDelete(ids);
await this.removeAllPeople(people);
}
@Chunked()
private async delete(people: { id: string; thumbnailPath: string }[]) {
private async removeAllPeople(people: { id: string; thumbnailPath: string }[]) {
await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath)));
await this.personRepository.delete(people.map((person) => person.id));
this.logger.debug(`Deleted ${people.length} people`);
@ -246,7 +256,7 @@ export class PersonService extends BaseService {
@OnJob({ name: JobName.PERSON_CLEANUP, queue: QueueName.BACKGROUND_TASK })
async handlePersonCleanup(): Promise<JobStatus> {
const people = await this.personRepository.getAllWithoutFaces();
await this.delete(people);
await this.removeAllPeople(people);
return JobStatus.SUCCESS;
}
@ -589,7 +599,7 @@ export class PersonService extends BaseService {
this.logger.log(`Merging ${mergeName} into ${primaryName}`);
await this.personRepository.reassignFaces(mergeData);
await this.delete([mergePerson]);
await this.removeAllPeople([mergePerson]);
this.logger.log(`Merged ${mergeName} into ${primaryName}`);
results.push({ id: mergeId, success: true });

View File

@ -256,22 +256,17 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
return access.memory.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_READ: {
return await access.person.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_UPDATE: {
return await access.person.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_MERGE: {
return await access.person.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_CREATE: {
return access.person.checkFaceOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_READ:
case Permission.PERSON_UPDATE:
case Permission.PERSON_DELETE:
case Permission.PERSON_MERGE: {
return await access.person.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_REASSIGN: {
return access.person.checkFaceOwnerAccess(auth.user.id, ids);
}

View File

@ -7,6 +7,7 @@ import { AssetFace } from 'src/database';
import { Albums, AssetJobStatus, Assets, DB, FaceSearch, Person, Sessions } from 'src/db';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetType, AssetVisibility, SourceType, SyncRequestType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
@ -24,6 +25,7 @@ import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { SearchRepository } from 'src/repositories/search.repository';
import { SessionRepository } from 'src/repositories/session.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SyncRepository } from 'src/repositories/sync.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { UserRepository } from 'src/repositories/user.repository';
@ -40,6 +42,7 @@ const sha256 = (value: string) => createHash('sha256').update(value).digest('bas
// type Repositories = Omit<ServiceOverrides, 'access' | 'telemetry'>;
type RepositoriesTypes = {
access: AccessRepository;
activity: ActivityRepository;
album: AlbumRepository;
albumUser: AlbumUserRepository;
@ -58,6 +61,7 @@ type RepositoriesTypes = {
person: PersonRepository;
search: SearchRepository;
session: SessionRepository;
storage: StorageRepository;
sync: SyncRepository;
systemMetadata: SystemMetadataRepository;
versionHistory: VersionHistoryRepository;
@ -180,6 +184,10 @@ export const newMediumService = <R extends RepositoryOptions, S extends BaseServ
export const getRepository = <K extends keyof RepositoriesTypes>(key: K, db: Kysely<DB>) => {
switch (key) {
case 'access': {
return new AccessRepository(db);
}
case 'activity': {
return new ActivityRepository(db);
}
@ -352,6 +360,10 @@ const getRepositoryMock = <K extends keyof RepositoryMocks>(key: K) => {
return automock(SessionRepository);
}
case 'storage': {
return automock(StorageRepository, { args: [{ setContext: () => {} }] });
}
case 'sync': {
return automock(SyncRepository);
}
@ -411,7 +423,7 @@ export const asDeps = (repositories: ServiceOverrides) => {
repositories.session || getRepositoryMock('session'),
repositories.sharedLink,
repositories.stack,
repositories.storage,
repositories.storage || getRepositoryMock('storage'),
repositories.sync || getRepositoryMock('sync'),
repositories.systemMetadata || getRepositoryMock('systemMetadata'),
repositories.tag,

View File

@ -0,0 +1,90 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { PersonService } from 'src/services/person.service';
import { mediumFactory, newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
describe.concurrent(PersonService.name, () => {
let defaultDatabase: Kysely<DB>;
const createSut = (db?: Kysely<DB>) => {
return newMediumService(PersonService, {
database: db || defaultDatabase,
repos: {
access: 'real',
database: 'real',
person: 'real',
storage: 'mock',
},
});
};
beforeEach(async () => {
defaultDatabase = await getKyselyDB();
});
describe('delete', () => {
it('should throw an error when there is no access', async () => {
const { sut } = createSut();
const auth = factory.auth();
const personId = factory.uuid();
await expect(sut.delete(auth, personId)).rejects.toThrow('Not found or no person.delete access');
});
it('should delete the person', async () => {
const { sut, getRepository, mocks } = createSut();
const user = mediumFactory.userInsert();
const auth = factory.auth({ user });
const person = mediumFactory.personInsert({ ownerId: auth.user.id });
mocks.storage.unlink.mockResolvedValue();
const userRepo = getRepository('user');
await userRepo.create(user);
const personRepo = getRepository('person');
await personRepo.create(person);
await expect(personRepo.getById(person.id)).resolves.toEqual(expect.objectContaining({ id: person.id }));
await expect(sut.delete(auth, person.id)).resolves.toBeUndefined();
await expect(personRepo.getById(person.id)).resolves.toBeUndefined();
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
});
});
describe('deleteAll', () => {
it('should throw an error when there is no access', async () => {
const { sut } = createSut();
const auth = factory.auth();
const personId = factory.uuid();
await expect(sut.deleteAll(auth, { ids: [personId] })).rejects.toThrow('Not found or no person.delete access');
});
it('should delete the person', async () => {
const { sut, getRepository, mocks } = createSut();
const user = mediumFactory.userInsert();
const auth = factory.auth({ user });
const person1 = mediumFactory.personInsert({ ownerId: auth.user.id });
const person2 = mediumFactory.personInsert({ ownerId: auth.user.id });
mocks.storage.unlink.mockResolvedValue();
const userRepo = getRepository('user');
await userRepo.create(user);
const personRepo = getRepository('person');
await personRepo.create(person1);
await personRepo.create(person2);
await expect(sut.deleteAll(auth, { ids: [person1.id, person2.id] })).resolves.toBeUndefined();
await expect(personRepo.getById(person1.id)).resolves.toBeUndefined();
await expect(personRepo.getById(person2.id)).resolves.toBeUndefined();
expect(mocks.storage.unlink).toHaveBeenCalledTimes(2);
expect(mocks.storage.unlink).toHaveBeenCalledWith(person1.thumbnailPath);
expect(mocks.storage.unlink).toHaveBeenCalledWith(person2.thumbnailPath);
});
});
});

View File

@ -1,38 +0,0 @@
import { PersonRepository } from 'src/repositories/person.repository';
import { RepositoryInterface } from 'src/types';
import { Mocked, vitest } from 'vitest';
export const newPersonRepositoryMock = (): Mocked<RepositoryInterface<PersonRepository>> => {
return {
reassignFaces: vitest.fn(),
unassignFaces: vitest.fn(),
delete: vitest.fn(),
deleteFaces: vitest.fn(),
getAllFaces: vitest.fn(),
getAll: vitest.fn(),
getAllForUser: vitest.fn(),
getAllWithoutFaces: vitest.fn(),
getFaces: vitest.fn(),
getFaceById: vitest.fn(),
getFaceForFacialRecognitionJob: vitest.fn(),
getDataForThumbnailGenerationJob: vitest.fn(),
reassignFace: vitest.fn(),
getById: vitest.fn(),
getByName: vitest.fn(),
getDistinctNames: vitest.fn(),
getStatistics: vitest.fn(),
getNumberOfPeople: vitest.fn(),
create: vitest.fn(),
createAll: vitest.fn(),
refreshFaces: vitest.fn(),
update: vitest.fn(),
updateAll: vitest.fn(),
getFacesByIds: vitest.fn(),
getRandomFace: vitest.fn(),
getLatestFaceDate: vitest.fn(),
createAssetFace: vitest.fn(),
deleteAssetFace: vitest.fn(),
softDeleteAssetFaces: vitest.fn(),
vacuum: vitest.fn(),
};
};

View File

@ -67,7 +67,6 @@ import { newDatabaseRepositoryMock } from 'test/repositories/database.repository
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock';
import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { ITelemetryRepositoryMock, newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
@ -278,7 +277,7 @@ export const newTestService = <T extends BaseService>(
notification: automock(NotificationRepository),
oauth: automock(OAuthRepository, { args: [loggerMock] }),
partner: automock(PartnerRepository, { strict: false }),
person: newPersonRepositoryMock(),
person: automock(PersonRepository, { strict: false }),
process: automock(ProcessRepository),
search: automock(SearchRepository, { strict: false }),
// eslint-disable-next-line no-sparse-arrays