feat: people management (#31620)

Co-authored-by: Jason Rasmussen <jason@rasm.me>
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
Daniel Dietzler
2026-09-28 21:29:15 +02:00
committed by GitHub
co-authored by Jason Rasmussen shenlong-tanwen
parent 0f179b9914
commit c12d5e6d35
67 changed files with 4812 additions and 582 deletions
+13 -1
View File
@@ -791,6 +791,7 @@
"copied_image_to_clipboard": "Copied image to clipboard.",
"copied_to_clipboard": "Copied to clipboard!",
"copy_error": "Copy error",
"copy_from_my_person": "Copy from my person",
"copy_image": "Copy Image",
"copy_json": "Copy JSON",
"copy_link": "Copy link",
@@ -943,6 +944,7 @@
"edit_location_dialog_title": "Location",
"edit_name": "Edit name",
"edit_people": "Edit people",
"edit_person": "Edit person",
"edit_tag": "Edit tag",
"edit_title": "Edit Title",
"edit_user": "Edit user",
@@ -1210,6 +1212,7 @@
"headers_settings_tile_title": "Custom proxy headers",
"height": "Height",
"hi_user": "Hi {name} ({email})",
"hidden": "Hidden",
"hide_all_people": "Hide all people",
"hide_asset_owners": "Hide asset owners",
"hide_gallery": "Hide gallery",
@@ -1398,6 +1401,8 @@
"manage_media_access_subtitle": "Allow the Immich app to manage and move media files.",
"manage_media_access_title": "Media Management Access",
"manage_oauth_account": "Manage OAuth Account",
"manage_person_access": "Manage access",
"manage_person_access_description": "Give other users access to this person, allowing them to view or manage it on your behalf.",
"manage_sharing_with_other_users": "Manage sharing with other users",
"manage_sharing_with_partners": "Manage sharing with partners",
"manage_the_app_settings": "Manage the app settings",
@@ -1550,6 +1555,7 @@
"no_exif_info_available": "No exif info available",
"no_explore_results_message": "Upload more photos to explore your collection.",
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
"no_filter": "No filter",
"no_libraries_message": "Create an external library to view your photos and videos",
"no_local_assets_found": "No local assets found with this checksum",
"no_locked_photos_message": "Photos and videos in the locked folder are hidden and won't show up as you browse or search your library.",
@@ -1668,8 +1674,11 @@
"person_age_year_months": "1 year, {months, plural, one {# month} other {# months}} old",
"person_age_years": "{years, plural, one {# year} other {# years}} old",
"person_birthdate": "Born on {date}",
"person_edit_change_for_all_users": "Also change for {people}?",
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
"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.",
"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",
@@ -1759,6 +1768,7 @@
"rating_description": "Display the EXIF rating in the info panel",
"reaction_options": "Reaction options",
"read_changelog": "Read Changelog",
"readonly_access": "Read-only access",
"readonly_mode_disabled": "Read-only mode disabled",
"readonly_mode_enabled": "Read-only mode enabled",
"ready_for_upload": "Ready for upload",
@@ -1965,7 +1975,6 @@
"set_as_album_cover": "Set as album cover",
"set_as_featured_photo": "Set as featured photo",
"set_as_profile_picture": "Set as profile picture",
"set_date_of_birth": "Set date of birth",
"set_profile_picture": "Set profile picture",
"set_slideshow_to_fullscreen": "Set Slideshow to fullscreen",
"set_stack_primary_asset": "Set as primary asset",
@@ -2043,6 +2052,7 @@
"shared_links": "Shared links",
"shared_links_description": "Share photos and videos with a link",
"shared_photos_and_videos_count": "{assetCount, plural, other {# shared photos & videos.}}",
"shared_with": "Shared with",
"shared_with_me": "Shared with me",
"shared_with_partner": "Shared with {partner}",
"sharing": "Sharing",
@@ -2313,6 +2323,8 @@
"view_album": "View Album",
"view_all": "View All",
"view_all_users": "View all users",
"view_and_edit_person_fields": "View and edit person fields for this user.",
"view_as": "View as {name}",
"view_asset_owners": "View asset owners",
"view_details": "View Details",
"view_group": "View group",
@@ -74,8 +74,8 @@ class TimelineFactory {
TimelineService place(String place) => TimelineService(_timelineRepository.place(place, groupBy));
TimelineService person(String userId, String personId) =>
TimelineService(_timelineRepository.person(userId, personId, groupBy));
TimelineService person(List<String> userIds, String personId) =>
TimelineService(_timelineRepository.person(userIds, personId, groupBy));
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssets(assets, type));
@@ -366,10 +366,10 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
origin: TimelineOrigin.place,
);
TimelineQuery person(String userId, String personId, GroupAssetsBy groupBy) => (
bucketSource: () => _watchPersonBucket(userId, personId, groupBy: groupBy),
TimelineQuery person(List<String> userIds, String personId, GroupAssetsBy groupBy) => (
bucketSource: () => _watchPersonBucket(userIds, personId, groupBy: groupBy),
assetSource: (offset, count) =>
_getPersonBucketAssets(userId, personId, groupBy: groupBy, offset: offset, count: count),
_getPersonBucketAssets(userIds, personId, groupBy: groupBy, offset: offset, count: count),
origin: TimelineOrigin.person,
);
@@ -430,24 +430,30 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
}
Stream<List<Bucket>> _watchPersonBucket(String userId, String personId, {GroupAssetsBy groupBy = GroupAssetsBy.day}) {
final idQuery = _db.assetFaceEntity.selectOnly()
Expression<bool> _personAssetFilter(List<String> userIds, String personId) {
final faceAssetIds = _db.assetFaceEntity.selectOnly()
..addColumns([_db.assetFaceEntity.assetId])
..where(
_db.assetFaceEntity.personId.equals(personId) &
_db.assetFaceEntity.isVisible.equals(true) &
_db.assetFaceEntity.deletedAt.isNull(),
);
final albumAssetIds = _db.remoteAlbumAssetEntity.selectOnly()..addColumns([_db.remoteAlbumAssetEntity.assetId]);
return _db.remoteAssetEntity.id.isInQuery(faceAssetIds) &
(_db.remoteAssetEntity.ownerId.isIn(userIds) | _db.remoteAssetEntity.id.isInQuery(albumAssetIds)) &
_db.remoteAssetEntity.deletedAt.isNull() &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline);
}
Stream<List<Bucket>> _watchPersonBucket(
List<String> userIds,
String personId, {
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) {
if (groupBy == GroupAssetsBy.none) {
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([_db.remoteAssetEntity.id.count()])
..where(
_db.remoteAssetEntity.id.isInQuery(idQuery) &
_db.remoteAssetEntity.deletedAt.isNull() &
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline),
);
..where(_personAssetFilter(userIds, personId));
return query.map((row) {
final count = row.read(_db.remoteAssetEntity.id.count())!;
@@ -460,12 +466,7 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([assetCountExp, dateExp])
..where(
_db.remoteAssetEntity.id.isInQuery(idQuery) &
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
_db.remoteAssetEntity.deletedAt.isNull(),
)
..where(_personAssetFilter(userIds, personId))
..groupBy([dateExp])
..orderBy([OrderingTerm.desc(dateExp)]);
@@ -477,28 +478,14 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
}
Future<List<BaseAsset>> _getPersonBucketAssets(
String userId,
List<String> userIds,
String personId, {
required int offset,
required int count,
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) {
final idQuery = _db.assetFaceEntity.selectOnly()
..addColumns([_db.assetFaceEntity.assetId])
..where(
_db.assetFaceEntity.personId.equals(personId) &
_db.assetFaceEntity.isVisible.equals(true) &
_db.assetFaceEntity.deletedAt.isNull(),
);
final query = _db.remoteAssetEntity.select()
..where(
(row) =>
row.id.isInQuery(idQuery) &
row.deletedAt.isNull() &
row.ownerId.equals(userId) &
row.visibility.equalsValue(AssetVisibility.timeline),
)
..where((_) => _personAssetFilter(userIds, personId))
..orderBy(_assetDateOrder(groupBy))
..limit(count, offset: offset);
@@ -82,7 +82,8 @@ class _PersonPageState extends ConsumerState<PersonPage> {
throw Exception('User must be logged in to view person timeline');
}
final timelineService = ref.watch(timelineFactoryProvider).person(user.id, _person.id);
final users = ref.watch(timelineUsersProvider).valueOrNull ?? [user.id];
final timelineService = ref.watch(timelineFactoryProvider).person(users, _person.id);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
+1
View File
@@ -42,6 +42,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
'ServerFeaturesDto': {'ocr': false, 'realtimeTranscoding': false},
'SearchAssetResponseDto': {'nextCursor': null},
'MemoriesResponse': {'duration': 5, 'sidebarWeb': false},
'PersonResponseDto': {'otherPeople': const [], 'sharedBy': const [], 'sharedWith': const []},
'WorkflowResponseDto': {'logging': false},
};
@@ -113,7 +113,7 @@ void main() {
await ctx.newFace(assetId: asset.id, personId: person.id);
await ctx.newFace(assetId: asset.id, personId: person.id);
final query = sut.person(user.id, person.id, .day);
final query = sut.person([user.id], person.id, .day);
final buckets = await query.bucketSource().first;
expect(buckets, hasLength(1));
@@ -140,7 +140,7 @@ void main() {
await ctx.newFace(assetId: shiftedLater.id, personId: person.id);
await ctx.newFace(assetId: shiftedEarlier.id, personId: person.id);
final query = sut.person(user.id, person.id, .day);
final query = sut.person([user.id], person.id, .day);
final buckets = await query.bucketSource().first;
expect(buckets, hasLength(2));
+532 -1
View File
@@ -9717,7 +9717,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BulkIdsDto"
"$ref": "#/components/schemas/PeopleDeleteDto"
}
}
},
@@ -9786,6 +9786,24 @@
"type": "string"
}
},
{
"name": "isFavorite",
"required": false,
"in": "query",
"description": "Filter by favorite status",
"schema": {
"type": "boolean"
}
},
{
"name": "isHidden",
"required": false,
"in": "query",
"description": "Filter by hidden status",
"schema": {
"type": "boolean"
}
},
{
"name": "page",
"required": false,
@@ -9798,6 +9816,28 @@
"type": "integer"
}
},
{
"name": "sharedById",
"required": false,
"in": "query",
"description": "Only include people to which the user gave access",
"schema": {
"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"
}
},
{
"name": "sharedWithId",
"required": false,
"in": "query",
"description": "Only include people to which the user was given access",
"schema": {
"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"
}
},
{
"name": "size",
"required": false,
@@ -10043,6 +10083,197 @@
"x-immich-state": "Stable"
}
},
"/people/users": {
"delete": {
"description": "Remove user access to people",
"operationId": "removeUsersFromPeople",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PersonUsersDeleteDto"
}
}
},
"required": true
},
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Remove users from people",
"tags": [
"People"
],
"x-immich-history": [
{
"version": "v3.3",
"state": "Added"
},
{
"version": "v3.3",
"state": "Stable"
}
],
"x-immich-permission": "person.delete",
"x-immich-state": "Stable"
},
"get": {
"description": "Retrieve a list of users and the people to which they have been given access",
"operationId": "getUsersForPeople",
"parameters": [
{
"name": "direction",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/SharingDirection"
}
},
{
"name": "personId",
"required": false,
"in": "query",
"description": "Person ID",
"schema": {
"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"
}
},
{
"name": "role",
"required": false,
"in": "query",
"description": "Role of user",
"schema": {
"$ref": "#/components/schemas/PersonUserRole"
}
},
{
"name": "sharedById",
"required": false,
"in": "query",
"description": "User ID of the user that gave access",
"schema": {
"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"
}
},
{
"name": "sharedWithId",
"required": false,
"in": "query",
"description": "User ID of the user that was given access",
"schema": {
"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"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PersonUsersResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Get people access",
"tags": [
"People"
],
"x-immich-history": [
{
"version": "v3.3",
"state": "Added"
},
{
"version": "v3.3",
"state": "Stable"
}
],
"x-immich-permission": "person.read",
"x-immich-state": "Stable"
},
"put": {
"description": "Give users access to people",
"operationId": "addUsersToPeople",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PersonUsersCreateDto"
}
}
},
"required": true
},
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Give users access to people",
"tags": [
"People"
],
"x-immich-history": [
{
"version": "v3.3",
"state": "Added"
},
{
"version": "v3.3",
"state": "Stable"
}
],
"x-immich-permission": "person.update",
"x-immich-state": "Stable"
}
},
"/people/{id}": {
"delete": {
"description": "Delete an individual person.",
@@ -10059,6 +10290,16 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PersonDeleteDto"
}
}
},
"required": true
},
"responses": {
"204": {
"description": ""
@@ -20263,6 +20504,12 @@
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"userId": {
"description": "User ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"width": {
"description": "Face bounding box width",
"maximum": 9007199254740991,
@@ -20403,6 +20650,12 @@
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"userId": {
"description": "User ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
"required": [
@@ -24650,6 +24903,32 @@
],
"type": "object"
},
"PeopleDeleteDto": {
"properties": {
"ids": {
"description": "IDs to process",
"items": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"type": "array"
},
"userId": {
"type": "string"
}
},
"required": [
"ids"
],
"type": "object",
"x-immich-history": [
{
"version": "v3.3",
"state": "Added"
}
]
},
"PeopleResponse": {
"properties": {
"enabled": {
@@ -24788,6 +25067,12 @@
"name": {
"description": "Person name",
"type": "string"
},
"userId": {
"description": "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"
}
},
"required": [
@@ -24795,6 +25080,52 @@
],
"type": "object"
},
"PeopleUserResponseDto": {
"properties": {
"avatarColor": {
"$ref": "#/components/schemas/UserAvatarColor"
},
"email": {
"description": "User email",
"format": "email",
"pattern": "^[\\p{L}\\p{M}\\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?(?:\\.[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?)*$",
"type": "string"
},
"id": {
"description": "User ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"name": {
"description": "User name",
"type": "string"
},
"profileChangedAt": {
"description": "Profile change date",
"format": "date-time",
"type": "string"
},
"profileImagePath": {
"description": "Profile image path",
"type": "string"
},
"role": {
"$ref": "#/components/schemas/PersonUserRole",
"description": "Access role"
}
},
"required": [
"avatarColor",
"email",
"id",
"name",
"profileChangedAt",
"profileImagePath",
"role"
],
"type": "object"
},
"Permission": {
"description": "List of permissions",
"enum": [
@@ -24998,6 +25329,52 @@
},
"type": "object"
},
"PersonDeleteDto": {
"default": {},
"properties": {
"userId": {
"type": "string"
}
},
"type": "object",
"x-immich-history": [
{
"version": "v3.3",
"state": "Added"
},
{
"version": "v3.3",
"state": "Stable"
}
],
"x-immich-state": "Stable"
},
"PersonOtherResponseDto": {
"properties": {
"birthDate": {
"nullable": true,
"type": "string"
},
"name": {
"type": "string"
},
"role": {
"$ref": "#/components/schemas/PersonUserRole"
},
"sharedById": {
"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"
}
},
"required": [
"birthDate",
"name",
"role",
"sharedById"
],
"type": "object"
},
"PersonResponseDto": {
"properties": {
"birthDate": {
@@ -25050,6 +25427,26 @@
"description": "Person name",
"type": "string"
},
"otherPeople": {
"items": {
"$ref": "#/components/schemas/PersonOtherResponseDto"
},
"type": "array"
},
"sharedBy": {
"description": "Users that gave the current user access to this person",
"items": {
"$ref": "#/components/schemas/PeopleUserResponseDto"
},
"type": "array"
},
"sharedWith": {
"description": "Users the current user gave access to this person",
"items": {
"$ref": "#/components/schemas/PeopleUserResponseDto"
},
"type": "array"
},
"thumbnailPath": {
"description": "Thumbnail path",
"type": "string"
@@ -25076,6 +25473,9 @@
"id",
"isHidden",
"name",
"otherPeople",
"sharedBy",
"sharedWith",
"thumbnailPath"
],
"type": "object"
@@ -25125,10 +25525,133 @@
"name": {
"description": "Person name",
"type": "string"
},
"userId": {
"description": "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"
}
},
"type": "object"
},
"PersonUserRole": {
"description": "Levels of access for managing people resources on behalf of another user.",
"enum": [
"read",
"write",
"admin"
],
"type": "string"
},
"PersonUsersCreateDto": {
"properties": {
"personIds": {
"description": "Person IDs",
"items": {
"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"
},
"type": "array"
},
"role": {
"$ref": "#/components/schemas/PersonUserRole",
"description": "Role that should be applied"
},
"sharedWithIds": {
"description": "User IDs that should be given access to the person",
"items": {
"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"
},
"type": "array"
}
},
"required": [
"personIds",
"role",
"sharedWithIds"
],
"type": "object"
},
"PersonUsersDeleteDto": {
"items": {
"properties": {
"personId": {
"description": "Person 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"
},
"sharedById": {
"description": "User ID of the user that gave access to the person",
"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"
},
"sharedWithId": {
"description": "User ID of the user that was given access to the person",
"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"
}
},
"required": [
"personId",
"sharedWithId"
],
"type": "object"
},
"type": "array"
},
"PersonUsersResponseDto": {
"items": {
"properties": {
"personId": {
"description": "Person 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"
},
"role": {
"$ref": "#/components/schemas/PersonUserRole",
"description": "Access role"
},
"sharedBy": {
"$ref": "#/components/schemas/UserResponseDto",
"description": "The user that gave access to this person"
},
"sharedById": {
"description": "User ID of the user that gave access to this person",
"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"
},
"sharedWith": {
"$ref": "#/components/schemas/UserResponseDto",
"description": "The user that was given access to this person"
},
"sharedWithId": {
"description": "User ID of the user that was given access to this person",
"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"
}
},
"required": [
"personId",
"sharedById",
"sharedWithId",
"sharedBy",
"sharedWith",
"role"
],
"type": "object"
},
"type": "array"
},
"PinCodeChangeDto": {
"properties": {
"newPinCode": {
@@ -27972,6 +28495,14 @@
},
"type": "object"
},
"SharingDirection": {
"description": "Sharing direction",
"enum": [
"shared-by",
"shared-with"
],
"type": "string"
},
"SignUpDto": {
"properties": {
"email": {
+146 -8
View File
@@ -1216,6 +1216,27 @@ export type ExifResponseDto = {
/** Time zone */
timeZone?: string | null;
};
export type PersonOtherResponseDto = {
birthDate: string | null;
name: string;
role: PersonUserRole;
sharedById: string;
};
export type PeopleUserResponseDto = {
avatarColor: UserAvatarColor;
/** User email */
email: string;
/** User ID */
id: string;
/** User name */
name: string;
/** Profile change date */
profileChangedAt: string;
/** Profile image path */
profileImagePath: string;
/** Access role */
role: PersonUserRole;
};
export type PersonResponseDto = {
/** Person date of birth */
birthDate: string | null;
@@ -1229,6 +1250,11 @@ export type PersonResponseDto = {
isHidden: boolean;
/** Person name */
name: string;
otherPeople: PersonOtherResponseDto[];
/** Users that gave the current user access to this person */
sharedBy: PeopleUserResponseDto[];
/** Users the current user gave access to this person */
sharedWith: PeopleUserResponseDto[];
/** Thumbnail path */
thumbnailPath: string;
/** Last update date */
@@ -1700,6 +1726,8 @@ export type AssetFaceCreateDto = {
imageWidth: number;
/** Person ID */
personId: string;
/** User ID */
userId?: string;
/** Face bounding box width */
width: number;
/** Face bounding box X coordinate */
@@ -1964,6 +1992,11 @@ export type PartnerUpdateDto = {
/** Show partner assets in timeline */
inTimeline: boolean;
};
export type PeopleDeleteDto = {
/** IDs to process */
ids: string[];
userId?: string;
};
export type PeopleResponseDto = {
/** Whether there are more pages */
hasNextPage?: boolean;
@@ -2000,6 +2033,8 @@ export type PeopleUpdateItem = {
isHidden?: boolean;
/** Person name */
name?: string;
/** User ID */
userId?: string;
};
export type PeopleUpdateDto = {
/** People to update */
@@ -2009,6 +2044,39 @@ export type MergePersonDto = {
/** Person IDs to merge */
ids: string[];
};
export type PersonUsersDeleteDto = {
/** Person ID */
personId: string;
/** User ID of the user that gave access to the person */
sharedById?: string;
/** User ID of the user that was given access to the person */
sharedWithId: string;
}[];
export type PersonUsersResponseDto = {
/** Person ID */
personId: string;
/** Access role */
role: PersonUserRole;
/** The user that gave access to this person */
sharedBy: UserResponseDto;
/** User ID of the user that gave access to this person */
sharedById: string;
/** The user that was given access to this person */
sharedWith: UserResponseDto;
/** User ID of the user that was given access to this person */
sharedWithId: string;
}[];
export type PersonUsersCreateDto = {
/** Person IDs */
personIds: string[];
/** Role that should be applied */
role: PersonUserRole;
/** User IDs that should be given access to the person */
sharedWithIds: string[];
};
export type PersonDeleteDto = {
userId?: string;
};
export type PersonUpdateDto = {
/** Person date of birth */
birthDate?: string | null;
@@ -2022,12 +2090,16 @@ export type PersonUpdateDto = {
isHidden?: boolean;
/** Person name */
name?: string;
/** User ID */
userId?: string;
};
export type AssetFaceUpdateItem = {
/** Asset ID */
assetId: string;
/** Person ID */
personId: string;
/** User ID */
userId?: string;
};
export type AssetFaceUpdateDto = {
/** Face update items */
@@ -6010,22 +6082,26 @@ export function updatePartner({ id, partnerUpdateDto }: {
/**
* Delete people
*/
export function deletePeople({ bulkIdsDto }: {
bulkIdsDto: BulkIdsDto;
export function deletePeople({ peopleDeleteDto }: {
peopleDeleteDto: PeopleDeleteDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/people", oazapfts.json({
...opts,
method: "DELETE",
body: bulkIdsDto
body: peopleDeleteDto
})));
}
/**
* Get all people
*/
export function getAllPeople({ closestAssetId, closestPersonId, page, size, withHidden }: {
export function getAllPeople({ closestAssetId, closestPersonId, isFavorite, isHidden, page, sharedById, sharedWithId, size, withHidden }: {
closestAssetId?: string;
closestPersonId?: string;
isFavorite?: boolean;
isHidden?: boolean;
page?: number;
sharedById?: string;
sharedWithId?: string;
size?: number;
withHidden?: boolean;
}, opts?: Oazapfts.RequestOpts) {
@@ -6035,7 +6111,11 @@ export function getAllPeople({ closestAssetId, closestPersonId, page, size, with
}>(`/people${QS.query(QS.explode({
closestAssetId,
closestPersonId,
isFavorite,
isHidden,
page,
sharedById,
sharedWithId,
size,
withHidden
}))}`, {
@@ -6087,16 +6167,65 @@ export function mergePeople({ mergePersonDto }: {
body: mergePersonDto
})));
}
/**
* Remove users from people
*/
export function removeUsersFromPeople({ personUsersDeleteDto }: {
personUsersDeleteDto: PersonUsersDeleteDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/people/users", oazapfts.json({
...opts,
method: "DELETE",
body: personUsersDeleteDto
})));
}
/**
* Get people access
*/
export function getUsersForPeople({ direction, personId, role, sharedById, sharedWithId }: {
direction?: SharingDirection;
personId?: string;
role?: PersonUserRole;
sharedById?: string;
sharedWithId?: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PersonUsersResponseDto;
}>(`/people/users${QS.query(QS.explode({
direction,
personId,
role,
sharedById,
sharedWithId
}))}`, {
...opts
}));
}
/**
* Give users access to people
*/
export function addUsersToPeople({ personUsersCreateDto }: {
personUsersCreateDto: PersonUsersCreateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/people/users", oazapfts.json({
...opts,
method: "PUT",
body: personUsersCreateDto
})));
}
/**
* Delete person
*/
export function deletePerson({ id }: {
export function deletePerson({ id, personDeleteDto }: {
id: string;
personDeleteDto: PersonDeleteDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/people/${encodeURIComponent(id)}`, {
return oazapfts.ok(oazapfts.fetchText(`/people/${encodeURIComponent(id)}`, oazapfts.json({
...opts,
method: "DELETE"
}));
method: "DELETE",
body: personDeleteDto
})));
}
/**
* Get a person
@@ -8145,6 +8274,11 @@ export enum AssetJobName {
RegenerateThumbnail = "regenerate-thumbnail",
TranscodeVideo = "transcode-video"
}
export enum PersonUserRole {
Read = "read",
Write = "write",
Admin = "admin"
}
export enum AssetTypeEnum {
Image = "IMAGE",
Video = "VIDEO",
@@ -8229,6 +8363,10 @@ export enum PartnerDirection {
SharedBy = "shared-by",
SharedWith = "shared-with"
}
export enum SharingDirection {
SharedBy = "shared-by",
SharedWith = "shared-with"
}
export enum WorkflowType {
AssetV1 = "AssetV1"
}
+3
View File
@@ -1005,6 +1005,9 @@ importers:
eslint-plugin-compat:
specifier: ^7.0.0
version: 7.0.2(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))
eslint-plugin-prettier:
specifier: ^5.5.6
version: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1)))(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))(prettier@3.9.8)
eslint-plugin-svelte:
specifier: ^3.12.4
version: 3.23.0(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))(svelte@5.57.0(@typescript-eslint/types@8.70.0))
@@ -149,13 +149,13 @@ describe(PersonController.name, () => {
describe('DELETE /people/:id', () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/people/invalid`);
const { status, body } = await request(ctx.getHttpServer()).delete(`/people/invalid`).send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
it('should respond with 204', async () => {
const { status } = await request(ctx.getHttpServer()).delete(`/people/${factory.uuid()}`);
const { status } = await request(ctx.getHttpServer()).delete(`/people/${factory.uuid()}`).send({});
expect(status).toBe(204);
expect(service.delete).toHaveBeenCalled();
});
+43 -4
View File
@@ -17,17 +17,23 @@ import { ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger';
import type { NextFunction, Response } from 'express';
import type { AuthDto } from 'src/dtos/auth.dto.js';
import { Endpoint, HistoryBuilder } from 'src/decorators.js';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto.js';
import { BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto.js';
import {
AssetFaceUpdateDto,
MergePersonDto,
PeopleDeleteDto,
PeopleResponseDto,
PeopleUpdateDto,
PersonCreateDto,
PersonDeleteDto,
PersonResponseDto,
PersonSearchDto,
PersonStatisticsResponseDto,
PersonUpdateDto,
PersonUsersCreateDto,
PersonUsersDeleteDto,
PersonUsersResponseDto,
PersonUsersSearchDto,
} from 'src/dtos/person.dto.js';
import { ApiTag, Permission } from 'src/enum.js';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard.js';
@@ -87,7 +93,7 @@ export class PersonController {
description: 'Bulk delete a list of people at once.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deletePeople(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<void> {
deletePeople(@Auth() auth: AuthDto, @Body() dto: PeopleDeleteDto): Promise<void> {
return this.service.deleteAll(auth, dto);
}
@@ -140,8 +146,8 @@ export class PersonController {
description: 'Delete an individual person.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deletePerson(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
deletePerson(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: PersonDeleteDto): Promise<void> {
return this.service.delete(auth, id, dto);
}
@Get(':id/statistics')
@@ -219,4 +225,37 @@ export class PersonController {
): Promise<BulkIdResponseDto[]> {
return this.service.mergePeople(auth, { ids: [id, ...dto.ids] });
}
@Get('users')
@Authenticated({ permission: Permission.PersonRead })
@Endpoint({
summary: 'Get people access',
description: 'Retrieve a list of users and the people to which they have been given access',
history: new HistoryBuilder().added('v3.3').stable('v3.3'),
})
getUsersForPeople(@Auth() auth: AuthDto, @Query() dto: PersonUsersSearchDto): Promise<PersonUsersResponseDto> {
return this.service.getUsersForPeople(auth, dto);
}
@Put('users')
@Authenticated({ permission: Permission.PersonUpdate })
@Endpoint({
summary: 'Give users access to people',
description: 'Give users access to people',
history: new HistoryBuilder().added('v3.3').stable('v3.3'),
})
addUsersToPeople(@Auth() auth: AuthDto, @Body() dto: PersonUsersCreateDto): Promise<void> {
return this.service.addUsersToPeople(auth, dto);
}
@Delete('users')
@Authenticated({ permission: Permission.PersonDelete })
@Endpoint({
summary: 'Remove users from people',
description: 'Remove user access to people',
history: new HistoryBuilder().added('v3.3').stable('v3.3'),
})
removeUsersFromPeople(@Auth() auth: AuthDto, @Body() dto: PersonUsersDeleteDto): Promise<void> {
return this.service.removeUsersFromPeople(auth, dto);
}
}
+16 -2
View File
@@ -1,6 +1,7 @@
import { Selectable, ShallowDehydrateObject } from 'kysely';
import type { UserMetadataItem } from 'src/types.js';
import { MapAsset } from 'src/dtos/asset-response.dto.js';
import { PersonUserRole } from 'src/dtos/person.dto.js';
import {
AlbumUserRole,
AssetFileType,
@@ -253,8 +254,13 @@ export type Person = {
faceAssetId: string | null;
isHidden: boolean;
thumbnailPath: string;
otherPeople: { sharedById: string; name: string; birthDate: string | null; role: PersonUserRole }[];
sharedBy: PersonUser[];
sharedWith: PersonUser[];
};
export type PersonUser = User & { role: PersonUserRole };
export type AssetFace = {
id: string;
deletedAt: Date | null;
@@ -276,7 +282,7 @@ export type AssetFace = {
export type Plugin = Selectable<PluginTable>;
const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const;
const userWithPrefixColumns = [
const user2Columns = [
'user2.id',
'user2.name',
'user2.email',
@@ -374,7 +380,15 @@ export const columns = {
authApiKey: ['api_key.id', 'api_key.permissions'],
authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'],
user: userColumns,
userWithPrefix: userWithPrefixColumns,
user2: user2Columns,
userPrefix: [
'user.id',
'user.name',
'user.email',
'user.avatarColor',
'user.profileImagePath',
'user.profileChangedAt',
],
userAdmin: [
...userColumns,
'clusterGroupId',
+1 -3
View File
@@ -1,9 +1,7 @@
import { createZodDto } from 'nestjs-zod';
import z from 'zod';
import { UserResponseSchema } from 'src/dtos/user.dto.js';
import { PartnerDirection } from 'src/repositories/partner.repository.js';
const PartnerDirectionSchema = z.enum(PartnerDirection).describe('Partner direction').meta({ id: 'PartnerDirection' });
import { PartnerDirectionSchema } from 'src/enum.js';
const PartnerCreateSchema = z
.object({
+129 -4
View File
@@ -2,11 +2,13 @@ import { Selectable } from 'kysely';
import { createZodDto } from 'nestjs-zod';
import z from 'zod';
import type { ImageDimensions, MaybeDehydrated } from 'src/types.js';
import { AssetFace, Person } from 'src/database.js';
import { AssetFace, Person, PersonUser, User } from 'src/database.js';
import { HistoryBuilder } from 'src/decorators.js';
import { BulkIdsSchema } from 'src/dtos/asset-ids.response.dto.js';
import { AuthDto } from 'src/dtos/auth.dto.js';
import { AssetEditActionItem } from 'src/dtos/editing.dto.js';
import { SourceTypeSchema } from 'src/enum.js';
import { UserResponseSchema, mapUser } from 'src/dtos/user.dto.js';
import { SharingDirectionSchema, SourceTypeSchema } from 'src/enum.js';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table.js';
import { asDateString, asDateTimeString } from 'src/utils/date.js';
import { transformFaceBoundingBox } from 'src/utils/transform.js';
@@ -30,6 +32,7 @@ const PersonCreateSchema = z
const PersonUpdateSchema = 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 PeopleUpdateItemSchema = PersonUpdateSchema.extend({
@@ -55,9 +58,37 @@ const PersonSearchSchema = z
closestAssetId: z.uuidv4().optional().describe('Closest asset ID for similarity search'),
page: z.coerce.number().int().min(1).default(1).describe('Page number for pagination'),
size: z.coerce.number().int().min(1).max(1000).default(500).describe('Number of items per page'),
sharedById: z.uuid().optional().describe('Only include people to which the user gave access'),
sharedWithId: z.uuid().optional().describe('Only include people to which the user was given access'),
isFavorite: stringToBool.optional().describe('Filter by favorite status'),
isHidden: stringToBool.optional().describe('Filter by hidden status'),
})
.meta({ id: 'PersonSearchDto' });
export enum PersonUserRole {
Read = 'read',
Write = 'write',
Admin = 'admin',
}
const PersonUserRoleSchema = z
.enum(PersonUserRole)
.describe('Levels of access for managing people resources on behalf of another user.')
.meta({ id: 'PersonUserRole' });
const PersonOtherResponseSchema = z
.object({
sharedById: z.uuid(),
name: z.string(),
birthDate: z.string().nullable(),
role: PersonUserRoleSchema,
})
.meta({ id: 'PersonOtherResponseDto' });
const PeopleUserResponseSchema = UserResponseSchema.extend({
role: PersonUserRoleSchema.describe('Access role'),
}).meta({ id: 'PeopleUserResponseDto' });
export const PersonResponseSchema = z
.object({
id: z.uuidv4().describe('Person ID'),
@@ -83,17 +114,33 @@ export const PersonResponseSchema = z
.optional()
.describe('Person color (hex)')
.meta(new HistoryBuilder().added('v1.126.0').stable('v2').getExtensions()),
otherPeople: z.array(PersonOtherResponseSchema),
sharedBy: z.array(PeopleUserResponseSchema).describe('Users that gave the current user access to this person'),
sharedWith: z.array(PeopleUserResponseSchema).describe('Users the current user gave access to this person'),
})
.meta({ id: 'PersonResponseDto' });
const PersonDeleteSchema = z
.object({ userId: z.string().optional() })
.default({})
.meta({ id: 'PersonDeleteDto', ...new HistoryBuilder().added('v3.3').stable('v3.3').getExtensions() });
// TODO(v4) change to {userId: string, personId: string}[]
const PeopleDeleteSchema = BulkIdsSchema.extend({ userId: z.string().optional() }).meta({
id: 'PeopleDeleteDto',
...new HistoryBuilder().added('v3.3').getExtensions(),
});
export class PersonCreateDto extends createZodDto(PersonCreateSchema) {}
export class PersonUpdateDto extends createZodDto(PersonUpdateSchema) {}
export class PersonDeleteDto extends createZodDto(PersonDeleteSchema) {}
export class PeopleDeleteDto extends createZodDto(PeopleDeleteSchema) {}
export class PeopleUpdateDto extends createZodDto(PeopleUpdateSchema) {}
export class MergePersonDto extends createZodDto(MergePersonSchema) {}
export class PersonSearchDto extends createZodDto(PersonSearchSchema) {}
export class PersonResponseDto extends createZodDto(PersonResponseSchema) {}
export class PeopleUserResponseDto extends createZodDto(PeopleUserResponseSchema) {}
export const AssetFaceResponseSchema = z
const AssetFaceResponseSchema = z
.object({
id: z.uuidv4().describe('Face ID'),
imageHeight: z.int().min(0).describe('Image height in pixels'),
@@ -114,6 +161,7 @@ const AssetFaceUpdateItemSchema = z
.object({
personId: z.uuidv4().describe('Person ID'),
assetId: z.uuidv4().describe('Asset ID'),
userId: z.uuidv4().optional().describe('User ID'),
})
.meta({ id: 'AssetFaceUpdateItem' });
@@ -150,11 +198,56 @@ const PersonStatisticsResponseSchema = z
})
.meta({ id: 'PersonStatisticsResponseDto' });
const PersonUsersResponseSchema = z
.array(
z.object({
personId: z.uuid().describe('Person ID'),
sharedById: z.uuid().describe('User ID of the user that gave access to this person'),
sharedWithId: z.uuid().describe('User ID of the user that was given access to this person'),
sharedBy: UserResponseSchema.describe('The user that gave access to this person'),
sharedWith: UserResponseSchema.describe('The user that was given access to this person'),
role: PersonUserRoleSchema.describe('Access role'),
}),
)
.meta({ id: 'PersonUsersResponseDto' });
const PersonUsersSearchSchema = z
.object({
personId: z.uuid().optional().describe('Person ID'),
direction: SharingDirectionSchema.optional(),
sharedById: z.uuid().optional().describe('User ID of the user that gave access'),
sharedWithId: z.uuid().optional().describe('User ID of the user that was given access'),
role: PersonUserRoleSchema.optional().describe('Role of user'),
})
.meta({ id: 'PersonUsersSearchDto' });
const PersonUsersCreateSchema = z
.object({
personIds: z.array(z.uuid()).describe('Person IDs'),
sharedWithIds: z.array(z.uuid()).describe('User IDs that should be given access to the person'),
role: PersonUserRoleSchema.describe('Role that should be applied'),
})
.meta({ id: 'PersonUsersCreateDto' });
const PersonUsersDeleteSchema = z
.array(
z.object({
personId: z.uuid().describe('Person ID'),
sharedWithId: z.uuid().describe('User ID of the user that was given access to the person'),
sharedById: z.uuid().optional().describe('User ID of the user that gave access to the person'),
}),
)
.meta({ id: 'PersonUsersDeleteDto' });
export class AssetFaceUpdateDto extends createZodDto(AssetFaceUpdateSchema) {}
export class FaceDto extends createZodDto(FaceSchema) {}
export class AssetFaceCreateDto extends createZodDto(AssetFaceCreateSchema) {}
export class AssetFaceDeleteDto extends createZodDto(AssetFaceDeleteSchema) {}
export class PersonStatisticsResponseDto extends createZodDto(PersonStatisticsResponseSchema) {}
export class PersonUsersResponseDto extends createZodDto(PersonUsersResponseSchema) {}
export class PersonUsersSearchDto extends createZodDto(PersonUsersSearchSchema) {}
export class PersonUsersCreateDto extends createZodDto(PersonUsersCreateSchema) {}
export class PersonUsersDeleteDto extends createZodDto(PersonUsersDeleteSchema) {}
const PeopleResponseSchema = z
.object({
@@ -171,7 +264,11 @@ const PeopleResponseSchema = z
.describe('People response');
export class PeopleResponseDto extends createZodDto(PeopleResponseSchema) {}
export function mapPerson(person: MaybeDehydrated<Person>): PersonResponseDto {
type OptionalKeys = 'otherPeople' | 'sharedBy' | 'sharedWith';
export function mapPerson(
person: MaybeDehydrated<Omit<Person, OptionalKeys> & Partial<Pick<Person, OptionalKeys>>>,
): PersonResponseDto {
return {
id: person.personGroupId,
name: person.name,
@@ -181,9 +278,37 @@ export function mapPerson(person: MaybeDehydrated<Person>): PersonResponseDto {
isFavorite: person.isFavorite,
color: person.color ?? undefined,
updatedAt: asDateTimeString(person.updatedAt),
// TODO: use different response dtos for asset faces, which do not load the sharing properties
otherPeople: person.otherPeople ?? [],
sharedBy: (person.sharedBy ?? []).map((user) => mapPeopleUser(user)),
sharedWith: (person.sharedWith ?? []).map((user) => mapPeopleUser(user)),
};
}
const mapPeopleUser = (user: MaybeDehydrated<PersonUser>): PeopleUserResponseDto => ({
...mapUser(user),
role: user.role,
});
type PersonUserShare = {
personId: string;
sharedById: string;
sharedWithId: string;
role: PersonUserRole;
sharedBy: MaybeDehydrated<User>;
sharedWith: MaybeDehydrated<User>;
};
export const mapPersonUsers = (shares: PersonUserShare[]): PersonUsersResponseDto =>
shares.map((share) => ({
personId: share.personId,
sharedById: share.sharedById,
sharedWithId: share.sharedWithId,
role: share.role,
sharedBy: mapUser(share.sharedBy),
sharedWith: mapUser(share.sharedWith),
}));
function mapFacesWithoutPerson(
face: MaybeDehydrated<Selectable<AssetFaceTable>>,
edits?: AssetEditActionItem[],
+16
View File
@@ -96,6 +96,22 @@ export enum MemoryType {
export const MemoryTypeSchema = z.enum(MemoryType).describe('Memory type').meta({ id: 'MemoryType' });
export enum SharingDirection {
SharedBy = 'shared-by',
SharedWith = 'shared-with',
}
export const SharingDirectionSchema = z
.enum(SharingDirection)
.describe('Sharing direction')
.meta({ id: 'SharingDirection' });
// TODO(v4) replace with SharingDirection
export const PartnerDirectionSchema = z
.enum(SharingDirection)
.describe('Partner direction')
.meta({ id: 'PartnerDirection' });
export enum AssetOrderWithRandom {
// Include existing values
Asc = AssetOrder.Asc,
+31 -5
View File
@@ -241,14 +241,40 @@ where
"user"."id" = $2
)
-- AccessRepository.person.checkOwnerAccess
-- AccessRepository.person.checkAccess
select
"person"."personGroupId"
"personGroupId",
"ownerId"
from
"person"
(
select
unnest($1::uuid[]) as "personGroupId",
unnest($2::uuid[]) as "ownerId"
) as "people"
where
"person"."personGroupId" in ($1)
and "person"."ownerId" = $2
(
exists (
select
*
from
"person"
where
"people"."personGroupId" = "person"."personGroupId"
and "people"."ownerId" = "person"."ownerId"
and "person"."ownerId" = $3
)
or exists (
select
*
from
"person_user"
where
"person_user"."sharedWithId" = $4
and "person_user"."role" in ($5)
and "people"."personGroupId" = "person_user"."personGroupId"
and "people"."ownerId" = "person_user"."sharedById"
)
)
-- AccessRepository.person.checkFaceOwnerAccess
select
+671 -86
View File
@@ -10,6 +10,19 @@ where
"asset_face"."assetId" = "asset"."id"
and "asset_face"."personGroupId" = $2
-- PersonRepository.updateGroupId
begin
update "asset_face"
set
"personGroupId" = $1
from
"asset"
where
"asset_face"."assetId" = "asset"."id"
and "asset_face"."personGroupId" = $2
and "asset"."ownerId" = $3
rollback
-- PersonRepository.unassignFaces
update "asset_face"
set
@@ -135,49 +148,148 @@ limit
-- PersonRepository.getAllForUser
select
"person".*
(
select
to_json(obj)
from
(
select
"person".*
from
"person"
where
"person"."personGroupId" = "person_group"."id"
and "person"."ownerId" = $1
) as obj
) as "ownedPerson",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"person_user"."sharedById",
"person_user"."role",
"other"."name",
"other"."birthDate"
from
"person" as "other"
inner join "person_user" on "person_user"."personGroupId" = "other"."personGroupId"
and "person_user"."sharedById" = "other"."ownerId"
and "person_user"."sharedWithId" = $2
where
"other"."personGroupId" = "person_group"."id"
and (
"other"."birthDate" is not null
or "other"."name" != $3
)
) as agg
) as "otherPeople",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"person_user"."role"
from
"person_user"
inner join "user" on "user"."id" = "person_user"."sharedById"
and "user"."deletedAt" is null
where
"person_user"."personGroupId" = "person_group"."id"
and "person_user"."sharedWithId" = $4
order by
"user"."name"
) as agg
) as "sharedBy",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"person_user"."role"
from
"person_user"
inner join "user" on "user"."id" = "person_user"."sharedWithId"
and "user"."deletedAt" is null
where
"person_user"."personGroupId" = "person_group"."id"
and "person_user"."sharedById" = $5
order by
"user"."name"
) as agg
) as "sharedWith"
from
"person"
inner join "asset_face" on "asset_face"."personGroupId" = "person"."personGroupId"
inner join "asset" on "asset_face"."assetId" = "asset"."id"
and "asset"."ownerId" = "person"."ownerId"
"person_group"
inner join "person" as "owned" on "owned"."personGroupId" = "person_group"."id"
and "owned"."ownerId" = $6
left join "asset_face" on "asset_face"."personGroupId" = "person_group"."id"
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
left join "asset" on "asset"."id" = "asset_face"."assetId"
and "asset"."ownerId" = $7
and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null
where
"person"."ownerId" = $1
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
and "person"."isHidden" = $2
"owned"."isHidden" = $8
and 1 = 1
group by
"person"."ownerId",
"person"."personGroupId"
"person_group"."id",
"owned"."ownerId",
"owned"."personGroupId"
having
(
"person"."name" != $3
or count("asset_face"."assetId") >= COALESCE(
exists (
select
"person_user"."sharedWithId"
from
"person_user"
where
"person_user"."personGroupId" = "person_group"."id"
and "person_user"."sharedWithId" = $9
)
or (
count("asset"."id") > $10
and "owned"."name" != $11
)
or count("asset"."id") >= COALESCE(
(
SELECT
value -> 'people' ->> 'minimumFaces'
FROM
user_metadata
WHERE
"userId" = $4
"userId" = $12
AND key = 'preferences'
),
'3'
)::int
)
order by
"person"."isHidden" asc,
"person"."isFavorite" desc,
NULLIF(person.name, '') is null asc,
count("asset_face"."assetId") desc,
NULLIF(person.name, '') asc nulls last,
"person"."createdAt"
"owned"."isHidden" asc,
"owned"."isFavorite" desc,
NULLIF("owned"."name", '') is null asc,
count("asset"."id") desc,
NULLIF("owned"."name", '') asc nulls last,
"owned"."createdAt"
limit
$5
$13
offset
$6
$14
-- PersonRepository.getAllWithoutFaces
select
@@ -206,20 +318,91 @@ select
from
(
select
"person".*
"person".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"person_user"."sharedById",
"person_user"."role",
"other"."name",
"other"."birthDate"
from
"person" as "other"
inner join "person_user" on "person_user"."personGroupId" = "other"."personGroupId"
and "person_user"."sharedById" = "other"."ownerId"
and "person_user"."sharedWithId" = $1
where
"other"."personGroupId" = "person"."personGroupId"
and (
"other"."birthDate" is not null
or "other"."name" != $2
)
) as agg
) as "otherPeople",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"person_user"."role"
from
"person_user"
inner join "user" on "user"."id" = "person_user"."sharedById"
and "user"."deletedAt" is null
where
"person_user"."personGroupId" = "person"."personGroupId"
and "person_user"."sharedWithId" = $3
order by
"user"."name"
) as agg
) as "sharedBy",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"person_user"."role"
from
"person_user"
inner join "user" on "user"."id" = "person_user"."sharedWithId"
and "user"."deletedAt" is null
where
"person_user"."personGroupId" = "person"."personGroupId"
and "person_user"."sharedById" = $4
order by
"user"."name"
) as agg
) as "sharedWith"
from
"person"
where
"person"."personGroupId" = "asset_face"."personGroupId"
and "person"."ownerId" = $1
and "person"."ownerId" = $5
) as obj
) as "person"
from
"asset_face"
where
"asset_face"."assetId" = $2
"asset_face"."assetId" = $6
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $3
and "asset_face"."isVisible" = $7
order by
"asset_face"."boundingBoxX1" asc
@@ -232,18 +415,89 @@ select
from
(
select
"person".*
"person".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"person_user"."sharedById",
"person_user"."role",
"other"."name",
"other"."birthDate"
from
"person" as "other"
inner join "person_user" on "person_user"."personGroupId" = "other"."personGroupId"
and "person_user"."sharedById" = "other"."ownerId"
and "person_user"."sharedWithId" = $1
where
"other"."personGroupId" = "person"."personGroupId"
and (
"other"."birthDate" is not null
or "other"."name" != $2
)
) as agg
) as "otherPeople",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"person_user"."role"
from
"person_user"
inner join "user" on "user"."id" = "person_user"."sharedById"
and "user"."deletedAt" is null
where
"person_user"."personGroupId" = "person"."personGroupId"
and "person_user"."sharedWithId" = $3
order by
"user"."name"
) as agg
) as "sharedBy",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"person_user"."role"
from
"person_user"
inner join "user" on "user"."id" = "person_user"."sharedWithId"
and "user"."deletedAt" is null
where
"person_user"."personGroupId" = "person"."personGroupId"
and "person_user"."sharedById" = $4
order by
"user"."name"
) as agg
) as "sharedWith"
from
"person"
where
"person"."personGroupId" = "asset_face"."personGroupId"
and "person"."ownerId" = $1
and "person"."ownerId" = $5
) as obj
) as "person"
from
"asset_face"
where
"asset_face"."id" = $2
"asset_face"."id" = $6
and "asset_face"."deletedAt" is null
-- PersonRepository.getFaceForFacialRecognitionJob
@@ -326,14 +580,216 @@ set
where
"asset_face"."id" = $2
-- PersonRepository.getForUser
select
(
select
to_json(obj)
from
(
select
"person".*
from
"person"
where
"person"."personGroupId" = "person_group"."id"
and "person"."ownerId" = $1
) as obj
) as "ownedPerson",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"person_user"."sharedById",
"person_user"."role",
"other"."name",
"other"."birthDate"
from
"person" as "other"
inner join "person_user" on "person_user"."personGroupId" = "other"."personGroupId"
and "person_user"."sharedById" = "other"."ownerId"
and "person_user"."sharedWithId" = $2
where
"other"."personGroupId" = "person_group"."id"
and (
"other"."birthDate" is not null
or "other"."name" != $3
)
) as agg
) as "otherPeople",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"person_user"."role"
from
"person_user"
inner join "user" on "user"."id" = "person_user"."sharedById"
and "user"."deletedAt" is null
where
"person_user"."personGroupId" = "person_group"."id"
and "person_user"."sharedWithId" = $4
order by
"user"."name"
) as agg
) as "sharedBy",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"person_user"."role"
from
"person_user"
inner join "user" on "user"."id" = "person_user"."sharedWithId"
and "user"."deletedAt" is null
where
"person_user"."personGroupId" = "person_group"."id"
and "person_user"."sharedById" = $5
order by
"user"."name"
) as agg
) as "sharedWith"
from
"person_group"
where
"person_group"."id" = $6
and exists (
select
"person"."ownerId"
from
"person"
where
"person"."personGroupId" = "person_group"."id"
and "person"."ownerId" = $7
)
-- PersonRepository.getByGroupId
select
"person".*
"person".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"person_user"."sharedById",
"person_user"."role",
"other"."name",
"other"."birthDate"
from
"person" as "other"
inner join "person_user" on "person_user"."personGroupId" = "other"."personGroupId"
and "person_user"."sharedById" = "other"."ownerId"
and "person_user"."sharedWithId" = $1
where
"other"."personGroupId" = "person"."personGroupId"
and (
"other"."birthDate" is not null
or "other"."name" != $2
)
) as agg
) as "otherPeople",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"person_user"."role"
from
"person_user"
inner join "user" on "user"."id" = "person_user"."sharedById"
and "user"."deletedAt" is null
where
"person_user"."personGroupId" = "person"."personGroupId"
and "person_user"."sharedWithId" = $3
order by
"user"."name"
) as agg
) as "sharedBy",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"person_user"."role"
from
"person_user"
inner join "user" on "user"."id" = "person_user"."sharedWithId"
and "user"."deletedAt" is null
where
"person_user"."personGroupId" = "person"."personGroupId"
and "person_user"."sharedById" = $4
order by
"user"."name"
) as agg
) as "sharedWith"
from
"person"
where
"person"."personGroupId" = $1
and "person"."ownerId" = $2
"person"."personGroupId" = $5
and "person"."ownerId" = $6
-- PersonRepository.getForThumbnail
select
(
select
"person"."thumbnailPath"
from
"person"
where
"person"."personGroupId" = "person_group"."id"
and "person"."ownerId" = $1
) as "thumbnailPath",
(
select
"person"."thumbnailPath"
from
"person"
inner join "person_user" on "person_user"."personGroupId" = "person"."personGroupId"
and "person_user"."sharedById" = "person"."ownerId"
and "person_user"."sharedWithId" = $2
where
"person"."personGroupId" = "person_group"."id"
and "person"."thumbnailPath" != $3
limit
$4
) as "sharedThumbnailPath"
from
"person_group"
where
"person_group"."id" = $5
-- PersonRepository.getByName
with
@@ -342,17 +798,88 @@ with
set_config('pg_trgm.word_similarity_threshold', '0.5', true) as "thresh"
)
select
"person".*
"person".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"person_user"."sharedById",
"person_user"."role",
"other"."name",
"other"."birthDate"
from
"person" as "other"
inner join "person_user" on "person_user"."personGroupId" = "other"."personGroupId"
and "person_user"."sharedById" = "other"."ownerId"
and "person_user"."sharedWithId" = $1
where
"other"."personGroupId" = "person"."personGroupId"
and (
"other"."birthDate" is not null
or "other"."name" != $2
)
) as agg
) as "otherPeople",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"person_user"."role"
from
"person_user"
inner join "user" on "user"."id" = "person_user"."sharedById"
and "user"."deletedAt" is null
where
"person_user"."personGroupId" = "person"."personGroupId"
and "person_user"."sharedWithId" = $3
order by
"user"."name"
) as agg
) as "sharedBy",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"person_user"."role"
from
"person_user"
inner join "user" on "user"."id" = "person_user"."sharedWithId"
and "user"."deletedAt" is null
where
"person_user"."personGroupId" = "person"."personGroupId"
and "person_user"."sharedById" = $4
order by
"user"."name"
) as agg
) as "sharedWith"
from
"similarity_threshold",
"person"
where
"person"."ownerId" = $1
and f_unaccent ("person"."name") %> f_unaccent ($2)
"person"."ownerId" = $5
and f_unaccent ("person"."name") %> f_unaccent ($6)
order by
f_unaccent ("person"."name") <->>> f_unaccent ($3)
f_unaccent ("person"."name") <->>> f_unaccent ($7)
limit
$4
$8
-- PersonRepository.getDistinctNames
select distinct
@@ -366,66 +893,55 @@ where
and "person"."name" != $2
)
-- PersonRepository.getStatistics
select
count(distinct ("asset"."id")) as "count"
from
"asset_face"
left join "asset" on "asset"."id" = "asset_face"."assetId"
and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null
and (
"asset"."ownerId" = $1::uuid
or exists (
select
1 as "exists"
from
"album_asset"
inner join "album" on "album"."id" = "album_asset"."albumId"
and "album"."deletedAt" is null
inner join "album_user" on "album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2::uuid
where
"album_asset"."assetId" = "asset"."id"
)
)
where
"asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
and "asset_face"."personGroupId" = $3
-- PersonRepository.getNumberOfPeople
select
coalesce(count(*), 0) as "total",
coalesce(
count(*) filter (
where
"isHidden" = $1
"owned"."isHidden" = $1
),
0
) as "hidden"
from
"person"
"person_group"
left join "person" as "owned" on "owned"."personGroupId" = "person_group"."id"
and "owned"."ownerId" = $2
where
exists (
select
from
"asset_face"
where
"asset_face"."personGroupId" = "person"."personGroupId"
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $2
(
(
"owned"."ownerId" is not null
and exists (
select
from
"asset"
"asset_face"
where
"asset"."id" = "asset_face"."assetId"
and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null
"asset_face"."personGroupId" = "person_group"."id"
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $3
and exists (
select
from
"asset"
where
"asset"."id" = "asset_face"."assetId"
and "asset"."ownerId" = $4
and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null
)
)
)
or exists (
select
"person_user"."sharedById"
from
"person_user"
where
"person_user"."personGroupId" = "person_group"."id"
and "person_user"."sharedWithId" = $5
)
)
and "person"."ownerId" = $3
and 1 = 1
-- PersonRepository.createGroup
insert into
@@ -609,19 +1125,90 @@ select
from
(
select
"person".*
"person".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"person_user"."sharedById",
"person_user"."role",
"other"."name",
"other"."birthDate"
from
"person" as "other"
inner join "person_user" on "person_user"."personGroupId" = "other"."personGroupId"
and "person_user"."sharedById" = "other"."ownerId"
and "person_user"."sharedWithId" = $1
where
"other"."personGroupId" = "person"."personGroupId"
and (
"other"."birthDate" is not null
or "other"."name" != $2
)
) as agg
) as "otherPeople",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"person_user"."role"
from
"person_user"
inner join "user" on "user"."id" = "person_user"."sharedById"
and "user"."deletedAt" is null
where
"person_user"."personGroupId" = "person"."personGroupId"
and "person_user"."sharedWithId" = $3
order by
"user"."name"
) as agg
) as "sharedBy",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"person_user"."role"
from
"person_user"
inner join "user" on "user"."id" = "person_user"."sharedWithId"
and "user"."deletedAt" is null
where
"person_user"."personGroupId" = "person"."personGroupId"
and "person_user"."sharedById" = $4
order by
"user"."name"
) as agg
) as "sharedWith"
from
"person"
where
"person"."personGroupId" = "asset_face"."personGroupId"
and "person"."ownerId" = $1
and "person"."ownerId" = $5
) as obj
) as "person"
from
"asset_face"
where
"asset_face"."assetId" in ($2)
and "asset_face"."personGroupId" in ($3)
"asset_face"."assetId" in ($6)
and "asset_face"."personGroupId" in ($7)
and "asset_face"."deletedAt" is null
-- PersonRepository.getRandomFace
@@ -671,5 +1258,3 @@ from
"person"
where
"person"."personGroupId" in ($1)
order by
"person"."ownerId"
@@ -0,0 +1,85 @@
-- NOTE: This file is auto generated by ./sql-generator
-- PersonUserRepository.searchPeopleUsers
select
"person_user"."personGroupId" as "personId",
"person_user"."sharedById",
"person_user"."sharedWithId",
"person_user"."role",
(
select
to_json(obj)
from
(
select
"user"."id",
"user"."name",
"user"."email",
"user"."avatarColor",
"user"."profileImagePath",
"user"."profileChangedAt"
from
"user"
where
"user"."id" = "person_user"."sharedById"
) as obj
) as "sharedBy",
(
select
to_json(obj)
from
(
select
"user"."id",
"user"."name",
"user"."email",
"user"."avatarColor",
"user"."profileImagePath",
"user"."profileChangedAt"
from
"user"
where
"user"."id" = "person_user"."sharedWithId"
) as obj
) as "sharedWith"
from
"person_user"
where
(
"person_user"."sharedById" = $1
or "person_user"."sharedWithId" = $2
)
-- PersonUserRepository.createAll
insert into
"person_user" (
"personGroupId",
"sharedById",
"sharedWithId",
"role"
)
values
($1, $2, $3, $4)
on conflict ("personGroupId", "sharedById", "sharedWithId") do update
set
"role" = "excluded"."role"
returning
*
-- PersonUserRepository.deleteAll
delete from "person_user"
where
exists (
select
*
from
(
select
unnest($1::uuid[]) as "personGroupId",
unnest($2::uuid[]) as "sharedWithId"
) as "people"
where
"people"."personGroupId" = "person_user"."personGroupId"
and "people"."sharedWithId" = "person_user"."sharedWithId"
)
and "person_user"."sharedById" = $3
+45 -9
View File
@@ -2,7 +2,9 @@ import { Injectable } from '@nestjs/common';
import { type Kysely, type NotNull, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators.js';
import { PersonUserRole } from 'src/dtos/person.dto.js';
import { AlbumUserRole, AssetVisibility } from 'src/enum.js';
import { PersonId } from 'src/repositories/person.repository.js';
import { DB } from 'src/schema/index.js';
import { asUuid } from 'src/utils/database.js';
@@ -519,20 +521,54 @@ class ClusterGroupRequestAccess {
class PersonAccess {
constructor(private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET, [PersonUserRole.Admin]] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, personGroupIds: Set<string>) {
if (personGroupIds.size === 0) {
return new Set<string>();
async checkAccess(userId: string, personIds: Set<PersonId>, roles: PersonUserRole[]) {
if (personIds.size === 0 || roles.length === 0) {
return new Set<PersonId>();
}
const personGroupIds = personIds
.values()
.map(({ personGroupId }) => personGroupId)
.toArray();
const ownerIds = personIds
.values()
.map(({ ownerId }) => ownerId)
.toArray();
return this.db
.selectFrom('person')
.select('person.personGroupId')
.where('person.personGroupId', 'in', [...personGroupIds])
.where('person.ownerId', '=', userId)
.selectFrom(
sql<{ personGroupId: string; ownerId: string }>`(
select
unnest(${personGroupIds}::uuid[]) as "personGroupId",
unnest(${ownerIds}::uuid[]) as "ownerId"
)`.as('people'),
)
.select(['personGroupId', 'ownerId'])
.where((eb) =>
eb.or([
eb.exists(
eb
.selectFrom('person')
.whereRef('people.personGroupId', '=', 'person.personGroupId')
.whereRef('people.ownerId', '=', 'person.ownerId')
.where('person.ownerId', '=', userId)
.selectAll(),
),
eb.exists(
eb
.selectFrom('person_user')
.where('person_user.sharedWithId', '=', userId)
.where('person_user.role', 'in', roles)
.whereRef('people.personGroupId', '=', 'person_user.personGroupId')
.whereRef('people.ownerId', '=', 'person_user.sharedById')
.selectAll(),
),
]),
)
.execute()
.then((persons) => new Set(persons.map((person) => person.personGroupId)));
.then((items) => new Set(items));
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@@ -31,7 +31,7 @@ export class ActivityRepository {
join.onRef('user2.id', '=', 'activity.userId').on('user2.deletedAt', 'is', null),
)
.innerJoinLateral(
(eb) => eb.selectFrom(dummy).select(columns.userWithPrefix).as('user'),
(eb) => eb.selectFrom(dummy).select(columns.user2).as('user'),
(join) => join.onTrue(),
)
.select((eb) => eb.fn.toJson('user').as('user'))
+2
View File
@@ -31,6 +31,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository
import { OAuthRepository } from 'src/repositories/oauth.repository.js';
import { OcrRepository } from 'src/repositories/ocr.repository.js';
import { PartnerRepository } from 'src/repositories/partner.repository.js';
import { PersonUserRepository } from 'src/repositories/person-user.repository.js';
import { PersonRepository } from 'src/repositories/person.repository.js';
import { PluginRepository } from 'src/repositories/plugin.repository.js';
import { ProcessRepository } from 'src/repositories/process.repository.js';
@@ -89,6 +90,7 @@ export const repositories = [
ClusterGroupRepository,
PartnerRepository,
PersonRepository,
PersonUserRepository,
PluginRepository,
ProcessRepository,
SearchRepository,
@@ -12,11 +12,6 @@ export interface PartnerIds {
sharedWithId: string;
}
export enum PartnerDirection {
SharedBy = 'shared-by',
SharedWith = 'shared-with',
}
const withSharedBy = (eb: ExpressionBuilder<DB, 'partner'>) => {
return jsonObjectFrom(
eb.selectFrom('user as sharedBy').select(columns.user).whereRef('sharedBy.id', '=', 'partner.sharedById'),
@@ -0,0 +1,111 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, sql } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database.js';
import { DummyValue, GenerateSql } from 'src/decorators.js';
import { PersonUserRole, PersonUsersDeleteDto, PersonUsersSearchDto } from 'src/dtos/person.dto.js';
import { SharingDirection } from 'src/enum.js';
import { DB } from 'src/schema/index.js';
import { PersonUserTable } from 'src/schema/tables/person-user.table.js';
@Injectable()
export class PersonUserRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, {}] })
searchPeopleUsers(ownerId: string, dto: PersonUsersSearchDto) {
return this.db
.selectFrom('person_user')
.select([
'person_user.personGroupId as personId',
'person_user.sharedById',
'person_user.sharedWithId',
'person_user.role',
])
.select((eb) => [
jsonObjectFrom(
eb.selectFrom('user').select(columns.userPrefix).whereRef('user.id', '=', 'person_user.sharedById'),
)
.$notNull()
.as('sharedBy'),
jsonObjectFrom(
eb.selectFrom('user').select(columns.userPrefix).whereRef('user.id', '=', 'person_user.sharedWithId'),
)
.$notNull()
.as('sharedWith'),
])
.$if(!!dto.personId, (qb) => qb.where('person_user.personGroupId', '=', dto.personId!))
.$if(!!dto.sharedWithId, (qb) => qb.where('person_user.sharedWithId', '=', dto.sharedWithId!))
.$if(!!dto.sharedById, (qb) => qb.where('person_user.sharedById', '=', dto.sharedById!))
.$if(!!dto.role, (qb) => qb.where('person_user.role', '=', dto.role!))
.$if(dto.direction === undefined, (qb) =>
qb.where((eb) =>
eb.or([eb('person_user.sharedById', '=', ownerId), eb('person_user.sharedWithId', '=', ownerId)]),
),
)
.$if(dto.direction !== undefined, (qb) =>
qb.where(
dto.direction === SharingDirection.SharedBy ? 'person_user.sharedById' : 'person_user.sharedWithId',
'=',
ownerId,
),
)
.execute();
}
@GenerateSql({
params: [
{
personGroupId: DummyValue.UUID,
sharedById: DummyValue.UUID,
sharedWithId: DummyValue.UUID,
role: PersonUserRole.Admin,
},
],
})
createAll(dto: Insertable<PersonUserTable>[]) {
if (dto.length === 0) {
return [];
}
return this.db
.insertInto('person_user')
.values(dto)
.onConflict((oc) =>
oc
.columns(['personGroupId', 'sharedById', 'sharedWithId'])
.doUpdateSet((eb) => ({ role: eb.ref('excluded.role') })),
)
.returningAll()
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, [{ personId: DummyValue.UUID, sharedWithId: DummyValue.UUID }]] })
async deleteAll(sharedById: string, dto: PersonUsersDeleteDto) {
if (dto.length === 0) {
return;
}
const personGroupIds = dto.map(({ personId }) => personId);
const sharedWithIds = dto.map(({ sharedWithId }) => sharedWithId);
const people = sql<{ personGroupId: string; sharedWithId: string }>`(
select
unnest(${personGroupIds}::uuid[]) as "personGroupId",
unnest(${sharedWithIds}::uuid[]) as "sharedWithId"
)`.as('people');
await this.db
.deleteFrom('person_user')
.where(({ exists, selectFrom }) =>
exists(
selectFrom(people)
.whereRef('people.personGroupId', '=', 'person_user.personGroupId')
.whereRef('people.sharedWithId', '=', 'person_user.sharedWithId')
.selectAll(),
),
)
.where('person_user.sharedById', '=', sharedById)
.execute();
}
}
+309 -54
View File
@@ -1,21 +1,47 @@
import { Injectable } from '@nestjs/common';
import { type ExpressionBuilder, type Insertable, type Kysely, type Updateable, sql } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import {
type Expression,
type ExpressionBuilder,
type Insertable,
type Kysely,
type Selectable,
type ShallowDehydrateObject,
type SqlBool,
type Updateable,
expressionBuilder,
sql,
} from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { AssetFace } from 'src/database.js';
import { AssetFace, PersonUser, columns } from 'src/database.js';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators.js';
import { AssetFileType, AssetVisibility, SourceType, UserMetadataKey } from 'src/enum.js';
import { PersonUserRole } from 'src/dtos/person.dto.js';
import { AssetFileType, AssetVisibility, SharingDirection, SourceType, UserMetadataKey } from 'src/enum.js';
import { type YearMonthDay } from 'src/repositories/asset.repository.js';
import { DB } from 'src/schema/index.js';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table.js';
import { FaceSearchTable } from 'src/schema/tables/face-search.table.js';
import { PersonGroupTable } from 'src/schema/tables/person-group.table.js';
import { PersonTable } from 'src/schema/tables/person.table.js';
import { asUuid, dummy, inSharedAlbum, removeUndefinedKeys, withFilePath } from 'src/utils/database.js';
import { anyUuid, dummy, inSharedAlbum, removeUndefinedKeys, withFilePath } from 'src/utils/database.js';
import { isLeapDayObserved } from 'src/utils/date.js';
import { type PaginationOptions, paginationHelper } from 'src/utils/pagination.js';
export interface PersonSearchOptions {
type PersonGroupRow = {
ownedPerson: ShallowDehydrateObject<Selectable<PersonTable>>;
otherPeople: { sharedById: string; role: PersonUserRole; name: string; birthDate: string | null }[];
sharedBy: PersonUser[];
sharedWith: PersonUser[];
};
export interface PersonFilterOptions {
sharedById?: string;
sharedWithId?: string;
isFavorite?: boolean;
isHidden?: boolean;
}
export interface PersonSearchOptions extends PersonFilterOptions {
withHidden: boolean;
closestFaceAssetId?: string;
}
@@ -34,6 +60,12 @@ export interface AssetFaceId {
personGroupId: string;
}
export interface UpdateGroupIdData {
oldPersonGroupId: string;
ownerId: string;
newPersonGroupId: string;
}
export interface UpdateFacesData {
oldPersonGroupId?: string;
faceIds?: string[];
@@ -77,12 +109,117 @@ export type WithPersonOptions = {
viewingUserId: string;
};
const withOwnedPerson = (userId: string) => {
return (eb: ExpressionBuilder<DB, 'person_group'>) =>
jsonObjectFrom(
eb
.selectFrom('person')
.selectAll('person')
.whereRef('person.personGroupId', '=', 'person_group.id')
.where('person.ownerId', '=', userId),
)
.$notNull()
.as('ownedPerson');
};
const withOtherPeopleFor = (userId: string, personGroupId: Expression<string>) =>
jsonArrayFrom(
expressionBuilder<DB>()
.selectFrom('person as other')
.innerJoin('person_user', (join) =>
join
.onRef('person_user.personGroupId', '=', 'other.personGroupId')
.onRef('person_user.sharedById', '=', 'other.ownerId')
.on('person_user.sharedWithId', '=', userId),
)
.select(['person_user.sharedById', 'person_user.role', 'other.name', 'other.birthDate'])
.where('other.personGroupId', '=', personGroupId)
.where((eb) => eb.or([eb('other.birthDate', 'is not', null), eb('other.name', '!=', '')])),
).as('otherPeople');
const withPersonUsersFor = (userId: string, personGroupId: Expression<string>, direction: SharingDirection) => {
const [userColumn, viewerColumn] =
direction === SharingDirection.SharedBy
? (['person_user.sharedById', 'person_user.sharedWithId'] as const)
: (['person_user.sharedWithId', 'person_user.sharedById'] as const);
return jsonArrayFrom(
expressionBuilder<DB>()
.selectFrom('person_user')
.innerJoin('user', (join) => join.onRef('user.id', '=', userColumn).on('user.deletedAt', 'is', null))
.select(columns.user)
.select('person_user.role')
.where('person_user.personGroupId', '=', personGroupId)
.where(viewerColumn, '=', userId)
.orderBy('user.name'),
)
.$castTo<PersonUser[]>()
.as(direction === SharingDirection.SharedBy ? 'sharedBy' : 'sharedWith');
};
const withSharing = (userId: string, personGroupId: Expression<string>) => [
withOtherPeopleFor(userId, personGroupId),
withPersonUsersFor(userId, personGroupId, SharingDirection.SharedBy),
withPersonUsersFor(userId, personGroupId, SharingDirection.SharedWith),
];
const withOtherPeople = (userId: string) => {
return (eb: ExpressionBuilder<DB, 'person_group'>) => withSharing(userId, eb.ref('person_group.id'));
};
const withOtherPeopleForPerson = (userId: string) => {
return (eb: ExpressionBuilder<DB, 'person'>) => withSharing(userId, eb.ref('person.personGroupId'));
};
const withFilters = (userId: string, options: PersonFilterOptions = {}) => {
const { sharedById, sharedWithId, isFavorite, isHidden } = options;
return (eb: ExpressionBuilder<DB & { owned: DB['person'] }, 'person_group' | 'owned'>) => {
const filters: Expression<SqlBool>[] = [];
if (sharedById || sharedWithId) {
filters.push(
eb.exists(
eb
.selectFrom('person_user')
.whereRef('person_user.personGroupId', '=', 'person_group.id')
// only consider shares that involve the current user
.where((eb) =>
eb.or([eb('person_user.sharedById', '=', userId), eb('person_user.sharedWithId', '=', userId)]),
)
.$if(!!sharedById, (qb) => qb.where('person_user.sharedById', '=', sharedById!))
.$if(!!sharedWithId, (qb) => qb.where('person_user.sharedWithId', '=', sharedWithId!)),
),
);
}
if (isFavorite !== undefined) {
filters.push(eb('owned.isFavorite', '=', isFavorite));
}
if (isHidden !== undefined) {
filters.push(eb('owned.isHidden', '=', isHidden));
}
return eb.and(filters);
};
};
const asPerson = ({ ownedPerson, otherPeople, sharedBy, sharedWith }: PersonGroupRow) => ({
...ownedPerson,
otherPeople,
sharedBy,
sharedWith,
});
const faceCount = (eb: ExpressionBuilder<DB, 'asset'>) => eb.fn.count('asset.id');
const withPerson = ({ viewingUserId }: WithPersonOptions) => {
return (eb: ExpressionBuilder<DB, 'asset_face'>) =>
jsonObjectFrom(
eb
.selectFrom('person')
.selectAll('person')
.select(withOtherPeopleForPerson(viewingUserId))
.whereRef('person.personGroupId', '=', 'asset_face.personGroupId')
.where('person.ownerId', '=', viewingUserId),
).as('person');
@@ -113,6 +250,28 @@ export class PersonRepository {
return Number(result.numUpdatedRows ?? 0);
}
@GenerateSql({ params: [{ oldPersonGroupId: DummyValue.UUID, newPersonGroupId: DummyValue.UUID }] })
updateGroupId({ oldPersonGroupId, ownerId, newPersonGroupId }: UpdateGroupIdData) {
return this.db.transaction().execute(async (trx) => {
await trx
.updateTable('asset_face')
.from('asset')
.whereRef('asset_face.assetId', '=', 'asset.id')
.set({ personGroupId: newPersonGroupId })
.where('asset_face.personGroupId', '=', oldPersonGroupId)
.where('asset.ownerId', '=', ownerId)
.executeTakeFirst();
return trx
.updateTable('person')
.set({ personGroupId: newPersonGroupId })
.where('person.personGroupId', '=', oldPersonGroupId)
.where('person.ownerId', '=', ownerId)
.returningAll()
.executeTakeFirstOrThrow();
});
}
@GenerateSql({ params: [{ sourceType: SourceType.MachineLearning, clusterGroupId: DummyValue.UUID }] })
async unassignFaces({ sourceType, clusterGroupId }: UnassignFacesOptions): Promise<void> {
await this.db
@@ -270,26 +429,39 @@ export class PersonRepository {
@GenerateSql({ params: [{ take: 1, skip: 0 }, DummyValue.UUID] })
async getAllForUser(pagination: PaginationOptions, userId: string, options?: PersonSearchOptions) {
const items = await this.db
.selectFrom('person')
.selectAll('person')
.innerJoin('asset_face', 'asset_face.personGroupId', 'person.personGroupId')
.innerJoin('asset', (join) =>
.selectFrom('person_group')
.innerJoin('person as owned', (join) =>
join.onRef('owned.personGroupId', '=', 'person_group.id').on('owned.ownerId', '=', userId),
)
.leftJoin('asset_face', (join) =>
join
.onRef('asset_face.assetId', '=', 'asset.id')
.onRef('asset.ownerId', '=', 'person.ownerId')
.onRef('asset_face.personGroupId', '=', 'person_group.id')
.on('asset_face.deletedAt', 'is', null)
.on('asset_face.isVisible', 'is', true),
)
.leftJoin('asset', (join) =>
join
.onRef('asset.id', '=', 'asset_face.assetId')
.on('asset.ownerId', '=', userId)
.on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
.on('asset.deletedAt', 'is', null),
)
.where('person.ownerId', '=', userId)
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true)
.orderBy('person.isHidden', 'asc')
.orderBy('person.isFavorite', 'desc')
.select(withOwnedPerson(userId))
.select(withOtherPeople(userId))
.groupBy(['person_group.id', 'owned.ownerId', 'owned.personGroupId'])
.having((eb) =>
eb.or([
eb('person.name', '!=', ''),
// shared people are always included
eb.exists(
eb
.selectFrom('person_user')
.select('person_user.sharedWithId')
.whereRef('person_user.personGroupId', '=', 'person_group.id')
.where('person_user.sharedWithId', '=', userId),
),
eb.and([eb(faceCount, '>', 0), eb('owned.name', '!=', '')]),
eb(
(innerEb) => innerEb.fn.count('asset_face.assetId'),
faceCount,
'>=',
sql<number>`COALESCE(
(SELECT value -> 'people' ->> 'minimumFaces'
@@ -301,7 +473,8 @@ export class PersonRepository {
),
]),
)
.groupBy(['person.ownerId', 'person.personGroupId'])
.orderBy('owned.isHidden', 'asc')
.orderBy('owned.isFavorite', 'desc')
.$if(!!options?.closestFaceAssetId, (qb) =>
qb.orderBy((eb) =>
eb(
@@ -309,7 +482,7 @@ export class PersonRepository {
eb
.selectFrom('face_search')
.select('face_search.embedding')
.whereRef('face_search.faceId', '=', 'person.faceAssetId'),
.whereRef('face_search.faceId', '=', 'owned.faceAssetId'),
'<=>',
(eb) =>
eb
@@ -321,17 +494,22 @@ export class PersonRepository {
)
.$if(!options?.closestFaceAssetId, (qb) =>
qb
.orderBy(sql`NULLIF(person.name, '') is null`, 'asc')
.orderBy((eb) => eb.fn.count('asset_face.assetId'), 'desc')
.orderBy(sql`NULLIF(person.name, '')`, (om) => om.asc().nullsLast())
.orderBy('person.createdAt'),
.orderBy(sql`NULLIF("owned"."name", '') is null`, 'asc')
.orderBy(faceCount, 'desc')
.orderBy(sql`NULLIF("owned"."name", '')`, (om) => om.asc().nullsLast())
.orderBy('owned.createdAt'),
)
.$if(!options?.withHidden, (qb) => qb.where('person.isHidden', '=', false))
// an explicit isHidden filter takes precedence over withHidden
.$if(!options?.withHidden && options?.isHidden === undefined, (qb) => qb.where('owned.isHidden', '=', false))
.where(withFilters(userId, options))
.offset(pagination.skip ?? 0)
.limit(pagination.take + 1)
.execute();
return paginationHelper(items, pagination.take);
return paginationHelper(
items.map((item) => asPerson(item)),
pagination.take,
);
}
@GenerateSql()
@@ -431,16 +609,67 @@ export class PersonRepository {
return Number(result.numChangedRows ?? 0);
}
@GenerateSql({ params: [{ userId: DummyValue.UUID, personGroupId: DummyValue.UUID }] })
async getForUser({ userId, personGroupId }: { userId: string; personGroupId: string }) {
const group = await this.db
.selectFrom('person_group')
.select(withOwnedPerson(userId))
.select(withOtherPeople(userId))
.where('person_group.id', '=', personGroupId)
.where((eb) =>
eb.exists(
eb
.selectFrom('person')
.select('person.ownerId')
.whereRef('person.personGroupId', '=', 'person_group.id')
.where('person.ownerId', '=', userId),
),
)
.executeTakeFirst();
return group && asPerson(group);
}
@GenerateSql({ params: [{ ownerId: DummyValue.UUID, personGroupId: DummyValue.UUID }] })
getByGroupId({ ownerId, personGroupId }: PersonId) {
return this.db //
return this.db
.selectFrom('person')
.selectAll('person')
.select(withOtherPeopleForPerson(ownerId))
.where('person.personGroupId', '=', personGroupId)
.where('person.ownerId', '=', ownerId)
.executeTakeFirst();
}
@GenerateSql({ params: [{ ownerId: DummyValue.UUID, personGroupId: DummyValue.UUID }] })
getForThumbnail({ ownerId, personGroupId }: PersonId) {
return this.db
.selectFrom('person_group')
.select(({ selectFrom }) => [
// TODO-DANIEL I would've done a left join of person_user and sorted by person.ownerId = ownerId desc.
// should probably discuss with Mert which approach is more efficient.
selectFrom('person')
.select('person.thumbnailPath')
.whereRef('person.personGroupId', '=', 'person_group.id')
.where('person.ownerId', '=', ownerId)
.as('thumbnailPath'),
selectFrom('person')
.innerJoin('person_user', (join) =>
join
.onRef('person_user.personGroupId', '=', 'person.personGroupId')
.onRef('person_user.sharedById', '=', 'person.ownerId')
.on('person_user.sharedWithId', '=', ownerId),
)
.select('person.thumbnailPath')
.whereRef('person.personGroupId', '=', 'person_group.id')
.where('person.thumbnailPath', '!=', '')
.limit(1)
.as('sharedThumbnailPath'),
])
.where('person_group.id', '=', personGroupId)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] })
getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions) {
return this.db
@@ -449,6 +678,7 @@ export class PersonRepository {
)
.selectFrom(['similarity_threshold', 'person'])
.selectAll('person')
.select(withOtherPeopleForPerson(userId))
.where('person.ownerId', '=', userId)
.where(() => sql`f_unaccent("person"."name") %> f_unaccent(${personName})`)
.orderBy(sql`f_unaccent("person"."name") <->>> f_unaccent(${personName})`)
@@ -469,7 +699,10 @@ export class PersonRepository {
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async getStatistics(personGroupId: string, userId: string): Promise<PersonStatistics> {
async getStatistics(
personGroupId: string,
{ ownerId, partnerIds }: { ownerId: string; partnerIds: string[] },
): Promise<PersonStatistics> {
const result = await this.db
.selectFrom('asset_face')
.leftJoin('asset', (join) =>
@@ -477,7 +710,7 @@ export class PersonRepository {
.onRef('asset.id', '=', 'asset_face.assetId')
.on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
.on('asset.deletedAt', 'is', null)
.on((eb) => eb.or([eb('asset.ownerId', '=', asUuid(userId)), inSharedAlbum(eb, userId)])),
.on((eb) => eb.or([eb('asset.ownerId', '=', anyUuid([ownerId, ...partnerIds])), inSharedAlbum(eb, ownerId)])),
)
.select((eb) => eb.fn.count(eb.fn('distinct', ['asset.id'])).as('count'))
.where('asset_face.deletedAt', 'is', null)
@@ -489,38 +722,60 @@ export class PersonRepository {
assets: result ? Number(result.count) : 0,
};
}
@GenerateSql({ params: [DummyValue.UUID] })
getNumberOfPeople(userId: string) {
getNumberOfPeople(userId: string, options?: PersonFilterOptions) {
const zero = sql.lit(0);
return this.db
.selectFrom('person')
.where((eb) =>
eb.exists((eb) =>
eb
.selectFrom('asset_face')
.whereRef('asset_face.personGroupId', '=', 'person.personGroupId')
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', '=', true)
.where((eb) =>
eb.exists((eb) =>
eb
.selectFrom('asset')
.whereRef('asset.id', '=', 'asset_face.assetId')
.where('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
.where('asset.deletedAt', 'is', null),
),
),
),
.selectFrom('person_group')
.leftJoin('person as owned', (join) =>
join.onRef('owned.personGroupId', '=', 'person_group.id').on('owned.ownerId', '=', userId),
)
.where('person.ownerId', '=', userId)
.where((eb) =>
eb.or([
eb.and([
eb('owned.ownerId', 'is not', null),
eb.exists((eb) =>
eb
.selectFrom('asset_face')
.whereRef('asset_face.personGroupId', '=', 'person_group.id')
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', '=', true)
.where((eb) =>
eb.exists((eb) =>
eb
.selectFrom('asset')
.whereRef('asset.id', '=', 'asset_face.assetId')
.where('asset.ownerId', '=', userId)
.where('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
.where('asset.deletedAt', 'is', null),
),
),
),
]),
eb.exists((eb) =>
eb
.selectFrom('person_user')
.select('person_user.sharedById')
.whereRef('person_user.personGroupId', '=', 'person_group.id')
.where('person_user.sharedWithId', '=', userId),
),
]),
)
.where(withFilters(userId, options))
.select((eb) => eb.fn.coalesce(eb.fn.countAll<number>(), zero).as('total'))
.select((eb) => eb.fn.coalesce(eb.fn.countAll<number>().filterWhere('isHidden', '=', true), zero).as('hidden'))
.select((eb) =>
eb.fn.coalesce(eb.fn.countAll<number>().filterWhere('owned.isHidden', '=', true), zero).as('hidden'),
)
.executeTakeFirstOrThrow();
}
create(person: Insertable<PersonTable>) {
return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow();
return this.db
.insertInto('person')
.values(person)
.returningAll()
.returning(withOtherPeopleForPerson(person.ownerId))
.executeTakeFirstOrThrow();
}
async createAll(people: Insertable<PersonTable>[]) {
@@ -674,6 +929,7 @@ export class PersonRepository {
.where('person.ownerId', '=', person.ownerId)
.where('person.personGroupId', '=', person.personGroupId)
.returningAll()
.returning(withOtherPeopleForPerson(person.ownerId))
.executeTakeFirstOrThrow();
}
@@ -816,7 +1072,6 @@ export class PersonRepository {
.selectFrom('person')
.selectAll('person')
.where('person.personGroupId', 'in', personGroupIds)
.orderBy('person.ownerId')
.execute();
}
}
+6
View File
@@ -1,4 +1,5 @@
import { registerEnum } from '@immich/sql-tools';
import { PersonUserRole } from 'src/dtos/person.dto.js';
import { AlbumUserRole, AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType, VideoCodec } from 'src/enum.js';
export const album_user_role_enum = registerEnum({
@@ -30,3 +31,8 @@ export const video_stream_variant_codec_enum = registerEnum({
name: 'video_stream_variant_codec_enum',
values: [VideoCodec.Av1, VideoCodec.Hevc, VideoCodec.H264],
});
export const person_user_role_enum = registerEnum({
name: 'person_user_role_enum',
values: Object.values(PersonUserRole),
});
+37
View File
@@ -219,6 +219,43 @@ export const person_delete_audit = registerFunction({
END`,
});
export const person_user_after_insert = registerFunction({
name: 'person_user_after_insert',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO person ("ownerId", "personGroupId", "name", "birthDate")
SELECT DISTINCT ON (i."sharedWithId", i."personGroupId")
i."sharedWithId", i."personGroupId", shared."name", shared."birthDate"
FROM inserted_rows i
INNER JOIN person shared
ON shared."ownerId" = i."sharedById" AND shared."personGroupId" = i."personGroupId"
ORDER BY i."sharedWithId", i."personGroupId", shared."name" = '', shared."birthDate" IS NULL
ON CONFLICT ("ownerId", "personGroupId") DO UPDATE
SET
"name" = CASE WHEN person."name" = '' THEN EXCLUDED."name" ELSE person."name" END,
"birthDate" = COALESCE(person."birthDate", EXCLUDED."birthDate")
WHERE (person."name" = '' AND EXCLUDED."name" <> '')
OR (person."birthDate" IS NULL AND EXCLUDED."birthDate" IS NOT NULL);
RETURN NULL;
END`,
});
export const person_delete_shares = registerFunction({
name: 'person_delete_shares',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
DELETE FROM person_user
USING deleted_rows
WHERE person_user."personGroupId" = deleted_rows."personGroupId"
AND person_user."sharedWithId" = deleted_rows."ownerId";
RETURN NULL;
END`,
});
export const person_group_delete_audit = registerFunction({
name: 'person_group_delete_audit',
returnType: 'TRIGGER',
+11 -1
View File
@@ -4,6 +4,7 @@ import {
asset_face_source_type,
asset_visibility_enum,
assets_status_enum,
person_user_role_enum,
} from 'src/schema/enums.js';
import {
album_user_after_insert,
@@ -68,6 +69,7 @@ import { PartnerTable } from 'src/schema/tables/partner.table.js';
import { PersonAuditTable } from 'src/schema/tables/person-audit.table.js';
import { PersonGroupAuditTable } from 'src/schema/tables/person-group-audit.table.js';
import { PersonGroupTable } from 'src/schema/tables/person-group.table.js';
import { PersonUserTable } from 'src/schema/tables/person-user.table.js';
import { PersonTable } from 'src/schema/tables/person.table.js';
import { PluginMethodTable } from 'src/schema/tables/plugin-method.table.js';
import { PluginTable } from 'src/schema/tables/plugin.table.js';
@@ -139,6 +141,7 @@ export class ImmichDatabase {
PartnerTable,
PersonTable,
PersonAuditTable,
PersonUserTable,
PersonGroupTable,
PersonGroupAuditTable,
SessionTable,
@@ -189,7 +192,13 @@ export class ImmichDatabase {
album_user_delete,
];
enum = [album_user_role_enum, assets_status_enum, asset_face_source_type, asset_visibility_enum];
enum = [
album_user_role_enum,
assets_status_enum,
asset_face_source_type,
asset_visibility_enum,
person_user_role_enum,
];
}
export interface Migrations {
@@ -256,6 +265,7 @@ export interface DB {
person: PersonTable;
person_audit: PersonAuditTable;
person_user: PersonUserTable;
person_group: PersonGroupTable;
person_group_audit: PersonGroupAuditTable;
@@ -0,0 +1,102 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION person_user_after_insert()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO person ("ownerId", "personGroupId", "name", "birthDate")
SELECT i."sharedWithId", i."personGroupId", shared."name", shared."birthDate"
FROM inserted_rows i
INNER JOIN person shared
ON shared."ownerId" = i."sharedById" AND shared."personGroupId" = i."personGroupId"
ON CONFLICT ("ownerId", "personGroupId") DO NOTHING;
RETURN NULL;
END
$$;`.execute(db);
await sql`CREATE OR REPLACE FUNCTION person_delete_shares()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
DELETE FROM person_user
USING deleted_rows
WHERE person_user."personGroupId" = deleted_rows."personGroupId"
AND person_user."sharedWithId" = deleted_rows."ownerId";
RETURN NULL;
END
$$;`.execute(db);
await sql`CREATE TYPE "person_user_role_enum" AS ENUM ('read','write','admin');`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "person_delete_shares"
AFTER DELETE ON "person"
REFERENCING OLD TABLE AS "deleted_rows"
FOR EACH STATEMENT
EXECUTE FUNCTION person_delete_shares();`.execute(db);
await sql`CREATE TABLE "person_user" (
"personGroupId" uuid NOT NULL,
"sharedById" uuid NOT NULL,
"sharedWithId" uuid NOT NULL,
"role" person_user_role_enum NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
"updateId" uuid NOT NULL DEFAULT immich_uuid_v7(),
CONSTRAINT "person_user_sharedWithId_fkey" FOREIGN KEY ("sharedWithId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "person_user_sharedById_personGroupId_fkey" FOREIGN KEY ("sharedById", "personGroupId") REFERENCES "person" ("ownerId", "personGroupId") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "person_user_pkey" PRIMARY KEY ("personGroupId", "sharedById", "sharedWithId")
);`.execute(db);
await sql`CREATE INDEX "person_user_sharedById_personGroupId_idx" ON "person_user" ("sharedById", "personGroupId");`.execute(db);
await sql`CREATE INDEX "person_user_sharedWithId_idx" ON "person_user" ("sharedWithId");`.execute(db);
await sql`CREATE INDEX "person_user_updateId_idx" ON "person_user" ("updateId");`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "person_user_after_insert"
AFTER INSERT ON "person_user"
REFERENCING NEW TABLE AS "inserted_rows"
FOR EACH STATEMENT
EXECUTE FUNCTION person_user_after_insert();`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "person_user_updatedAt"
BEFORE UPDATE ON "person_user"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
await sql`CREATE OR REPLACE FUNCTION person_user_after_insert()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO person ("ownerId", "personGroupId", "name", "birthDate")
SELECT DISTINCT ON (i."sharedWithId", i."personGroupId")
i."sharedWithId", i."personGroupId", shared."name", shared."birthDate"
FROM inserted_rows i
INNER JOIN person shared
ON shared."ownerId" = i."sharedById" AND shared."personGroupId" = i."personGroupId"
ORDER BY i."sharedWithId", i."personGroupId", shared."name" = '', shared."birthDate" IS NULL
ON CONFLICT ("ownerId", "personGroupId") DO UPDATE
SET
"name" = CASE WHEN person."name" = '' THEN EXCLUDED."name" ELSE person."name" END,
"birthDate" = COALESCE(person."birthDate", EXCLUDED."birthDate")
WHERE (person."name" = '' AND EXCLUDED."name" <> '')
OR (person."birthDate" IS NULL AND EXCLUDED."birthDate" IS NOT NULL);
RETURN NULL;
END
$$;`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_person_user_after_insert', '{"type":"function","name":"person_user_after_insert","sql":"CREATE OR REPLACE FUNCTION person_user_after_insert()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person (\\"ownerId\\", \\"personGroupId\\", \\"name\\", \\"birthDate\\")\\n SELECT i.\\"sharedWithId\\", i.\\"personGroupId\\", shared.\\"name\\", shared.\\"birthDate\\"\\n FROM inserted_rows i\\n INNER JOIN person shared\\n ON shared.\\"ownerId\\" = i.\\"sharedById\\" AND shared.\\"personGroupId\\" = i.\\"personGroupId\\"\\n ON CONFLICT (\\"ownerId\\", \\"personGroupId\\") DO NOTHING;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_person_delete_shares', '{"type":"function","name":"person_delete_shares","sql":"CREATE OR REPLACE FUNCTION person_delete_shares()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n DELETE FROM person_user\\n USING deleted_rows\\n WHERE person_user.\\"personGroupId\\" = deleted_rows.\\"personGroupId\\"\\n AND person_user.\\"sharedWithId\\" = deleted_rows.\\"ownerId\\";\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_person_delete_shares', '{"type":"trigger","name":"person_delete_shares","sql":"CREATE OR REPLACE TRIGGER \\"person_delete_shares\\"\\n AFTER DELETE ON \\"person\\"\\n REFERENCING OLD TABLE AS \\"deleted_rows\\"\\n FOR EACH STATEMENT\\n EXECUTE FUNCTION person_delete_shares();"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_person_user_after_insert', '{"type":"trigger","name":"person_user_after_insert","sql":"CREATE OR REPLACE TRIGGER \\"person_user_after_insert\\"\\n AFTER INSERT ON \\"person_user\\"\\n REFERENCING NEW TABLE AS \\"inserted_rows\\"\\n FOR EACH STATEMENT\\n EXECUTE FUNCTION person_user_after_insert();"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_person_user_updatedAt', '{"type":"trigger","name":"person_user_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"person_user_updatedAt\\"\\n BEFORE UPDATE ON \\"person_user\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"person_user_after_insert","sql":"CREATE OR REPLACE FUNCTION person_user_after_insert()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person (\\"ownerId\\", \\"personGroupId\\", \\"name\\", \\"birthDate\\")\\n SELECT DISTINCT ON (i.\\"sharedWithId\\", i.\\"personGroupId\\")\\n i.\\"sharedWithId\\", i.\\"personGroupId\\", shared.\\"name\\", shared.\\"birthDate\\"\\n FROM inserted_rows i\\n INNER JOIN person shared\\n ON shared.\\"ownerId\\" = i.\\"sharedById\\" AND shared.\\"personGroupId\\" = i.\\"personGroupId\\"\\n ORDER BY i.\\"sharedWithId\\", i.\\"personGroupId\\", shared.\\"name\\" = '''', shared.\\"birthDate\\" IS NULL\\n ON CONFLICT (\\"ownerId\\", \\"personGroupId\\") DO UPDATE\\n SET\\n \\"name\\" = CASE WHEN person.\\"name\\" = '''' THEN EXCLUDED.\\"name\\" ELSE person.\\"name\\" END,\\n \\"birthDate\\" = COALESCE(person.\\"birthDate\\", EXCLUDED.\\"birthDate\\")\\n WHERE (person.\\"name\\" = '''' AND EXCLUDED.\\"name\\" <> '''')\\n OR (person.\\"birthDate\\" IS NULL AND EXCLUDED.\\"birthDate\\" IS NOT NULL);\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_person_user_after_insert';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TRIGGER "person_user_after_insert" ON "person_user";`.execute(db);
await sql`DROP FUNCTION person_user_after_insert;`.execute(db);
await sql`DROP TRIGGER "person_delete_shares" ON "person";`.execute(db);
await sql`DROP FUNCTION person_delete_shares;`.execute(db);
await sql`DROP TABLE "person_user";`.execute(db);
await sql`DROP TYPE "person_user_role_enum";`.execute(db);
await sql`DROP TRIGGER "person_user_updatedAt" ON "person_user";`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_person_user_after_insert';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_person_delete_shares';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_person_delete_shares';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_person_user_after_insert';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_person_user_updatedAt';`.execute(db);
}
+1
View File
@@ -96,3 +96,4 @@
1787148183730-DeleteMismatchedMemoryAssets
1789419229196-ConvertUserOAuthIdEmptyStringToNull
1790587508209-RenameGeoNamesCountries
1790616293884-PersonSharing
@@ -0,0 +1,60 @@
import {
AfterInsertTrigger,
Column,
CreateDateColumn,
ForeignKeyColumn,
ForeignKeyConstraint,
type Generated,
PrimaryColumn,
Table,
Timestamp,
UpdateDateColumn,
} from '@immich/sql-tools';
import { UpdateIdColumn, UpdatedAtTrigger } from 'src/decorators.js';
import { PersonUserRole } from 'src/dtos/person.dto.js';
import { person_user_role_enum } from 'src/schema/enums.js';
import { person_user_after_insert } from 'src/schema/functions.js';
import { PersonTable } from 'src/schema/tables/person.table.js';
import { UserTable } from 'src/schema/tables/user.table.js';
@Table('person_user')
@UpdatedAtTrigger('person_user_updatedAt')
@AfterInsertTrigger({
name: 'person_user_after_insert',
scope: 'statement',
referencingNewTableAs: 'inserted_rows',
function: person_user_after_insert,
})
@ForeignKeyConstraint({
columns: ['sharedById', 'personGroupId'],
referenceColumns: ['ownerId', 'personGroupId'],
referenceTable: () => PersonTable,
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
})
export class PersonUserTable {
@PrimaryColumn({ type: 'uuid' })
personGroupId!: string;
@PrimaryColumn({ type: 'uuid' })
sharedById!: string;
@ForeignKeyColumn(() => UserTable, {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
primary: true,
})
sharedWithId!: string;
@Column({ enum: person_user_role_enum })
role!: PersonUserRole;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}
+7 -1
View File
@@ -11,7 +11,7 @@ import {
UpdateDateColumn,
} from '@immich/sql-tools';
import { UpdateIdColumn, UpdatedAtTrigger } from 'src/decorators.js';
import { person_delete_audit } from 'src/schema/functions.js';
import { person_delete_audit, person_delete_shares } from 'src/schema/functions.js';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table.js';
import { PersonGroupTable } from 'src/schema/tables/person-group.table.js';
import { UserTable } from 'src/schema/tables/user.table.js';
@@ -29,6 +29,12 @@ import { UserTable } from 'src/schema/tables/user.table.js';
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() <= 1',
})
@AfterDeleteTrigger({
name: 'person_delete_shares',
scope: 'statement',
function: person_delete_shares,
referencingOldTableAs: 'deleted_rows',
})
@Check({ name: 'person_birthDate_chk', expression: `"birthDate" <= CURRENT_DATE` })
export class PersonTable {
@ForeignKeyColumn(() => UserTable, {
+20 -1
View File
@@ -39,6 +39,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository
import { OAuthRepository } from 'src/repositories/oauth.repository.js';
import { OcrRepository } from 'src/repositories/ocr.repository.js';
import { PartnerRepository } from 'src/repositories/partner.repository.js';
import { PersonUserRepository } from 'src/repositories/person-user.repository.js';
import { PersonRepository } from 'src/repositories/person.repository.js';
import { PluginRepository } from 'src/repositories/plugin.repository.js';
import { ProcessRepository } from 'src/repositories/process.repository.js';
@@ -62,7 +63,14 @@ import { ViewRepository } from 'src/repositories/view-repository.js';
import { WebsocketRepository } from 'src/repositories/websocket.repository.js';
import { WorkflowRepository } from 'src/repositories/workflow.repository.js';
import { UserTable } from 'src/schema/tables/user.table.js';
import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access.js';
import {
AccessPersonRequest,
AccessRequest,
checkAccess,
checkPersonAccess,
requireAccess,
requirePersonAccess,
} from 'src/utils/access.js';
import { getConfig, updateConfig } from 'src/utils/config.js';
export const BASE_SERVICE_DEPENDENCIES = [
@@ -100,6 +108,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
OcrRepository,
PartnerRepository,
PersonRepository,
PersonUserRepository,
PluginRepository,
ProcessRepository,
SearchRepository,
@@ -162,6 +171,7 @@ export class BaseService {
protected ocrRepository: OcrRepository,
protected partnerRepository: PartnerRepository,
protected personRepository: PersonRepository,
protected personUserRepository: PersonUserRepository,
protected pluginRepository: PluginRepository,
protected processRepository: ProcessRepository,
protected searchRepository: SearchRepository,
@@ -233,6 +243,7 @@ export class BaseService {
ctx.ocrRepository,
ctx.partnerRepository,
ctx.personRepository,
ctx.personUserRepository,
ctx.pluginRepository,
ctx.processRepository,
ctx.searchRepository,
@@ -289,6 +300,14 @@ export class BaseService {
return checkAccess(this.accessRepository, request);
}
requirePersonAccess(request: AccessPersonRequest) {
return requirePersonAccess(this.accessRepository, request);
}
checkPersonAccess(request: AccessPersonRequest) {
return checkPersonAccess(this.accessRepository, request);
}
async isSetupAvailable(): Promise<boolean> {
const { setup } = this.configRepository.getEnv();
return setup.allow && !(await this.userRepository.hasAdmin());
+3 -3
View File
@@ -1,5 +1,5 @@
import { BadRequestException } from '@nestjs/common';
import { PartnerDirection } from 'src/repositories/partner.repository.js';
import { SharingDirection } from 'src/enum.js';
import { PartnerService } from 'src/services/partner.service.js';
import { AuthFactory } from 'test/factories/auth.factory.js';
import { PartnerFactory } from 'test/factories/partner.factory.js';
@@ -29,7 +29,7 @@ describe(PartnerService.name, () => {
mocks.partner.getAll.mockResolvedValue([getForPartner(sharedWithUser1), getForPartner(sharedWithUser2)]);
await expect(sut.search(auth, { direction: PartnerDirection.SharedBy })).resolves.toBeDefined();
await expect(sut.search(auth, { direction: SharingDirection.SharedBy })).resolves.toBeDefined();
expect(mocks.partner.getAll).toHaveBeenCalledWith(user1.id);
});
@@ -41,7 +41,7 @@ describe(PartnerService.name, () => {
const auth = AuthFactory.create({ id: user1.id });
mocks.partner.getAll.mockResolvedValue([getForPartner(sharedWithUser1), getForPartner(sharedWithUser2)]);
await expect(sut.search(auth, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined();
await expect(sut.search(auth, { direction: SharingDirection.SharedWith })).resolves.toBeDefined();
expect(mocks.partner.getAll).toHaveBeenCalledWith(user1.id);
});
});
+7 -7
View File
@@ -3,8 +3,8 @@ import { Partner } from 'src/database.js';
import { AuthDto } from 'src/dtos/auth.dto.js';
import { PartnerCreateDto, PartnerResponseDto, PartnerSearchDto, PartnerUpdateDto } from 'src/dtos/partner.dto.js';
import { mapUser } from 'src/dtos/user.dto.js';
import { Permission } from 'src/enum.js';
import { PartnerDirection, PartnerIds } from 'src/repositories/partner.repository.js';
import { Permission, SharingDirection } from 'src/enum.js';
import { PartnerIds } from 'src/repositories/partner.repository.js';
import { BaseService } from 'src/services/base.service.js';
@Injectable()
@@ -23,7 +23,7 @@ export class PartnerService extends BaseService {
}
const partner = await this.partnerRepository.create(partnerId);
return this.mapPartner(partner, PartnerDirection.SharedBy);
return this.mapPartner(partner, SharingDirection.SharedBy);
}
async remove(auth: AuthDto, sharedWithId: string): Promise<void> {
@@ -38,7 +38,7 @@ export class PartnerService extends BaseService {
async search(auth: AuthDto, { direction }: PartnerSearchDto): Promise<PartnerResponseDto[]> {
const partners = await this.partnerRepository.getAll(auth.user.id);
const key = direction === PartnerDirection.SharedBy ? 'sharedById' : 'sharedWithId';
const key = direction === SharingDirection.SharedBy ? 'sharedById' : 'sharedWithId';
return partners
.filter((partner): partner is Partner => !!(partner.sharedBy && partner.sharedWith)) // Filter out soft deleted users
.filter((partner) => partner[key] === auth.user.id)
@@ -50,12 +50,12 @@ export class PartnerService extends BaseService {
const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id };
const entity = await this.partnerRepository.update(partnerId, { inTimeline: dto.inTimeline });
return this.mapPartner(entity, PartnerDirection.SharedWith);
return this.mapPartner(entity, SharingDirection.SharedWith);
}
private mapPartner(partner: Partner, direction: PartnerDirection): PartnerResponseDto {
private mapPartner(partner: Partner, direction: SharingDirection): PartnerResponseDto {
// this is opposite to return the non-me user of the "partner"
const sharedUser = direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy;
const sharedUser = direction === SharingDirection.SharedBy ? partner.sharedWith : partner.sharedBy;
const user = mapUser(sharedUser);
return { ...user, inTimeline: partner.inTimeline };
+209 -83
View File
@@ -1,12 +1,13 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto.js';
import { mapFaces, mapPerson } from 'src/dtos/person.dto.js';
import { PersonUserRole, mapFaces, mapPerson } from 'src/dtos/person.dto.js';
import { AssetFileType, CacheControl, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum.js';
import { PersonService } from 'src/services/person.service.js';
import { ImmichFileResponse } from 'src/utils/file.js';
import { AssetFaceFactory } from 'test/factories/asset-face.factory.js';
import { AssetFactory } from 'test/factories/asset.factory.js';
import { AuthFactory } from 'test/factories/auth.factory.js';
import { PartnerFactory } from 'test/factories/partner.factory.js';
import { PersonGroupFactory } from 'test/factories/person-group.factory.js';
import { PersonFactory } from 'test/factories/person.factory.js';
import { UserFactory } from 'test/factories/user.factory.js';
@@ -14,15 +15,20 @@ import { authStub } from 'test/fixtures/auth.stub.js';
import { systemConfigStub } from 'test/fixtures/system-config.stub.js';
import {
getAsDetectedFace,
getDehydrated,
getForAsset,
getForAssetFace,
getForDetectedFaces,
getForFaceSearch,
getForFacialRecognitionJob,
getForPartner,
} from 'test/mappers.js';
import { newDate, newUuid } from 'test/small.factory.js';
import { ServiceMocks, makeStream, newTestService } from 'test/utils.js';
const PERSON_READ_ROLES = [PersonUserRole.Read, PersonUserRole.Write, PersonUserRole.Admin];
const PERSON_WRITE_ROLES = [PersonUserRole.Write, PersonUserRole.Admin];
describe(PersonService.name, () => {
let sut: PersonService;
let mocks: ServiceMocks;
@@ -41,7 +47,7 @@ describe(PersonService.name, () => {
const [person, hiddenPerson] = [PersonFactory.create(), PersonFactory.create({ isHidden: true })];
mocks.person.getAllForUser.mockResolvedValue({
items: [person, hiddenPerson],
items: [getDehydrated(person), getDehydrated(hiddenPerson)],
hasNextPage: false,
});
mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 });
@@ -67,7 +73,7 @@ describe(PersonService.name, () => {
const [isFavorite, person] = [PersonFactory.create({ isFavorite: true }), PersonFactory.create()];
mocks.person.getAllForUser.mockResolvedValue({
items: [isFavorite, person],
items: [getDehydrated(isFavorite), getDehydrated(person)],
hasNextPage: false,
});
mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 });
@@ -93,32 +99,39 @@ describe(PersonService.name, () => {
it('should require person.read permission', async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create();
mocks.person.getByGroupId.mockResolvedValue(person);
mocks.person.getForUser.mockResolvedValue(getDehydrated(person));
await expect(sut.getById(auth, person.personGroupId)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(
auth.user.id,
new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]),
PERSON_READ_ROLES,
);
});
it('should throw a bad request when person is not found', async () => {
const auth = AuthFactory.create();
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['unknown']));
const ids = [{ personGroupId: 'unknown', ownerId: auth.user.id }];
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
await expect(sut.getById(auth, 'unknown')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['unknown']));
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_READ_ROLES);
});
it('should get a person by id', async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create();
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
mocks.person.getByGroupId.mockResolvedValue(person);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
mocks.person.getForUser.mockResolvedValue(getDehydrated(person));
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
await expect(sut.getById(auth, person.personGroupId)).resolves.toEqual(
expect.objectContaining({ id: person.personGroupId }),
);
expect(mocks.person.getByGroupId).toHaveBeenCalledWith({
ownerId: auth.user.id,
expect(mocks.person.getForUser).toHaveBeenCalledWith({
userId: auth.user.id,
personGroupId: person.personGroupId,
});
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_READ_ROLES);
});
});
@@ -127,38 +140,72 @@ describe(PersonService.name, () => {
const auth = AuthFactory.create();
const person = PersonFactory.create();
mocks.person.getByGroupId.mockResolvedValue(person);
mocks.person.getForThumbnail.mockResolvedValue({
thumbnailPath: person.thumbnailPath,
sharedThumbnailPath: null,
});
await expect(sut.getThumbnail(auth, person.personGroupId)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(
auth.user.id,
new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]),
PERSON_READ_ROLES,
);
});
it('should throw an error when personId is invalid', async () => {
const auth = AuthFactory.create();
const ids = [{ personGroupId: 'unknown', ownerId: auth.user.id }];
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['unknown']));
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
mocks.person.getForThumbnail.mockResolvedValue(undefined);
await expect(sut.getThumbnail(auth, 'unknown')).rejects.toBeInstanceOf(NotFoundException);
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['unknown']));
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_READ_ROLES);
});
it('should throw an error when person has no thumbnail', async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create({ thumbnailPath: '' });
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
mocks.person.getByGroupId.mockResolvedValue(person);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
mocks.person.getForThumbnail.mockResolvedValue({ thumbnailPath: '', sharedThumbnailPath: null });
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
await expect(sut.getThumbnail(auth, person.personGroupId)).rejects.toBeInstanceOf(NotFoundException);
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_READ_ROLES);
});
it('should serve the thumbnail', async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create();
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
mocks.person.getByGroupId.mockResolvedValue(person);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
mocks.person.getForThumbnail.mockResolvedValue({
thumbnailPath: person.thumbnailPath,
sharedThumbnailPath: null,
});
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
await expect(sut.getThumbnail(auth, person.personGroupId)).resolves.toEqual(
new ImmichFileResponse({
path: person.thumbnailPath,
contentType: 'image/jpeg',
cacheControl: CacheControl.PrivateWithoutCache,
}),
);
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_READ_ROLES);
});
it('should fall back to a shared thumbnail', async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create();
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
mocks.person.getForThumbnail.mockResolvedValue({
thumbnailPath: '',
sharedThumbnailPath: person.thumbnailPath,
});
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
await expect(sut.getThumbnail(auth, person.personGroupId)).resolves.toEqual(
new ImmichFileResponse({
path: person.thumbnailPath,
@@ -166,39 +213,45 @@ describe(PersonService.name, () => {
cacheControl: CacheControl.PrivateWithoutCache,
}),
);
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
});
});
describe('update', () => {
it('should require person.write permission', async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create();
const person = PersonFactory.create({ ownerId: auth.user.id });
mocks.person.getByGroupId.mockResolvedValue(person);
await expect(sut.update(auth, person.personGroupId, { name: 'Person 1' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(
auth.user.id,
new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]),
PERSON_WRITE_ROLES,
);
});
it('should throw an error when personId is invalid', async () => {
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set());
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf(
BadRequestException,
);
const auth = AuthFactory.create();
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.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(
auth.user.id,
new Set([{ personGroupId: 'person-1', ownerId: auth.user.id }]),
PERSON_WRITE_ROLES,
);
});
it("should update a person's name", async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create({ name: 'Person 1' });
const person = PersonFactory.create({ ownerId: auth.user.id, name: 'Person 1' });
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
mocks.person.getByGroupId.mockResolvedValue(person);
mocks.person.update.mockResolvedValue(person);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
await expect(sut.update(auth, person.personGroupId, { name: 'Person 1' })).resolves.toEqual(
expect.objectContaining({ id: person.personGroupId, name: 'Person 1' }),
@@ -209,16 +262,36 @@ describe(PersonService.name, () => {
personGroupId: person.personGroupId,
name: 'Person 1',
});
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES);
});
it("should update another user's person when userId is provided", async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create({ name: 'Person 1' });
const ids = [{ personGroupId: person.personGroupId, ownerId: person.ownerId }];
mocks.person.update.mockResolvedValue(person);
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
await expect(
sut.update(auth, person.personGroupId, { name: 'Person 1', userId: person.ownerId }),
).resolves.toEqual(expect.objectContaining({ id: person.personGroupId, name: 'Person 1' }));
expect(mocks.person.update).toHaveBeenCalledWith({
ownerId: person.ownerId,
personGroupId: person.personGroupId,
name: 'Person 1',
});
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 () => {
const auth = AuthFactory.create();
const person = PersonFactory.create({ birthDate: new Date('1976-06-30') });
const person = PersonFactory.create({ ownerId: auth.user.id, birthDate: new Date('1976-06-30') });
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
mocks.person.getByGroupId.mockResolvedValue(person);
mocks.person.update.mockResolvedValue(person);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
await expect(sut.update(auth, person.personGroupId, { birthDate: '1976-06-30' })).resolves.toEqual({
id: person.personGroupId,
@@ -227,6 +300,9 @@ describe(PersonService.name, () => {
thumbnailPath: person.thumbnailPath,
isHidden: false,
isFavorite: false,
otherPeople: [],
sharedBy: [],
sharedWith: [],
updatedAt: expect.any(String),
});
expect(mocks.person.update).toHaveBeenCalledWith({
@@ -236,16 +312,16 @@ describe(PersonService.name, () => {
});
expect(mocks.job.queue).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled();
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES);
});
it('should update a person visibility', async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create({ isHidden: true });
const person = PersonFactory.create({ ownerId: auth.user.id, isHidden: true });
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
mocks.person.getByGroupId.mockResolvedValue(person);
mocks.person.update.mockResolvedValue(person);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
await expect(sut.update(auth, person.personGroupId, { isHidden: true })).resolves.toEqual(
expect.objectContaining({ isHidden: true }),
@@ -256,16 +332,16 @@ describe(PersonService.name, () => {
personGroupId: person.personGroupId,
isHidden: true,
});
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES);
});
it('should update a person favorite status', async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create({ isFavorite: true });
const person = PersonFactory.create({ ownerId: auth.user.id, isFavorite: true });
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
mocks.person.getByGroupId.mockResolvedValue(person);
mocks.person.update.mockResolvedValue(person);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
await expect(sut.update(auth, person.personGroupId, { isFavorite: true })).resolves.toEqual(
expect.objectContaining({ isFavorite: true }),
@@ -276,19 +352,19 @@ describe(PersonService.name, () => {
personGroupId: person.personGroupId,
isFavorite: true,
});
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES);
});
it("should update a person's thumbnailPath", async () => {
const face = AssetFaceFactory.create();
const auth = AuthFactory.create();
const person = PersonFactory.create();
const person = PersonFactory.create({ ownerId: auth.user.id });
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
mocks.person.getByGroupId.mockResolvedValue(person);
mocks.person.update.mockResolvedValue(person);
mocks.person.getForFeatureFaceUpdate.mockResolvedValue(face);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([face.assetId]));
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
await expect(sut.update(auth, person.personGroupId, { featureFaceAssetId: face.assetId })).resolves.toEqual(
expect.objectContaining({ id: person.personGroupId }),
@@ -307,39 +383,45 @@ describe(PersonService.name, () => {
name: JobName.PersonGenerateThumbnail,
data: { ownerId: person.ownerId, personGroupId: person.personGroupId },
});
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES);
});
it('should throw an error when the face feature assetId is invalid', async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create();
const person = PersonFactory.create({ ownerId: auth.user.id });
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
mocks.person.getByGroupId.mockResolvedValue(person);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['-1']));
mocks.person.getForFeatureFaceUpdate.mockResolvedValue(undefined);
await expect(sut.update(auth, person.personGroupId, { featureFaceAssetId: '-1' })).rejects.toThrow(
BadRequestException,
);
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES);
});
});
describe('updateAll', () => {
it('should throw an error when personId is invalid', async () => {
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set());
mocks.access.person.checkAccess.mockResolvedValue(new Set());
await expect(sut.updateAll(authStub.admin, { people: [{ id: 'person-1', name: 'Person 1' }] })).resolves.toEqual([
{ error: BulkIdErrorReason.UNKNOWN, id: 'person-1', success: false },
]);
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(
authStub.admin.user.id,
new Set([{ personGroupId: 'person-1', ownerId: authStub.admin.user.id }]),
PERSON_WRITE_ROLES,
);
});
});
describe('reassignFaces', () => {
it('should throw an error if user has no access to the person', async () => {
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set());
mocks.access.person.checkAccess.mockResolvedValue(new Set());
await expect(
sut.reassignFaces(AuthFactory.create(), 'person-id', {
@@ -355,7 +437,9 @@ describe(PersonService.name, () => {
const auth = AuthFactory.create();
const person = PersonFactory.create();
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
mocks.access.person.checkAccess.mockResolvedValue(
new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]),
);
mocks.person.getByGroupId.mockResolvedValue(person);
mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id]));
mocks.person.getFacesByIds.mockResolvedValue([getForAssetFace(face)]);
@@ -421,7 +505,9 @@ describe(PersonService.name, () => {
});
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
mocks.access.person.checkAccess.mockResolvedValue(
new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]),
);
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
mocks.person.getByGroupId.mockResolvedValue(person);
mocks.person.getRandomFace.mockResolvedValue(featureFace);
@@ -472,7 +558,9 @@ describe(PersonService.name, () => {
const person = PersonFactory.create({ faceAssetId: newUuid() });
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
mocks.access.person.checkAccess.mockResolvedValue(
new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]),
);
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
mocks.person.getByGroupId.mockResolvedValue(person);
@@ -501,7 +589,9 @@ describe(PersonService.name, () => {
const person = PersonFactory.create({ faceAssetId: null });
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set());
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
mocks.access.person.checkAccess.mockResolvedValue(
new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]),
);
await expect(
sut.createFace(auth, {
@@ -537,40 +627,48 @@ describe(PersonService.name, () => {
describe('reassignFacesById', () => {
it('should create a new person', async () => {
const auth = AuthFactory.create();
const face = AssetFaceFactory.create();
const person = PersonFactory.create();
const person = PersonFactory.create({ ownerId: auth.user.id, faceAssetId: newUuid() });
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
mocks.access.person.checkAccess.mockResolvedValue(
new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]),
);
mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id]));
mocks.person.getFaceById.mockResolvedValue(getForAssetFace(face));
mocks.person.reassignFace.mockResolvedValue(1);
mocks.person.getByGroupId.mockResolvedValue(person);
await expect(sut.reassignFacesById(AuthFactory.create(), person.personGroupId, { id: face.id })).resolves.toEqual(
{
birthDate: person.birthDate,
isHidden: person.isHidden,
isFavorite: person.isFavorite,
id: person.personGroupId,
name: person.name,
thumbnailPath: person.thumbnailPath,
updatedAt: expect.any(String),
},
);
await expect(sut.reassignFacesById(auth, person.personGroupId, { id: face.id })).resolves.toEqual({
birthDate: person.birthDate,
isHidden: person.isHidden,
isFavorite: person.isFavorite,
id: person.personGroupId,
name: person.name,
otherPeople: [],
sharedBy: [],
sharedWith: [],
thumbnailPath: person.thumbnailPath,
updatedAt: expect.any(String),
});
expect(mocks.job.queue).not.toHaveBeenCalledWith();
expect(mocks.job.queueAll).not.toHaveBeenCalledWith();
});
it('should fail if user has not the correct permissions on the asset', async () => {
const auth = AuthFactory.create();
const face = AssetFaceFactory.create();
const person = PersonFactory.create();
const person = PersonFactory.create({ ownerId: auth.user.id });
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
mocks.access.person.checkAccess.mockResolvedValue(
new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]),
);
mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set());
mocks.person.getFaceById.mockResolvedValue(getForAssetFace(face));
mocks.person.reassignFace.mockResolvedValue(1);
mocks.person.getByGroupId.mockResolvedValue(person);
await expect(
sut.reassignFacesById(AuthFactory.create(), person.personGroupId, {
sut.reassignFacesById(auth, person.personGroupId, {
id: face.id,
}),
).rejects.toBeInstanceOf(BadRequestException);
@@ -1296,22 +1394,50 @@ describe(PersonService.name, () => {
it('should get correct number of person', async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create();
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
mocks.person.getByGroupId.mockResolvedValue(person);
mocks.person.getStatistics.mockResolvedValue({ assets: 3 });
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
mocks.partner.getAll.mockResolvedValue([]);
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
await expect(sut.getStatistics(auth, person.personGroupId)).resolves.toEqual({ assets: 3 });
expect(mocks.person.getStatistics).toHaveBeenCalledWith(person.personGroupId, auth.user.id);
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
expect(mocks.person.getStatistics).toHaveBeenCalledWith(person.personGroupId, {
ownerId: auth.user.id,
partnerIds: [],
});
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_READ_ROLES);
});
it('should only include partners that are shown in the timeline', async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create();
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
const inTimeline = UserFactory.create();
const notInTimeline = UserFactory.create();
mocks.person.getStatistics.mockResolvedValue({ assets: 3 });
mocks.partner.getAll.mockResolvedValue([
getForPartner(PartnerFactory.from({ inTimeline: true }).sharedBy(inTimeline).sharedWith(auth.user).build()),
getForPartner(PartnerFactory.from({ inTimeline: false }).sharedBy(notInTimeline).sharedWith(auth.user).build()),
]);
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
await expect(sut.getStatistics(auth, person.personGroupId)).resolves.toEqual({ assets: 3 });
expect(mocks.person.getStatistics).toHaveBeenCalledWith(person.personGroupId, {
ownerId: auth.user.id,
partnerIds: [inTimeline.id],
});
});
it('should require person.read permission', async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create();
mocks.person.getByGroupId.mockResolvedValue(person);
await expect(sut.getStatistics(auth, person.personGroupId)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
expect(mocks.person.getStatistics).not.toHaveBeenCalled();
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(
auth.user.id,
new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]),
PERSON_READ_ROLES,
);
});
});
+200 -87
View File
@@ -1,10 +1,9 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Insertable, Selectable, Updateable } from 'kysely';
import { Insertable } from 'kysely';
import { isUndefined, omitBy } from 'lodash-es';
import type { JobItem, JobOf } from 'src/types.js';
import { Person } from 'src/database.js';
import { Chunked, OnJob } from 'src/decorators.js';
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto.js';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto.js';
import { AuthDto } from 'src/dtos/auth.dto.js';
import {
AssetFaceCreateDto,
@@ -13,15 +12,22 @@ import {
AssetFaceUpdateDto,
FaceDto,
MergePersonDto,
PeopleDeleteDto,
PeopleResponseDto,
PeopleUpdateDto,
PersonCreateDto,
PersonDeleteDto,
PersonResponseDto,
PersonSearchDto,
PersonStatisticsResponseDto,
PersonUpdateDto,
PersonUsersCreateDto,
PersonUsersDeleteDto,
PersonUsersResponseDto,
PersonUsersSearchDto,
mapFaces,
mapPerson,
mapPersonUsers,
} from 'src/dtos/person.dto.js';
import {
AssetVisibility,
@@ -36,14 +42,15 @@ import {
VectorIndex,
} from 'src/enum.js';
import { BoundingBox } from 'src/repositories/machine-learning.repository.js';
import { PersonId, UpdateFacesData } from 'src/repositories/person.repository.js';
import { PersonId } from 'src/repositories/person.repository.js';
import { DB } from 'src/schema/index.js';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table.js';
import { FaceSearchTable } from 'src/schema/tables/face-search.table.js';
import { PersonTable } from 'src/schema/tables/person.table.js';
import { PersonUserTable } from 'src/schema/tables/person-user.table.js';
import { BaseService } from 'src/services/base.service.js';
import { getDimensions } from 'src/utils/asset.util.js';
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 { Point, transformPoints } from 'src/utils/transform.js';
@@ -53,7 +60,7 @@ const personKey = ({ ownerId, personGroupId }: PersonId) => `${ownerId}/${person
@Injectable()
export class PersonService extends BaseService {
async getAll(auth: AuthDto, dto: PersonSearchDto): Promise<PeopleResponseDto> {
const { withHidden = false, closestAssetId, closestPersonId, page, size } = dto;
const { withHidden = false, closestAssetId, closestPersonId, page, size, ...filters } = dto;
let closestFaceAssetId = closestAssetId;
const pagination = {
take: size,
@@ -73,8 +80,9 @@ export class PersonService extends BaseService {
const { items, hasNextPage } = await this.personRepository.getAllForUser(pagination, auth.user.id, {
withHidden,
closestFaceAssetId,
...filters,
});
const { total, hidden } = await this.personRepository.getNumberOfPeople(auth.user.id);
const { total, hidden } = await this.personRepository.getNumberOfPeople(auth.user.id, filters);
return {
people: items.map((person) => mapPerson(person)),
@@ -85,8 +93,16 @@ export class PersonService extends BaseService {
}
async reassignFaces(auth: AuthDto, personGroupId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personGroupId] });
const ids = dto.data.map((item) => ({ personGroupId: item.personId, ownerId: item.userId ?? auth.user.id }));
await this.requirePersonAccess({
auth,
permission: Permission.PersonUpdate,
ids: [{ personGroupId, ownerId: auth.user.id }, ...ids],
});
const person = await this.findOrFail(auth, personGroupId);
const result: PersonResponseDto[] = [];
const changeFeaturePhoto = new Map<string, PersonId>();
for (const data of dto.data) {
@@ -121,7 +137,11 @@ export class PersonService extends BaseService {
}
async reassignFacesById(auth: AuthDto, personGroupId: string, dto: FaceDto): Promise<PersonResponseDto> {
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personGroupId] });
await this.requirePersonAccess({
auth,
permission: Permission.PersonUpdate,
ids: [{ personGroupId, ownerId: auth.user.id }],
});
await this.requireAccess({ auth, permission: Permission.PersonCreate, ids: [dto.id] });
const face = await this.personRepository.getFaceById(dto.id, { viewingUserId: auth.user.id });
const person = await this.findOrFail(auth, personGroupId);
@@ -167,25 +187,46 @@ export class PersonService extends BaseService {
}
async getById(auth: AuthDto, personGroupId: string): Promise<PersonResponseDto> {
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [personGroupId] });
return mapPerson(await this.findOrFail(auth, personGroupId));
await this.requirePersonAccess({
auth,
permission: Permission.PersonRead,
ids: [{ personGroupId, ownerId: auth.user.id }],
});
return mapPerson(
await findOrFail(() => this.personRepository.getForUser({ userId: auth.user.id, personGroupId }), 'Person'),
);
}
async getStatistics(auth: AuthDto, personGroupId: string): Promise<PersonStatisticsResponseDto> {
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [personGroupId] });
return this.personRepository.getStatistics(personGroupId, auth.user.id);
await this.requirePersonAccess({
auth,
permission: Permission.PersonRead,
ids: [{ personGroupId, ownerId: auth.user.id }],
});
const partnerIds = await getMyPartnerIds({
userId: auth.user.id,
repository: this.partnerRepository,
timelineEnabled: true,
});
return this.personRepository.getStatistics(personGroupId, { ownerId: auth.user.id, partnerIds });
}
async getThumbnail(auth: AuthDto, personGroupId: string): Promise<ImmichFileResponse> {
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [personGroupId] });
const person = await this.personRepository.getByGroupId({ ownerId: auth.user.id, personGroupId });
if (!person || !person.thumbnailPath) {
await this.requirePersonAccess({
auth,
permission: Permission.PersonRead,
ids: [{ personGroupId, ownerId: auth.user.id }],
});
const personGroup = await this.personRepository.getForThumbnail({ ownerId: auth.user.id, personGroupId });
const thumbnailPath = personGroup?.thumbnailPath || personGroup?.sharedThumbnailPath;
if (!thumbnailPath) {
throw new NotFoundException();
}
return new ImmichFileResponse({
path: person.thumbnailPath,
contentType: mimeTypes.lookup(person.thumbnailPath),
path: thumbnailPath,
contentType: mimeTypes.lookup(thumbnailPath),
cacheControl: CacheControl.PrivateWithoutCache,
});
}
@@ -206,9 +247,14 @@ export class PersonService extends BaseService {
}
async update(auth: AuthDto, personGroupId: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personGroupId] });
const ownerId = dto.userId ?? auth.user.id;
await this.requirePersonAccess({
auth,
permission: Permission.PersonUpdate,
ids: [{ personGroupId, ownerId }],
});
const { ownerId } = await this.findOrFail(auth, personGroupId);
const { name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite, color } = dto;
// TODO: set by faceId directly
let faceId: string | undefined;
@@ -240,8 +286,8 @@ export class PersonService extends BaseService {
return mapPerson(person);
}
delete(auth: AuthDto, id: string): Promise<void> {
return this.deleteAll(auth, { ids: [id] });
delete(auth: AuthDto, id: string, dto: PersonDeleteDto): Promise<void> {
return this.deleteAll(auth, { ids: [id], ...dto });
}
async updateAll(auth: AuthDto, dto: PeopleUpdateDto): Promise<BulkIdResponseDto[]> {
@@ -254,19 +300,28 @@ export class PersonService extends BaseService {
birthDate: person.birthDate,
featureFaceAssetId: person.featureFaceAssetId,
isFavorite: person.isFavorite,
userId: person.userId,
});
results.push({ id: person.id, success: true });
} catch (error: Error | any) {
this.logger.error(`Unable to update ${person.id} : ${error}`, error?.stack);
if (!isHttpException(error)) {
this.logger.error(`Unable to update ${person.id} : ${error}`, error?.stack);
}
results.push({ id: person.id, success: false, error: BulkIdErrorReason.UNKNOWN });
}
}
return results;
}
async deleteAll(auth: AuthDto, { ids }: BulkIdsDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.PersonDelete, ids });
await this.removeAllPersonGroups(ids, auth.user.id);
async deleteAll(auth: AuthDto, { ids, userId }: PeopleDeleteDto): Promise<void> {
const ownerId = userId ?? auth.user.id;
await this.requirePersonAccess({
auth,
permission: Permission.PersonDelete,
ids: ids.map((id) => ({ personGroupId: id, ownerId })),
});
await this.removeAllPersonGroups(ids, ownerId);
await this.personRepository.deleteEmptyGroups();
}
@Chunked()
@@ -277,7 +332,6 @@ export class PersonService extends BaseService {
const people = await this.personRepository.delete(groupIds, ownerId);
await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath)));
await this.personRepository.deleteEmptyGroups();
this.logger.debug(`Deleted ${groupIds.length} people`);
}
@@ -595,80 +649,91 @@ export class PersonService extends BaseService {
throw new BadRequestException('Cannot merge a person into themselves');
}
const results: BulkIdResponseDto[] = [];
const allowed = await this.checkPersonAccess({
auth,
permission: Permission.PersonMerge,
ids: ids.map((id) => ({ personGroupId: id, ownerId: auth.user.id })),
});
const allowedIds = new Set(allowed.values().map((item) => item.personGroupId));
const results = new Map<string, BulkIdResponseDto>(
ids.map((id) => [
id,
{
id,
success: false,
error: allowedIds.has(id) ? BulkIdErrorReason.NOT_FOUND : BulkIdErrorReason.NO_PERMISSION,
},
]),
);
const allowedIds = await this.checkAccess({ auth, permission: Permission.PersonMerge, ids });
const orderedIds = ids.filter((id) => allowedIds.has(id));
const [targetId, ...sourceIds] = orderedIds;
const people = targetId ? await this.personRepository.getForMergePerson(orderedIds) : [];
const lookup = new Map(people.map((person) => [personKey(person), person]));
const ownerIds = new Set([auth.user.id, ...people.map((person) => person.ownerId)]);
const targets = new Map(
people.filter((person) => person.personGroupId === targetId).map((person) => [person.ownerId, person]),
);
const peopleMap: Record<string, Selectable<PersonTable>[]> = {};
for (const mergePerson of await this.personRepository.getForMergePerson(ids)) {
if (!peopleMap[mergePerson.personGroupId]) {
peopleMap[mergePerson.personGroupId] = [];
}
peopleMap[mergePerson.personGroupId].push(mergePerson);
if (targets.has(auth.user.id)) {
results.set(targetId, { id: targetId, success: true });
}
const targetPeople: Record<string, Selectable<PersonTable>> = {};
for (const mergeId of ids) {
const hasAccess = allowedIds.has(mergeId);
if (!hasAccess) {
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NO_PERMISSION });
continue;
}
for (const mergePerson of peopleMap[mergeId]) {
if (!targetPeople[mergePerson.ownerId]) {
targetPeople[mergePerson.ownerId] = mergePerson;
for (const id of sourceIds) {
for (const ownerId of ownerIds) {
const isAuthUser = ownerId === auth.user.id;
const source = lookup.get(personKey({ ownerId, personGroupId: id }));
let target = targets.get(ownerId);
if (!source) {
continue;
}
const targetPerson = targetPeople[mergePerson.ownerId];
if (
mergePerson.ownerId !== auth.user.id &&
((targetPerson.name && mergePerson.name) || (targetPerson.birthDate && mergePerson.birthDate))
) {
// skip other users when there are conflicts
if (!isAuthUser && target && ((target.name && source.name) || (target.birthDate && source.birthDate))) {
continue;
}
const changes: Updateable<Person> = omitBy(
{
name: mergePerson.name && !targetPerson.name ? mergePerson.name : undefined,
birthDate: mergePerson.birthDate && !targetPerson.birthDate ? mergePerson.birthDate : undefined,
},
isUndefined,
);
if (Object.keys(changes).length > 0) {
targetPeople[mergePerson.ownerId] = await this.personRepository.update({
ownerId: targetPerson.ownerId,
personGroupId: targetPerson.personGroupId,
...changes,
});
}
const mergeName = mergePerson.name || mergePerson.personGroupId;
const mergeData: UpdateFacesData = {
oldPersonGroupId: mergeId,
newPersonGroupId: targetPerson.personGroupId,
ownerId: targetPerson.ownerId,
};
this.logger.log(`Merging ${mergeName} into ${targetPerson.name || targetPerson.personGroupId}`);
try {
await this.personRepository.reassignFaces(mergeData);
await this.removeAllPersonGroups([mergeId], targetPerson.ownerId);
if (!target) {
// user has no person in the target group yet, so this one is updated to it
targets.set(
ownerId,
await this.personRepository.updateGroupId({ ownerId, oldPersonGroupId: id, newPersonGroupId: targetId }),
);
continue;
}
this.logger.log(`Merged ${mergeName} into ${targetPerson.name || targetPerson.personGroupId}`);
results.push({ id: mergeId, success: true });
const changes = omitBy(
{
name: source.name && !target.name ? source.name : undefined,
birthDate: source.birthDate && !target.birthDate ? source.birthDate : undefined,
},
isUndefined,
);
if (Object.keys(changes).length > 0) {
target = await this.personRepository.update({ ownerId, personGroupId: targetId, ...changes });
targets.set(ownerId, target);
}
await this.personRepository.reassignFaces({ oldPersonGroupId: id, newPersonGroupId: targetId, ownerId });
await this.removeAllPersonGroups([id], ownerId);
this.logger.log(`Merged ${source.name || id} into ${target.name || targetId}`);
if (isAuthUser) {
results.set(id, { id, success: true });
}
} catch (error: any) {
this.logger.error(`Unable to merge ${mergeId} into ${targetPerson.personGroupId}: ${error}`, error?.stack);
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.UNKNOWN });
this.logger.error(`Unable to merge ${id} into ${targetId}: ${error}`, error?.stack);
if (isAuthUser) {
results.set(id, { id, success: false, error: BulkIdErrorReason.UNKNOWN });
}
}
}
}
return results;
await this.personRepository.deleteEmptyGroups();
return results.values().toArray();
}
private findOrFail(auth: AuthDto, personGroupId: string) {
@@ -679,7 +744,11 @@ export class PersonService extends BaseService {
async createFace(auth: AuthDto, dto: AssetFaceCreateDto): Promise<void> {
await Promise.all([
this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [dto.assetId] }),
this.requireAccess({ auth, permission: Permission.PersonRead, ids: [dto.personId] }),
this.requirePersonAccess({
auth,
permission: Permission.PersonRead,
ids: [{ personGroupId: dto.personId, ownerId: auth.user.id }],
}),
]);
const [asset, person] = await Promise.all([
@@ -707,7 +776,6 @@ export class PersonService extends BaseService {
const scaleFactor = asset.width / dto.imageWidth;
topLeft = { x: topLeft.x * scaleFactor, y: topLeft.y * scaleFactor };
bottomRight = { x: bottomRight.x * scaleFactor, y: bottomRight.y * scaleFactor };
const [invertedTopLeft, invertedBottomRight] = transformPoints(
[topLeft, bottomRight],
edits,
@@ -754,6 +822,51 @@ export class PersonService extends BaseService {
return dto.force ? this.personRepository.deleteAssetFace(id) : this.personRepository.softDeleteAssetFaces(id);
}
async getUsersForPeople(auth: AuthDto, dto: PersonUsersSearchDto): Promise<PersonUsersResponseDto> {
const sharedUsers = await this.personUserRepository.searchPeopleUsers(auth.user.id, dto);
return mapPersonUsers(sharedUsers);
}
async addUsersToPeople(auth: AuthDto, dto: PersonUsersCreateDto) {
await this.requirePersonAccess({
auth,
permission: Permission.PersonUpdate,
ids: dto.personIds.map((item) => ({ personGroupId: item, ownerId: auth.user.id })),
});
const user = await findOrFail(() => this.userRepository.get(auth.user.id, {}), 'User');
const clusterGroupUsers = await this.clusterGroupRepository.getUsers({
clusterGroupId: user.clusterGroupId,
userId: user.id,
});
const clusterGroupUserIds = new Set(clusterGroupUsers.map(({ id }) => id));
const items: Insertable<PersonUserTable>[] = [];
const sharedById = auth.user.id;
for (const sharedWithId of dto.sharedWithIds) {
if (!clusterGroupUserIds.has(sharedWithId)) {
continue;
}
for (const personGroupId of dto.personIds) {
items.push({ personGroupId, sharedById, sharedWithId, role: dto.role });
}
}
await this.personUserRepository.createAll(items);
}
async removeUsersFromPeople(auth: AuthDto, dto: PersonUsersDeleteDto) {
await this.requirePersonAccess({
auth,
permission: Permission.PersonUpdate,
ids: dto.map((item) => ({ personGroupId: item.personId, ownerId: item.sharedById ?? auth.user.id })),
});
await this.personUserRepository.deleteAll(auth.user.id, dto);
}
private vacuum(...tables: (keyof DB)[]): Promise<unknown> {
return Promise.all(
tables.map((table) =>
+51 -7
View File
@@ -1,8 +1,10 @@
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { AuthSharedLink } from 'src/database.js';
import { AuthDto } from 'src/dtos/auth.dto.js';
import { PersonUserRole } from 'src/dtos/person.dto.js';
import { AlbumUserRole, Permission } from 'src/enum.js';
import { AccessRepository } from 'src/repositories/access.repository.js';
import { PersonId } from 'src/repositories/person.repository.js';
import { areSetsEqual, isSetSuperset, setDifference, setUnion } from 'src/utils/set.js';
export type GrantedRequest = {
@@ -24,6 +26,12 @@ export type AccessRequest = {
ids: Set<string> | string[];
};
export type AccessPersonRequest = {
auth: AuthDto;
permission: Permission;
ids: Set<PersonId> | PersonId[];
};
type SharedLinkAccessRequest = { sharedLink: AuthSharedLink; permission: Permission; ids: Set<string> };
type OtherAccessRequest = { auth: AuthDto; permission: Permission; ids: Set<string> };
@@ -41,6 +49,49 @@ export const requireAccess = async (access: AccessRepository, request: AccessReq
}
};
const PERSON_READ_ROLES = [PersonUserRole.Read, PersonUserRole.Write, PersonUserRole.Admin];
const PERSON_WRITE_ROLES = [PersonUserRole.Write, PersonUserRole.Admin];
const PERSON_ADMIN_ROLES = [PersonUserRole.Admin];
const asStringSet = (source: Set<PersonId>): Set<string> =>
new Set(source.values().map((item) => `${item.personGroupId}/$${item.ownerId}`));
export const requirePersonAccess = async (access: AccessRepository, request: AccessPersonRequest) => {
const allowedIds = await checkPersonAccess(access, request);
if (!areSetsEqual(asStringSet(new Set(request.ids)), asStringSet(allowedIds))) {
throw new BadRequestException(`Not found or no ${request.permission} access`);
}
};
export const checkPersonAccess = async (
access: AccessRepository,
{ ids, auth, permission }: AccessPersonRequest,
): Promise<Set<PersonId>> => {
const idSet = Array.isArray(ids) ? new Set(ids) : ids;
if (idSet.size === 0) {
return new Set<PersonId>();
}
switch (permission) {
case Permission.PersonRead: {
return access.person.checkAccess(auth.user.id, idSet, PERSON_READ_ROLES);
}
case Permission.PersonUpdate: {
return access.person.checkAccess(auth.user.id, idSet, PERSON_WRITE_ROLES);
}
case Permission.PersonDelete:
case Permission.PersonMerge: {
return access.person.checkAccess(auth.user.id, idSet, PERSON_ADMIN_ROLES);
}
default: {
return new Set();
}
}
};
export const checkAccess = async (
access: AccessRepository,
{ ids, auth, permission }: AccessRequest,
@@ -297,13 +348,6 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
return access.person.checkFaceOwnerAccess(auth.user.id, ids);
}
case Permission.PersonRead:
case Permission.PersonUpdate:
case Permission.PersonDelete:
case Permission.PersonMerge: {
return await access.person.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PersonReassign: {
return access.person.checkFaceOwnerAccess(auth.user.id, ids);
}
+5 -4
View File
@@ -1,10 +1,8 @@
import { Selectable } from 'kysely';
import { PersonTable } from 'src/schema/tables/person.table.js';
import { PersonLike } from 'test/factories/types.js';
import { PersonLike, PersonRow } from 'test/factories/types.js';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory.js';
export class PersonFactory {
private constructor(private readonly value: Selectable<PersonTable>) {}
private constructor(private readonly value: PersonRow) {}
static create(dto: PersonLike = {}) {
return PersonFactory.from(dto).build();
@@ -20,6 +18,9 @@ export class PersonFactory {
isFavorite: false,
isHidden: false,
name: 'person',
otherPeople: [],
sharedBy: [],
sharedWith: [],
ownerId: newUuid(),
thumbnailPath: '/data/thumbs/person-thumbnail.jpg',
updatedAt: newDate(),
+8 -1
View File
@@ -1,4 +1,6 @@
import { Selectable } from 'kysely';
import { PersonUser } from 'src/database.js';
import { PersonUserRole } from 'src/dtos/person.dto.js';
import { OAuthProfile } from 'src/repositories/oauth.repository.js';
import { ActivityTable } from 'src/schema/tables/activity.table.js';
import { AlbumUserTable } from 'src/schema/tables/album-user.table.js';
@@ -30,7 +32,12 @@ export type AlbumUserLike = Partial<Selectable<AlbumUserTable>>;
export type SharedLinkLike = Partial<Selectable<SharedLinkTable>>;
export type UserLike = Partial<Selectable<UserTable>>;
export type AssetFaceLike = Partial<Selectable<AssetFaceTable>>;
export type PersonLike = Partial<Selectable<PersonTable>>;
export type PersonRow = Selectable<PersonTable> & {
otherPeople: { sharedById: string; role: PersonUserRole; name: string; birthDate: string | null }[];
sharedBy: PersonUser[];
sharedWith: PersonUser[];
};
export type PersonLike = Partial<PersonRow>;
export type PersonGroupLike = Partial<Selectable<PersonGroupTable>>;
export type ClusterGroupLike = Partial<Selectable<ClusterGroupTable>>;
export type StackLike = Partial<Selectable<StackTable>>;
+27
View File
@@ -10,6 +10,7 @@ import { AssetFace } from 'src/database.js';
import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto.js';
import { SystemConfig } from 'src/dtos/config.dto.js';
import { AssetEditActionItem, AssetEditsCreateDto } from 'src/dtos/editing.dto.js';
import { PersonUserRole } from 'src/dtos/person.dto.js';
import {
AlbumUserRole,
AssetType,
@@ -49,6 +50,7 @@ import { MetadataRepository } from 'src/repositories/metadata.repository.js';
import { NotificationRepository } from 'src/repositories/notification.repository.js';
import { OcrRepository } from 'src/repositories/ocr.repository.js';
import { PartnerRepository } from 'src/repositories/partner.repository.js';
import { PersonUserRepository } from 'src/repositories/person-user.repository.js';
import { PersonRepository } from 'src/repositories/person.repository.js';
import { PluginRepository } from 'src/repositories/plugin.repository.js';
import { SearchRepository } from 'src/repositories/search.repository.js';
@@ -75,6 +77,7 @@ import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table.js';
import { AssetTable } from 'src/schema/tables/asset.table.js';
import { FaceSearchTable } from 'src/schema/tables/face-search.table.js';
import { MemoryTable } from 'src/schema/tables/memory.table.js';
import { PersonUserTable } from 'src/schema/tables/person-user.table.js';
import { PersonTable } from 'src/schema/tables/person.table.js';
import { SessionTable } from 'src/schema/tables/session.table.js';
import { StackTable } from 'src/schema/tables/stack.table.js';
@@ -291,6 +294,14 @@ export class MediumTestContext<S extends ClassConstructor<typeof BaseService> =
return { person, result };
}
async newPersonUser(
dto: Partial<Insertable<PersonUserTable>> & { personGroupId: string; sharedById: string; sharedWithId: string },
) {
const personUser = mediumFactory.personUserInsert(dto);
const [result] = await this.get(PersonUserRepository).createAll([personUser]);
return { personUser, result };
}
async newSession(dto: Partial<Insertable<SessionTable>> & { userId: string }) {
const session = mediumFactory.sessionInsert(dto);
const result = await this.get(SessionRepository).create(session);
@@ -485,6 +496,7 @@ const newRealRepository = <T extends BaseServiceDeps[number]>(key: T, db: Kysely
case OcrRepository:
case PartnerRepository:
case PersonRepository:
case PersonUserRepository:
case SearchRepository:
case SessionRepository:
case SharedLinkRepository:
@@ -732,6 +744,20 @@ const personInsert = (person: Partial<Insertable<PersonTable>> & { ownerId: stri
};
};
const personUserInsert = (
personUser: Partial<Insertable<PersonUserTable>> & {
personGroupId: string;
sharedById: string;
sharedWithId: string;
},
) => {
const defaults = {
role: personUser.role ?? PersonUserRole.Write,
};
return { ...defaults, ...personUser };
};
const sha256 = (value: string) => createHash('sha256').update(value).digest();
const sessionInsert = ({
@@ -872,6 +898,7 @@ export const mediumFactory = {
albumInsert,
faceInsert,
personInsert,
personUserInsert,
sessionInsert,
syncStream,
userInsert,
@@ -0,0 +1,400 @@
import { Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { PersonUserRole } from 'src/dtos/person.dto.js';
import { SharingDirection } from 'src/enum.js';
import { LoggingRepository } from 'src/repositories/logging.repository.js';
import { PersonUserRepository } from 'src/repositories/person-user.repository.js';
import { PersonRepository } from 'src/repositories/person.repository.js';
import { person_delete_shares, person_user_after_insert } from 'src/schema/functions.js';
import { DB } from 'src/schema/index.js';
import { BaseService } from 'src/services/base.service.js';
import { MediumTestContext, newMediumService } from 'test/medium.factory.js';
import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
const { ctx } = newMediumService(BaseService, {
database: db || defaultDatabase,
real: [],
mock: [LoggingRepository],
});
return { ctx, sut: ctx.get(PersonUserRepository) };
};
const getPeopleByGroupId = (ctx: MediumTestContext, personGroupId: string) =>
ctx.database
.selectFrom('person')
.selectAll()
.where('personGroupId', '=', personGroupId)
.orderBy('createdAt')
.execute();
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
const newShares = async () => {
const { ctx, sut } = setup();
const [{ user: owner }, { user: reader }, { user: writer }, { user: stranger }] = [
await ctx.newUser(),
await ctx.newUser(),
await ctx.newUser(),
await ctx.newUser(),
];
const { result: alice } = await ctx.newPerson({ ownerId: owner.id, name: 'Alice' });
const { result: bob } = await ctx.newPerson({ ownerId: stranger.id, name: 'Bob' });
await sut.createAll([
{
personGroupId: alice.personGroupId,
sharedById: owner.id,
sharedWithId: reader.id,
role: PersonUserRole.Read,
},
{
personGroupId: alice.personGroupId,
sharedById: owner.id,
sharedWithId: writer.id,
role: PersonUserRole.Write,
},
{
personGroupId: bob.personGroupId,
sharedById: stranger.id,
sharedWithId: owner.id,
role: PersonUserRole.Admin,
},
]);
return { ctx, sut, owner, reader, writer, stranger, alice, bob };
};
describe(PersonUserRepository.name, () => {
describe('createAll', () => {
describe(person_user_after_insert.name, () => {
it('should create automatically person for the user it was shared with', async () => {
const { ctx, sut } = setup();
const [{ user: owner }, { user: sharedWith }] = [await ctx.newUser(), await ctx.newUser()];
const { result: person } = await ctx.newPerson({
ownerId: owner.id,
name: 'Alice',
birthDate: DateTime.fromISO('1990-04-01').toJSDate(),
});
await sut.createAll([
{
personGroupId: person.personGroupId,
sharedById: owner.id,
sharedWithId: sharedWith.id,
role: PersonUserRole.Read,
},
]);
const people = await getPeopleByGroupId(ctx, person.personGroupId);
expect(people).toHaveLength(2);
expect(people).toEqual(
expect.arrayContaining([
expect.objectContaining({
ownerId: owner.id,
personGroupId: person.personGroupId,
}),
expect.objectContaining({
ownerId: sharedWith.id,
personGroupId: person.personGroupId,
name: person.name,
birthDate: person.birthDate,
}),
]),
);
});
it('should create a person for each user in a bulk insert', async () => {
const { ctx, sut } = setup();
const [{ user: owner }, { user: sharedWith1 }, { user: sharedWith2 }] = [
await ctx.newUser(),
await ctx.newUser(),
await ctx.newUser(),
];
const { result: person } = await ctx.newPerson({ ownerId: owner.id, name: 'Alice' });
await sut.createAll([
{
personGroupId: person.personGroupId,
sharedById: owner.id,
sharedWithId: sharedWith1.id,
role: PersonUserRole.Read,
},
{
personGroupId: person.personGroupId,
sharedById: owner.id,
sharedWithId: sharedWith2.id,
role: PersonUserRole.Write,
},
]);
const people = await getPeopleByGroupId(ctx, person.personGroupId);
expect(people).toEqual(
expect.arrayContaining([
expect.objectContaining({
ownerId: owner.id,
personGroupId: person.personGroupId,
}),
expect.objectContaining({
ownerId: sharedWith1.id,
personGroupId: person.personGroupId,
}),
expect.objectContaining({
ownerId: sharedWith2.id,
personGroupId: person.personGroupId,
}),
]),
);
});
it('should create one person when two owners share the same group with a user', async () => {
const { ctx, sut } = setup();
const [{ user: owner1 }, { user: owner2 }, { user: sharedWith }] = [
await ctx.newUser(),
await ctx.newUser(),
await ctx.newUser(),
];
const { result: person } = await ctx.newPerson({ ownerId: owner1.id, name: 'Alice' });
await ctx
.get(PersonRepository)
.create({ ownerId: owner2.id, personGroupId: person.personGroupId, name: 'Alice (theirs)' });
await sut.createAll([
{
personGroupId: person.personGroupId,
sharedById: owner1.id,
sharedWithId: sharedWith.id,
role: PersonUserRole.Read,
},
{
personGroupId: person.personGroupId,
sharedById: owner2.id,
sharedWithId: sharedWith.id,
role: PersonUserRole.Read,
},
]);
const people = await getPeopleByGroupId(ctx, person.personGroupId);
expect(people).toHaveLength(3);
expect(people.filter(({ ownerId }) => ownerId === sharedWith.id)).toHaveLength(1);
});
it('should skip existing records', async () => {
const { ctx, sut } = setup();
const [{ user: owner }, { user: sharedWith }] = [await ctx.newUser(), await ctx.newUser()];
const { result: person } = await ctx.newPerson({ ownerId: owner.id, name: 'Alice' });
const existing = await ctx.get(PersonRepository).create({
ownerId: sharedWith.id,
personGroupId: person.personGroupId,
name: 'Alice (mine)',
isFavorite: true,
});
await sut.createAll([
{
personGroupId: person.personGroupId,
sharedById: owner.id,
sharedWithId: sharedWith.id,
role: PersonUserRole.Read,
},
]);
const people = await getPeopleByGroupId(ctx, person.personGroupId);
expect(people).toHaveLength(2);
expect(people).toEqual(
expect.arrayContaining([
expect.objectContaining({
ownerId: owner.id,
personGroupId: person.personGroupId,
}),
expect.objectContaining({
ownerId: sharedWith.id,
personGroupId: person.personGroupId,
name: 'Alice (mine)',
isFavorite: true,
// the updatedAt trigger rewrites updateId on any update, so an unchanged
// updateId proves the row was skipped rather than overwritten
updateId: existing.updateId,
}),
]),
);
});
it('should not create a person when only the role changes', async () => {
const { ctx, sut } = setup();
const [{ user: owner }, { user: sharedWith }] = [await ctx.newUser(), await ctx.newUser()];
const { result: person } = await ctx.newPerson({ ownerId: owner.id, name: 'Alice' });
const share = {
personGroupId: person.personGroupId,
sharedById: owner.id,
sharedWithId: sharedWith.id,
role: PersonUserRole.Read,
};
await sut.createAll([share]);
const [, created] = await getPeopleByGroupId(ctx, person.personGroupId);
await sut.createAll([{ ...share, role: PersonUserRole.Write }]);
const people = await getPeopleByGroupId(ctx, person.personGroupId);
expect(people).toHaveLength(2);
expect(people).toEqual(
expect.arrayContaining([
expect.objectContaining({
ownerId: sharedWith.id,
personGroupId: person.personGroupId,
updateId: created.updateId,
}),
]),
);
await expect(sut.searchPeopleUsers(owner.id, {})).resolves.toEqual([
expect.objectContaining({ sharedWithId: sharedWith.id, role: PersonUserRole.Write }),
]);
});
});
});
describe('searchPeopleUsers', () => {
it('should return shares in both directions by default', async () => {
const { sut, owner, reader, writer } = await newShares();
const results = await sut.searchPeopleUsers(owner.id, {});
expect(results).toHaveLength(3);
expect(results).toEqual(
expect.arrayContaining([
expect.objectContaining({ sharedWithId: reader.id, role: PersonUserRole.Read }),
expect.objectContaining({ sharedWithId: writer.id, role: PersonUserRole.Write }),
expect.objectContaining({ sharedWithId: owner.id, role: PersonUserRole.Admin }),
]),
);
});
it('should filter by role', async () => {
const { sut, owner, writer } = await newShares();
await expect(sut.searchPeopleUsers(owner.id, { role: PersonUserRole.Write })).resolves.toEqual([
expect.objectContaining({ sharedWithId: writer.id, role: PersonUserRole.Write }),
]);
});
it('should filter by sharedWithId', async () => {
const { sut, owner, reader } = await newShares();
await expect(sut.searchPeopleUsers(owner.id, { sharedWithId: reader.id })).resolves.toEqual([
expect.objectContaining({ sharedWithId: reader.id, role: PersonUserRole.Read }),
]);
});
it('should filter by sharedById', async () => {
const { sut, owner, stranger } = await newShares();
await expect(sut.searchPeopleUsers(owner.id, { sharedById: stranger.id })).resolves.toEqual([
expect.objectContaining({ sharedById: stranger.id, sharedWithId: owner.id }),
]);
});
it('should filter by personId', async () => {
const { sut, owner, bob } = await newShares();
await expect(sut.searchPeopleUsers(owner.id, { personId: bob.personGroupId })).resolves.toEqual([
expect.objectContaining({ personId: bob.personGroupId, sharedWithId: owner.id }),
]);
});
it('should only return shares the owner created for the shared-by direction', async () => {
const { sut, owner, reader, writer } = await newShares();
const results = await sut.searchPeopleUsers(owner.id, { direction: SharingDirection.SharedBy });
expect(results).toHaveLength(2);
expect(results).toEqual(
expect.arrayContaining([
expect.objectContaining({ sharedWithId: reader.id }),
expect.objectContaining({ sharedWithId: writer.id }),
]),
);
});
it('should only return shares pointed at the owner for the shared-with direction', async () => {
const { sut, owner, stranger } = await newShares();
await expect(sut.searchPeopleUsers(owner.id, { direction: SharingDirection.SharedWith })).resolves.toEqual([
expect.objectContaining({ sharedById: stranger.id, sharedWithId: owner.id }),
]);
});
it('should combine role and direction filters', async () => {
const { sut, owner } = await newShares();
await expect(
sut.searchPeopleUsers(owner.id, { direction: SharingDirection.SharedBy, role: PersonUserRole.Admin }),
).resolves.toEqual([]);
});
});
describe(person_delete_shares.name, () => {
it('should delete shares pointing at a user when their person is deleted', async () => {
const { ctx, sut } = setup();
const [{ user: owner }, { user: sharedWith }] = [await ctx.newUser(), await ctx.newUser()];
const { result: person } = await ctx.newPerson({ ownerId: owner.id, name: 'Alice' });
await sut.createAll([
{
personGroupId: person.personGroupId,
sharedById: owner.id,
sharedWithId: sharedWith.id,
role: PersonUserRole.Read,
},
]);
await expect(sut.searchPeopleUsers(owner.id, {})).resolves.toHaveLength(1);
// the recipient deletes their own copy, as PersonService.deleteAll does
await ctx.get(PersonRepository).delete([person.personGroupId], sharedWith.id);
await expect(sut.searchPeopleUsers(owner.id, {})).resolves.toEqual([]);
expect(await getPeopleByGroupId(ctx, person.personGroupId)).toEqual([
expect.objectContaining({ ownerId: owner.id }),
]);
});
it('should leave shares with other users intact', async () => {
const { ctx, sut } = setup();
const [{ user: owner }, { user: sharedWith1 }, { user: sharedWith2 }] = [
await ctx.newUser(),
await ctx.newUser(),
await ctx.newUser(),
];
const { result: person } = await ctx.newPerson({ ownerId: owner.id, name: 'Alice' });
await sut.createAll([
{
personGroupId: person.personGroupId,
sharedById: owner.id,
sharedWithId: sharedWith1.id,
role: PersonUserRole.Read,
},
{
personGroupId: person.personGroupId,
sharedById: owner.id,
sharedWithId: sharedWith2.id,
role: PersonUserRole.Read,
},
]);
await ctx.get(PersonRepository).delete([person.personGroupId], sharedWith1.id);
await expect(sut.searchPeopleUsers(owner.id, {})).resolves.toEqual([
expect.objectContaining({ sharedWithId: sharedWith2.id }),
]);
});
});
});
@@ -1,10 +1,12 @@
import { Kysely } from 'kysely';
import { PersonUserRole } from 'src/dtos/person.dto.js';
import { AssetFileType } from 'src/enum.js';
import { LoggingRepository } from 'src/repositories/logging.repository.js';
import { PersonUserRepository } from 'src/repositories/person-user.repository.js';
import { PersonRepository } from 'src/repositories/person.repository.js';
import { DB } from 'src/schema/index.js';
import { BaseService } from 'src/services/base.service.js';
import { newMediumService } from 'test/medium.factory.js';
import { MediumTestContext, newMediumService } from 'test/medium.factory.js';
import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>;
@@ -18,6 +20,31 @@ const setup = (db?: Kysely<DB>) => {
return { ctx, sut: ctx.get(PersonRepository) };
};
const listFor = async (sut: PersonRepository, userId: string, options?: { withHidden: boolean }) => {
const { items } = await sut.getAllForUser({ take: 100, skip: 0 }, userId, options);
return items;
};
// a named person with at least one visible face clears the minimum-faces rule
const newNamedPerson = async (ctx: MediumTestContext, ownerId: string, name: string) => {
const { result: person } = await ctx.newPerson({ ownerId, name });
const { asset } = await ctx.newAsset({ ownerId });
await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId });
return person;
};
const otherPeopleOf = async (sut: PersonRepository, ctx: MediumTestContext, userId: string, groupId: string) => {
const listItems = await listFor(sut, userId);
const listed = listItems.find((item) => item.personGroupId === groupId);
const forUser = await sut.getForUser({ userId, personGroupId: groupId });
const byGroupId = await sut.getByGroupId({ ownerId: userId, personGroupId: groupId });
return {
getAllForUser: listed?.otherPeople,
getForUser: forUser?.otherPeople,
getByGroupId: byGroupId?.otherPeople,
};
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
@@ -128,6 +155,77 @@ describe(PersonRepository.name, () => {
});
});
describe('otherPeople', () => {
it('should report the person shared with the user from every lookup', async () => {
const { ctx, sut } = setup();
const [{ user: owner }, { user: recipient }] = [await ctx.newUser(), await ctx.newUser()];
const person = await newNamedPerson(ctx, owner.id, 'Alice');
await ctx.get(PersonUserRepository).createAll([
{
personGroupId: person.personGroupId,
sharedById: owner.id,
sharedWithId: recipient.id,
role: PersonUserRole.Write,
},
]);
const expected = [{ sharedById: owner.id, role: PersonUserRole.Write, name: 'Alice', birthDate: null }];
const results = await otherPeopleOf(sut, ctx, recipient.id, person.personGroupId);
expect(results.getAllForUser).toEqual(expected);
expect(results.getForUser).toEqual(expected);
expect(results.getByGroupId).toEqual(expected);
});
it('should not report a share pointed at a different user', async () => {
const { ctx, sut } = setup();
const [{ user: owner }, { user: first }, { user: second }] = [
await ctx.newUser(),
await ctx.newUser(),
await ctx.newUser(),
];
const person = await newNamedPerson(ctx, owner.id, 'Alice');
await ctx.get(PersonUserRepository).createAll([
{
personGroupId: person.personGroupId,
sharedById: owner.id,
sharedWithId: first.id,
role: PersonUserRole.Read,
},
{
personGroupId: person.personGroupId,
sharedById: owner.id,
sharedWithId: second.id,
role: PersonUserRole.Read,
},
]);
const expected = [{ sharedById: owner.id, role: PersonUserRole.Read, name: 'Alice', birthDate: null }];
const results = await otherPeopleOf(sut, ctx, first.id, person.personGroupId);
expect(results.getAllForUser).toEqual(expected);
expect(results.getForUser).toEqual(expected);
expect(results.getByGroupId).toEqual(expected);
});
it('should be empty for a group the user was never shared into', async () => {
const { ctx, sut } = setup();
const [{ user: owner }, { user: other }] = [await ctx.newUser(), await ctx.newUser()];
const person = await newNamedPerson(ctx, owner.id, 'Alice');
await ctx.database
.insertInto('person')
.values({ ownerId: other.id, name: 'Alice', personGroupId: person.personGroupId })
.execute();
const results = await otherPeopleOf(sut, ctx, other.id, person.personGroupId);
expect(results.getForUser).toEqual([]);
expect(results.getByGroupId).toEqual([]);
});
});
describe('deleteEmptyGroups', () => {
it('should delete groups that no longer have any people', async () => {
const { ctx, sut } = setup(await getKyselyDB());
@@ -332,4 +430,108 @@ describe(PersonRepository.name, () => {
).resolves.toEqual(undefined);
});
});
describe('getAllForUser', () => {
it('should return the user own people', async () => {
const { ctx, sut } = setup(await getKyselyDB());
const { user } = await ctx.newUser();
const person = await newNamedPerson(ctx, user.id, 'Alice');
await expect(listFor(sut, user.id)).resolves.toEqual([
expect.objectContaining({ ownerId: user.id, personGroupId: person.personGroupId, name: 'Alice' }),
]);
});
it('should not return people belonging to another user', async () => {
const { ctx, sut } = setup(await getKyselyDB());
const [{ user: user1 }, { user: user2 }] = [await ctx.newUser(), await ctx.newUser()];
await newNamedPerson(ctx, user1.id, 'Alice');
await expect(listFor(sut, user2.id)).resolves.toEqual([]);
});
it('should return a person shared with the user', async () => {
const { ctx, sut } = setup(await getKyselyDB());
const [{ user: owner }, { user: sharedWith }] = [await ctx.newUser(), await ctx.newUser()];
const person = await newNamedPerson(ctx, owner.id, 'Alice');
await ctx.get(PersonUserRepository).createAll([
{
personGroupId: person.personGroupId,
sharedById: owner.id,
sharedWithId: sharedWith.id,
role: PersonUserRole.Read,
},
]);
await expect(listFor(sut, sharedWith.id)).resolves.toEqual([
expect.objectContaining({
ownerId: sharedWith.id,
personGroupId: person.personGroupId,
// the trigger seeds the name from the sharer
name: 'Alice',
otherPeople: [expect.objectContaining({ sharedById: owner.id, name: 'Alice' })],
}),
]);
});
it('should return a shared person even when it has no faces of its own', async () => {
const { ctx, sut } = setup(await getKyselyDB());
const [{ user: owner }, { user: sharedWith }] = [await ctx.newUser(), await ctx.newUser()];
// no faces at all, so only the "shared people are always included" rule can let it through
const { result: person } = await ctx.newPerson({ ownerId: owner.id, name: 'Alice' });
await ctx.get(PersonUserRepository).createAll([
{
personGroupId: person.personGroupId,
sharedById: owner.id,
sharedWithId: sharedWith.id,
role: PersonUserRole.Read,
},
]);
await expect(listFor(sut, sharedWith.id)).resolves.toEqual([
expect.objectContaining({ ownerId: sharedWith.id, personGroupId: person.personGroupId }),
]);
});
it('should stop returning a shared person once the user deletes their copy', async () => {
const { ctx, sut } = setup(await getKyselyDB());
const [{ user: owner }, { user: sharedWith }] = [await ctx.newUser(), await ctx.newUser()];
const person = await newNamedPerson(ctx, owner.id, 'Alice');
await ctx.get(PersonUserRepository).createAll([
{
personGroupId: person.personGroupId,
sharedById: owner.id,
sharedWithId: sharedWith.id,
role: PersonUserRole.Read,
},
]);
await expect(listFor(sut, sharedWith.id)).resolves.toHaveLength(1);
await sut.delete([person.personGroupId], sharedWith.id);
await expect(listFor(sut, sharedWith.id)).resolves.toEqual([]);
// the owner still has theirs
await expect(listFor(sut, owner.id)).resolves.toHaveLength(1);
});
it('should exclude hidden people unless asked for them', async () => {
const { ctx, sut } = setup(await getKyselyDB());
const { user } = await ctx.newUser();
const visible = await newNamedPerson(ctx, user.id, 'Alice');
const hidden = await newNamedPerson(ctx, user.id, 'Bob');
await ctx.database
.updateTable('person')
.set({ isHidden: true })
.where('personGroupId', '=', hidden.personGroupId)
.execute();
await expect(listFor(sut, user.id)).resolves.toEqual([
expect.objectContaining({ personGroupId: visible.personGroupId }),
]);
await expect(listFor(sut, user.id, { withHidden: true })).resolves.toHaveLength(2);
});
});
});
@@ -1,7 +1,8 @@
import { Kysely } from 'kysely';
import { SharingDirection } from 'src/enum.js';
import { AccessRepository } from 'src/repositories/access.repository.js';
import { LoggingRepository } from 'src/repositories/logging.repository.js';
import { PartnerDirection, PartnerRepository } from 'src/repositories/partner.repository.js';
import { PartnerRepository } from 'src/repositories/partner.repository.js';
import { UserRepository } from 'src/repositories/user.repository.js';
import { DB } from 'src/schema/index.js';
import { PartnerService } from 'src/services/partner.service.js';
@@ -61,7 +62,7 @@ describe(PartnerService.name, () => {
const { user: partner } = await ctx.newUser();
await ctx.newPartner({ sharedById: user.id, sharedWithId: partner.id });
await expect(sut.search(factory.auth({ user }), { direction: PartnerDirection.SharedBy })).resolves.toEqual([
await expect(sut.search(factory.auth({ user }), { direction: SharingDirection.SharedBy })).resolves.toEqual([
expect.objectContaining({ id: partner.id }),
]);
});
@@ -72,7 +73,7 @@ describe(PartnerService.name, () => {
const { user: partner } = await ctx.newUser();
await ctx.newPartner({ sharedById: partner.id, sharedWithId: user.id });
await expect(sut.search(factory.auth({ user }), { direction: PartnerDirection.SharedWith })).resolves.toEqual([
await expect(sut.search(factory.auth({ user }), { direction: SharingDirection.SharedWith })).resolves.toEqual([
expect.objectContaining({ id: partner.id }),
]);
});
@@ -111,7 +112,7 @@ describe(PartnerService.name, () => {
await ctx.newPartner({ sharedById: user.id, sharedWithId: partner.id });
await expect(sut.remove(factory.auth({ user }), partner.id)).resolves.toBeUndefined();
await expect(sut.search(factory.auth({ user }), { direction: PartnerDirection.SharedBy })).resolves.toEqual([]);
await expect(sut.search(factory.auth({ user }), { direction: SharingDirection.SharedBy })).resolves.toEqual([]);
});
it('should throw when the partner does not exist', async () => {
@@ -1,24 +1,28 @@
import { NotFoundException } from '@nestjs/common';
import { Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { AssetEditAction, MirrorAxis } from 'src/dtos/editing.dto.js';
import { AssetFaceCreateDto } from 'src/dtos/person.dto.js';
import { AssetFaceCreateDto, PersonSearchDto, PersonUserRole } from 'src/dtos/person.dto.js';
import { AssetFileType, JobName } from 'src/enum.js';
import { AccessRepository } from 'src/repositories/access.repository.js';
import { AssetEditRepository } from 'src/repositories/asset-edit.repository.js';
import { AssetJobRepository } from 'src/repositories/asset-job.repository.js';
import { AssetRepository } from 'src/repositories/asset.repository.js';
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository.js';
import { ConfigRepository } from 'src/repositories/config.repository.js';
import { DatabaseRepository } from 'src/repositories/database.repository.js';
import { JobRepository } from 'src/repositories/job.repository.js';
import { LoggingRepository } from 'src/repositories/logging.repository.js';
import { MachineLearningRepository } from 'src/repositories/machine-learning.repository.js';
import { PersonUserRepository } from 'src/repositories/person-user.repository.js';
import { PersonRepository } from 'src/repositories/person.repository.js';
import { StorageRepository } from 'src/repositories/storage.repository.js';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository.js';
import { UserRepository } from 'src/repositories/user.repository.js';
import { DB } from 'src/schema/index.js';
import { PersonService } from 'src/services/person.service.js';
import { newMediumService } from 'test/medium.factory.js';
import { factory } from 'test/small.factory.js';
import { factory, newUuid } from 'test/small.factory.js';
import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>;
@@ -32,9 +36,12 @@ const setup = (db?: Kysely<DB>) => {
ConfigRepository,
DatabaseRepository,
PersonRepository,
PersonUserRepository,
AssetRepository,
AssetEditRepository,
SystemMetadataRepository,
UserRepository,
ClusterGroupRepository,
],
mock: [JobRepository, LoggingRepository, StorageRepository, MachineLearningRepository],
});
@@ -45,12 +52,263 @@ beforeAll(async () => {
});
describe(PersonService.name, () => {
describe('reassignFaces', () => {
it('should require access to the person the auth user is assigning to', 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.reassignFaces(factory.auth({ user }), person.personGroupId, { data: [] })).rejects.toThrow(
'Not found or no person.update access',
);
});
it('should allow person owner access', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
await expect(sut.reassignFaces(factory.auth({ user }), person.personGroupId, { data: [] })).resolves.toEqual([]);
});
it('should not allow shared read only 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 ctx.newPersonUser({
personGroupId: person.personGroupId,
sharedById: user2.id,
sharedWithId: user.id,
role: PersonUserRole.Read,
});
await expect(
sut.reassignFaces(factory.auth({ user }), person.personGroupId, {
data: [{ personId: person.personGroupId, userId: user2.id, assetId: newUuid() }],
}),
).rejects.toThrow('Not found or no person.update access');
});
it('should allow shared write 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 ctx.newPersonUser({
personGroupId: person.personGroupId,
sharedById: user2.id,
sharedWithId: user.id,
role: PersonUserRole.Write,
});
await expect(
sut.reassignFaces(factory.auth({ user }), person.personGroupId, {
data: [{ personId: person.personGroupId, userId: user2.id, assetId: newUuid() }],
}),
).resolves.toEqual([expect.objectContaining({ id: person.personGroupId })]);
});
});
describe('getAll', () => {
it('should filter by sharing direction and user', async () => {
const { ctx, sut } = setup(await getKyselyDB());
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser({ clusterGroupId: user1.clusterGroupId });
const { user: user3 } = await ctx.newUser({ clusterGroupId: user1.clusterGroupId });
const { asset } = await ctx.newAsset({ ownerId: user1.id });
const { person: sharedByMe } = await ctx.newPerson({ ownerId: user1.id, name: 'Shared by me' });
const { person: notShared } = await ctx.newPerson({ ownerId: user1.id, name: 'Not shared' });
const { person: sharedWithMe } = await ctx.newPerson({ ownerId: user3.id, name: 'Shared with me' });
await ctx.newAssetFace({ assetId: asset.id, personGroupId: sharedByMe.personGroupId });
await ctx.newAssetFace({ assetId: asset.id, personGroupId: notShared.personGroupId });
await ctx.newPersonUser({
personGroupId: sharedByMe.personGroupId,
sharedById: user1.id,
sharedWithId: user2.id,
});
await ctx.newPersonUser({
personGroupId: sharedWithMe.personGroupId,
sharedById: user3.id,
sharedWithId: user1.id,
});
const auth = factory.auth({ user: user1 });
const getIds = async (dto: Partial<PersonSearchDto>) => {
const { people, total } = await sut.getAll(auth, { page: 1, size: 10, ...dto });
expect(total).toBe(people.length);
return people.map(({ id }) => id).toSorted();
};
await expect(getIds({})).resolves.toEqual(
[sharedByMe.personGroupId, notShared.personGroupId, sharedWithMe.personGroupId].toSorted(),
);
await expect(getIds({ sharedById: user1.id })).resolves.toEqual([sharedByMe.personGroupId]);
await expect(getIds({ sharedWithId: user2.id })).resolves.toEqual([sharedByMe.personGroupId]);
await expect(getIds({ sharedWithId: user3.id })).resolves.toEqual([]);
await expect(getIds({ sharedWithId: user1.id })).resolves.toEqual([sharedWithMe.personGroupId]);
await expect(getIds({ sharedById: user3.id })).resolves.toEqual([sharedWithMe.personGroupId]);
await expect(getIds({ sharedById: user2.id })).resolves.toEqual([]);
});
it('should filter by favorite and hidden', async () => {
const { ctx, sut } = setup(await getKyselyDB());
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const { person: favorite } = await ctx.newPerson({ ownerId: user.id, isFavorite: true });
const { person: hidden } = await ctx.newPerson({ ownerId: user.id, isHidden: true });
const { person: neither } = await ctx.newPerson({ ownerId: user.id });
for (const person of [favorite, hidden, neither]) {
await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId });
}
const auth = factory.auth({ user });
const getIds = async (dto: Partial<PersonSearchDto>) => {
const { people, total } = await sut.getAll(auth, { page: 1, size: 10, withHidden: true, ...dto });
expect(total).toBe(people.length);
return people.map(({ id }) => id).toSorted();
};
await expect(getIds({ isFavorite: true })).resolves.toEqual([favorite.personGroupId]);
await expect(getIds({ isFavorite: false })).resolves.toEqual(
[hidden.personGroupId, neither.personGroupId].toSorted(),
);
await expect(getIds({ isHidden: true })).resolves.toEqual([hidden.personGroupId]);
await expect(getIds({ isHidden: false })).resolves.toEqual(
[favorite.personGroupId, neither.personGroupId].toSorted(),
);
await expect(getIds({ isFavorite: true, isHidden: true })).resolves.toEqual([]);
});
it('should include hidden people when filtering by isHidden without withHidden', async () => {
const { ctx, sut } = setup(await getKyselyDB());
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const { person: hidden } = await ctx.newPerson({ ownerId: user.id, isHidden: true });
const { person: visible } = await ctx.newPerson({ ownerId: user.id });
await ctx.newAssetFace({ assetId: asset.id, personGroupId: hidden.personGroupId });
await ctx.newAssetFace({ assetId: asset.id, personGroupId: visible.personGroupId });
const auth = factory.auth({ user });
await expect(sut.getAll(auth, { page: 1, size: 10, isHidden: true })).resolves.toEqual(
expect.objectContaining({ people: [expect.objectContaining({ id: hidden.personGroupId })], total: 1 }),
);
await expect(sut.getAll(auth, { page: 1, size: 10, isHidden: false })).resolves.toEqual(
expect.objectContaining({ people: [expect.objectContaining({ id: visible.personGroupId })], total: 1 }),
);
});
});
describe('getById', () => {
it('should require person.read access', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
await expect(sut.getById(factory.auth(), person.personGroupId)).rejects.toThrow(
'Not found or no person.read access',
);
});
it('should return own version of shared person but copy over a shared name and birth date', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user2.id, name: "User2's person", birthDate: new Date() });
await ctx.newPersonUser({ personGroupId: person.personGroupId, sharedById: user2.id, sharedWithId: user.id });
await expect(sut.getById(factory.auth({ user }), person.personGroupId)).resolves.toEqual(
expect.objectContaining({
id: person.personGroupId,
name: person.name,
birthDate: expect.any(String),
otherPeople: [expect.objectContaining({ sharedById: user2.id })],
}),
);
});
it('should include the users the person is shared by and shared with', async () => {
const { ctx, sut } = setup();
const { user: owner } = await ctx.newUser();
const { user: user1 } = await ctx.newUser({ clusterGroupId: owner.clusterGroupId });
const { person } = await ctx.newPerson({ ownerId: owner.id });
await ctx.newPersonUser({
personGroupId: person.personGroupId,
sharedById: owner.id,
sharedWithId: user1.id,
role: PersonUserRole.Write,
});
await expect(sut.getById(factory.auth({ user: owner }), person.personGroupId)).resolves.toEqual(
expect.objectContaining({
sharedBy: [],
sharedWith: [expect.objectContaining({ id: user1.id, email: user1.email, role: PersonUserRole.Write })],
}),
);
await expect(sut.getById(factory.auth({ user: user1 }), person.personGroupId)).resolves.toEqual(
expect.objectContaining({
sharedBy: [expect.objectContaining({ id: owner.id, email: owner.email, role: PersonUserRole.Write })],
sharedWith: [],
}),
);
});
});
describe('getThumbnail', () => {
it('should require person.read access', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
await expect(sut.getThumbnail(factory.auth(), person.personGroupId)).rejects.toThrow(
'Not found or no person.read access',
);
});
it('should return own thumbnail path', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
await expect(sut.getThumbnail(factory.auth({ user }), person.personGroupId)).resolves.toEqual(
expect.objectContaining({ path: person.thumbnailPath }),
);
});
it('should fall back to shared thumbnail path', 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 ctx.newPersonUser({ personGroupId: person.personGroupId, sharedById: user2.id, sharedWithId: user.id });
await expect(sut.getThumbnail(factory.auth({ user }), person.personGroupId)).resolves.toEqual(
expect.objectContaining({ path: person.thumbnailPath }),
);
});
it('should fail if there is no (shared) thumbnail available', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id, thumbnailPath: undefined });
await ctx.newPerson({ ownerId: user2.id, personGroupId: person.personGroupId });
await expect(sut.getThumbnail(factory.auth({ user }), person.personGroupId)).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
describe('delete', () => {
it('should throw an error when there is no access', async () => {
const { sut } = setup();
const auth = factory.auth();
const personId = factory.uuid();
await expect(sut.delete(auth, personId)).rejects.toThrow('Not found or no person.delete access');
await expect(sut.delete(auth, personId, {})).rejects.toThrow('Not found or no person.delete access');
});
it('should delete the person', async () => {
@@ -65,7 +323,7 @@ describe(PersonService.name, () => {
await expect(personRepo.getByGroupId(person)).resolves.toEqual(
expect.objectContaining({ personGroupId: person.personGroupId }),
);
await expect(sut.delete(auth, person.personGroupId)).resolves.toBeUndefined();
await expect(sut.delete(auth, person.personGroupId, {})).resolves.toBeUndefined();
await expect(personRepo.getByGroupId(person)).resolves.toBeUndefined();
expect(storageMock.unlink).toHaveBeenCalledWith(person.thumbnailPath);
@@ -299,33 +557,6 @@ describe(PersonService.name, () => {
]),
);
});
it('should not merge into person another user does not have', async () => {
const { sut, ctx } = setup();
const storageMock = ctx.getMock(StorageRepository);
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser({ clusterGroupId: user1.clusterGroupId });
const { person: person1 } = await ctx.newPerson({ ownerId: user1.id });
const { person: person2 } = await ctx.newPerson({ ownerId: user1.id });
await ctx.newPerson({
ownerId: user2.id,
personGroupId: person2.personGroupId,
});
const { asset } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newAssetFace({ assetId: asset.id, personGroupId: person2.personGroupId });
storageMock.unlink.mockResolvedValue();
const auth = factory.auth({ user: user1 });
await sut.mergePeople(auth, { ids: [person1.personGroupId, person2.personGroupId] });
const user1People = await Array.fromAsync(ctx.get(PersonRepository).getAll({ ownerId: user1.id }));
const user2People = await Array.fromAsync(ctx.get(PersonRepository).getAll({ ownerId: user2.id }));
expect(user1People).toEqual([expect.objectContaining({ personGroupId: person1.personGroupId })]);
expect(user2People).toEqual([expect.objectContaining({ personGroupId: person2.personGroupId })]);
await expect(ctx.get(PersonRepository).getFaces(asset.id, { viewingUserId: asset.ownerId })).resolves.toEqual([
expect.objectContaining({ personGroupId: person2.personGroupId }),
]);
});
});
describe('createFace', () => {
@@ -1012,4 +1243,171 @@ describe(PersonService.name, () => {
);
});
});
describe('addUsersToPeople', () => {
it('should skip sharedWith users that are not in the same cluster group', async () => {
const { sut, ctx } = setup();
const { user: owner } = await ctx.newUser();
const { user: user1 } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: owner.id });
const auth = factory.auth({ user: owner });
await sut.addUsersToPeople(auth, {
personIds: [person.personGroupId],
sharedWithIds: [user1.id],
role: PersonUserRole.Read,
});
await expect(sut.getUsersForPeople(auth, {})).resolves.toHaveLength(0);
});
it('should add user to person', async () => {
const { sut, ctx } = setup();
const { user: owner } = await ctx.newUser();
const { user: user1 } = await ctx.newUser({ clusterGroupId: owner.clusterGroupId });
const { person } = await ctx.newPerson({ ownerId: owner.id });
const auth = factory.auth({ user: owner });
await sut.addUsersToPeople(auth, {
personIds: [person.personGroupId],
sharedWithIds: [user1.id],
role: PersonUserRole.Read,
});
await expect(sut.getUsersForPeople(auth, {})).resolves.toEqual([
expect.objectContaining({
sharedBy: expect.objectContaining({ id: owner.id }),
sharedWith: expect.objectContaining({ id: user1.id }),
}),
]);
});
it('should update the name and birthdate when sharing to an existing copy', async () => {
const { sut, ctx } = setup();
const { user: owner } = await ctx.newUser();
const { user: user1 } = await ctx.newUser({ clusterGroupId: owner.clusterGroupId });
const { person } = await ctx.newPerson({ ownerId: owner.id, name: 'Owner name', birthDate: '1990-01-01' });
await ctx.newPerson({ ownerId: user1.id, personGroupId: person.personGroupId, name: '', birthDate: null });
await sut.addUsersToPeople(factory.auth({ user: owner }), {
personIds: [person.personGroupId],
sharedWithIds: [user1.id],
role: PersonUserRole.Read,
});
await expect(sut.getById(factory.auth({ user: user1 }), person.personGroupId)).resolves.toEqual(
expect.objectContaining({ name: 'Owner name', birthDate: '1990-01-01' }),
);
});
it('should skip updating when sharing to an existing copy with values', async () => {
const { sut, ctx } = setup();
const { user: owner } = await ctx.newUser();
const { user: user1 } = await ctx.newUser({ clusterGroupId: owner.clusterGroupId });
const { person } = await ctx.newPerson({ ownerId: owner.id, name: 'Owner name', birthDate: '1990-01-01' });
await ctx.newPerson({
ownerId: user1.id,
personGroupId: person.personGroupId,
name: 'User1 name',
birthDate: '2000-02-02',
});
await sut.addUsersToPeople(factory.auth({ user: owner }), {
personIds: [person.personGroupId],
sharedWithIds: [user1.id],
role: PersonUserRole.Read,
});
await expect(sut.getById(factory.auth({ user: user1 }), person.personGroupId)).resolves.toEqual(
expect.objectContaining({ name: 'User1 name', birthDate: '2000-02-02' }),
);
});
});
describe('removeUsersFromPeople', () => {
it('should work with an empty list', async () => {
const { sut, ctx } = setup();
const { user: owner } = await ctx.newUser();
const { user: sharedWith } = await ctx.newUser({ clusterGroupId: owner.clusterGroupId });
const { person } = await ctx.newPerson({ ownerId: owner.id });
const auth = factory.auth({ user: owner });
await sut.addUsersToPeople(auth, {
personIds: [person.personGroupId],
sharedWithIds: [sharedWith.id],
role: PersonUserRole.Read,
});
await sut.removeUsersFromPeople(auth, []);
await expect(sut.getUsersForPeople(auth, {})).resolves.toHaveLength(1);
});
it('should throw an error when there is no access', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: owner } = await ctx.newUser({ clusterGroupId: user.clusterGroupId });
const { person } = await ctx.newPerson({ ownerId: owner.id });
const auth = factory.auth({ user });
await expect(
sut.removeUsersFromPeople(auth, [{ personId: person.personGroupId, sharedWithId: user.id }]),
).rejects.toThrow('Not found or no person.update access');
});
it('should delete only the requested person and user pairs', async () => {
const { sut, ctx } = setup();
const { user: owner } = await ctx.newUser();
const { user: user1 } = await ctx.newUser({ clusterGroupId: owner.clusterGroupId });
const { user: user2 } = await ctx.newUser({ clusterGroupId: owner.clusterGroupId });
const { person: person1 } = await ctx.newPerson({ ownerId: owner.id });
const { person: person2 } = await ctx.newPerson({ ownerId: owner.id });
const auth = factory.auth({ user: owner });
await sut.addUsersToPeople(auth, {
personIds: [person1.personGroupId, person2.personGroupId],
sharedWithIds: [user1.id, user2.id],
role: PersonUserRole.Read,
});
await expect(sut.getUsersForPeople(auth, {})).resolves.toHaveLength(4);
await sut.removeUsersFromPeople(auth, [
{ personId: person1.personGroupId, sharedWithId: user1.id },
{ personId: person2.personGroupId, sharedWithId: user2.id },
]);
const shares = await sut.getUsersForPeople(auth, {});
expect(shares).toHaveLength(2);
expect(shares).toEqual(
expect.arrayContaining([
expect.objectContaining({ personId: person1.personGroupId, sharedWithId: user2.id }),
expect.objectContaining({ personId: person2.personGroupId, sharedWithId: user1.id }),
]),
);
});
it('should not delete the same pair shared by another user', async () => {
const { sut, ctx } = setup();
const { user: owner } = await ctx.newUser();
const { user: otherOwner } = await ctx.newUser({ clusterGroupId: owner.clusterGroupId });
const { user: sharedWith } = await ctx.newUser({ clusterGroupId: owner.clusterGroupId });
const { person } = await ctx.newPerson({ ownerId: owner.id });
await ctx.newPerson({ ownerId: otherOwner.id, personGroupId: person.personGroupId });
const auth = factory.auth({ user: owner });
const otherAuth = factory.auth({ user: otherOwner });
const dto = { personIds: [person.personGroupId], sharedWithIds: [sharedWith.id], role: PersonUserRole.Read };
await sut.addUsersToPeople(auth, dto);
await sut.addUsersToPeople(otherAuth, dto);
await sut.removeUsersFromPeople(auth, [{ personId: person.personGroupId, sharedWithId: sharedWith.id }]);
await expect(sut.getUsersForPeople(auth, {})).resolves.toEqual([]);
await expect(sut.getUsersForPeople(otherAuth, {})).resolves.toEqual([
expect.objectContaining({ personId: person.personGroupId, sharedWithId: sharedWith.id }),
]);
});
});
});
@@ -61,7 +61,7 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
person: {
checkFaceOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
checkAccess: vitest.fn().mockResolvedValue(new Set()),
},
partner: {
+4
View File
@@ -52,6 +52,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository
import { OAuthRepository } from 'src/repositories/oauth.repository.js';
import { OcrRepository } from 'src/repositories/ocr.repository.js';
import { PartnerRepository } from 'src/repositories/partner.repository.js';
import { PersonUserRepository } from 'src/repositories/person-user.repository.js';
import { PersonRepository } from 'src/repositories/person.repository.js';
import { PluginRepository } from 'src/repositories/plugin.repository.js';
import { ProcessRepository } from 'src/repositories/process.repository.js';
@@ -266,6 +267,7 @@ export type ServiceOverrides = {
oauth: OAuthRepository;
partner: PartnerRepository;
person: PersonRepository;
personUser: PersonUserRepository;
plugin: PluginRepository;
process: ProcessRepository;
search: SearchRepository;
@@ -352,6 +354,7 @@ export const getMocks = () => {
oauth: automock(OAuthRepository, { args: [loggerMock] }),
partner: automock(PartnerRepository, { strict: false }),
person: automock(PersonRepository, { strict: false }),
personUser: automock(PersonUserRepository, { strict: false }),
plugin: automock(PluginRepository, { strict: true, args: [databaseMock, loggerMock] }),
process: automock(ProcessRepository),
search: automock(SearchRepository, { strict: false }),
@@ -426,6 +429,7 @@ export const newTestService = <T extends BaseService>(
overrides.ocr || (mocks.ocr as As<OcrRepository>),
overrides.partner || (mocks.partner as As<PartnerRepository>),
overrides.person || (mocks.person as As<PersonRepository>),
overrides.personUser || (mocks.personUser as As<PersonUserRepository>),
overrides.plugin || (mocks.plugin as As<PluginRepository>),
overrides.process || (mocks.process as As<ProcessRepository>),
overrides.search || (mocks.search as As<SearchRepository>),
+10 -15
View File
@@ -1,10 +1,11 @@
import js from '@eslint/js';
import tslintPluginCompat from '@koddsson/eslint-plugin-tscompat';
import prettier from 'eslint-config-prettier';
import eslintPluginBetterTailwindcss from 'eslint-plugin-better-tailwindcss';
import eslintPluginCompat from 'eslint-plugin-compat';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import eslintPluginSvelte from 'eslint-plugin-svelte';
import eslintPluginUnicorn from 'eslint-plugin-unicorn';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import parser from 'svelte-eslint-parser';
import typescriptEslint from 'typescript-eslint';
@@ -19,14 +20,18 @@ const NAVIGATION_PARAM_READ = [
" [arguments.0.property.name='PREVIOUS_ROUTE'])",
].join('');
export default typescriptEslint.config(
export default defineConfig(
...eslintPluginSvelte.configs.recommended,
eslintPluginUnicorn.configs.recommended,
js.configs.recommended,
prettier,
{
plugins: {
tscompat: tslintPluginCompat,
compat: eslintPluginCompat,
},
settings: {
polyfills: [],
lintAllEsApis: true,
},
rules: {
'tscompat/tscompat': [
@@ -39,6 +44,7 @@ export default typescriptEslint.config(
.filter((line) => line && !line.startsWith('#')),
},
],
'compat/compat': 'error',
},
languageOptions: {
parser,
@@ -49,18 +55,6 @@ export default typescriptEslint.config(
},
// ignores: ['**/service-worker/**'],
},
{
plugins: {
compat: eslintPluginCompat,
},
settings: {
polyfills: [],
lintAllEsApis: true,
},
rules: {
'compat/compat': 'error',
},
},
{
ignores: [
'**/.DS_Store',
@@ -203,4 +197,5 @@ export default typescriptEslint.config(
},
},
},
eslintPluginPrettierRecommended,
);
+1
View File
@@ -98,6 +98,7 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-better-tailwindcss": "^4.5.0",
"eslint-plugin-compat": "^7.0.0",
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-svelte": "^3.12.4",
"eslint-plugin-unicorn": "^75.0.0",
"factory.ts": "^1.4.1",
+4
View File
@@ -47,12 +47,16 @@ export enum QueryParameter {
ACTION = 'action',
ASSET_ID = 'assetId',
ID = 'id',
IS_FAVORITE = 'isFavorite',
IS_HIDDEN = 'isHidden',
IS_OPEN = 'isOpen',
IS_SAVED = 'isSaved',
OPEN_SETTING = 'openSetting',
PREVIOUS_ROUTE = 'previousRoute',
QUERY = 'query',
SEARCHED_PEOPLE = 'searchedPeople',
SHARED_BY_ID = 'sharedById',
SHARED_WITH_ID = 'sharedWithId',
SMART_SEARCH = 'smartSearch',
PAGE = 'page',
PATH = 'path',
@@ -8,6 +8,7 @@ import type {
LibraryResponseDto,
LoginResponseDto,
PersonResponseDto,
PersonUserRole,
QueueResponseDto,
ReleaseEventV1,
SharedLinkResponseDto,
@@ -53,6 +54,9 @@ export type Events = {
AlbumUserDelete: [{ albumId: string; userId: string }];
PersonUpdate: [PersonResponseDto];
PersonShare: [{ personId: string }];
PersonUserUpdate: [{ personId: string; userId: string; role: PersonUserRole }];
PersonUserDelete: [{ personId: string; userId: string }];
PersonThumbnailReady: [{ id: string }];
PersonAssetDelete: [{ id: string; assetId: string }];
@@ -1,9 +1,8 @@
<script lang="ts">
import { initInput } from '$lib/actions/focus';
import UserAvatar from '$lib/components/shared-components/UserAvatar.svelte';
import { handleAddUsersToAlbum } from '$lib/services/album.service';
import { normalizeSearchString } from '$lib/utils/string-utils';
import { searchUsers, type AlbumResponseDto, type UserResponseDto } from '@immich/sdk';
import { searchUsers, type UserResponseDto } from '@immich/sdk';
import { FormModal, ListButton, LoadingSpinner, Stack, Text } from '@immich/ui';
import { sortBy } from 'lodash-es';
import { onMount } from 'svelte';
@@ -11,16 +10,24 @@
import { SvelteMap } from 'svelte/reactivity';
type Props = {
album: AlbumResponseDto;
excludedUserIds: string[];
emptyMessage?: string;
onAddUsers: (users: UserResponseDto[]) => Promise<boolean | undefined>;
loadUsers?: () => Promise<UserResponseDto[]>;
onClose: () => void;
};
let search = $state('');
const { album, onClose }: Props = $props();
const {
excludedUserIds,
emptyMessage = $t('album_share_no_users'),
onAddUsers,
loadUsers = searchUsers,
onClose,
}: Props = $props();
let users: UserResponseDto[] = $state([]);
const excludedUserIds = $derived(album.albumUsers.map(({ user: { id } }) => id));
const filteredUsers = $derived(
sortBy(
users.filter(
@@ -43,14 +50,14 @@
};
const onSubmit = async () => {
const success = await handleAddUsersToAlbum(album, [...selectedUsers.values()]);
const success = await onAddUsers([...selectedUsers.values()]);
if (success) {
onClose();
}
};
onMount(async () => {
users = await searchUsers();
users = await loadUsers();
loading = false;
});
</script>
@@ -84,7 +91,7 @@
</div>
</ListButton>
{:else}
<Text class="py-6">{$t('album_share_no_users')}</Text>
<Text class="py-6">{emptyMessage}</Text>
{/each}
</Stack>
{/if}
+1 -1
View File
@@ -102,7 +102,7 @@
<div>
<HStack fullWidth class="mb-2 justify-between">
<Text size="medium" fontWeight="semi-bold">{$t('people')}</Text>
<Text size="medium" fontWeight="semi-bold">{$t('users')}</Text>
{#if !readOnly}
<HeaderActionButton action={AddUsers} />
{/if}
+137
View File
@@ -0,0 +1,137 @@
<script lang="ts">
import { authManager } from '$lib/managers/auth-manager.svelte';
import PeopleFilterUserPicker from '$lib/modals/PeopleFilterUserPicker.svelte';
import type { PeopleFilter } from '$lib/types';
import { handleError } from '$lib/utils/handle-error';
import { getClusterGroupUsers, type UserResponseDto } from '@immich/sdk';
import { Button, Checkbox, FormModal, Label, modalManager, Stack, Text } from '@immich/ui';
import { mdiClose, mdiTune } from '@mdi/js';
import { onMount, untrack } from 'svelte';
import { t } from 'svelte-i18n';
type Props = {
filter: PeopleFilter;
onClose: (filter?: PeopleFilter) => void;
};
let { filter, onClose }: Props = $props();
let sharedById = $state(untrack(() => filter.sharedById ?? ''));
let sharedWithId = $state(untrack(() => filter.sharedWithId ?? ''));
let isFavorite = $state(untrack(() => filter.isFavorite));
let isHidden = $state(untrack(() => filter.isHidden));
let users = $state<UserResponseDto[]>([]);
const cycle = (value?: boolean) => (value === undefined ? true : value ? false : undefined);
onMount(async () => {
try {
const clusterGroupUsers = await getClusterGroupUsers({ id: authManager.user.clusterGroupId });
users = clusterGroupUsers.filter(({ id }) => id !== authManager.user.id);
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
});
const getUser = (userId: string) => [authManager.user, ...users].find(({ id }) => id === userId);
const getUserLabel = $derived((userId: string) => {
if (!userId) {
return $t('no_filter');
}
if (userId === authManager.user.id) {
return $t('you');
}
return users.find(({ id }) => id === userId)?.name ?? userId;
});
const onPickSharedBy = async () => {
const userId = await modalManager.show(PeopleFilterUserPicker, {
title: $t('shared_by'),
selected: sharedById,
users,
});
if (userId !== undefined) {
sharedById = userId;
}
};
const onPickSharedWith = async () => {
const userId = await modalManager.show(PeopleFilterUserPicker, {
title: $t('shared_with'),
selected: sharedWithId,
users,
});
if (userId !== undefined) {
sharedWithId = userId;
}
};
const onReset = () => {
sharedById = '';
sharedWithId = '';
isFavorite = undefined;
isHidden = undefined;
};
const onSubmit = () => {
onClose({ sharedById: sharedById || undefined, sharedWithId: sharedWithId || undefined, isFavorite, isHidden });
};
</script>
{#snippet row(id: string, label: string, userId: string, onPick: () => Promise<void>)}
{@const user = getUser(userId)}
<div class="flex items-center justify-between gap-2">
<Label id="{id}-label" for={id}>{label}</Label>
<Button
{id}
aria-labelledby="{id}-label {id}"
onclick={onPick}
size="small"
variant="outline"
shape="round"
color="secondary"
trailingIcon={user ? mdiClose : undefined}
>
<div class="flex gap-2 text-start">
{#if user}
<div>
<Text fontWeight="medium" size="small">{getUserLabel(userId)}</Text>
</div>
{:else}
<Text fontWeight="medium">{getUserLabel(userId)}</Text>
{/if}
</div>
</Button>
</div>
{/snippet}
<FormModal title={$t('filters')} icon={mdiTune} {onClose} {onSubmit} size="small">
<Stack gap={6}>
{@render row('people-filter-shared-by', $t('shared_by'), sharedById, onPickSharedBy)}
{@render row('people-filter-shared-with', $t('shared_with'), sharedWithId, onPickSharedWith)}
<div class="flex items-center justify-between gap-2">
<Label id="people-filter-favorite-label" for="people-filter-favorite">{$t('favorite')}</Label>
<Checkbox
id="people-filter-favorite"
aria-labelledby="people-filter-favorite-label"
bind:checked={() => isFavorite === true, () => (isFavorite = cycle(isFavorite))}
indeterminate={isFavorite === undefined}
/>
</div>
<div class="flex items-center justify-between gap-2">
<Label id="people-filter-hidden-label" for="people-filter-hidden">{$t('hidden')}</Label>
<Checkbox
id="people-filter-hidden"
aria-labelledby="people-filter-hidden-label"
bind:checked={() => isHidden === true, () => (isHidden = cycle(isHidden))}
indeterminate={isHidden === undefined}
/>
</div>
<div class="flex justify-end">
<Button size="small" variant="outline" color="secondary" shape="round" onclick={onReset}>{$t('reset')}</Button>
</div>
</Stack>
</FormModal>
@@ -0,0 +1,42 @@
<script lang="ts">
import UserAvatar from '$lib/components/shared-components/UserAvatar.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { UserResponseDto } from '@immich/sdk';
import { BasicModal, Icon, ListButton, Stack, Text } from '@immich/ui';
import { mdiFilterOffOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
title: string;
selected: string;
users: UserResponseDto[];
/** show a "no filter" option, which resolves to an empty string */
allowEmpty?: boolean;
// an empty string means "any user"
onClose: (userId?: string) => void;
};
const { title, selected, users, allowEmpty = true, onClose }: Props = $props();
</script>
<BasicModal {title} onClose={() => onClose()} size="small">
<Stack>
{#if allowEmpty}
<ListButton selected={selected === ''} onclick={() => onClose('')}>
<div class="flex size-10 shrink-0 items-center justify-center">
<Icon icon={mdiFilterOffOutline} size="24" />
</div>
<Text fontWeight="medium" class="grow text-start">{$t('no_filter')}</Text>
</ListButton>
{/if}
{#each [authManager.user, ...users] as user (user.id)}
<ListButton selected={selected === user.id} onclick={() => onClose(user.id)}>
<UserAvatar {user} size="md" />
<div class="grow text-start">
<Text fontWeight="medium">{user.id === authManager.user.id ? $t('you') : user.name}</Text>
<Text size="tiny" color="muted">{user.email}</Text>
</div>
</ListButton>
{/each}
</Stack>
</BasicModal>
@@ -0,0 +1,90 @@
<script lang="ts">
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import UserAvatar from '$lib/components/shared-components/UserAvatar.svelte';
import {
getPersonUserActions,
getPersonUsersActions,
handleUpdatePersonUserRole,
} from '$lib/services/person-user.service';
import { handleError } from '$lib/utils/handle-error';
import {
getUsersForPeople,
PersonUserRole,
SharingDirection,
type PersonResponseDto,
type PersonUsersResponseDto,
} from '@immich/sdk';
import { ActionButton, BasicModal, Field, HStack, Select, Text } from '@immich/ui';
import { mdiAccountMultipleOutline } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
type Props = {
person: PersonResponseDto;
onClose: () => void;
};
let { person, onClose }: Props = $props();
let personUsers = $state<PersonUsersResponseDto>([]);
const { AddUsers } = $derived(getPersonUsersActions($t, person, personUsers));
const refreshPersonUsers = async () => {
try {
personUsers = await getUsersForPeople({ personId: person.id, direction: SharingDirection.SharedBy });
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
onMount(async () => {
await refreshPersonUsers();
});
</script>
<OnEvents
onPersonShare={refreshPersonUsers}
onPersonUserUpdate={refreshPersonUsers}
onPersonUserDelete={refreshPersonUsers}
/>
<BasicModal title={$t('manage_person_access')} size="small" icon={mdiAccountMultipleOutline} {onClose}>
<Text size="small" color="muted" class="mb-4">
{$t('manage_person_access_description')}
</Text>
<HStack fullWidth class="my-2 justify-between">
<Text size="medium" fontWeight="semi-bold">{$t('users')}</Text>
<HeaderActionButton action={AddUsers} />
</HStack>
<div class="ps-2">
{#each personUsers as { sharedWith, role } (sharedWith.id)}
{@const { Delete } = getPersonUserActions($t, person, sharedWith)}
<div class="flex items-center justify-between gap-4 py-2">
<div class="flex w-full items-center justify-between gap-4">
<div class="flex flex-row items-center gap-2">
<div>
<UserAvatar user={sharedWith} size="md" />
</div>
<Text size="small">{sharedWith.name}</Text>
</div>
<Field class="w-32">
<Select
value={role}
options={[
{ label: 'Read', value: PersonUserRole.Read },
{ label: 'Write', value: PersonUserRole.Write },
{ label: 'Admin', value: PersonUserRole.Admin },
]}
onChange={(value) =>
handleUpdatePersonUserRole({ personId: person.id, userId: sharedWith.id, role: value })}
/>
</Field>
</div>
<ActionButton type="icon" action={Delete} />
</div>
{/each}
</div>
</BasicModal>
@@ -1,39 +0,0 @@
<script lang="ts">
import { handleUpdatePersonBirthDate } from '$lib/services/person.service';
import { type PersonResponseDto } from '@immich/sdk';
import { Button, DatePicker, Field, FormModal, HelperText } from '@immich/ui';
import { mdiCake } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
type Props = {
person: PersonResponseDto;
onClose: () => void;
};
let { person, onClose }: Props = $props();
let birthDate = $derived(person.birthDate ? DateTime.fromISO(person.birthDate) : undefined);
const onSubmit = async () => {
const success = await handleUpdatePersonBirthDate(person, birthDate?.toISODate() ?? null);
if (success) {
onClose();
}
};
</script>
<FormModal title={$t('set_date_of_birth')} size="small" icon={mdiCake} {onClose} {onSubmit}>
<div class="my-2 flex flex-col gap-2">
<Field label={$t('date_of_birth')}>
<DatePicker bind:value={birthDate} maxDate={DateTime.now()} />
<HelperText>{$t('birthdate_set_description')}</HelperText>
</Field>
{#if person.birthDate}
<div class="flex justify-end">
<Button shape="round" color="secondary" size="small" onclick={() => (birthDate = undefined)}>
{$t('clear')}
</Button>
</div>
{/if}
</div>
</FormModal>
+196
View File
@@ -0,0 +1,196 @@
<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 { locale } from '$lib/stores/preferences.store';
import { PersonUserRole, type PersonResponseDto } from '@immich/sdk';
import {
Alert,
Button,
Checkbox,
DatePicker,
Field,
FormModal,
HelperText,
HStack,
Input,
Label,
modalManager,
Stack,
Text,
} from '@immich/ui';
import { mdiAccountMultipleOutline, mdiChevronDown, mdiText } from '@mdi/js';
import { DateTime } from 'luxon';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
type Props = {
person: PersonResponseDto;
targetUserId?: string;
onClose: () => void;
};
const { person, targetUserId: initialTargetUserId, onClose }: Props = $props();
const candidates = $derived([
{ name: person.name, birthDate: person.birthDate, sharedById: authManager.user.id },
...(person.otherPeople ?? []),
]);
const userNames = $derived(
new Map([
[authManager.user.id, authManager.user.name],
...person.sharedBy.map(({ id, name }) => [id, name] as const),
]),
);
const writableRoles = new Set([PersonUserRole.Write, PersonUserRole.Admin]);
let targetUserId = $state(initialTargetUserId ?? authManager.user.id);
let targetPerson = $state(candidates[0]);
let applyToEveryone = $state(false);
const isWritable = $derived.by(() => {
if (!targetUserId) {
return false;
}
if (targetUserId === authManager.user.id) {
return true;
}
const sharedBy = person.sharedBy.find((user) => user.id === targetUserId);
if (!sharedBy) {
return false;
}
return writableRoles.has(sharedBy.role);
});
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,
})),
});
if (success) {
onClose();
}
};
const handleCopyFromMine = () => {
targetPerson.name = person.name;
targetPerson.birthDate = person.birthDate;
};
const onChange = (value: string) => {
targetUserId = value;
const match = candidates.find((person) => person.sharedById === value);
if (match) {
targetPerson.name = match.name;
targetPerson.birthDate = match.birthDate;
targetPerson.sharedById = match.sharedById;
}
};
const onViewAsAnotherUser = async () => {
const candidateIds = new Set(candidates.map(({ sharedById }) => sharedById));
const userId = await modalManager.show(PeopleFilterUserPicker, {
title: $t('user'),
selected: targetUserId,
users: person.sharedBy.filter(({ id }) => candidateIds.has(id)),
allowEmpty: false,
});
if (userId) {
onChange(userId);
}
};
onMount(() => {
onChange(targetUserId);
});
</script>
<FormModal
title={$t('person')}
size="small"
icon={mdiText}
{onClose}
{onSubmit}
submitText={$t('submit')}
disabled={!isWritable}
>
<Stack gap={6}>
{#if candidates.length > 1}
<div class="flex flex-col gap-2">
<Button
color="secondary"
size="small"
shape="round"
trailingIcon={mdiChevronDown}
onclick={onViewAsAnotherUser}
>
{$t('view_as', { values: { name: userNames.get(targetUserId) ?? targetUserId } })}
</Button>
{#if isWritable}
<Button
size="small"
color="secondary"
shape="round"
variant="ghost"
leadingIcon={mdiAccountMultipleOutline}
onclick={handleCopyFromMine}
>
{$t('copy_from_my_person')}
</Button>
{:else}
<Alert shape="rectangle" color="warning" size="small" icon={false} class="mt-4" title={$t('readonly_access')}>
<Text size="tiny">{$t('person_read_access_message')}</Text>
</Alert>
{/if}
</div>
{/if}
<Field label={$t('name')} disabled={!isWritable}>
<Input bind:value={targetPerson.name} />
</Field>
<Field label={$t('date_of_birth')} disabled={!isWritable}>
<DatePicker
bind:value={
() => (targetPerson.birthDate ? DateTime.fromISO(targetPerson.birthDate) : undefined),
(value) => (targetPerson.birthDate = value?.toISO() ?? null)
}
maxDate={DateTime.now()}
/>
<HelperText>{$t('birthdate_set_description')}</HelperText>
</Field>
{#if candidates.length > 1 && isWritable}
<HStack fullWidth gap={4}>
<Checkbox id="apply-for-all-users-checkbox" color="secondary" size="small" bind:checked={applyToEveryone} />
<Label
label={$t('person_edit_change_for_all_users', {
values: {
people: new Intl.ListFormat($locale, { style: 'long' }).format(
candidates
.filter(({ sharedById }) => sharedById !== targetUserId)
.map(({ sharedById }) => userNames.get(sharedById) ?? sharedById),
),
},
})}
size="small"
for="apply-for-all-users-checkbox"
/>
</HStack>
{/if}
</Stack>
</FormModal>
+8 -3
View File
@@ -34,7 +34,7 @@ import { goto } from '$app/navigation';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import AlbumAddUsersModal from '$lib/modals/AlbumAddUsersModal.svelte';
import AddUsersModal from '$lib/modals/AddUsersModal.svelte';
import AlbumEditModal from '$lib/modals/AlbumEditModal.svelte';
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
@@ -66,10 +66,15 @@ export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) =
const isEditor = isAlbumEditor(album);
const AddUsers: ActionItem = {
title: $t('invite_people'),
title: $t('add_user'),
icon: mdiPlus,
color: 'primary',
onAction: () => modalManager.show(AlbumAddUsersModal, { album }),
onAction: () =>
modalManager.show(AddUsersModal, {
excludedUserIds: album.albumUsers.map(({ user: { id } }) => id),
// TODO that explicit UserResponseDto[] shouldn't be necessary, but svelte's types seem to be messed up right now and AlbumAddUsersModal has a bad type
onAddUsers: (users: UserResponseDto[]) => handleAddUsersToAlbum(album, users),
}),
};
const CreateSharedLink: ActionItem = {
+100
View File
@@ -0,0 +1,100 @@
import {
addUsersToPeople,
getClusterGroupUsers,
PersonUserRole,
removeUsersFromPeople,
type PersonResponseDto,
type PersonUsersResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiCheck, mdiPlus, mdiTrashCanOutline } 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 AddUsersModal from '$lib/modals/AddUsersModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
export const getPersonUsersActions = (
$t: MessageFormatter,
person: PersonResponseDto,
personUsers: PersonUsersResponseDto,
) => {
const AddUsers: ActionItem = {
title: $t('add_user'),
icon: mdiPlus,
color: 'primary',
onAction: () =>
modalManager.show(AddUsersModal, {
emptyMessage: $t('person_share_no_users'),
excludedUserIds: [authManager.user.id, ...personUsers.map(({ sharedWithId }) => sharedWithId)],
loadUsers: () => getClusterGroupUsers({ id: authManager.user.clusterGroupId }),
onAddUsers: (users: UserResponseDto[]) => handleSharePersonWithUsers(person, users),
}),
};
return { AddUsers };
};
export const getPersonUserActions = ($t: MessageFormatter, person: PersonResponseDto, user: UserResponseDto) => {
const Delete: ActionItem = {
title: $t('delete_user'),
icon: mdiTrashCanOutline,
onAction: () => handleDeletePersonUser(person, user),
};
return { Delete };
};
export const handleSharePersonWithUsers = async (person: PersonResponseDto, users: UserResponseDto[]) => {
const $t = await getFormatter();
try {
await addUsersToPeople({
personUsersCreateDto: {
personIds: [person.id],
sharedWithIds: users.map(({ id }) => id),
role: PersonUserRole.Read,
},
});
eventManager.emit('PersonShare', { personId: person.id });
return true;
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
export const handleUpdatePersonUserRole = async ({
personId,
userId,
role,
}: {
personId: string;
userId: string;
role: PersonUserRole;
}) => {
const $t = await getFormatter();
try {
await addUsersToPeople({
personUsersCreateDto: { personIds: [personId], sharedWithIds: [userId], role },
});
eventManager.emit('PersonUserUpdate', { personId, userId, role });
toastManager.primary({ icon: mdiCheck, title: $t('saved') });
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
export const handleDeletePersonUser = async (person: PersonResponseDto, user: UserResponseDto) => {
const $t = await getFormatter();
try {
await removeUsersFromPeople({ personUsersDeleteDto: [{ personId: person.id, sharedWithId: user.id }] });
eventManager.emit('PersonUserDelete', { personId: person.id, userId: user.id });
toastManager.primary({ icon: mdiCheck, title: $t('saved') });
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
+59 -8
View File
@@ -1,24 +1,34 @@
import { updatePerson, type AssetResponseDto, type PersonResponseDto } from '@immich/sdk';
import {
getPerson,
updatePeople,
updatePerson,
type AssetResponseDto,
type PeopleUpdateDto,
type PersonResponseDto,
type PersonUpdateDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import {
mdiCalendarEditOutline,
mdiAccountMultipleOutline,
mdiEyeOffOutline,
mdiEyeOutline,
mdiFaceManProfile,
mdiHeartMinusOutline,
mdiHeartOutline,
mdiPencilOutline,
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { eventManager } from '$lib/managers/event-manager.svelte';
import PersonEditBirthDateModal from '$lib/modals/PersonEditBirthDateModal.svelte';
import PersonEditAccessModal from '$lib/modals/PersonEditAccessModal.svelte';
import PersonEditModal from '$lib/modals/PersonEditModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
export const getPersonActions = ($t: MessageFormatter, person: PersonResponseDto) => {
const SetDateOfBirth: ActionItem = {
title: $t('set_date_of_birth'),
icon: mdiCalendarEditOutline,
onAction: () => modalManager.show(PersonEditBirthDateModal, { person }),
const Edit: ActionItem = {
title: $t('edit_person'),
icon: mdiPencilOutline,
onAction: () => modalManager.show(PersonEditModal, { person }),
};
const Favorite: ActionItem = {
@@ -49,7 +59,13 @@ export const getPersonActions = ($t: MessageFormatter, person: PersonResponseDto
onAction: () => handleShowPerson(person),
};
return { SetDateOfBirth, Favorite, Unfavorite, HidePerson, ShowPerson };
const Access: ActionItem = {
title: 'Manage access',
icon: mdiAccountMultipleOutline,
onAction: () => modalManager.show(PersonEditAccessModal, { person }),
};
return { Edit, Favorite, Unfavorite, HidePerson, ShowPerson, Access };
};
export const getPersonAssetActions = ($t: MessageFormatter, person: PersonResponseDto, asset: AssetResponseDto) => {
@@ -98,6 +114,41 @@ const handleHidePerson = async (person: { id: string }) => {
}
};
export const handleUpdatePerson = async (id: string, personUpdateDto: PersonUpdateDto) => {
const $t = await getFormatter();
try {
await updatePerson({ id, personUpdateDto });
return true;
} 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();
+7
View File
@@ -76,6 +76,13 @@ export type SearchFilter = {
rating?: number | null;
};
export type PeopleFilter = {
sharedById?: string;
sharedWithId?: string;
isFavorite?: boolean;
isHidden?: boolean;
};
export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object';
export type JSONSchemaProperty = {
+3
View File
@@ -7,6 +7,9 @@ const makePerson = (overrides: Partial<PersonResponseDto> = {}): PersonResponseD
thumbnailPath: '',
isHidden: false,
birthDate: null,
otherPeople: [],
sharedBy: [],
sharedWith: [],
...overrides,
});
+54 -10
View File
@@ -9,6 +9,7 @@
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { QueryParameter, SessionStorageKey } from '$lib/constants';
import PeopleFilterModal from '$lib/modals/PeopleFilterModal.svelte';
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
import { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store';
@@ -18,8 +19,8 @@
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, modalManager, toastManager } from '@immich/ui';
import { mdiAccountOff, mdiEyeOutline } from '@mdi/js';
import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
import { mdiAccountOff, mdiEyeOutline, mdiTune } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -72,7 +73,7 @@
handlePromiseError(
Promise.all(
Array.from({ length: pagesToLoad }, (_, i) => {
return getAllPeople({ withHidden: true, page: startingPage + i });
return getAllPeople({ withHidden: true, page: startingPage + i, ...data.filter });
}),
).then((pages) => {
for (const page of pages) {
@@ -96,7 +97,11 @@
}
try {
const { people: newPeople, hasNextPage } = await getAllPeople({ withHidden: true, page: nextPage });
const { people: newPeople, hasNextPage } = await getAllPeople({
withHidden: true,
page: nextPage,
...data.filter,
});
people = people.concat(newPeople);
if (nextPage !== null) {
currentPage = nextPage;
@@ -165,14 +170,44 @@
await goto(Route.viewPerson(detail, { previousRoute: Route.people(), action: 'merge' }));
};
const hasFilter = $derived(Object.values(data.filter).some((value) => value !== undefined));
const handleFilter = async () => {
const filter = await modalManager.show(PeopleFilterModal, { filter: data.filter });
if (!filter) {
return;
}
const url = new URL($page.url);
for (const [key, value] of [
[QueryParameter.SHARED_BY_ID, filter.sharedById],
[QueryParameter.SHARED_WITH_ID, filter.sharedWithId],
[QueryParameter.IS_FAVORITE, filter.isFavorite],
[QueryParameter.IS_HIDDEN, filter.isHidden],
] as const) {
if (value === undefined) {
url.searchParams.delete(key);
} else {
url.searchParams.set(key, String(value));
}
}
await goto(url, { keepFocus: true });
currentPage = 1;
nextPage = data.people.hasNextPage ? 2 : null;
};
const onResetSearchBar = async () => {
await clearQueryParam(QueryParameter.SEARCHED_PEOPLE, $page.url);
};
let people = $derived(data.people.people);
let visiblePeople = $derived(people.filter((people) => !people.isHidden));
let countVisiblePeople = $derived(searchName ? searchedPeopleLocal.length : data.people.total - data.people.hidden);
// hidden people are only shown when explicitly filtering for them
let visiblePeople = $derived(data.filter.isHidden ? people : people.filter((people) => !people.isHidden));
let countVisiblePeople = $derived(
searchName ? searchedPeopleLocal.length : data.people.total - (data.filter.isHidden ? 0 : data.people.hidden),
);
let showPeople = $derived(searchName ? searchedPeopleLocal : visiblePeople);
const onNameChangeInputFocus = (person: PersonResponseDto) => {
@@ -272,8 +307,8 @@
]}
>
{#snippet buttons()}
{#if people.length > 0}
<div class="flex items-center justify-center gap-2">
<div class="flex items-center justify-center gap-2">
{#if people.length > 0}
<div class="hidden sm:block">
<div class="h-10 w-40 lg:w-80">
<SearchPeople
@@ -294,8 +329,17 @@
variant="ghost"
color="secondary">{$t('show_and_hide_people')}</Button
>
</div>
{/if}
{/if}
<IconButton
shape="round"
color="secondary"
variant="ghost"
indicator={hasFilter ? 'primary' : undefined}
icon={mdiTune}
aria-label={$t('filters')}
onclick={handleFilter}
/>
</div>
{/snippet}
{#if countVisiblePeople > 0 && (!searchName || searchedPeopleLocal.length > 0)}
+15 -1
View File
@@ -1,4 +1,6 @@
import { getAllPeople } from '@immich/sdk';
import { QueryParameter } from '$lib/constants';
import type { PeopleFilter } from '$lib/types';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
@@ -6,11 +8,23 @@ import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url);
const people = await getAllPeople({ withHidden: true });
const getBoolean = (key: QueryParameter) => {
const value = url.searchParams.get(key);
return value === null ? undefined : value === 'true';
};
const filter: PeopleFilter = {
sharedById: url.searchParams.get(QueryParameter.SHARED_BY_ID) ?? undefined,
sharedWithId: url.searchParams.get(QueryParameter.SHARED_WITH_ID) ?? undefined,
isFavorite: getBoolean(QueryParameter.IS_FAVORITE),
isHidden: getBoolean(QueryParameter.IS_HIDDEN),
};
const people = await getAllPeople({ withHidden: true, ...filter });
const $t = await getFormatter();
return {
people,
filter,
meta: {
title: $t('people'),
},
@@ -15,11 +15,11 @@
let { person, onMergePeople }: Props = $props();
const { SetDateOfBirth, HidePerson, Favorite, Unfavorite } = $derived(getPersonActions($t, person));
const { Edit, HidePerson, Favorite, Unfavorite, Access } = $derived(getPersonActions($t, person));
const items = $derived([
Edit,
HidePerson,
SetDateOfBirth,
{
icon: mdiAccountMultipleCheckOutline,
title: $t('merge_people'),
@@ -27,6 +27,7 @@
},
Favorite,
Unfavorite,
Access,
]);
</script>
@@ -27,6 +27,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import PersonEditModal from '$lib/modals/PersonEditModal.svelte';
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service';
@@ -36,17 +37,24 @@
import { getPeopleThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { normalizeSearchString } from '$lib/utils/string-utils';
import { AssetVisibility, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk';
import { AssetVisibility, PersonUserRole, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk';
import {
ActionButton,
CommandPaletteDefaultProvider,
ContextMenuButton,
IconButton,
LoadingSpinner,
modalManager,
toastManager,
type ActionItem,
} from '@immich/ui';
import { mdiAccountBoxOutline, mdiAccountMultipleCheckOutline, mdiArrowLeft, mdiDotsVertical } from '@mdi/js';
import {
mdiAccountBoxOutline,
mdiAccountMultipleCheckOutline,
mdiArrowLeft,
mdiDotsVertical,
mdiPencilOutline,
} from '@mdi/js';
import { DateTime } from 'luxon';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -63,6 +71,7 @@
let numberOfAssets = $derived(data.statistics.assets);
let person = $derived(data.person);
const altItems = $derived(person.otherPeople.filter(({ name }) => !!name && name !== person.name));
let thumbnailData = $derived(getPeopleThumbnailUrl(person));
let timelineManager = $state<TimelineManager>() as TimelineManager;
@@ -305,7 +314,7 @@
await updateAssetCount();
};
const { SetDateOfBirth, Favorite, Unfavorite, HidePerson, ShowPerson } = $derived(getPersonActions($t, person));
const { Edit, Favorite, Unfavorite, HidePerson, ShowPerson, Access } = $derived(getPersonActions($t, person));
const SelectFeaturePhoto: ActionItem = {
title: $t('select_featured_photo'),
icon: mdiAccountBoxOutline,
@@ -373,44 +382,75 @@
{thumbnailData}
/>
{:else}
<div class="relative">
<button
type="button"
class="flex items-center justify-center"
title={$t('edit_name')}
onclick={() => (isEditingName = true)}
>
<ImageThumbnail
circle
shadow
url={thumbnailData}
altText={person.name}
widthStyle="3.375rem"
heightStyle="3.375rem"
/>
<div class="flex flex-col justify-center px-4 text-start text-primary">
<p class="w-40 truncate font-medium sm:w-72">{person.name || $t('add_a_name')}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
{$t('assets_count', { values: { count: numberOfAssets } })}
</p>
{#if person.birthDate}
<p class="text-sm text-gray-500 dark:text-gray-400">
{$t('person_birthdate', {
values: {
date: DateTime.fromISO(person.birthDate).toLocaleString(
{
month: 'numeric',
day: 'numeric',
year: 'numeric',
},
{ locale: $locale },
),
},
})}
<div class="relative flex gap-4">
<ImageThumbnail
circle
shadow
url={thumbnailData}
altText={person.name}
widthStyle="3.375rem"
heightStyle="3.375rem"
/>
<div class="flex flex-col text-start text-primary">
<div class="flex gap-2">
<button type="button" title={$t('edit_name')} onclick={() => (isEditingName = true)}>
<p class="w-max-40 sm:w-max-72 truncate text-start font-medium">
{person.name || $t('add_a_name')}
</p>
{/if}
</button>
<IconButton
icon={mdiPencilOutline}
shape="round"
size="small"
variant="ghost"
onclick={() => modalManager.show(PersonEditModal, { person })}
aria-label={$t('edit')}
/>
</div>
</button>
{#if altItems.length > 0}
{@const parts = new Intl.ListFormat($locale).formatToParts(
altItems.map(({ sharedById }) => sharedById),
)}
<p class="text-sm text-gray-500 dark:text-gray-400">
{#each parts as { type, value } (value)}
{#if type === 'element'}
{@const altItem = altItems.find(({ sharedById }) => sharedById === value)!}
{#if [PersonUserRole.Write, PersonUserRole.Admin].includes(altItem.role)}
<button
type="button"
onclick={() =>
modalManager.show(PersonEditModal, { person, targetUserId: altItem.sharedById })}
class="underline">{altItem.name}</button
>
{:else}
{altItem.name}
{/if}
{:else}
{value}
{/if}
{/each}
</p>
{/if}
<p class="text-sm text-gray-500 dark:text-gray-400">
{$t('assets_count', { values: { count: numberOfAssets } })}
</p>
{#if person.birthDate}
<p class="text-sm text-gray-500 dark:text-gray-400">
{$t('person_birthdate', {
values: {
date: DateTime.fromISO(person.birthDate).toLocaleString(
{
month: 'numeric',
day: 'numeric',
year: 'numeric',
},
{ locale: $locale },
),
},
})}
</p>
{/if}
</div>
</div>
{/if}
</section>
@@ -501,8 +541,9 @@
{#if viewMode === PersonPageViewMode.VIEW_ASSETS}
<ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(previousRoute)}>
{#snippet trailing()}
<ActionButton action={Access} />
<ContextMenuButton
items={[SelectFeaturePhoto, HidePerson, ShowPerson, SetDateOfBirth, Merge, Favorite, Unfavorite]}
items={[SelectFeaturePhoto, Edit, HidePerson, ShowPerson, Merge, Favorite, Unfavorite]}
aria-label={$t('open')}
/>
{/snippet}
@@ -9,4 +9,7 @@ export const personFactory = Sync.makeFactory<PersonResponseDto>({
name: Sync.each(() => faker.person.fullName()),
thumbnailPath: Sync.each(() => faker.system.filePath()),
updatedAt: Sync.each(() => faker.date.recent().toISOString()),
otherPeople: [],
sharedBy: [],
sharedWith: [],
});