mirror of
https://github.com/immich-app/immich.git
synced 2026-09-30 13:23:21 +08:00
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
(cherry picked from commit aec7c0d97b)
This commit is contained in:
committed by
immich-push-o-matic[bot]
parent
f3abf9fa40
commit
0074ee7c5d
@@ -1679,6 +1679,10 @@
|
|||||||
"person_read_access_message": "You only have read access to this person.",
|
"person_read_access_message": "You only have read access to this person.",
|
||||||
"person_recognized": "Person recognized",
|
"person_recognized": "Person recognized",
|
||||||
"person_share_no_users": "Every user in your cluster group already has access to this person.",
|
"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.",
|
"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": "Photos",
|
||||||
"photos_and_videos": "Photos & Videos",
|
"photos_and_videos": "Photos & Videos",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
|
|||||||
'folders': FoldersResponse(enabled: false, sidebarWeb: false).toJson(),
|
'folders': FoldersResponse(enabled: false, sidebarWeb: false).toJson(),
|
||||||
'memories': MemoriesResponse(enabled: true, duration: 5, sidebarWeb: false).toJson(),
|
'memories': MemoriesResponse(enabled: true, duration: 5, sidebarWeb: false).toJson(),
|
||||||
'ratings': RatingsResponse(enabled: 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(),
|
'tags': TagsResponse(enabled: false, sidebarWeb: false).toJson(),
|
||||||
'sharedLinks': SharedLinksResponse(enabled: true, sidebarWeb: false).toJson(),
|
'sharedLinks': SharedLinksResponse(enabled: true, sidebarWeb: false).toJson(),
|
||||||
'cast': CastResponse(gCastEnabled: false).toJson(),
|
'cast': CastResponse(gCastEnabled: false).toJson(),
|
||||||
@@ -42,6 +42,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
|
|||||||
'ServerFeaturesDto': {'ocr': false, 'realtimeTranscoding': false},
|
'ServerFeaturesDto': {'ocr': false, 'realtimeTranscoding': false},
|
||||||
'SearchAssetResponseDto': {'nextCursor': null},
|
'SearchAssetResponseDto': {'nextCursor': null},
|
||||||
'MemoriesResponse': {'duration': 5, 'sidebarWeb': false},
|
'MemoriesResponse': {'duration': 5, 'sidebarWeb': false},
|
||||||
|
'PeopleResponse': {'updateStrategy': 'everyone'},
|
||||||
'PersonResponseDto': {'otherPeople': const [], 'sharedBy': const [], 'sharedWith': const []},
|
'PersonResponseDto': {'otherPeople': const [], 'sharedBy': const [], 'sharedWith': const []},
|
||||||
'WorkflowResponseDto': {'logging': false},
|
'WorkflowResponseDto': {'logging': false},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24944,11 +24944,15 @@
|
|||||||
"sidebarWeb": {
|
"sidebarWeb": {
|
||||||
"description": "Whether people appear in web sidebar",
|
"description": "Whether people appear in web sidebar",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"updateStrategy": {
|
||||||
|
"$ref": "#/components/schemas/PersonUpdateStrategy"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"enabled",
|
"enabled",
|
||||||
"sidebarWeb"
|
"sidebarWeb",
|
||||||
|
"updateStrategy"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -25011,6 +25015,9 @@
|
|||||||
"sidebarWeb": {
|
"sidebarWeb": {
|
||||||
"description": "Whether people appear in web sidebar",
|
"description": "Whether people appear in web sidebar",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"updateStrategy": {
|
||||||
|
"$ref": "#/components/schemas/PersonUpdateStrategy"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
@@ -25069,7 +25076,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"userId": {
|
"userId": {
|
||||||
"description": "User ID",
|
"description": "Restrict the update to the person record of this User ID",
|
||||||
"format": "uuid",
|
"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)$",
|
"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"
|
"type": "string"
|
||||||
@@ -25527,7 +25534,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"userId": {
|
"userId": {
|
||||||
"description": "User ID",
|
"description": "Restrict the update to the person record of this User ID",
|
||||||
"format": "uuid",
|
"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)$",
|
"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"
|
"type": "string"
|
||||||
@@ -25535,6 +25542,14 @@
|
|||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"PersonUpdateStrategy": {
|
||||||
|
"description": "Which person records to update when editing a person",
|
||||||
|
"enum": [
|
||||||
|
"self",
|
||||||
|
"everyone"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"PersonUserRole": {
|
"PersonUserRole": {
|
||||||
"description": "Levels of access for managing people resources on behalf of another user.",
|
"description": "Levels of access for managing people resources on behalf of another user.",
|
||||||
"enum": [
|
"enum": [
|
||||||
|
|||||||
@@ -688,6 +688,7 @@ export type PeopleResponse = {
|
|||||||
minimumFaces?: number;
|
minimumFaces?: number;
|
||||||
/** Whether people appear in web sidebar */
|
/** Whether people appear in web sidebar */
|
||||||
sidebarWeb: boolean;
|
sidebarWeb: boolean;
|
||||||
|
updateStrategy: PersonUpdateStrategy;
|
||||||
};
|
};
|
||||||
export type PurchaseResponse = {
|
export type PurchaseResponse = {
|
||||||
/** Date until which to hide buy button */
|
/** Date until which to hide buy button */
|
||||||
@@ -774,6 +775,7 @@ export type PeopleUpdate = {
|
|||||||
minimumFaces?: number;
|
minimumFaces?: number;
|
||||||
/** Whether people appear in web sidebar */
|
/** Whether people appear in web sidebar */
|
||||||
sidebarWeb?: boolean;
|
sidebarWeb?: boolean;
|
||||||
|
updateStrategy?: PersonUpdateStrategy;
|
||||||
};
|
};
|
||||||
export type PurchaseUpdate = {
|
export type PurchaseUpdate = {
|
||||||
/** Date until which to hide buy button */
|
/** Date until which to hide buy button */
|
||||||
@@ -2033,7 +2035,7 @@ export type PeopleUpdateItem = {
|
|||||||
isHidden?: boolean;
|
isHidden?: boolean;
|
||||||
/** Person name */
|
/** Person name */
|
||||||
name?: string;
|
name?: string;
|
||||||
/** User ID */
|
/** Restrict the update to the person record of this User ID */
|
||||||
userId?: string;
|
userId?: string;
|
||||||
};
|
};
|
||||||
export type PeopleUpdateDto = {
|
export type PeopleUpdateDto = {
|
||||||
@@ -2090,7 +2092,7 @@ export type PersonUpdateDto = {
|
|||||||
isHidden?: boolean;
|
isHidden?: boolean;
|
||||||
/** Person name */
|
/** Person name */
|
||||||
name?: string;
|
name?: string;
|
||||||
/** User ID */
|
/** Restrict the update to the person record of this User ID */
|
||||||
userId?: string;
|
userId?: string;
|
||||||
};
|
};
|
||||||
export type AssetFaceUpdateItem = {
|
export type AssetFaceUpdateItem = {
|
||||||
@@ -8061,6 +8063,10 @@ export enum AssetOrder {
|
|||||||
Asc = "asc",
|
Asc = "asc",
|
||||||
Desc = "desc"
|
Desc = "desc"
|
||||||
}
|
}
|
||||||
|
export enum PersonUpdateStrategy {
|
||||||
|
Self = "self",
|
||||||
|
Everyone = "everyone"
|
||||||
|
}
|
||||||
export enum AssetVisibility {
|
export enum AssetVisibility {
|
||||||
Archive = "archive",
|
Archive = "archive",
|
||||||
Timeline = "timeline",
|
Timeline = "timeline",
|
||||||
|
|||||||
@@ -91,6 +91,23 @@ describe(PersonController.name, () => {
|
|||||||
expect(body).toEqual(errorDto.validationError([{ path: ['featureFaceAssetId'], message: 'Invalid UUID' }]));
|
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 () => {
|
it(`should require isFavorite to be a boolean`, async () => {
|
||||||
const { status, body } = await request(ctx.getHttpServer())
|
const { status, body } = await request(ctx.getHttpServer())
|
||||||
.put(`/people/${factory.uuid()}`)
|
.put(`/people/${factory.uuid()}`)
|
||||||
|
|||||||
@@ -29,10 +29,17 @@ const PersonCreateSchema = z
|
|||||||
})
|
})
|
||||||
.meta({ id: 'PersonCreateDto' });
|
.meta({ id: 'PersonCreateDto' });
|
||||||
|
|
||||||
const PersonUpdateSchema = PersonCreateSchema.extend({
|
const PersonUpdateBaseSchema = PersonCreateSchema.extend({
|
||||||
featureFaceAssetId: z.uuidv4().optional().describe('Asset ID used for feature face thumbnail'),
|
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({
|
const PeopleUpdateItemSchema = PersonUpdateSchema.extend({
|
||||||
id: z.uuidv4().describe('Person ID'),
|
id: z.uuidv4().describe('Person ID'),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
import type { UserPreferences } from 'src/types.js';
|
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
|
const AlbumsUpdateSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -47,6 +47,7 @@ const PeopleUpdateSchema = z
|
|||||||
enabled: z.boolean().optional().describe('Whether people are enabled'),
|
enabled: z.boolean().optional().describe('Whether people are enabled'),
|
||||||
sidebarWeb: z.boolean().optional().describe('Whether people appear in web sidebar'),
|
sidebarWeb: z.boolean().optional().describe('Whether people appear in web sidebar'),
|
||||||
minimumFaces: z.int().min(1).optional().describe('People face threshold'),
|
minimumFaces: z.int().min(1).optional().describe('People face threshold'),
|
||||||
|
updateStrategy: PersonUpdateStrategySchema.optional(),
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.meta({ id: 'PeopleUpdate' });
|
.meta({ id: 'PeopleUpdate' });
|
||||||
@@ -150,6 +151,7 @@ const PeopleResponseSchema = z
|
|||||||
enabled: z.boolean().describe('Whether people are enabled'),
|
enabled: z.boolean().describe('Whether people are enabled'),
|
||||||
sidebarWeb: z.boolean().describe('Whether people appear in web sidebar'),
|
sidebarWeb: z.boolean().describe('Whether people appear in web sidebar'),
|
||||||
minimumFaces: z.int().min(1).optional().describe('People face threshold'),
|
minimumFaces: z.int().min(1).optional().describe('People face threshold'),
|
||||||
|
updateStrategy: PersonUpdateStrategySchema,
|
||||||
})
|
})
|
||||||
.meta({ id: 'PeopleResponse' });
|
.meta({ id: 'PeopleResponse' });
|
||||||
|
|
||||||
|
|||||||
@@ -417,6 +417,16 @@ export const UserAvatarColorSchema = z
|
|||||||
.describe('User avatar color')
|
.describe('User avatar color')
|
||||||
.meta({ id: 'UserAvatarColor' });
|
.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 {
|
export enum UserStatus {
|
||||||
Active = 'active',
|
Active = 'active',
|
||||||
Removing = 'removing',
|
Removing = 'removing',
|
||||||
|
|||||||
@@ -1116,6 +1116,23 @@ from
|
|||||||
1
|
1
|
||||||
) as "dummy"
|
) 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
|
-- PersonRepository.getFacesByIds
|
||||||
select
|
select
|
||||||
"asset_face".*,
|
"asset_face".*,
|
||||||
|
|||||||
@@ -933,6 +933,26 @@ export class PersonRepository {
|
|||||||
.executeTakeFirstOrThrow();
|
.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> {
|
async updateAll(people: Insertable<PersonTable>[]): Promise<void> {
|
||||||
if (people.length === 0) {
|
if (people.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -122,8 +122,8 @@ describe(PersonService.name, () => {
|
|||||||
const person = PersonFactory.create();
|
const person = PersonFactory.create();
|
||||||
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
|
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
|
||||||
|
|
||||||
mocks.person.getForUser.mockResolvedValue(getDehydrated(person));
|
|
||||||
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
|
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
|
||||||
|
mocks.person.getForUser.mockResolvedValue(getDehydrated(person));
|
||||||
await expect(sut.getById(auth, person.personGroupId)).resolves.toEqual(
|
await expect(sut.getById(auth, person.personGroupId)).resolves.toEqual(
|
||||||
expect.objectContaining({ id: person.personGroupId }),
|
expect.objectContaining({ id: person.personGroupId }),
|
||||||
);
|
);
|
||||||
@@ -221,7 +221,7 @@ describe(PersonService.name, () => {
|
|||||||
const auth = AuthFactory.create();
|
const auth = AuthFactory.create();
|
||||||
const person = PersonFactory.create({ ownerId: auth.user.id });
|
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,
|
BadRequestException,
|
||||||
);
|
);
|
||||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||||
@@ -237,28 +237,46 @@ describe(PersonService.name, () => {
|
|||||||
|
|
||||||
mocks.access.person.checkAccess.mockResolvedValue(new Set());
|
mocks.access.person.checkAccess.mockResolvedValue(new Set());
|
||||||
await expect(sut.update(auth, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf(BadRequestException);
|
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(
|
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(
|
||||||
auth.user.id,
|
auth.user.id,
|
||||||
new Set([{ personGroupId: 'person-1', ownerId: auth.user.id }]),
|
new Set([{ personGroupId: 'person-1', ownerId: auth.user.id }]),
|
||||||
PERSON_WRITE_ROLES,
|
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 auth = AuthFactory.create();
|
||||||
const person = PersonFactory.create({ ownerId: auth.user.id, name: 'Person 1' });
|
const person = PersonFactory.create({ ownerId: auth.user.id, name: 'Person 1' });
|
||||||
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
|
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
|
||||||
|
|
||||||
mocks.person.update.mockResolvedValue(person);
|
|
||||||
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
|
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(
|
await expect(sut.update(auth, person.personGroupId, { name: 'Person 1' })).resolves.toEqual(
|
||||||
expect.objectContaining({ id: person.personGroupId, name: 'Person 1' }),
|
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({
|
expect(mocks.person.update).toHaveBeenCalledWith({
|
||||||
ownerId: person.ownerId,
|
ownerId: auth.user.id,
|
||||||
personGroupId: person.personGroupId,
|
personGroupId: person.personGroupId,
|
||||||
name: 'Person 1',
|
name: 'Person 1',
|
||||||
});
|
});
|
||||||
@@ -282,17 +300,41 @@ describe(PersonService.name, () => {
|
|||||||
personGroupId: person.personGroupId,
|
personGroupId: person.personGroupId,
|
||||||
name: 'Person 1',
|
name: 'Person 1',
|
||||||
});
|
});
|
||||||
|
expect(mocks.person.updateForWritableOwners).not.toHaveBeenCalled();
|
||||||
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES);
|
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 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 }];
|
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
|
||||||
|
|
||||||
mocks.person.update.mockResolvedValue(person);
|
mocks.person.update.mockResolvedValue(person);
|
||||||
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
|
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({
|
await expect(sut.update(auth, person.personGroupId, { birthDate: '1976-06-30' })).resolves.toEqual({
|
||||||
id: person.personGroupId,
|
id: person.personGroupId,
|
||||||
name: person.name,
|
name: person.name,
|
||||||
@@ -305,14 +347,19 @@ describe(PersonService.name, () => {
|
|||||||
sharedWith: [],
|
sharedWith: [],
|
||||||
updatedAt: expect.any(String),
|
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({
|
expect(mocks.person.update).toHaveBeenCalledWith({
|
||||||
ownerId: person.ownerId,
|
ownerId: auth.user.id,
|
||||||
personGroupId: person.personGroupId,
|
personGroupId: person.personGroupId,
|
||||||
birthDate: '1976-06-30',
|
birthDate: '1976-06-30',
|
||||||
});
|
});
|
||||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||||
expect(mocks.job.queueAll).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 () => {
|
it('should update a person visibility', async () => {
|
||||||
@@ -332,6 +379,7 @@ describe(PersonService.name, () => {
|
|||||||
personGroupId: person.personGroupId,
|
personGroupId: person.personGroupId,
|
||||||
isHidden: true,
|
isHidden: true,
|
||||||
});
|
});
|
||||||
|
expect(mocks.person.updateForWritableOwners).not.toHaveBeenCalled();
|
||||||
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES);
|
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,
|
personGroupId: person.personGroupId,
|
||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
});
|
});
|
||||||
|
expect(mocks.person.updateForWritableOwners).not.toHaveBeenCalled();
|
||||||
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES);
|
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 () => {
|
it("should update a person's thumbnailPath", async () => {
|
||||||
const face = AssetFaceFactory.create();
|
const face = AssetFaceFactory.create();
|
||||||
const auth = AuthFactory.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 () => {
|
it('should throw an error when the face feature assetId is invalid', async () => {
|
||||||
const auth = AuthFactory.create();
|
const auth = AuthFactory.create();
|
||||||
const person = PersonFactory.create({ ownerId: auth.user.id });
|
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.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['-1']));
|
||||||
mocks.person.getForFeatureFaceUpdate.mockResolvedValue(undefined);
|
mocks.person.getForFeatureFaceUpdate.mockResolvedValue(undefined);
|
||||||
|
|
||||||
@@ -399,7 +478,7 @@ describe(PersonService.name, () => {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
);
|
);
|
||||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
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 },
|
{ error: BulkIdErrorReason.UNKNOWN, id: 'person-1', success: false },
|
||||||
]);
|
]);
|
||||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.person.updateForWritableOwners).not.toHaveBeenCalled();
|
||||||
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(
|
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(
|
||||||
authStub.admin.user.id,
|
authStub.admin.user.id,
|
||||||
new Set([{ personGroupId: 'person-1', ownerId: authStub.admin.user.id }]),
|
new Set([{ personGroupId: 'person-1', ownerId: authStub.admin.user.id }]),
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import { getDimensions, getMyPartnerIds } from 'src/utils/asset.util.js';
|
|||||||
import { ImmichFileResponse } from 'src/utils/file.js';
|
import { ImmichFileResponse } from 'src/utils/file.js';
|
||||||
import { isHttpException } from 'src/utils/logger.js';
|
import { isHttpException } from 'src/utils/logger.js';
|
||||||
import { mimeTypes } from 'src/utils/mime-types.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';
|
import { Point, transformPoints } from 'src/utils/transform.js';
|
||||||
|
|
||||||
const personKey = ({ ownerId, personGroupId }: PersonId) => `${ownerId}/${personGroupId}`;
|
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> {
|
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({
|
await this.requirePersonAccess({
|
||||||
auth,
|
auth,
|
||||||
permission: Permission.PersonUpdate,
|
permission: Permission.PersonUpdate,
|
||||||
ids: [{ personGroupId, ownerId }],
|
ids: [{ personGroupId, ownerId: targetOwnerId }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const { name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite, color } = dto;
|
if (!userIdOverride && hasSharedProperties) {
|
||||||
// TODO: set by faceId directly
|
await this.personRepository.updateForWritableOwners({ userId: auth.user.id, personGroupId }, { name, birthDate });
|
||||||
|
}
|
||||||
|
|
||||||
let faceId: string | undefined;
|
let faceId: string | undefined;
|
||||||
|
|
||||||
if (assetId) {
|
if (assetId) {
|
||||||
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [assetId] });
|
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [assetId] });
|
||||||
const face = await this.personRepository.getForFeatureFaceUpdate({ personGroupId, assetId });
|
const face = await this.personRepository.getForFeatureFaceUpdate({ personGroupId, assetId });
|
||||||
@@ -269,7 +279,7 @@ export class PersonService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const person = await this.personRepository.update({
|
const person = await this.personRepository.update({
|
||||||
ownerId,
|
ownerId: targetOwnerId,
|
||||||
personGroupId,
|
personGroupId,
|
||||||
faceAssetId: faceId,
|
faceAssetId: faceId,
|
||||||
name,
|
name,
|
||||||
@@ -280,7 +290,10 @@ export class PersonService extends BaseService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (assetId) {
|
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);
|
return mapPerson(person);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
IntegrityReport,
|
IntegrityReport,
|
||||||
JobName,
|
JobName,
|
||||||
MemoryType,
|
MemoryType,
|
||||||
|
PersonUpdateStrategy,
|
||||||
QueueName,
|
QueueName,
|
||||||
StorageFolder,
|
StorageFolder,
|
||||||
SyncEntityType,
|
SyncEntityType,
|
||||||
@@ -580,6 +581,7 @@ export type UserPreferences = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
sidebarWeb: boolean;
|
sidebarWeb: boolean;
|
||||||
minimumFaces: number;
|
minimumFaces: number;
|
||||||
|
updateStrategy: PersonUpdateStrategy;
|
||||||
};
|
};
|
||||||
ratings: {
|
ratings: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
@@ -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));
|
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>> => {
|
export const findOrFail = async <T>(find: () => Promise<T>, entity: string): Promise<NonNullable<T>> => {
|
||||||
const value = await find();
|
const value = await find();
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { get, isEqual, set } from 'lodash-es';
|
import { get, isEqual, set } from 'lodash-es';
|
||||||
import type { DeepPartial, UserMetadataItem, UserPreferences } from 'src/types.js';
|
import type { DeepPartial, UserMetadataItem, UserPreferences } from 'src/types.js';
|
||||||
import { UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto.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 { HumanReadableSize } from 'src/utils/bytes.js';
|
||||||
import { getKeysDeep } from 'src/utils/misc.js';
|
import { getKeysDeep } from 'src/utils/misc.js';
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ const getDefaultPreferences = (): UserPreferences => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
sidebarWeb: false,
|
sidebarWeb: false,
|
||||||
minimumFaces: 3,
|
minimumFaces: 3,
|
||||||
|
updateStrategy: PersonUpdateStrategy.Everyone,
|
||||||
},
|
},
|
||||||
sharedLinks: {
|
sharedLinks: {
|
||||||
enabled: true,
|
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', () => {
|
describe('delete', () => {
|
||||||
it('should throw an error when there is no access', async () => {
|
it('should throw an error when there is no access', async () => {
|
||||||
const { sut } = setup();
|
const { sut } = setup();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Kysely } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
import { DateTime } from 'luxon';
|
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 { ClusterGroupRepository } from 'src/repositories/cluster-group.repository.js';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository.js';
|
import { ConfigRepository } from 'src/repositories/config.repository.js';
|
||||||
import { CryptoRepository } from 'src/repositories/crypto.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.updateMyPreferences(auth, dto)).resolves.toMatchObject(dto);
|
||||||
await expect(sut.getMyPreferences(auth)).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', () => {
|
describe('setLicense', () => {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import PeopleFilterUserPicker from '$lib/modals/PeopleFilterUserPicker.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 { locale } from '$lib/stores/preferences.store';
|
||||||
import { PersonUserRole, type PersonResponseDto } from '@immich/sdk';
|
import { PersonUpdateStrategy, PersonUserRole, type PersonResponseDto } from '@immich/sdk';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
|
|
||||||
let targetUserId = $state(initialTargetUserId ?? authManager.user.id);
|
let targetUserId = $state(initialTargetUserId ?? authManager.user.id);
|
||||||
let targetPerson = $state(candidates[0]);
|
let targetPerson = $state(candidates[0]);
|
||||||
let applyToEveryone = $state(false);
|
let applyToEveryone = $state(authManager.preferences.people?.updateStrategy === PersonUpdateStrategy.Everyone);
|
||||||
|
|
||||||
const isWritable = $derived.by(() => {
|
const isWritable = $derived.by(() => {
|
||||||
if (!targetUserId) {
|
if (!targetUserId) {
|
||||||
@@ -68,20 +68,14 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
const userIdsToUpdate = applyToEveryone
|
const response = await handleUpdatePerson({
|
||||||
? candidates.map(({ sharedById }) => sharedById)
|
id: person.id,
|
||||||
: [targetPerson.sharedById];
|
name: targetPerson.name,
|
||||||
|
birthDate: targetPerson.birthDate,
|
||||||
const success = await handleUpdatePeople({
|
userId: applyToEveryone ? undefined : targetPerson.sharedById,
|
||||||
people: userIdsToUpdate.map((userId) => ({
|
|
||||||
id: person.id,
|
|
||||||
name: targetPerson.name,
|
|
||||||
birthDate: targetPerson.birthDate,
|
|
||||||
userId,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (success) {
|
if (response) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
getPerson,
|
getPerson,
|
||||||
updatePeople,
|
PersonUpdateStrategy,
|
||||||
updatePerson,
|
updatePerson,
|
||||||
type AssetResponseDto,
|
type AssetResponseDto,
|
||||||
type PeopleUpdateDto,
|
|
||||||
type PersonResponseDto,
|
type PersonResponseDto,
|
||||||
type PersonUpdateDto,
|
type PersonUpdateDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
@@ -18,6 +17,7 @@ import {
|
|||||||
mdiPencilOutline,
|
mdiPencilOutline,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import type { MessageFormatter } from 'svelte-i18n';
|
import type { MessageFormatter } from 'svelte-i18n';
|
||||||
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import PersonEditAccessModal from '$lib/modals/PersonEditAccessModal.svelte';
|
import PersonEditAccessModal from '$lib/modals/PersonEditAccessModal.svelte';
|
||||||
import PersonEditModal from '$lib/modals/PersonEditModal.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();
|
const $t = await getFormatter();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updatePerson({ id, personUpdateDto });
|
const response = await updatePerson({ id, personUpdateDto });
|
||||||
return true;
|
const isOtherUser = !!personUpdateDto.userId && personUpdateDto.userId !== authManager.user.id;
|
||||||
|
eventManager.emit('PersonUpdate', isOtherUser ? await getPerson({ id }) : response);
|
||||||
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.something_went_wrong'));
|
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 handleShowPerson = async (person: { id: string }) => {
|
||||||
const $t = await getFormatter();
|
const $t = await getFormatter();
|
||||||
|
|
||||||
@@ -161,17 +139,22 @@ const handleShowPerson = async (person: { id: string }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleUpdatePersonBirthDate = async (person: PersonResponseDto, birthDate: string | null) => {
|
export const withUpdateStrategy = (dto: PersonUpdateDto): PersonUpdateDto =>
|
||||||
const $t = await getFormatter();
|
authManager.preferences.people?.updateStrategy === PersonUpdateStrategy.Self
|
||||||
|
? { ...dto, userId: authManager.user.id }
|
||||||
|
: dto;
|
||||||
|
|
||||||
try {
|
export const handleUpdatePersonName = async (
|
||||||
const response = await updatePerson({ id: person.id, personUpdateDto: { birthDate } });
|
{ id, name }: { id: string; name: string },
|
||||||
toastManager.primary($t('date_of_birth_saved'));
|
options?: { notify: boolean },
|
||||||
eventManager.emit('PersonUpdate', response);
|
) => {
|
||||||
return true;
|
const response = await handleUpdatePerson({ id, ...withUpdateStrategy({ name }) });
|
||||||
} catch (error) {
|
if (response && options?.notify) {
|
||||||
handleError(error, $t('errors.unable_to_save_date_of_birth'));
|
const $t = await getFormatter();
|
||||||
|
toastManager.primary($t('change_name_successfully'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSetFeaturedPhoto = async (person: PersonResponseDto, featureFaceAssetId: string) => {
|
const handleSetFeaturedPhoto = async (person: PersonResponseDto, featureFaceAssetId: string) => {
|
||||||
|
|||||||
@@ -18,8 +18,9 @@
|
|||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { clearQueryParam } from '$lib/utils/navigation';
|
import { clearQueryParam } from '$lib/utils/navigation';
|
||||||
import { getAllPeople, getPerson, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk';
|
import { handleUpdatePersonName } from '$lib/services/person.service';
|
||||||
import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
|
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 { mdiAccountOff, mdiEyeOutline, mdiTune } from '@mdi/js';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@@ -150,19 +151,7 @@
|
|||||||
* the person he's editing
|
* the person he's editing
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
try {
|
await handleUpdatePersonName({ id: personToBeMergedInto.id, name: newName }, { notify: true });
|
||||||
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'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -255,11 +244,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateName = async (id: string, name: string) => {
|
const updateName = async (id: string, name: string) => {
|
||||||
await updatePerson({
|
await handleUpdatePersonName({ id, name });
|
||||||
id,
|
|
||||||
personUpdateDto: { name },
|
|
||||||
});
|
|
||||||
|
|
||||||
newName = '';
|
newName = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+2
-7
@@ -31,7 +31,7 @@
|
|||||||
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
|
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
import { getAssetBulkActions } from '$lib/services/asset.service';
|
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 { locale } from '$lib/stores/preferences.store';
|
||||||
import { websocketEvents } from '$lib/stores/websocket';
|
import { websocketEvents } from '$lib/stores/websocket';
|
||||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||||
@@ -217,12 +217,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
person = (await handleUpdatePersonName({ id: person.id, name: personName }, { notify: true })) ?? person;
|
||||||
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'));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelEditName = () => {
|
const handleCancelEditName = () => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte';
|
import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
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 { Button, Field, NumberInput, Select, Switch, toastManager } from '@immich/ui';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
let peopleEnabled = $state(authManager.preferences.people?.enabled ?? false);
|
let peopleEnabled = $state(authManager.preferences.people?.enabled ?? false);
|
||||||
let peopleSidebar = $state(authManager.preferences.people?.sidebarWeb ?? false);
|
let peopleSidebar = $state(authManager.preferences.people?.sidebarWeb ?? false);
|
||||||
let peopleMinFaces = $state(authManager.preferences.people?.minimumFaces ?? serverConfigManager.value.minFaces);
|
let peopleMinFaces = $state(authManager.preferences.people?.minimumFaces ?? serverConfigManager.value.minFaces);
|
||||||
|
let peopleUpdateStrategy = $state(authManager.preferences.people?.updateStrategy ?? PersonUpdateStrategy.Everyone);
|
||||||
|
|
||||||
// Ratings
|
// Ratings
|
||||||
let ratingsEnabled = $state(authManager.preferences.ratings?.enabled ?? false);
|
let ratingsEnabled = $state(authManager.preferences.ratings?.enabled ?? false);
|
||||||
@@ -49,7 +50,12 @@
|
|||||||
albums: { defaultAssetOrder },
|
albums: { defaultAssetOrder },
|
||||||
folders: { enabled: foldersEnabled, sidebarWeb: foldersSidebar },
|
folders: { enabled: foldersEnabled, sidebarWeb: foldersSidebar },
|
||||||
memories: { enabled: memoriesEnabled, duration: memoriesDuration, sidebarWeb: memoriesSidebar },
|
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 },
|
ratings: { enabled: ratingsEnabled },
|
||||||
sharedLinks: { enabled: sharedLinksEnabled, sidebarWeb: sharedLinkSidebar },
|
sharedLinks: { enabled: sharedLinksEnabled, sidebarWeb: sharedLinkSidebar },
|
||||||
tags: { enabled: tagsEnabled, sidebarWeb: tagsSidebar },
|
tags: { enabled: tagsEnabled, sidebarWeb: tagsSidebar },
|
||||||
@@ -133,6 +139,15 @@
|
|||||||
<Field label={$t('minFaces')} description={$t('minFaces_description')}>
|
<Field label={$t('minFaces')} description={$t('minFaces_description')}>
|
||||||
<NumberInput bind:value={peopleMinFaces} />
|
<NumberInput bind:value={peopleMinFaces} />
|
||||||
</Field>
|
</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</SettingAccordion>
|
</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';
|
import { Sync } from 'factory.ts';
|
||||||
|
|
||||||
export const preferencesFactory = Sync.makeFactory<UserPreferencesResponseDto>({
|
export const preferencesFactory = Sync.makeFactory<UserPreferencesResponseDto>({
|
||||||
@@ -29,6 +29,7 @@ export const preferencesFactory = Sync.makeFactory<UserPreferencesResponseDto>({
|
|||||||
people: {
|
people: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
sidebarWeb: false,
|
sidebarWeb: false,
|
||||||
|
updateStrategy: PersonUpdateStrategy.Everyone,
|
||||||
},
|
},
|
||||||
purchase: {
|
purchase: {
|
||||||
hideBuyButtonUntil: '',
|
hideBuyButtonUntil: '',
|
||||||
|
|||||||
Reference in New Issue
Block a user