mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat: Mark people as favorite (#14866)
* feat: added ability to mark people as favorite, which get sorted to the front of the people list * feat(server): added unit test for favorite people * feat(server): refactored for better readability * fixed person service unit tests * fixed open-api and sql checks * fixed bad codegen and removed unnecessary type assertion again * chore: clean up --------- Co-authored-by: Alex <alex.tran1502@gmail.com> Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
parent
69e88ef985
commit
7ec3610753
20 changed files with 355 additions and 28 deletions
17
server/src/db.d.ts
vendored
17
server/src/db.d.ts
vendored
|
|
@ -279,6 +279,7 @@ export interface Person {
|
|||
createdAt: Generated<Timestamp>;
|
||||
faceAssetId: string | null;
|
||||
id: Generated<string>;
|
||||
isFavorite: Generated<boolean>;
|
||||
isHidden: Generated<boolean>;
|
||||
name: Generated<string>;
|
||||
ownerId: string;
|
||||
|
|
@ -327,11 +328,6 @@ export interface SocketIoAttachments {
|
|||
payload: Buffer | null;
|
||||
}
|
||||
|
||||
export interface SystemConfig {
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
export interface SystemMetadata {
|
||||
key: string;
|
||||
value: Json;
|
||||
|
|
@ -357,6 +353,15 @@ export interface TagsClosure {
|
|||
id_descendant: string;
|
||||
}
|
||||
|
||||
export interface TypeormMetadata {
|
||||
database: string | null;
|
||||
name: string | null;
|
||||
schema: string | null;
|
||||
table: string | null;
|
||||
type: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
export interface UserMetadata {
|
||||
key: string;
|
||||
userId: string;
|
||||
|
|
@ -431,11 +436,11 @@ export interface DB {
|
|||
shared_links: SharedLinks;
|
||||
smart_search: SmartSearch;
|
||||
socket_io_attachments: SocketIoAttachments;
|
||||
system_config: SystemConfig;
|
||||
system_metadata: SystemMetadata;
|
||||
tag_asset: TagAsset;
|
||||
tags: Tags;
|
||||
tags_closure: TagsClosure;
|
||||
typeorm_metadata: TypeormMetadata;
|
||||
user_metadata: UserMetadata;
|
||||
users: Users;
|
||||
"vectors.pg_vector_index_stat": VectorsPgVectorIndexStat;
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ export class PersonCreateDto {
|
|||
*/
|
||||
@ValidateBoolean({ optional: true })
|
||||
isHidden?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
isFavorite?: boolean;
|
||||
}
|
||||
|
||||
export class PersonUpdateDto extends PersonCreateDto {
|
||||
|
|
@ -97,6 +100,8 @@ export class PersonResponseDto {
|
|||
isHidden!: boolean;
|
||||
@PropertyLifecycle({ addedAt: 'v1.107.0' })
|
||||
updatedAt?: Date;
|
||||
@PropertyLifecycle({ addedAt: 'v1.126.0' })
|
||||
isFavorite?: boolean;
|
||||
}
|
||||
|
||||
export class PersonWithFacesResponseDto extends PersonResponseDto {
|
||||
|
|
@ -170,6 +175,7 @@ export function mapPerson(person: PersonEntity): PersonResponseDto {
|
|||
birthDate: person.birthDate,
|
||||
thumbnailPath: person.thumbnailPath,
|
||||
isHidden: person.isHidden,
|
||||
isFavorite: person.isFavorite,
|
||||
updatedAt: person.updatedAt,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,4 +49,7 @@ export class PersonEntity {
|
|||
|
||||
@Column({ default: false })
|
||||
isHidden!: boolean;
|
||||
|
||||
@Column({ default: false })
|
||||
isFavorite!: boolean;
|
||||
}
|
||||
|
|
|
|||
14
server/src/migrations/1734879118272-AddIsFavoritePerson.ts
Normal file
14
server/src/migrations/1734879118272-AddIsFavoritePerson.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddIsFavoritePerson1734879118272 implements MigrationInterface {
|
||||
name = 'AddIsFavoritePerson1734879118272'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "person" ADD "isFavorite" boolean NOT NULL DEFAULT false`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "isFavorite"`);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -132,6 +132,7 @@ export class PersonRepository implements IPersonRepository {
|
|||
)
|
||||
.where('person.ownerId', '=', userId)
|
||||
.orderBy('person.isHidden', 'asc')
|
||||
.orderBy('person.isFavorite', 'desc')
|
||||
.having((eb) =>
|
||||
eb.or([
|
||||
eb('person.name', '!=', ''),
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ const responseDto: PersonResponseDto = {
|
|||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
isHidden: false,
|
||||
updatedAt: expect.any(Date),
|
||||
isFavorite: false,
|
||||
};
|
||||
|
||||
const statistics = { assets: 3 };
|
||||
|
|
@ -116,6 +117,7 @@ describe(PersonService.name, () => {
|
|||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
isHidden: true,
|
||||
isFavorite: false,
|
||||
updatedAt: expect.any(Date),
|
||||
},
|
||||
],
|
||||
|
|
@ -125,6 +127,35 @@ describe(PersonService.name, () => {
|
|||
withHidden: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should get all visible people and favorites should be first in the array', async () => {
|
||||
personMock.getAllForUser.mockResolvedValue({
|
||||
items: [personStub.isFavorite, personStub.withName],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 });
|
||||
await expect(sut.getAll(authStub.admin, { withHidden: false, page: 1, size: 10 })).resolves.toEqual({
|
||||
hasNextPage: false,
|
||||
total: 2,
|
||||
hidden: 1,
|
||||
people: [
|
||||
{
|
||||
id: 'person-4',
|
||||
name: personStub.isFavorite.name,
|
||||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
isHidden: false,
|
||||
isFavorite: true,
|
||||
updatedAt: expect.any(Date),
|
||||
},
|
||||
responseDto,
|
||||
],
|
||||
});
|
||||
expect(personMock.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, {
|
||||
minimumFaceCount: 3,
|
||||
withHidden: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
|
|
@ -227,6 +258,7 @@ describe(PersonService.name, () => {
|
|||
birthDate: '1976-06-30',
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
updatedAt: expect.any(Date),
|
||||
});
|
||||
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: '1976-06-30' });
|
||||
|
|
@ -245,6 +277,16 @@ describe(PersonService.name, () => {
|
|||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it('should update a person favorite status', async () => {
|
||||
personMock.update.mockResolvedValue(personStub.withName);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
|
||||
await expect(sut.update(authStub.admin, 'person-1', { isFavorite: true })).resolves.toEqual(responseDto);
|
||||
|
||||
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isFavorite: true });
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it("should update a person's thumbnailPath", async () => {
|
||||
personMock.update.mockResolvedValue(personStub.withName);
|
||||
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
|
||||
|
|
@ -375,6 +417,7 @@ describe(PersonService.name, () => {
|
|||
).resolves.toEqual({
|
||||
birthDate: personStub.noName.birthDate,
|
||||
isHidden: personStub.noName.isHidden,
|
||||
isFavorite: personStub.noName.isFavorite,
|
||||
id: personStub.noName.id,
|
||||
name: personStub.noName.name,
|
||||
thumbnailPath: personStub.noName.thumbnailPath,
|
||||
|
|
|
|||
|
|
@ -184,13 +184,14 @@ export class PersonService extends BaseService {
|
|||
name: dto.name,
|
||||
birthDate: dto.birthDate,
|
||||
isHidden: dto.isHidden,
|
||||
isFavorite: dto.isFavorite,
|
||||
});
|
||||
}
|
||||
|
||||
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [id] });
|
||||
|
||||
const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto;
|
||||
const { name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite } = dto;
|
||||
// TODO: set by faceId directly
|
||||
let faceId: string | undefined = undefined;
|
||||
if (assetId) {
|
||||
|
|
@ -203,7 +204,14 @@ export class PersonService extends BaseService {
|
|||
faceId = face.id;
|
||||
}
|
||||
|
||||
const person = await this.personRepository.update({ id, faceAssetId: faceId, name, birthDate, isHidden });
|
||||
const person = await this.personRepository.update({
|
||||
id,
|
||||
faceAssetId: faceId,
|
||||
name,
|
||||
birthDate,
|
||||
isHidden,
|
||||
isFavorite,
|
||||
});
|
||||
|
||||
if (assetId) {
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } });
|
||||
|
|
@ -221,6 +229,7 @@ export class PersonService extends BaseService {
|
|||
name: person.name,
|
||||
birthDate: person.birthDate,
|
||||
featureFaceAssetId: person.featureFaceAssetId,
|
||||
isFavorite: person.isFavorite,
|
||||
});
|
||||
results.push({ id: person.id, success: true });
|
||||
} catch (error: Error | any) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue