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:
co-authored by
Jason Rasmussen
shenlong-tanwen
parent
0f179b9914
commit
c12d5e6d35
+13
-1
@@ -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;
|
||||
}),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+3
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,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({
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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'))
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
|
||||
+15
-8
@@ -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}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -7,6 +7,9 @@ const makePerson = (overrides: Partial<PersonResponseDto> = {}): PersonResponseD
|
||||
thumbnailPath: '',
|
||||
isHidden: false,
|
||||
birthDate: null,
|
||||
otherPeople: [],
|
||||
sharedBy: [],
|
||||
sharedWith: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
+81
-40
@@ -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: [],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user