feat: auto-sync name changes to connected users (#31923)

* feat: auto-sync name changes to connected users

* fix: rename to faceId

* fix: pr feedback

* fix: mobile build
This commit is contained in:
Jason Rasmussen
2026-09-29 18:12:25 -04:00
committed by GitHub
parent a29153e90e
commit aec7c0d97b
23 changed files with 416 additions and 115 deletions
+4
View File
@@ -1679,6 +1679,10 @@
"person_read_access_message": "You only have read access to this person.",
"person_recognized": "Person recognized",
"person_share_no_users": "Every user in your cluster group already has access to this person.",
"person_update_strategy": "Apply changes to",
"person_update_strategy_description": "Which people to update when you change a name or date of birth",
"person_update_strategy_everyone": "Everyone with shared access",
"person_update_strategy_self": "Only me",
"photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.",
"photos": "Photos",
"photos_and_videos": "Photos & Videos",
+2 -1
View File
@@ -21,7 +21,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
'folders': FoldersResponse(enabled: false, sidebarWeb: false).toJson(),
'memories': MemoriesResponse(enabled: true, duration: 5, sidebarWeb: false).toJson(),
'ratings': RatingsResponse(enabled: false).toJson(),
'people': PeopleResponse(enabled: true, sidebarWeb: false).toJson(),
'people': PeopleResponse(enabled: true, sidebarWeb: false, updateStrategy: PersonUpdateStrategy.everyone).toJson(),
'tags': TagsResponse(enabled: false, sidebarWeb: false).toJson(),
'sharedLinks': SharedLinksResponse(enabled: true, sidebarWeb: false).toJson(),
'cast': CastResponse(gCastEnabled: false).toJson(),
@@ -42,6 +42,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
'ServerFeaturesDto': {'ocr': false, 'realtimeTranscoding': false},
'SearchAssetResponseDto': {'nextCursor': null},
'MemoriesResponse': {'duration': 5, 'sidebarWeb': false},
'PeopleResponse': {'updateStrategy': 'everyone'},
'PersonResponseDto': {'otherPeople': const [], 'sharedBy': const [], 'sharedWith': const []},
'WorkflowResponseDto': {'logging': false},
};
+18 -3
View File
@@ -24944,11 +24944,15 @@
"sidebarWeb": {
"description": "Whether people appear in web sidebar",
"type": "boolean"
},
"updateStrategy": {
"$ref": "#/components/schemas/PersonUpdateStrategy"
}
},
"required": [
"enabled",
"sidebarWeb"
"sidebarWeb",
"updateStrategy"
],
"type": "object"
},
@@ -25011,6 +25015,9 @@
"sidebarWeb": {
"description": "Whether people appear in web sidebar",
"type": "boolean"
},
"updateStrategy": {
"$ref": "#/components/schemas/PersonUpdateStrategy"
}
},
"type": "object"
@@ -25069,7 +25076,7 @@
"type": "string"
},
"userId": {
"description": "User ID",
"description": "Restrict the update to the person record of this User ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
"type": "string"
@@ -25527,7 +25534,7 @@
"type": "string"
},
"userId": {
"description": "User ID",
"description": "Restrict the update to the person record of this User ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
"type": "string"
@@ -25535,6 +25542,14 @@
},
"type": "object"
},
"PersonUpdateStrategy": {
"description": "Which person records to update when editing a person",
"enum": [
"self",
"everyone"
],
"type": "string"
},
"PersonUserRole": {
"description": "Levels of access for managing people resources on behalf of another user.",
"enum": [
+8 -2
View File
@@ -688,6 +688,7 @@ export type PeopleResponse = {
minimumFaces?: number;
/** Whether people appear in web sidebar */
sidebarWeb: boolean;
updateStrategy: PersonUpdateStrategy;
};
export type PurchaseResponse = {
/** Date until which to hide buy button */
@@ -774,6 +775,7 @@ export type PeopleUpdate = {
minimumFaces?: number;
/** Whether people appear in web sidebar */
sidebarWeb?: boolean;
updateStrategy?: PersonUpdateStrategy;
};
export type PurchaseUpdate = {
/** Date until which to hide buy button */
@@ -2033,7 +2035,7 @@ export type PeopleUpdateItem = {
isHidden?: boolean;
/** Person name */
name?: string;
/** User ID */
/** Restrict the update to the person record of this User ID */
userId?: string;
};
export type PeopleUpdateDto = {
@@ -2090,7 +2092,7 @@ export type PersonUpdateDto = {
isHidden?: boolean;
/** Person name */
name?: string;
/** User ID */
/** Restrict the update to the person record of this User ID */
userId?: string;
};
export type AssetFaceUpdateItem = {
@@ -8061,6 +8063,10 @@ export enum AssetOrder {
Asc = "asc",
Desc = "desc"
}
export enum PersonUpdateStrategy {
Self = "self",
Everyone = "everyone"
}
export enum AssetVisibility {
Archive = "archive",
Timeline = "timeline",
@@ -91,6 +91,23 @@ describe(PersonController.name, () => {
expect(body).toEqual(errorDto.validationError([{ path: ['featureFaceAssetId'], message: 'Invalid UUID' }]));
});
it('should require at least one property to update', async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/people/${factory.uuid()}`)
.send({ userId: factory.uuid() })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([
{
path: [],
message:
'At least one of the following fields is required: name, birthDate, isHidden, isFavorite, color, featureFaceAssetId',
},
]),
);
});
it(`should require isFavorite to be a boolean`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/people/${factory.uuid()}`)
+10 -3
View File
@@ -29,10 +29,17 @@ const PersonCreateSchema = z
})
.meta({ id: 'PersonCreateDto' });
const PersonUpdateSchema = PersonCreateSchema.extend({
const PersonUpdateBaseSchema = PersonCreateSchema.extend({
featureFaceAssetId: z.uuidv4().optional().describe('Asset ID used for feature face thumbnail'),
userId: z.uuid().optional().describe('User ID'),
}).meta({ id: 'PersonUpdateDto' });
});
const PersonUpdateSchema = PersonUpdateBaseSchema.extend({
userId: z.uuid().optional().describe('Restrict the update to the person record of this User ID'),
})
.refine((dto) => Object.entries(dto).some(([key, value]) => key !== 'userId' && value !== undefined), {
message: `At least one of the following fields is required: ${Object.keys(PersonUpdateBaseSchema.shape).join(', ')}`,
})
.meta({ id: 'PersonUpdateDto' });
const PeopleUpdateItemSchema = PersonUpdateSchema.extend({
id: z.uuidv4().describe('Person ID'),
+3 -1
View File
@@ -1,7 +1,7 @@
import { createZodDto } from 'nestjs-zod';
import z from 'zod';
import type { UserPreferences } from 'src/types.js';
import { AssetOrderSchema, UserAvatarColorSchema } from 'src/enum.js';
import { AssetOrderSchema, PersonUpdateStrategySchema, UserAvatarColorSchema } from 'src/enum.js';
const AlbumsUpdateSchema = z
.object({
@@ -47,6 +47,7 @@ const PeopleUpdateSchema = z
enabled: z.boolean().optional().describe('Whether people are enabled'),
sidebarWeb: z.boolean().optional().describe('Whether people appear in web sidebar'),
minimumFaces: z.int().min(1).optional().describe('People face threshold'),
updateStrategy: PersonUpdateStrategySchema.optional(),
})
.optional()
.meta({ id: 'PeopleUpdate' });
@@ -150,6 +151,7 @@ const PeopleResponseSchema = z
enabled: z.boolean().describe('Whether people are enabled'),
sidebarWeb: z.boolean().describe('Whether people appear in web sidebar'),
minimumFaces: z.int().min(1).optional().describe('People face threshold'),
updateStrategy: PersonUpdateStrategySchema,
})
.meta({ id: 'PeopleResponse' });
+10
View File
@@ -417,6 +417,16 @@ export const UserAvatarColorSchema = z
.describe('User avatar color')
.meta({ id: 'UserAvatarColor' });
export enum PersonUpdateStrategy {
Self = 'self',
Everyone = 'everyone',
}
export const PersonUpdateStrategySchema = z
.enum(PersonUpdateStrategy)
.describe('Which person records to update when editing a person')
.meta({ id: 'PersonUpdateStrategy' });
export enum UserStatus {
Active = 'active',
Removing = 'removing',
+17
View File
@@ -1116,6 +1116,23 @@ from
1
) as "dummy"
-- PersonRepository.updateForWritableOwners
update "person"
set
"name" = $1
where
"person"."personGroupId" = $2
and "person"."ownerId" in (
select
"person_user"."sharedById"
from
"person_user"
where
"person_user"."personGroupId" = $3
and "person_user"."sharedWithId" = $4
and "person_user"."role" in ($5, $6)
)
-- PersonRepository.getFacesByIds
select
"asset_face".*,
@@ -933,6 +933,26 @@ export class PersonRepository {
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [{ userId: DummyValue.UUID, personGroupId: DummyValue.UUID }, { name: DummyValue.STRING }] })
async updateForWritableOwners(
{ userId, personGroupId }: { userId: string; personGroupId: string },
person: Pick<Updateable<PersonTable>, 'name' | 'birthDate'>,
): Promise<void> {
await this.db
.updateTable('person')
.set(person)
.where('person.personGroupId', '=', personGroupId)
.where('person.ownerId', 'in', (eb) =>
eb
.selectFrom('person_user')
.select('person_user.sharedById')
.where('person_user.personGroupId', '=', personGroupId)
.where('person_user.sharedWithId', '=', userId)
.where('person_user.role', 'in', [PersonUserRole.Write, PersonUserRole.Admin]),
)
.execute();
}
async updateAll(people: Insertable<PersonTable>[]): Promise<void> {
if (people.length === 0) {
return;
+93 -13
View File
@@ -122,8 +122,8 @@ describe(PersonService.name, () => {
const person = PersonFactory.create();
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
mocks.person.getForUser.mockResolvedValue(getDehydrated(person));
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
mocks.person.getForUser.mockResolvedValue(getDehydrated(person));
await expect(sut.getById(auth, person.personGroupId)).resolves.toEqual(
expect.objectContaining({ id: person.personGroupId }),
);
@@ -221,7 +221,7 @@ describe(PersonService.name, () => {
const auth = AuthFactory.create();
const person = PersonFactory.create({ ownerId: auth.user.id });
await expect(sut.update(auth, person.personGroupId, { name: 'Person 1' })).rejects.toBeInstanceOf(
await expect(sut.update(auth, person.personGroupId, { isHidden: true })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(mocks.person.update).not.toHaveBeenCalled();
@@ -237,28 +237,46 @@ describe(PersonService.name, () => {
mocks.access.person.checkAccess.mockResolvedValue(new Set());
await expect(sut.update(auth, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(
auth.user.id,
new Set([{ personGroupId: 'person-1', ownerId: auth.user.id }]),
PERSON_WRITE_ROLES,
);
expect(mocks.person.updateForWritableOwners).not.toHaveBeenCalled();
});
it("should update a person's name", async () => {
it('should throw an error when personal properties are updated for another user', async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create();
await expect(
sut.update(auth, person.personGroupId, { userId: person.ownerId, name: 'Person 1', isFavorite: true }),
).rejects.toThrow('Only name and birthDate can be updated for other users');
expect(mocks.access.person.checkAccess).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.person.updateForWritableOwners).not.toHaveBeenCalled();
});
it("should update a person's name for every user with write access", async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create({ ownerId: auth.user.id, name: 'Person 1' });
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
mocks.person.update.mockResolvedValue(person);
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
mocks.person.update.mockResolvedValue(person);
await expect(sut.update(auth, person.personGroupId, { name: 'Person 1' })).resolves.toEqual(
expect.objectContaining({ id: person.personGroupId, name: 'Person 1' }),
);
expect(mocks.person.updateForWritableOwners).toHaveBeenCalledWith(
{ userId: auth.user.id, personGroupId: person.personGroupId },
{
name: 'Person 1',
},
);
expect(mocks.person.update).toHaveBeenCalledWith({
ownerId: person.ownerId,
ownerId: auth.user.id,
personGroupId: person.personGroupId,
name: 'Person 1',
});
@@ -282,17 +300,41 @@ describe(PersonService.name, () => {
personGroupId: person.personGroupId,
name: 'Person 1',
});
expect(mocks.person.updateForWritableOwners).not.toHaveBeenCalled();
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES);
});
it("should update a person's date of birth", async () => {
it("should only update the user's own person when userId is the current user", async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create({ ownerId: auth.user.id, birthDate: new Date('1976-06-30') });
const person = PersonFactory.create({ ownerId: auth.user.id, name: 'Person 1' });
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
mocks.person.update.mockResolvedValue(person);
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
await expect(
sut.update(auth, person.personGroupId, { name: 'Person 1', isFavorite: true, userId: auth.user.id }),
).resolves.toEqual(expect.objectContaining({ id: person.personGroupId, name: 'Person 1' }));
expect(mocks.person.update).toHaveBeenCalledWith({
ownerId: auth.user.id,
personGroupId: person.personGroupId,
name: 'Person 1',
isFavorite: true,
});
expect(mocks.person.updateForWritableOwners).not.toHaveBeenCalled();
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES);
});
it("should update a person's date of birth for every user with write access", async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create({ ownerId: auth.user.id, birthDate: new Date('1976-06-30') });
mocks.access.person.checkAccess.mockResolvedValue(
new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]),
);
mocks.person.update.mockResolvedValue(person);
await expect(sut.update(auth, person.personGroupId, { birthDate: '1976-06-30' })).resolves.toEqual({
id: person.personGroupId,
name: person.name,
@@ -305,14 +347,19 @@ describe(PersonService.name, () => {
sharedWith: [],
updatedAt: expect.any(String),
});
expect(mocks.person.updateForWritableOwners).toHaveBeenCalledWith(
{ userId: auth.user.id, personGroupId: person.personGroupId },
{
birthDate: '1976-06-30',
},
);
expect(mocks.person.update).toHaveBeenCalledWith({
ownerId: person.ownerId,
ownerId: auth.user.id,
personGroupId: person.personGroupId,
birthDate: '1976-06-30',
});
expect(mocks.job.queue).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled();
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES);
});
it('should update a person visibility', async () => {
@@ -332,6 +379,7 @@ describe(PersonService.name, () => {
personGroupId: person.personGroupId,
isHidden: true,
});
expect(mocks.person.updateForWritableOwners).not.toHaveBeenCalled();
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES);
});
@@ -352,9 +400,42 @@ describe(PersonService.name, () => {
personGroupId: person.personGroupId,
isFavorite: true,
});
expect(mocks.person.updateForWritableOwners).not.toHaveBeenCalled();
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES);
});
it('should update shared and personal properties together', async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create({ ownerId: auth.user.id, name: 'Person 1', isFavorite: true });
mocks.access.person.checkAccess.mockResolvedValue(
new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]),
);
mocks.person.update.mockResolvedValue(person);
await expect(sut.update(auth, person.personGroupId, { name: 'Person 1', isFavorite: true })).resolves.toEqual(
expect.objectContaining({ name: 'Person 1', isFavorite: true }),
);
expect(mocks.person.updateForWritableOwners).toHaveBeenCalledWith(
{ userId: auth.user.id, personGroupId: person.personGroupId },
{
name: 'Person 1',
},
);
expect(mocks.person.update).toHaveBeenCalledWith({
ownerId: auth.user.id,
personGroupId: person.personGroupId,
name: 'Person 1',
isFavorite: true,
});
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(
auth.user.id,
new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]),
PERSON_WRITE_ROLES,
);
});
it("should update a person's thumbnailPath", async () => {
const face = AssetFaceFactory.create();
const auth = AuthFactory.create();
@@ -389,9 +470,7 @@ describe(PersonService.name, () => {
it('should throw an error when the face feature assetId is invalid', async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create({ ownerId: auth.user.id });
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['-1']));
mocks.person.getForFeatureFaceUpdate.mockResolvedValue(undefined);
@@ -399,7 +478,7 @@ describe(PersonService.name, () => {
BadRequestException,
);
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES);
expect(mocks.person.updateForWritableOwners).not.toHaveBeenCalled();
});
});
@@ -411,6 +490,7 @@ describe(PersonService.name, () => {
{ error: BulkIdErrorReason.UNKNOWN, id: 'person-1', success: false },
]);
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.person.updateForWritableOwners).not.toHaveBeenCalled();
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(
authStub.admin.user.id,
new Set([{ personGroupId: 'person-1', ownerId: authStub.admin.user.id }]),
+20 -7
View File
@@ -52,7 +52,7 @@ import { getDimensions, getMyPartnerIds } from 'src/utils/asset.util.js';
import { ImmichFileResponse } from 'src/utils/file.js';
import { isHttpException } from 'src/utils/logger.js';
import { mimeTypes } from 'src/utils/mime-types.js';
import { batched, findOrFail, isFacialRecognitionEnabled } from 'src/utils/misc.js';
import { batched, findOrFail, hasSomeDefined, isFacialRecognitionEnabled } from 'src/utils/misc.js';
import { Point, transformPoints } from 'src/utils/transform.js';
const personKey = ({ ownerId, personGroupId }: PersonId) => `${ownerId}/${personGroupId}`;
@@ -247,17 +247,27 @@ export class PersonService extends BaseService {
}
async update(auth: AuthDto, personGroupId: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
const ownerId = dto.userId ?? auth.user.id;
const { userId: userIdOverride, name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite, color } = dto;
const targetOwnerId = userIdOverride ?? auth.user.id;
const hasSharedProperties = hasSomeDefined([name, birthDate]);
const hasPersonalProperties = hasSomeDefined([isHidden, isFavorite, color, assetId]);
if (targetOwnerId !== auth.user.id && hasPersonalProperties) {
throw new BadRequestException('Only name and birthDate can be updated for other users');
}
await this.requirePersonAccess({
auth,
permission: Permission.PersonUpdate,
ids: [{ personGroupId, ownerId }],
ids: [{ personGroupId, ownerId: targetOwnerId }],
});
const { name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite, color } = dto;
// TODO: set by faceId directly
if (!userIdOverride && hasSharedProperties) {
await this.personRepository.updateForWritableOwners({ userId: auth.user.id, personGroupId }, { name, birthDate });
}
let faceId: string | undefined;
if (assetId) {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [assetId] });
const face = await this.personRepository.getForFeatureFaceUpdate({ personGroupId, assetId });
@@ -269,7 +279,7 @@ export class PersonService extends BaseService {
}
const person = await this.personRepository.update({
ownerId,
ownerId: targetOwnerId,
personGroupId,
faceAssetId: faceId,
name,
@@ -280,7 +290,10 @@ export class PersonService extends BaseService {
});
if (assetId) {
await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { ownerId, personGroupId } });
await this.jobRepository.queue({
name: JobName.PersonGenerateThumbnail,
data: { ownerId: targetOwnerId, personGroupId },
});
}
return mapPerson(person);
+2
View File
@@ -24,6 +24,7 @@ import {
IntegrityReport,
JobName,
MemoryType,
PersonUpdateStrategy,
QueueName,
StorageFolder,
SyncEntityType,
@@ -580,6 +581,7 @@ export type UserPreferences = {
enabled: boolean;
sidebarWeb: boolean;
minimumFaces: number;
updateStrategy: PersonUpdateStrategy;
};
ratings: {
enabled: boolean;
+2
View File
@@ -108,6 +108,8 @@ export const handlePromiseError = <T>(promise: Promise<T>, logger: LoggingReposi
promise.catch((error: Error | any) => logger.error(`Promise error: ${error}`, error?.stack));
};
export const hasSomeDefined = (values: unknown[]) => values.some((value) => value !== undefined);
export const findOrFail = async <T>(find: () => Promise<T>, entity: string): Promise<NonNullable<T>> => {
const value = await find();
if (!value) {
+2 -1
View File
@@ -1,7 +1,7 @@
import { get, isEqual, set } from 'lodash-es';
import type { DeepPartial, UserMetadataItem, UserPreferences } from 'src/types.js';
import { UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto.js';
import { AssetOrder, UserMetadataKey } from 'src/enum.js';
import { AssetOrder, PersonUpdateStrategy, UserMetadataKey } from 'src/enum.js';
import { HumanReadableSize } from 'src/utils/bytes.js';
import { getKeysDeep } from 'src/utils/misc.js';
@@ -23,6 +23,7 @@ const getDefaultPreferences = (): UserPreferences => {
enabled: true,
sidebarWeb: false,
minimumFaces: 3,
updateStrategy: PersonUpdateStrategy.Everyone,
},
sharedLinks: {
enabled: true,
@@ -303,6 +303,123 @@ describe(PersonService.name, () => {
});
});
describe('update', () => {
it('should throw an error when there is no access', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user2.id });
await expect(sut.update(factory.auth({ user }), person.personGroupId, { name: 'New name' })).rejects.toThrow(
'Not found or no person.update access',
);
});
it('should update the name and birth date for every user with write access', async () => {
const { ctx, sut } = setup();
const personRepo = ctx.get(PersonRepository);
const { user } = await ctx.newUser();
const { user: writer } = await ctx.newUser();
const { user: reader } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: writer.id, name: 'Old name' });
const { personGroupId } = person;
await ctx.newPerson({ ownerId: reader.id, personGroupId, name: 'Old name' });
await ctx.newPersonUser({
personGroupId,
sharedById: writer.id,
sharedWithId: user.id,
role: PersonUserRole.Write,
});
await ctx.newPersonUser({
personGroupId,
sharedById: reader.id,
sharedWithId: user.id,
role: PersonUserRole.Read,
});
await expect(
sut.update(factory.auth({ user }), personGroupId, { name: 'New name', birthDate: '2000-01-01' }),
).resolves.toEqual(expect.objectContaining({ name: 'New name', birthDate: '2000-01-01' }));
await expect(personRepo.getForUser({ userId: user.id, personGroupId })).resolves.toEqual(
expect.objectContaining({ name: 'New name', birthDate: '2000-01-01' }),
);
await expect(personRepo.getForUser({ userId: writer.id, personGroupId })).resolves.toEqual(
expect.objectContaining({ name: 'New name', birthDate: '2000-01-01' }),
);
await expect(personRepo.getForUser({ userId: reader.id, personGroupId })).resolves.toEqual(
expect.objectContaining({ name: 'Old name', birthDate: null }),
);
});
it('should only update the specified user when userId is provided', async () => {
const { ctx, sut } = setup();
const personRepo = ctx.get(PersonRepository);
const { user } = await ctx.newUser();
const { user: writer } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: writer.id, name: 'Old name' });
const { personGroupId } = person;
await ctx.newPersonUser({
personGroupId,
sharedById: writer.id,
sharedWithId: user.id,
role: PersonUserRole.Write,
});
await expect(
sut.update(factory.auth({ user }), personGroupId, { name: 'New name', userId: writer.id }),
).resolves.toEqual(expect.objectContaining({ name: 'New name' }));
await expect(personRepo.getForUser({ userId: writer.id, personGroupId })).resolves.toEqual(
expect.objectContaining({ name: 'New name' }),
);
await expect(personRepo.getForUser({ userId: user.id, personGroupId })).resolves.toEqual(
expect.objectContaining({ name: 'Old name' }),
);
});
it('should only update personal properties for the current user', async () => {
const { ctx, sut } = setup();
const personRepo = ctx.get(PersonRepository);
const { user } = await ctx.newUser();
const { user: writer } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: writer.id, name: 'Old name' });
const { personGroupId } = person;
await ctx.newPersonUser({
personGroupId,
sharedById: writer.id,
sharedWithId: user.id,
role: PersonUserRole.Write,
});
await expect(
sut.update(factory.auth({ user }), personGroupId, { name: 'New name', isFavorite: true, isHidden: true }),
).resolves.toEqual(expect.objectContaining({ name: 'New name', isFavorite: true, isHidden: true }));
await expect(personRepo.getForUser({ userId: writer.id, personGroupId })).resolves.toEqual(
expect.objectContaining({ name: 'New name', isFavorite: false, isHidden: false }),
);
});
it('should not allow personal properties to be updated for another user', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { user: writer } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: writer.id });
const { personGroupId } = person;
await ctx.newPersonUser({
personGroupId,
sharedById: writer.id,
sharedWithId: user.id,
role: PersonUserRole.Write,
});
await expect(
sut.update(factory.auth({ user }), personGroupId, { isFavorite: true, userId: writer.id }),
).rejects.toThrow('Only name and birthDate can be updated for other users');
});
});
describe('delete', () => {
it('should throw an error when there is no access', async () => {
const { sut } = setup();
@@ -1,6 +1,6 @@
import { Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { ImmichEnvironment, JobName, JobStatus, UserAvatarColor } from 'src/enum.js';
import { ImmichEnvironment, JobName, JobStatus, PersonUpdateStrategy, UserAvatarColor } from 'src/enum.js';
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository.js';
import { ConfigRepository } from 'src/repositories/config.repository.js';
import { CryptoRepository } from 'src/repositories/crypto.repository.js';
@@ -263,6 +263,20 @@ describe(UserService.name, () => {
await expect(sut.updateMyPreferences(auth, dto)).resolves.toMatchObject(dto);
await expect(sut.getMyPreferences(auth)).resolves.toMatchObject(dto);
});
it('should update the person update strategy', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user: { id: user.id } });
const dto = { people: { updateStrategy: PersonUpdateStrategy.Self } };
await expect(sut.getMyPreferences(auth)).resolves.toMatchObject({
people: { updateStrategy: PersonUpdateStrategy.Everyone },
});
await expect(sut.updateMyPreferences(auth, dto)).resolves.toMatchObject(dto);
await expect(sut.getMyPreferences(auth)).resolves.toMatchObject(dto);
});
});
describe('setLicense', () => {
+9 -15
View File
@@ -1,9 +1,9 @@
<script lang="ts">
import { authManager } from '$lib/managers/auth-manager.svelte';
import PeopleFilterUserPicker from '$lib/modals/PeopleFilterUserPicker.svelte';
import { handleUpdatePeople } from '$lib/services/person.service';
import { handleUpdatePerson } from '$lib/services/person.service';
import { locale } from '$lib/stores/preferences.store';
import { PersonUserRole, type PersonResponseDto } from '@immich/sdk';
import { PersonUpdateStrategy, PersonUserRole, type PersonResponseDto } from '@immich/sdk';
import {
Alert,
Button,
@@ -48,7 +48,7 @@
let targetUserId = $state(initialTargetUserId ?? authManager.user.id);
let targetPerson = $state(candidates[0]);
let applyToEveryone = $state(false);
let applyToEveryone = $state(authManager.preferences.people?.updateStrategy === PersonUpdateStrategy.Everyone);
const isWritable = $derived.by(() => {
if (!targetUserId) {
@@ -68,20 +68,14 @@
});
const onSubmit = async () => {
const userIdsToUpdate = applyToEveryone
? candidates.map(({ sharedById }) => sharedById)
: [targetPerson.sharedById];
const success = await handleUpdatePeople({
people: userIdsToUpdate.map((userId) => ({
id: person.id,
name: targetPerson.name,
birthDate: targetPerson.birthDate,
userId,
})),
const response = await handleUpdatePerson({
id: person.id,
name: targetPerson.name,
birthDate: targetPerson.birthDate,
userId: applyToEveryone ? undefined : targetPerson.sharedById,
});
if (success) {
if (response) {
onClose();
}
};
+21 -38
View File
@@ -1,9 +1,8 @@
import {
getPerson,
updatePeople,
PersonUpdateStrategy,
updatePerson,
type AssetResponseDto,
type PeopleUpdateDto,
type PersonResponseDto,
type PersonUpdateDto,
} from '@immich/sdk';
@@ -18,6 +17,7 @@ import {
mdiPencilOutline,
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import PersonEditAccessModal from '$lib/modals/PersonEditAccessModal.svelte';
import PersonEditModal from '$lib/modals/PersonEditModal.svelte';
@@ -114,41 +114,19 @@ const handleHidePerson = async (person: { id: string }) => {
}
};
export const handleUpdatePerson = async (id: string, personUpdateDto: PersonUpdateDto) => {
export const handleUpdatePerson = async ({ id, ...personUpdateDto }: { id: string } & PersonUpdateDto) => {
const $t = await getFormatter();
try {
await updatePerson({ id, personUpdateDto });
return true;
const response = await updatePerson({ id, personUpdateDto });
const isOtherUser = !!personUpdateDto.userId && personUpdateDto.userId !== authManager.user.id;
eventManager.emit('PersonUpdate', isOtherUser ? await getPerson({ id }) : response);
return response;
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
export const handleUpdatePeople = async (peopleUpdateDto: PeopleUpdateDto) => {
const $t = await getFormatter();
try {
const bulkResponse = await updatePeople({ peopleUpdateDto });
const ids = new Set(peopleUpdateDto.people.map(({ id }) => id));
const responses = await Promise.all([...ids].map((id) => getPerson({ id })));
for (const response of responses) {
eventManager.emit('PersonUpdate', response);
}
if (bulkResponse.some((response) => !response.success)) {
toastManager.danger($t('errors.something_went_wrong'));
return false;
}
return true;
} catch (error) {
console.log('uh oh');
handleError(error, $t('errors.something_went_wrong'));
}
};
const handleShowPerson = async (person: { id: string }) => {
const $t = await getFormatter();
@@ -161,17 +139,22 @@ const handleShowPerson = async (person: { id: string }) => {
}
};
export const handleUpdatePersonBirthDate = async (person: PersonResponseDto, birthDate: string | null) => {
const $t = await getFormatter();
export const withUpdateStrategy = (dto: PersonUpdateDto): PersonUpdateDto =>
authManager.preferences.people?.updateStrategy === PersonUpdateStrategy.Self
? { ...dto, userId: authManager.user.id }
: dto;
try {
const response = await updatePerson({ id: person.id, personUpdateDto: { birthDate } });
toastManager.primary($t('date_of_birth_saved'));
eventManager.emit('PersonUpdate', response);
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_save_date_of_birth'));
export const handleUpdatePersonName = async (
{ id, name }: { id: string; name: string },
options?: { notify: boolean },
) => {
const response = await handleUpdatePerson({ id, ...withUpdateStrategy({ name }) });
if (response && options?.notify) {
const $t = await getFormatter();
toastManager.primary($t('change_name_successfully'));
}
return response;
};
const handleSetFeaturedPhoto = async (person: PersonResponseDto, featureFaceAssetId: string) => {
+5 -20
View File
@@ -18,8 +18,9 @@
import { handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { clearQueryParam } from '$lib/utils/navigation';
import { getAllPeople, getPerson, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk';
import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
import { handleUpdatePersonName } from '$lib/services/person.service';
import { getAllPeople, getPerson, searchPerson, type PersonResponseDto } from '@immich/sdk';
import { Button, Icon, IconButton, modalManager } from '@immich/ui';
import { mdiAccountOff, mdiEyeOutline, mdiTune } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -150,19 +151,7 @@
* the person he's editing
*
*/
try {
await updatePerson({ id: personToBeMergedInto.id, personUpdateDto: { name: newName } });
for (const person of people) {
if (person.id === personToBeMergedInto.id) {
person.name = newName;
break;
}
}
toastManager.primary($t('change_name_successfully'));
} catch (error) {
handleError(error, $t('errors.unable_to_save_name'));
}
await handleUpdatePersonName({ id: personToBeMergedInto.id, name: newName }, { notify: true });
}
};
@@ -255,11 +244,7 @@
};
const updateName = async (id: string, name: string) => {
await updatePerson({
id,
personUpdateDto: { name },
});
await handleUpdatePersonName({ id, name });
newName = '';
};
@@ -31,7 +31,7 @@
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { getPersonActions } from '$lib/services/person.service';
import { getPersonActions, handleUpdatePersonName } from '$lib/services/person.service';
import { locale } from '$lib/stores/preferences.store';
import { websocketEvents } from '$lib/stores/websocket';
import { getPeopleThumbnailUrl } from '$lib/utils';
@@ -217,12 +217,7 @@
return;
}
try {
person = await updatePerson({ id: person.id, personUpdateDto: { name: personName } });
toastManager.primary($t('change_name_successfully'));
} catch (error) {
handleError(error, $t('errors.unable_to_save_name'));
}
person = (await handleUpdatePersonName({ id: person.id, name: personName }, { notify: true })) ?? person;
};
const handleCancelEditName = () => {
@@ -3,7 +3,7 @@
import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import { AssetOrder, updateMyPreferences } from '@immich/sdk';
import { AssetOrder, PersonUpdateStrategy, updateMyPreferences } from '@immich/sdk';
import { Button, Field, NumberInput, Select, Switch, toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -24,6 +24,7 @@
let peopleEnabled = $state(authManager.preferences.people?.enabled ?? false);
let peopleSidebar = $state(authManager.preferences.people?.sidebarWeb ?? false);
let peopleMinFaces = $state(authManager.preferences.people?.minimumFaces ?? serverConfigManager.value.minFaces);
let peopleUpdateStrategy = $state(authManager.preferences.people?.updateStrategy ?? PersonUpdateStrategy.Everyone);
// Ratings
let ratingsEnabled = $state(authManager.preferences.ratings?.enabled ?? false);
@@ -49,7 +50,12 @@
albums: { defaultAssetOrder },
folders: { enabled: foldersEnabled, sidebarWeb: foldersSidebar },
memories: { enabled: memoriesEnabled, duration: memoriesDuration, sidebarWeb: memoriesSidebar },
people: { enabled: peopleEnabled, sidebarWeb: peopleSidebar, minimumFaces: peopleMinFaces },
people: {
enabled: peopleEnabled,
sidebarWeb: peopleSidebar,
minimumFaces: peopleMinFaces,
updateStrategy: peopleUpdateStrategy,
},
ratings: { enabled: ratingsEnabled },
sharedLinks: { enabled: sharedLinksEnabled, sidebarWeb: sharedLinkSidebar },
tags: { enabled: tagsEnabled, sidebarWeb: tagsSidebar },
@@ -133,6 +139,15 @@
<Field label={$t('minFaces')} description={$t('minFaces_description')}>
<NumberInput bind:value={peopleMinFaces} />
</Field>
<Field label={$t('person_update_strategy')} description={$t('person_update_strategy_description')}>
<Select
options={[
{ label: $t('person_update_strategy_everyone'), value: PersonUpdateStrategy.Everyone },
{ label: $t('person_update_strategy_self'), value: PersonUpdateStrategy.Self },
]}
bind:value={peopleUpdateStrategy}
/>
</Field>
{/if}
</div>
</SettingAccordion>
@@ -1,4 +1,4 @@
import { AssetOrder, type UserPreferencesResponseDto } from '@immich/sdk';
import { AssetOrder, PersonUpdateStrategy, type UserPreferencesResponseDto } from '@immich/sdk';
import { Sync } from 'factory.ts';
export const preferencesFactory = Sync.makeFactory<UserPreferencesResponseDto>({
@@ -29,6 +29,7 @@ export const preferencesFactory = Sync.makeFactory<UserPreferencesResponseDto>({
people: {
enabled: false,
sidebarWeb: false,
updateStrategy: PersonUpdateStrategy.Everyone,
},
purchase: {
hideBuyButtonUntil: '',