diff --git a/e2e/src/api/specs/person.e2e-spec.ts b/e2e/src/api/specs/person.e2e-spec.ts index f3e0530696d6f..1a589da1f7657 100644 --- a/e2e/src/api/specs/person.e2e-spec.ts +++ b/e2e/src/api/specs/person.e2e-spec.ts @@ -195,6 +195,7 @@ describe('/people', () => { .send({ name: 'New Person', birthDate: '1990-01-01', + color: '#333', }); expect(status).toBe(201); expect(body).toMatchObject({ @@ -273,6 +274,24 @@ describe('/people', () => { expect(body).toMatchObject({ birthDate: null }); }); + it('should set a color', async () => { + const { status, body } = await request(app) + .put(`/people/${visiblePerson.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ color: '#555' }); + expect(status).toBe(200); + expect(body).toMatchObject({ color: '#555' }); + }); + + it('should clear a color', async () => { + const { status, body } = await request(app) + .put(`/people/${visiblePerson.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ color: null }); + expect(status).toBe(200); + expect(body.color).toBeUndefined(); + }); + it('should mark a person as favorite', async () => { const person = await utils.createPerson(admin.accessToken, { name: 'visible_person', diff --git a/mobile/openapi/lib/model/people_update_item.dart b/mobile/openapi/lib/model/people_update_item.dart index 6f8e312959ac3..ce324b859eb79 100644 --- a/mobile/openapi/lib/model/people_update_item.dart +++ b/mobile/openapi/lib/model/people_update_item.dart @@ -14,6 +14,7 @@ class PeopleUpdateItem { /// Returns a new [PeopleUpdateItem] instance. PeopleUpdateItem({ this.birthDate, + this.color, this.featureFaceAssetId, required this.id, this.isFavorite, @@ -24,6 +25,8 @@ class PeopleUpdateItem { /// Person date of birth. Note: the mobile app cannot currently set the birth date to null. DateTime? birthDate; + String? color; + /// Asset is used to get the feature face thumbnail. /// /// Please note: This property should have been non-nullable! Since the specification file @@ -65,6 +68,7 @@ class PeopleUpdateItem { @override bool operator ==(Object other) => identical(this, other) || other is PeopleUpdateItem && other.birthDate == birthDate && + other.color == color && other.featureFaceAssetId == featureFaceAssetId && other.id == id && other.isFavorite == isFavorite && @@ -75,6 +79,7 @@ class PeopleUpdateItem { int get hashCode => // ignore: unnecessary_parenthesis (birthDate == null ? 0 : birthDate!.hashCode) + + (color == null ? 0 : color!.hashCode) + (featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) + (id.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) + @@ -82,7 +87,7 @@ class PeopleUpdateItem { (name == null ? 0 : name!.hashCode); @override - String toString() => 'PeopleUpdateItem[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]'; + String toString() => 'PeopleUpdateItem[birthDate=$birthDate, color=$color, featureFaceAssetId=$featureFaceAssetId, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]'; Map toJson() { final json = {}; @@ -91,6 +96,11 @@ class PeopleUpdateItem { } else { // json[r'birthDate'] = null; } + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; + } if (this.featureFaceAssetId != null) { json[r'featureFaceAssetId'] = this.featureFaceAssetId; } else { @@ -125,6 +135,7 @@ class PeopleUpdateItem { return PeopleUpdateItem( birthDate: mapDateTime(json, r'birthDate', r''), + color: mapValueOfType(json, r'color'), featureFaceAssetId: mapValueOfType(json, r'featureFaceAssetId'), id: mapValueOfType(json, r'id')!, isFavorite: mapValueOfType(json, r'isFavorite'), diff --git a/mobile/openapi/lib/model/person_create_dto.dart b/mobile/openapi/lib/model/person_create_dto.dart index bc1d67c2407f4..87b426eaed1ed 100644 --- a/mobile/openapi/lib/model/person_create_dto.dart +++ b/mobile/openapi/lib/model/person_create_dto.dart @@ -14,6 +14,7 @@ class PersonCreateDto { /// Returns a new [PersonCreateDto] instance. PersonCreateDto({ this.birthDate, + this.color, this.isFavorite, this.isHidden, this.name, @@ -22,6 +23,8 @@ class PersonCreateDto { /// Person date of birth. Note: the mobile app cannot currently set the birth date to null. DateTime? birthDate; + String? color; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -51,6 +54,7 @@ class PersonCreateDto { @override bool operator ==(Object other) => identical(this, other) || other is PersonCreateDto && other.birthDate == birthDate && + other.color == color && other.isFavorite == isFavorite && other.isHidden == isHidden && other.name == name; @@ -59,12 +63,13 @@ class PersonCreateDto { int get hashCode => // ignore: unnecessary_parenthesis (birthDate == null ? 0 : birthDate!.hashCode) + + (color == null ? 0 : color!.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isHidden == null ? 0 : isHidden!.hashCode) + (name == null ? 0 : name!.hashCode); @override - String toString() => 'PersonCreateDto[birthDate=$birthDate, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]'; + String toString() => 'PersonCreateDto[birthDate=$birthDate, color=$color, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]'; Map toJson() { final json = {}; @@ -73,6 +78,11 @@ class PersonCreateDto { } else { // json[r'birthDate'] = null; } + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; + } if (this.isFavorite != null) { json[r'isFavorite'] = this.isFavorite; } else { @@ -101,6 +111,7 @@ class PersonCreateDto { return PersonCreateDto( birthDate: mapDateTime(json, r'birthDate', r''), + color: mapValueOfType(json, r'color'), isFavorite: mapValueOfType(json, r'isFavorite'), isHidden: mapValueOfType(json, r'isHidden'), name: mapValueOfType(json, r'name'), diff --git a/mobile/openapi/lib/model/person_response_dto.dart b/mobile/openapi/lib/model/person_response_dto.dart index 1884459928ff7..c9ebb14c721c6 100644 --- a/mobile/openapi/lib/model/person_response_dto.dart +++ b/mobile/openapi/lib/model/person_response_dto.dart @@ -14,6 +14,7 @@ class PersonResponseDto { /// Returns a new [PersonResponseDto] instance. PersonResponseDto({ required this.birthDate, + this.color, required this.id, this.isFavorite, required this.isHidden, @@ -24,6 +25,15 @@ class PersonResponseDto { DateTime? birthDate; + /// This property was added in v1.126.0 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? color; + String id; /// This property was added in v1.126.0 @@ -53,6 +63,7 @@ class PersonResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto && other.birthDate == birthDate && + other.color == color && other.id == id && other.isFavorite == isFavorite && other.isHidden == isHidden && @@ -64,6 +75,7 @@ class PersonResponseDto { int get hashCode => // ignore: unnecessary_parenthesis (birthDate == null ? 0 : birthDate!.hashCode) + + (color == null ? 0 : color!.hashCode) + (id.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isHidden.hashCode) + @@ -72,7 +84,7 @@ class PersonResponseDto { (updatedAt == null ? 0 : updatedAt!.hashCode); @override - String toString() => 'PersonResponseDto[birthDate=$birthDate, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; + String toString() => 'PersonResponseDto[birthDate=$birthDate, color=$color, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -80,6 +92,11 @@ class PersonResponseDto { json[r'birthDate'] = _dateFormatter.format(this.birthDate!.toUtc()); } else { // json[r'birthDate'] = null; + } + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; } json[r'id'] = this.id; if (this.isFavorite != null) { @@ -108,6 +125,7 @@ class PersonResponseDto { return PersonResponseDto( birthDate: mapDateTime(json, r'birthDate', r''), + color: mapValueOfType(json, r'color'), id: mapValueOfType(json, r'id')!, isFavorite: mapValueOfType(json, r'isFavorite'), isHidden: mapValueOfType(json, r'isHidden')!, diff --git a/mobile/openapi/lib/model/person_update_dto.dart b/mobile/openapi/lib/model/person_update_dto.dart index cf0688a27f463..6736b4e177284 100644 --- a/mobile/openapi/lib/model/person_update_dto.dart +++ b/mobile/openapi/lib/model/person_update_dto.dart @@ -14,6 +14,7 @@ class PersonUpdateDto { /// Returns a new [PersonUpdateDto] instance. PersonUpdateDto({ this.birthDate, + this.color, this.featureFaceAssetId, this.isFavorite, this.isHidden, @@ -23,6 +24,8 @@ class PersonUpdateDto { /// Person date of birth. Note: the mobile app cannot currently set the birth date to null. DateTime? birthDate; + String? color; + /// Asset is used to get the feature face thumbnail. /// /// Please note: This property should have been non-nullable! Since the specification file @@ -61,6 +64,7 @@ class PersonUpdateDto { @override bool operator ==(Object other) => identical(this, other) || other is PersonUpdateDto && other.birthDate == birthDate && + other.color == color && other.featureFaceAssetId == featureFaceAssetId && other.isFavorite == isFavorite && other.isHidden == isHidden && @@ -70,13 +74,14 @@ class PersonUpdateDto { int get hashCode => // ignore: unnecessary_parenthesis (birthDate == null ? 0 : birthDate!.hashCode) + + (color == null ? 0 : color!.hashCode) + (featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isHidden == null ? 0 : isHidden!.hashCode) + (name == null ? 0 : name!.hashCode); @override - String toString() => 'PersonUpdateDto[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]'; + String toString() => 'PersonUpdateDto[birthDate=$birthDate, color=$color, featureFaceAssetId=$featureFaceAssetId, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]'; Map toJson() { final json = {}; @@ -85,6 +90,11 @@ class PersonUpdateDto { } else { // json[r'birthDate'] = null; } + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; + } if (this.featureFaceAssetId != null) { json[r'featureFaceAssetId'] = this.featureFaceAssetId; } else { @@ -118,6 +128,7 @@ class PersonUpdateDto { return PersonUpdateDto( birthDate: mapDateTime(json, r'birthDate', r''), + color: mapValueOfType(json, r'color'), featureFaceAssetId: mapValueOfType(json, r'featureFaceAssetId'), isFavorite: mapValueOfType(json, r'isFavorite'), isHidden: mapValueOfType(json, r'isHidden'), diff --git a/mobile/openapi/lib/model/person_with_faces_response_dto.dart b/mobile/openapi/lib/model/person_with_faces_response_dto.dart index 7d61db11f373f..0bd38b087063e 100644 --- a/mobile/openapi/lib/model/person_with_faces_response_dto.dart +++ b/mobile/openapi/lib/model/person_with_faces_response_dto.dart @@ -14,6 +14,7 @@ class PersonWithFacesResponseDto { /// Returns a new [PersonWithFacesResponseDto] instance. PersonWithFacesResponseDto({ required this.birthDate, + this.color, this.faces = const [], required this.id, this.isFavorite, @@ -25,6 +26,15 @@ class PersonWithFacesResponseDto { DateTime? birthDate; + /// This property was added in v1.126.0 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? color; + List faces; String id; @@ -56,6 +66,7 @@ class PersonWithFacesResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is PersonWithFacesResponseDto && other.birthDate == birthDate && + other.color == color && _deepEquality.equals(other.faces, faces) && other.id == id && other.isFavorite == isFavorite && @@ -68,6 +79,7 @@ class PersonWithFacesResponseDto { int get hashCode => // ignore: unnecessary_parenthesis (birthDate == null ? 0 : birthDate!.hashCode) + + (color == null ? 0 : color!.hashCode) + (faces.hashCode) + (id.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) + @@ -77,7 +89,7 @@ class PersonWithFacesResponseDto { (updatedAt == null ? 0 : updatedAt!.hashCode); @override - String toString() => 'PersonWithFacesResponseDto[birthDate=$birthDate, faces=$faces, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; + String toString() => 'PersonWithFacesResponseDto[birthDate=$birthDate, color=$color, faces=$faces, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -85,6 +97,11 @@ class PersonWithFacesResponseDto { json[r'birthDate'] = _dateFormatter.format(this.birthDate!.toUtc()); } else { // json[r'birthDate'] = null; + } + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; } json[r'faces'] = this.faces; json[r'id'] = this.id; @@ -114,6 +131,7 @@ class PersonWithFacesResponseDto { return PersonWithFacesResponseDto( birthDate: mapDateTime(json, r'birthDate', r''), + color: mapValueOfType(json, r'color'), faces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'faces']), id: mapValueOfType(json, r'id')!, isFavorite: mapValueOfType(json, r'isFavorite'), diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f1ef466df4c9e..94ef49f12e7df 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -10286,6 +10286,10 @@ "nullable": true, "type": "string" }, + "color": { + "nullable": true, + "type": "string" + }, "featureFaceAssetId": { "description": "Asset is used to get the feature face thumbnail.", "type": "string" @@ -10402,6 +10406,10 @@ "nullable": true, "type": "string" }, + "color": { + "nullable": true, + "type": "string" + }, "isFavorite": { "type": "boolean" }, @@ -10423,6 +10431,10 @@ "nullable": true, "type": "string" }, + "color": { + "description": "This property was added in v1.126.0", + "type": "string" + }, "id": { "type": "string" }, @@ -10473,6 +10485,10 @@ "nullable": true, "type": "string" }, + "color": { + "nullable": true, + "type": "string" + }, "featureFaceAssetId": { "description": "Asset is used to get the feature face thumbnail.", "type": "string" @@ -10498,6 +10514,10 @@ "nullable": true, "type": "string" }, + "color": { + "description": "This property was added in v1.126.0", + "type": "string" + }, "faces": { "items": { "$ref": "#/components/schemas/AssetFaceWithoutPersonResponseDto" @@ -12611,7 +12631,6 @@ "properties": { "color": { "nullable": true, - "pattern": "^#?([0-9A-F]{3}|[0-9A-F]{4}|[0-9A-F]{6}|[0-9A-F]{8})$", "type": "string" } }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 6704a83cc7d6e..46ce207883095 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -213,6 +213,8 @@ export type AssetFaceWithoutPersonResponseDto = { }; export type PersonWithFacesResponseDto = { birthDate: string | null; + /** This property was added in v1.126.0 */ + color?: string; faces: AssetFaceWithoutPersonResponseDto[]; id: string; /** This property was added in v1.126.0 */ @@ -493,6 +495,8 @@ export type DuplicateResponseDto = { }; export type PersonResponseDto = { birthDate: string | null; + /** This property was added in v1.126.0 */ + color?: string; id: string; /** This property was added in v1.126.0 */ isFavorite?: boolean; @@ -693,6 +697,7 @@ export type PersonCreateDto = { /** Person date of birth. Note: the mobile app cannot currently set the birth date to null. */ birthDate?: string | null; + color?: string | null; isFavorite?: boolean; /** Person visibility */ isHidden?: boolean; @@ -703,6 +708,7 @@ export type PeopleUpdateItem = { /** Person date of birth. Note: the mobile app cannot currently set the birth date to null. */ birthDate?: string | null; + color?: string | null; /** Asset is used to get the feature face thumbnail. */ featureFaceAssetId?: string; /** Person id. */ @@ -720,6 +726,7 @@ export type PersonUpdateDto = { /** Person date of birth. Note: the mobile app cannot currently set the birth date to null. */ birthDate?: string | null; + color?: string | null; /** Asset is used to get the feature face thumbnail. */ featureFaceAssetId?: string; isFavorite?: boolean; diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 16f73c53e7906..2bffe2ba5f8ad 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -276,6 +276,7 @@ export interface Partners { export interface Person { birthDate: Timestamp | null; + color: string | null; createdAt: Generated; faceAssetId: string | null; id: Generated; diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index 8bf041be37141..ca705154a2a80 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -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, }; } diff --git a/server/src/dtos/tag.dto.ts b/server/src/dtos/tag.dto.ts index cff11962d744a..17200a887423c 100644 --- a/server/src/dtos/tag.dto.ts +++ b/server/src/dtos/tag.dto.ts @@ -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; } diff --git a/server/src/entities/person.entity.ts b/server/src/entities/person.entity.ts index 8cf416b766069..3785e1985e675 100644 --- a/server/src/entities/person.entity.ts +++ b/server/src/entities/person.entity.ts @@ -52,4 +52,7 @@ export class PersonEntity { @Column({ default: false }) isFavorite!: boolean; + + @Column({ type: 'varchar', nullable: true, default: null }) + color?: string | null; } diff --git a/server/src/migrations/1738889177573-AddPersonColor.ts b/server/src/migrations/1738889177573-AddPersonColor.ts new file mode 100644 index 0000000000000..ebdc86f52d4d4 --- /dev/null +++ b/server/src/migrations/1738889177573-AddPersonColor.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddPersonColor1738889177573 implements MigrationInterface { + name = 'AddPersonColor1738889177573' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "person" ADD "color" character varying`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "color"`); + } + +} diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 5407821fab9cc..1cd1b34ec4347 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -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 }); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 2f4a6bb0d148f..116d2ec6c85cc 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -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 { - return this.personRepository.create({ + async create(auth: AuthDto, dto: PersonCreateDto): Promise { + 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 { 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) { diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index 5c59e24b21436..9f16ddf82de60 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -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 }); diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index b833d0184c9a1..b74d3d3cbafde 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -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 { - 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 { diff --git a/server/src/validation.ts b/server/src/validation.ts index 177e439919b92..29e402826dcf3 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -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 };