feat(api): set person color (#15937)

This commit is contained in:
Jason Rasmussen 2025-02-07 10:06:58 -05:00 committed by GitHub
parent 2e5007adef
commit 23014c263b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 182 additions and 21 deletions

1
server/src/db.d.ts vendored
View file

@ -276,6 +276,7 @@ export interface Partners {
export interface Person {
birthDate: Timestamp | null;
color: string | null;
createdAt: Generated<Timestamp>;
faceAssetId: string | null;
id: Generated<string>;

View file

@ -7,7 +7,14 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { SourceType } from 'src/enum';
import { IsDateStringFormat, MaxDateString, Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
import {
IsDateStringFormat,
MaxDateString,
Optional,
ValidateBoolean,
ValidateHexColor,
ValidateUUID,
} from 'src/validation';
export class PersonCreateDto {
/**
@ -35,6 +42,10 @@ export class PersonCreateDto {
@ValidateBoolean({ optional: true })
isFavorite?: boolean;
@Optional({ emptyToNull: true, nullable: true })
@ValidateHexColor()
color?: string | null;
}
export class PersonUpdateDto extends PersonCreateDto {
@ -102,6 +113,8 @@ export class PersonResponseDto {
updatedAt?: Date;
@PropertyLifecycle({ addedAt: 'v1.126.0' })
isFavorite?: boolean;
@PropertyLifecycle({ addedAt: 'v1.126.0' })
color?: string;
}
export class PersonWithFacesResponseDto extends PersonResponseDto {
@ -176,6 +189,7 @@ export function mapPerson(person: PersonEntity): PersonResponseDto {
thumbnailPath: person.thumbnailPath,
isHidden: person.isHidden,
isFavorite: person.isFavorite,
color: person.color ?? undefined,
updatedAt: person.updatedAt,
};
}

View file

@ -1,8 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsHexColor, IsNotEmpty, IsString } from 'class-validator';
import { TagEntity } from 'src/entities/tag.entity';
import { Optional, ValidateUUID } from 'src/validation';
import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation';
export class TagCreateDto {
@IsString()
@ -18,9 +17,8 @@ export class TagCreateDto {
}
export class TagUpdateDto {
@Optional({ nullable: true, emptyToNull: true })
@IsHexColor()
@Transform(({ value }) => (typeof value === 'string' && value[0] !== '#' ? `#${value}` : value))
@Optional({ emptyToNull: true, nullable: true })
@ValidateHexColor()
color?: string | null;
}

View file

@ -52,4 +52,7 @@ export class PersonEntity {
@Column({ default: false })
isFavorite!: boolean;
@Column({ type: 'varchar', nullable: true, default: null })
color?: string | null;
}

View file

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddPersonColor1738889177573 implements MigrationInterface {
name = 'AddPersonColor1738889177573'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "person" ADD "color" character varying`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "color"`);
}
}

View file

@ -355,7 +355,7 @@ describe(PersonService.name, () => {
sut.reassignFaces(authStub.admin, personStub.noName.id, {
data: [{ personId: personStub.withName.id, assetId: assetStub.image.id }],
}),
).resolves.toEqual([personStub.noName]);
).resolves.toBeDefined();
expect(jobMock.queueAll).toHaveBeenCalledWith([
{
@ -448,7 +448,7 @@ describe(PersonService.name, () => {
it('should create a new person', async () => {
personMock.create.mockResolvedValue(personStub.primaryPerson);
await expect(sut.create(authStub.admin, {})).resolves.toBe(personStub.primaryPerson);
await expect(sut.create(authStub.admin, {})).resolves.toBeDefined();
expect(personMock.create).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id });
});

View file

@ -104,7 +104,7 @@ export class PersonService extends BaseService {
await this.personRepository.reassignFace(face.id, personId);
}
result.push(person);
result.push(mapPerson(person));
}
if (changeFeaturePhoto.length > 0) {
// Remove duplicates
@ -178,20 +178,23 @@ export class PersonService extends BaseService {
});
}
create(auth: AuthDto, dto: PersonCreateDto): Promise<PersonResponseDto> {
return this.personRepository.create({
async create(auth: AuthDto, dto: PersonCreateDto): Promise<PersonResponseDto> {
const person = await this.personRepository.create({
ownerId: auth.user.id,
name: dto.name,
birthDate: dto.birthDate,
isHidden: dto.isHidden,
isFavorite: dto.isFavorite,
color: dto.color,
});
return mapPerson(person);
}
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, isFavorite } = dto;
const { name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite, color } = dto;
// TODO: set by faceId directly
let faceId: string | undefined = undefined;
if (assetId) {
@ -211,6 +214,7 @@ export class PersonService extends BaseService {
birthDate,
isHidden,
isFavorite,
color,
});
if (assetId) {

View file

@ -31,6 +31,8 @@ describe(SearchService.name, () => {
it('should pass options to search', async () => {
const { name } = personStub.withName;
personMock.getByName.mockResolvedValue([]);
await sut.searchPerson(authStub.user1, { name, withHidden: false });
expect(personMock.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: false });

View file

@ -1,8 +1,9 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { AssetMapOptions, AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { PersonResponseDto } from 'src/dtos/person.dto';
import { mapPerson, PersonResponseDto } from 'src/dtos/person.dto';
import {
mapPlaces,
MetadataSearchDto,
PlacesResponseDto,
RandomSearchDto,
@ -12,7 +13,6 @@ import {
SearchSuggestionRequestDto,
SearchSuggestionType,
SmartSearchDto,
mapPlaces,
} from 'src/dtos/search.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetOrder } from 'src/enum';
@ -24,7 +24,8 @@ import { isSmartSearchEnabled } from 'src/utils/misc';
@Injectable()
export class SearchService extends BaseService {
async searchPerson(auth: AuthDto, dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
return this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden });
const people = await this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden });
return people.map((person) => mapPerson(person));
}
async searchPlaces(dto: SearchPlacesDto): Promise<PlacesResponseDto[]> {

View file

@ -12,6 +12,7 @@ import {
IsArray,
IsBoolean,
IsDate,
IsHexColor,
IsNotEmpty,
IsOptional,
IsString,
@ -97,6 +98,15 @@ export function Optional({ nullable, emptyToNull, ...validationOptions }: Option
return applyDecorators(...decorators);
}
export const ValidateHexColor = () => {
const decorators = [
IsHexColor(),
Transform(({ value }) => (typeof value === 'string' && value[0] !== '#' ? `#${value}` : value)),
];
return applyDecorators(...decorators);
};
type UUIDOptions = { optional?: boolean; each?: boolean; nullable?: boolean };
export const ValidateUUID = (options?: UUIDOptions) => {
const { optional, each, nullable } = { optional: false, each: false, nullable: false, ...options };