feat: cluster groups (#30739)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
This commit is contained in:
co-authored by
Daniel Dietzler
parent
f88fb628ff
commit
11b1aa5ecf
@@ -130,7 +130,7 @@ describe('/asset', () => {
|
|||||||
});
|
});
|
||||||
await utils.createFace({
|
await utils.createFace({
|
||||||
assetId: user1Assets[0].id,
|
assetId: user1Assets[0].id,
|
||||||
personId: person1.id,
|
personGroupId: person1.id,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
beforeAll(setupTests, 30_000);
|
beforeAll(setupTests, 30_000);
|
||||||
|
|||||||
@@ -82,32 +82,32 @@ describe('/people', () => {
|
|||||||
const asset4 = await utils.createAsset(admin.accessToken);
|
const asset4 = await utils.createAsset(admin.accessToken);
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
utils.createFace({ assetId: asset1.id, personId: visiblePerson.id }),
|
utils.createFace({ assetId: asset1.id, personGroupId: visiblePerson.id }),
|
||||||
utils.createFace({ assetId: asset1.id, personId: hiddenPerson.id }),
|
utils.createFace({ assetId: asset1.id, personGroupId: hiddenPerson.id }),
|
||||||
utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }),
|
utils.createFace({ assetId: asset1.id, personGroupId: multipleAssetsPerson.id }),
|
||||||
utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }),
|
utils.createFace({ assetId: asset1.id, personGroupId: multipleAssetsPerson.id }),
|
||||||
utils.createFace({ assetId: asset2.id, personId: multipleAssetsPerson.id }),
|
utils.createFace({ assetId: asset2.id, personGroupId: multipleAssetsPerson.id }),
|
||||||
utils.createFace({ assetId: asset3.id, personId: multipleAssetsPerson.id }), // 4 assets
|
utils.createFace({ assetId: asset3.id, personGroupId: multipleAssetsPerson.id }), // 4 assets
|
||||||
// Named persons
|
// Named persons
|
||||||
utils.createFace({ assetId: asset1.id, personId: nameCharliePerson.id }), // 1 asset
|
utils.createFace({ assetId: asset1.id, personGroupId: nameCharliePerson.id }), // 1 asset
|
||||||
utils.createFace({ assetId: asset1.id, personId: nameBobPerson.id }),
|
utils.createFace({ assetId: asset1.id, personGroupId: nameBobPerson.id }),
|
||||||
utils.createFace({ assetId: asset2.id, personId: nameBobPerson.id }), // 2 assets
|
utils.createFace({ assetId: asset2.id, personGroupId: nameBobPerson.id }), // 2 assets
|
||||||
utils.createFace({ assetId: asset1.id, personId: nameAlicePerson.id }), // 1 asset
|
utils.createFace({ assetId: asset1.id, personGroupId: nameAlicePerson.id }), // 1 asset
|
||||||
// Null-named person 4 assets
|
// Null-named person 4 assets
|
||||||
utils.createFace({ assetId: asset1.id, personId: nameNullPerson4Assets.id }),
|
utils.createFace({ assetId: asset1.id, personGroupId: nameNullPerson4Assets.id }),
|
||||||
utils.createFace({ assetId: asset2.id, personId: nameNullPerson4Assets.id }),
|
utils.createFace({ assetId: asset2.id, personGroupId: nameNullPerson4Assets.id }),
|
||||||
utils.createFace({ assetId: asset3.id, personId: nameNullPerson4Assets.id }),
|
utils.createFace({ assetId: asset3.id, personGroupId: nameNullPerson4Assets.id }),
|
||||||
utils.createFace({ assetId: asset4.id, personId: nameNullPerson4Assets.id }), // 4 assets
|
utils.createFace({ assetId: asset4.id, personGroupId: nameNullPerson4Assets.id }), // 4 assets
|
||||||
// Null-named person 3 assets
|
// Null-named person 3 assets
|
||||||
utils.createFace({ assetId: asset1.id, personId: nameNullPerson3Assets.id }),
|
utils.createFace({ assetId: asset1.id, personGroupId: nameNullPerson3Assets.id }),
|
||||||
utils.createFace({ assetId: asset2.id, personId: nameNullPerson3Assets.id }),
|
utils.createFace({ assetId: asset2.id, personGroupId: nameNullPerson3Assets.id }),
|
||||||
utils.createFace({ assetId: asset3.id, personId: nameNullPerson3Assets.id }), // 3 assets
|
utils.createFace({ assetId: asset3.id, personGroupId: nameNullPerson3Assets.id }), // 3 assets
|
||||||
// Null-named person 1 asset
|
// Null-named person 1 asset
|
||||||
utils.createFace({ assetId: asset3.id, personId: nameNullPerson1Asset.id }),
|
utils.createFace({ assetId: asset3.id, personGroupId: nameNullPerson1Asset.id }),
|
||||||
// Favourite People
|
// Favourite People
|
||||||
utils.createFace({ assetId: asset1.id, personId: nameFreddyPersonFavourite.id }),
|
utils.createFace({ assetId: asset1.id, personGroupId: nameFreddyPersonFavourite.id }),
|
||||||
utils.createFace({ assetId: asset2.id, personId: nameFreddyPersonFavourite.id }),
|
utils.createFace({ assetId: asset2.id, personGroupId: nameFreddyPersonFavourite.id }),
|
||||||
utils.createFace({ assetId: asset1.id, personId: nameBillPersonFavourite.id }),
|
utils.createFace({ assetId: asset1.id, personGroupId: nameBillPersonFavourite.id }),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -184,6 +184,8 @@ export const utils = {
|
|||||||
'library',
|
'library',
|
||||||
'shared_link',
|
'shared_link',
|
||||||
'person',
|
'person',
|
||||||
|
'person_group',
|
||||||
|
'cluster_group',
|
||||||
'album',
|
'album',
|
||||||
'asset',
|
'asset',
|
||||||
'asset_face',
|
'asset_face',
|
||||||
@@ -434,12 +436,12 @@ export const utils = {
|
|||||||
return person;
|
return person;
|
||||||
},
|
},
|
||||||
|
|
||||||
createFace: async ({ assetId, personId }: { assetId: string; personId: string }) => {
|
createFace: async ({ assetId, personGroupId }: { assetId: string; personGroupId: string }) => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.query('INSERT INTO asset_face ("assetId", "personId") VALUES ($1, $2)', [assetId, personId]);
|
await client.query('INSERT INTO asset_face ("assetId", "personGroupId") VALUES ($1, $2)', [assetId, personGroupId]);
|
||||||
},
|
},
|
||||||
|
|
||||||
setPersonThumbnail: async (personId: string) => {
|
setPersonThumbnail: async (personId: string) => {
|
||||||
@@ -447,7 +449,9 @@ export const utils = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.query(`UPDATE "person" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "id" = $1`, [personId]);
|
await client.query(`UPDATE "person" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "personGroupId" = $1`, [
|
||||||
|
personId,
|
||||||
|
]);
|
||||||
},
|
},
|
||||||
|
|
||||||
createSharedLink: (accessToken: string, dto: SharedLinkCreateDto) =>
|
createSharedLink: (accessToken: string, dto: SharedLinkCreateDto) =>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"about": "About",
|
"about": "About",
|
||||||
|
"accept": "Accept",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"account_settings": "Account Settings",
|
"account_settings": "Account Settings",
|
||||||
"acknowledge": "Acknowledge",
|
"acknowledge": "Acknowledge",
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
"add_to_bottom_bar": "Add to",
|
"add_to_bottom_bar": "Add to",
|
||||||
"add_upload_to_stack": "Add upload to stack",
|
"add_upload_to_stack": "Add upload to stack",
|
||||||
"add_url": "Add URL",
|
"add_url": "Add URL",
|
||||||
|
"add_user": "Add user",
|
||||||
"added_to_archive": "Added to archive",
|
"added_to_archive": "Added to archive",
|
||||||
"added_to_favorites": "Added to favorites",
|
"added_to_favorites": "Added to favorites",
|
||||||
"added_to_favorites_count": "Added {count, number} to favorites",
|
"added_to_favorites_count": "Added {count, number} to favorites",
|
||||||
@@ -734,6 +736,9 @@
|
|||||||
"client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate import/removal is available only before login",
|
"client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate import/removal is available only before login",
|
||||||
"client_cert_title": "SSL client certificate [EXPERIMENTAL]",
|
"client_cert_title": "SSL client certificate [EXPERIMENTAL]",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
|
"cluster_group": "Cluster group",
|
||||||
|
"cluster_group_description": "People are recognized across the photos of everyone in the group",
|
||||||
|
"cluster_group_invite_description": "You have been invited to join this cluster group.",
|
||||||
"collapse": "Collapse",
|
"collapse": "Collapse",
|
||||||
"collapse_all": "Collapse all",
|
"collapse_all": "Collapse all",
|
||||||
"color": "Color",
|
"color": "Color",
|
||||||
@@ -831,6 +836,7 @@
|
|||||||
"date_time_original": "Date/Time Original",
|
"date_time_original": "Date/Time Original",
|
||||||
"day": "Day",
|
"day": "Day",
|
||||||
"days": "Days",
|
"days": "Days",
|
||||||
|
"decline": "Decline",
|
||||||
"deduplicate_all": "Deduplicate All",
|
"deduplicate_all": "Deduplicate All",
|
||||||
"default_quality_subtitle": "Quality used when tapping share. Long press the share button to choose each time.",
|
"default_quality_subtitle": "Quality used when tapping share. Long press the share button to choose each time.",
|
||||||
"default_share_quality": "Default share quality",
|
"default_share_quality": "Default share quality",
|
||||||
@@ -1041,8 +1047,10 @@
|
|||||||
"unable_to_get_comments_number": "Unable to get number of comments",
|
"unable_to_get_comments_number": "Unable to get number of comments",
|
||||||
"unable_to_get_shared_link": "Failed to get shared link",
|
"unable_to_get_shared_link": "Failed to get shared link",
|
||||||
"unable_to_hide_person": "Unable to hide person",
|
"unable_to_hide_person": "Unable to hide person",
|
||||||
|
"unable_to_leave_cluster_group": "Unable to leave cluster group",
|
||||||
"unable_to_link_motion_video": "Unable to link motion video",
|
"unable_to_link_motion_video": "Unable to link motion video",
|
||||||
"unable_to_link_oauth_account": "Unable to link OAuth account",
|
"unable_to_link_oauth_account": "Unable to link OAuth account",
|
||||||
|
"unable_to_load_cluster_group": "Unable to load cluster group",
|
||||||
"unable_to_load_map": "Unable to load map",
|
"unable_to_load_map": "Unable to load map",
|
||||||
"unable_to_load_map_description": "The map requires WebGL to work properly.",
|
"unable_to_load_map_description": "The map requires WebGL to work properly.",
|
||||||
"unable_to_log_out_all_devices": "Unable to log out all devices",
|
"unable_to_log_out_all_devices": "Unable to log out all devices",
|
||||||
@@ -1263,6 +1271,8 @@
|
|||||||
"latitude": "Latitude",
|
"latitude": "Latitude",
|
||||||
"leave": "Leave",
|
"leave": "Leave",
|
||||||
"leave_album": "Leave album",
|
"leave_album": "Leave album",
|
||||||
|
"leave_group": "Leave group",
|
||||||
|
"leave_group_description": "People will no longer be recognized across the photos of everyone in the group. Are you sure you want to continue?",
|
||||||
"lens_model": "Lens model",
|
"lens_model": "Lens model",
|
||||||
"less": "Less",
|
"less": "Less",
|
||||||
"let_others_respond": "Let others respond",
|
"let_others_respond": "Let others respond",
|
||||||
@@ -1369,6 +1379,7 @@
|
|||||||
"manage_media_access_settings": "Open settings",
|
"manage_media_access_settings": "Open settings",
|
||||||
"manage_media_access_subtitle": "Allow the Immich app to manage and move media files.",
|
"manage_media_access_subtitle": "Allow the Immich app to manage and move media files.",
|
||||||
"manage_media_access_title": "Media Management Access",
|
"manage_media_access_title": "Media Management Access",
|
||||||
|
"manage_sharing_with_other_users": "Manage sharing with other users",
|
||||||
"manage_sharing_with_partners": "Manage sharing with partners",
|
"manage_sharing_with_partners": "Manage sharing with partners",
|
||||||
"manage_the_app_settings": "Manage the app settings",
|
"manage_the_app_settings": "Manage the app settings",
|
||||||
"manage_your_account": "Manage your account",
|
"manage_your_account": "Manage your account",
|
||||||
@@ -1775,6 +1786,7 @@
|
|||||||
"removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}",
|
"removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}",
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
"repository": "Repository",
|
"repository": "Repository",
|
||||||
|
"request_received_description": "You have been invited to another group",
|
||||||
"require_password": "Require password",
|
"require_password": "Require password",
|
||||||
"rescan": "Rescan",
|
"rescan": "Rescan",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
@@ -2268,6 +2280,7 @@
|
|||||||
"view_all_users": "View all users",
|
"view_all_users": "View all users",
|
||||||
"view_asset_owners": "View asset owners",
|
"view_asset_owners": "View asset owners",
|
||||||
"view_details": "View Details",
|
"view_details": "View Details",
|
||||||
|
"view_group": "View group",
|
||||||
"view_in_timeline": "View in timeline",
|
"view_in_timeline": "View in timeline",
|
||||||
"view_link": "View link",
|
"view_link": "View link",
|
||||||
"view_name": "View",
|
"view_name": "View",
|
||||||
@@ -2318,6 +2331,7 @@
|
|||||||
"year": "Year",
|
"year": "Year",
|
||||||
"years_ago": "{years, plural, one {# year} other {# years}} ago",
|
"years_ago": "{years, plural, one {# year} other {# years}} ago",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
|
"you": "You",
|
||||||
"you_dont_have_any_shared_links": "You don't have any shared links",
|
"you_dont_have_any_shared_links": "You don't have any shared links",
|
||||||
"your_wifi_name": "Your Wi-Fi name",
|
"your_wifi_name": "Your Wi-Fi name",
|
||||||
"zero_to_clear_rating": "press 0 to clear asset rating",
|
"zero_to_clear_rating": "press 0 to clear asset rating",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
|
|||||||
},
|
},
|
||||||
'UserResponseDto': {'profileChangedAt': _now},
|
'UserResponseDto': {'profileChangedAt': _now},
|
||||||
'AssetResponseDto': {'visibility': 'timeline', 'createdAt': _now, 'isEdited': false},
|
'AssetResponseDto': {'visibility': 'timeline', 'createdAt': _now, 'isEdited': false},
|
||||||
'UserAdminResponseDto': {'profileChangedAt': _now},
|
'UserAdminResponseDto': {'profileChangedAt': _now, 'clusterGroupId': ''},
|
||||||
'LoginResponseDto': {'isOnboarded': false},
|
'LoginResponseDto': {'isOnboarded': false},
|
||||||
'SyncUserV1': {'profileChangedAt': _now, 'hasProfileImage': false},
|
'SyncUserV1': {'profileChangedAt': _now, 'hasProfileImage': false},
|
||||||
'SyncAssetV1': {'isEdited': false},
|
'SyncAssetV1': {'isEdited': false},
|
||||||
|
|||||||
@@ -5861,6 +5861,355 @@
|
|||||||
"x-immich-state": "Stable"
|
"x-immich-state": "Stable"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/cluster-groups/requests": {
|
||||||
|
"get": {
|
||||||
|
"description": "Retrieve the pending requests for the current user to join a cluster group.",
|
||||||
|
"operationId": "getClusterGroupRequests",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ClusterGroupRequestResponseDto"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Retrieve cluster group requests",
|
||||||
|
"tags": [
|
||||||
|
"Cluster groups"
|
||||||
|
],
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v3.2.0",
|
||||||
|
"state": "Added"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-permission": "clusterGroupRequest.read"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/cluster-groups/requests/{id}": {
|
||||||
|
"delete": {
|
||||||
|
"description": "Delete a pending request to join a cluster group.",
|
||||||
|
"operationId": "deleteClusterGroupRequest",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Decline a cluster group request",
|
||||||
|
"tags": [
|
||||||
|
"Cluster groups"
|
||||||
|
],
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v3.2.0",
|
||||||
|
"state": "Added"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-permission": "clusterGroupRequest.delete"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/cluster-groups/requests/{id}/accept": {
|
||||||
|
"post": {
|
||||||
|
"description": "Join the cluster group the request was created for.",
|
||||||
|
"operationId": "acceptClusterGroupRequest",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Accept a cluster group request",
|
||||||
|
"tags": [
|
||||||
|
"Cluster groups"
|
||||||
|
],
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v3.2.0",
|
||||||
|
"state": "Added"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-permission": "clusterGroupRequest.create"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/cluster-groups/{id}/leave": {
|
||||||
|
"post": {
|
||||||
|
"description": "Move the current user into a new cluster group of their own.",
|
||||||
|
"operationId": "leaveClusterGroup",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Leave a cluster group",
|
||||||
|
"tags": [
|
||||||
|
"Cluster groups"
|
||||||
|
],
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v3.2.0",
|
||||||
|
"state": "Added"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-permission": "clusterGroup.leave"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/cluster-groups/{id}/requests": {
|
||||||
|
"get": {
|
||||||
|
"description": "Retrieve the pending requests for other users to join the cluster group.",
|
||||||
|
"operationId": "getClusterGroupRequestsForGroup",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ClusterGroupRequestResponseDto"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Retrieve the requests sent by a cluster group",
|
||||||
|
"tags": [
|
||||||
|
"Cluster groups"
|
||||||
|
],
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v3.2.0",
|
||||||
|
"state": "Added"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-permission": "clusterGroupRequest.read"
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"description": "Ask another user to join the cluster group of the current user.",
|
||||||
|
"operationId": "createClusterGroupRequest",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ClusterGroupRequestCreateDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ClusterGroupRequestResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Create a cluster group request",
|
||||||
|
"tags": [
|
||||||
|
"Cluster groups"
|
||||||
|
],
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v3.2.0",
|
||||||
|
"state": "Added"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-permission": "clusterGroupRequest.create"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/cluster-groups/{id}/users": {
|
||||||
|
"get": {
|
||||||
|
"description": "Retrieve the users that are a member of the cluster group.",
|
||||||
|
"operationId": "getClusterGroupUsers",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/UserResponseDto"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Retrieve the users of a cluster group",
|
||||||
|
"tags": [
|
||||||
|
"Cluster groups"
|
||||||
|
],
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v3.2.0",
|
||||||
|
"state": "Added"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-permission": "clusterGroup.read"
|
||||||
|
}
|
||||||
|
},
|
||||||
"/config": {
|
"/config": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Retrieve the system configuration properties that are visible to logged in users.",
|
"description": "Retrieve the system configuration properties that are visible to logged in users.",
|
||||||
@@ -16764,6 +17113,10 @@
|
|||||||
"name": "Authentication (admin)",
|
"name": "Authentication (admin)",
|
||||||
"description": "Administrative endpoints related to authentication."
|
"description": "Administrative endpoints related to authentication."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Cluster groups",
|
||||||
|
"description": "A cluster group is a set of users whose faces are clustered together, so that a person can be shared between them."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Config (user)",
|
"name": "Config (user)",
|
||||||
"description": "The system configuration properties that are visible to logged in users."
|
"description": "The system configuration properties that are visible to logged in users."
|
||||||
@@ -20142,6 +20495,56 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"ClusterGroupRequestCreateDto": {
|
||||||
|
"properties": {
|
||||||
|
"userId": {
|
||||||
|
"description": "User to invite into the cluster group",
|
||||||
|
"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": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"ClusterGroupRequestResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"clusterGroupId": {
|
||||||
|
"description": "Cluster group the user is invited to join",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"description": "Creation date",
|
||||||
|
"example": "2024-01-01T00:00:00.000Z",
|
||||||
|
"format": "date-time",
|
||||||
|
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"description": "Request 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"
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"description": "User the request was created for",
|
||||||
|
"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": [
|
||||||
|
"clusterGroupId",
|
||||||
|
"createdAt",
|
||||||
|
"id",
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"Colorspace": {
|
"Colorspace": {
|
||||||
"description": "Colorspace",
|
"description": "Colorspace",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -22105,6 +22508,7 @@
|
|||||||
"SystemMessage",
|
"SystemMessage",
|
||||||
"AlbumInvite",
|
"AlbumInvite",
|
||||||
"AlbumUpdate",
|
"AlbumUpdate",
|
||||||
|
"ClusterGroupRequest",
|
||||||
"Custom"
|
"Custom"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -22530,6 +22934,11 @@
|
|||||||
"backup.download",
|
"backup.download",
|
||||||
"backup.upload",
|
"backup.upload",
|
||||||
"backup.delete",
|
"backup.delete",
|
||||||
|
"clusterGroup.read",
|
||||||
|
"clusterGroup.leave",
|
||||||
|
"clusterGroupRequest.create",
|
||||||
|
"clusterGroupRequest.read",
|
||||||
|
"clusterGroupRequest.delete",
|
||||||
"adminConfig.read",
|
"adminConfig.read",
|
||||||
"adminConfig.update",
|
"adminConfig.update",
|
||||||
"userConfig.read",
|
"userConfig.read",
|
||||||
@@ -28040,6 +28449,18 @@
|
|||||||
"avatarColor": {
|
"avatarColor": {
|
||||||
"$ref": "#/components/schemas/UserAvatarColor"
|
"$ref": "#/components/schemas/UserAvatarColor"
|
||||||
},
|
},
|
||||||
|
"clusterGroupId": {
|
||||||
|
"description": "Cluster group the user is a member of",
|
||||||
|
"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",
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v3.2.0",
|
||||||
|
"state": "Added"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"description": "Creation date",
|
"description": "Creation date",
|
||||||
"example": "2024-01-01T00:00:00.000Z",
|
"example": "2024-01-01T00:00:00.000Z",
|
||||||
@@ -28132,6 +28553,7 @@
|
|||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"avatarColor",
|
"avatarColor",
|
||||||
|
"clusterGroupId",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"deletedAt",
|
"deletedAt",
|
||||||
"email",
|
"email",
|
||||||
|
|||||||
@@ -553,6 +553,8 @@ export type UserLicense = {
|
|||||||
};
|
};
|
||||||
export type UserAdminResponseDto = {
|
export type UserAdminResponseDto = {
|
||||||
avatarColor: UserAvatarColor;
|
avatarColor: UserAvatarColor;
|
||||||
|
/** Cluster group the user is a member of */
|
||||||
|
clusterGroupId: string;
|
||||||
/** Creation date */
|
/** Creation date */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
/** Deletion date */
|
/** Deletion date */
|
||||||
@@ -1474,6 +1476,20 @@ export type ValidateAccessTokenResponseDto = {
|
|||||||
/** Authentication status */
|
/** Authentication status */
|
||||||
authStatus: boolean;
|
authStatus: boolean;
|
||||||
};
|
};
|
||||||
|
export type ClusterGroupRequestResponseDto = {
|
||||||
|
/** Cluster group the user is invited to join */
|
||||||
|
clusterGroupId: string;
|
||||||
|
/** Creation date */
|
||||||
|
createdAt: string;
|
||||||
|
/** Request ID */
|
||||||
|
id: string;
|
||||||
|
/** User the request was created for */
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
export type ClusterGroupRequestCreateDto = {
|
||||||
|
/** User to invite into the cluster group */
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
export type UserConfigFFmpegRealtimeDto = {
|
export type UserConfigFFmpegRealtimeDto = {
|
||||||
/** Enable real-time HLS transcoding (alpha) */
|
/** Enable real-time HLS transcoding (alpha) */
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -4867,6 +4883,92 @@ export function validateAccessToken(opts?: Oazapfts.RequestOpts) {
|
|||||||
method: "POST"
|
method: "POST"
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Retrieve cluster group requests
|
||||||
|
*/
|
||||||
|
export function getClusterGroupRequests(opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: ClusterGroupRequestResponseDto[];
|
||||||
|
}>("/cluster-groups/requests", {
|
||||||
|
...opts
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Decline a cluster group request
|
||||||
|
*/
|
||||||
|
export function deleteClusterGroupRequest({ id }: {
|
||||||
|
id: string;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchText(`/cluster-groups/requests/${encodeURIComponent(id)}`, {
|
||||||
|
...opts,
|
||||||
|
method: "DELETE"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Accept a cluster group request
|
||||||
|
*/
|
||||||
|
export function acceptClusterGroupRequest({ id }: {
|
||||||
|
id: string;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchText(`/cluster-groups/requests/${encodeURIComponent(id)}/accept`, {
|
||||||
|
...opts,
|
||||||
|
method: "POST"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Leave a cluster group
|
||||||
|
*/
|
||||||
|
export function leaveClusterGroup({ id }: {
|
||||||
|
id: string;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchText(`/cluster-groups/${encodeURIComponent(id)}/leave`, {
|
||||||
|
...opts,
|
||||||
|
method: "POST"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Retrieve the requests sent by a cluster group
|
||||||
|
*/
|
||||||
|
export function getClusterGroupRequestsForGroup({ id }: {
|
||||||
|
id: string;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: ClusterGroupRequestResponseDto[];
|
||||||
|
}>(`/cluster-groups/${encodeURIComponent(id)}/requests`, {
|
||||||
|
...opts
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Create a cluster group request
|
||||||
|
*/
|
||||||
|
export function createClusterGroupRequest({ id, clusterGroupRequestCreateDto }: {
|
||||||
|
id: string;
|
||||||
|
clusterGroupRequestCreateDto: ClusterGroupRequestCreateDto;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: ClusterGroupRequestResponseDto;
|
||||||
|
}>(`/cluster-groups/${encodeURIComponent(id)}/requests`, oazapfts.json({
|
||||||
|
...opts,
|
||||||
|
method: "PUT",
|
||||||
|
body: clusterGroupRequestCreateDto
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Retrieve the users of a cluster group
|
||||||
|
*/
|
||||||
|
export function getClusterGroupUsers({ id }: {
|
||||||
|
id: string;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: UserResponseDto[];
|
||||||
|
}>(`/cluster-groups/${encodeURIComponent(id)}/users`, {
|
||||||
|
...opts
|
||||||
|
}));
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Get the configuration with user visibility
|
* Get the configuration with user visibility
|
||||||
*/
|
*/
|
||||||
@@ -7485,6 +7587,7 @@ export enum NotificationType {
|
|||||||
SystemMessage = "SystemMessage",
|
SystemMessage = "SystemMessage",
|
||||||
AlbumInvite = "AlbumInvite",
|
AlbumInvite = "AlbumInvite",
|
||||||
AlbumUpdate = "AlbumUpdate",
|
AlbumUpdate = "AlbumUpdate",
|
||||||
|
ClusterGroupRequest = "ClusterGroupRequest",
|
||||||
Custom = "Custom"
|
Custom = "Custom"
|
||||||
}
|
}
|
||||||
export enum UserStatus {
|
export enum UserStatus {
|
||||||
@@ -7562,6 +7665,11 @@ export enum Permission {
|
|||||||
BackupDownload = "backup.download",
|
BackupDownload = "backup.download",
|
||||||
BackupUpload = "backup.upload",
|
BackupUpload = "backup.upload",
|
||||||
BackupDelete = "backup.delete",
|
BackupDelete = "backup.delete",
|
||||||
|
ClusterGroupRead = "clusterGroup.read",
|
||||||
|
ClusterGroupLeave = "clusterGroup.leave",
|
||||||
|
ClusterGroupRequestCreate = "clusterGroupRequest.create",
|
||||||
|
ClusterGroupRequestRead = "clusterGroupRequest.read",
|
||||||
|
ClusterGroupRequestDelete = "clusterGroupRequest.delete",
|
||||||
AdminConfigRead = "adminConfig.read",
|
AdminConfigRead = "adminConfig.read",
|
||||||
AdminConfigUpdate = "adminConfig.update",
|
AdminConfigUpdate = "adminConfig.update",
|
||||||
UserConfigRead = "userConfig.read",
|
UserConfigRead = "userConfig.read",
|
||||||
|
|||||||
@@ -150,6 +150,8 @@ export const endpointTags: Record<ApiTag, string> = {
|
|||||||
[ApiTag.Assets]: 'An asset is an image or video that has been uploaded to Immich.',
|
[ApiTag.Assets]: 'An asset is an image or video that has been uploaded to Immich.',
|
||||||
[ApiTag.Authentication]: 'Endpoints related to user authentication, including OAuth.',
|
[ApiTag.Authentication]: 'Endpoints related to user authentication, including OAuth.',
|
||||||
[ApiTag.AuthenticationAdmin]: 'Administrative endpoints related to authentication.',
|
[ApiTag.AuthenticationAdmin]: 'Administrative endpoints related to authentication.',
|
||||||
|
[ApiTag.ClusterGroups]:
|
||||||
|
'A cluster group is a set of users whose faces are clustered together, so that a person can be shared between them.',
|
||||||
[ApiTag.ConfigUser]: 'The system configuration properties that are visible to logged in users.',
|
[ApiTag.ConfigUser]: 'The system configuration properties that are visible to logged in users.',
|
||||||
[ApiTag.ConfigAdmin]: 'Endpoints to view and modify the full system configuration.',
|
[ApiTag.ConfigAdmin]: 'Endpoints to view and modify the full system configuration.',
|
||||||
[ApiTag.ConfigPublic]: 'The system configuration properties that are visible to everyone.',
|
[ApiTag.ConfigPublic]: 'The system configuration properties that are visible to everyone.',
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Res } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||||
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
|
import { ClusterGroupRequestCreateDto, ClusterGroupRequestResponseDto } from 'src/dtos/cluster-group.dto';
|
||||||
|
import { UserResponseDto } from 'src/dtos/user.dto';
|
||||||
|
import { ApiTag, Permission } from 'src/enum';
|
||||||
|
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||||
|
import { ClusterGroupService } from 'src/services/cluster-group.service';
|
||||||
|
import { UUIDParamDto } from 'src/validation';
|
||||||
|
|
||||||
|
@ApiTags(ApiTag.ClusterGroups)
|
||||||
|
@Controller('cluster-groups')
|
||||||
|
export class ClusterGroupController {
|
||||||
|
constructor(private service: ClusterGroupService) {}
|
||||||
|
|
||||||
|
@Get('requests')
|
||||||
|
@Authenticated({ permission: Permission.ClusterGroupRequestRead })
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Retrieve cluster group requests',
|
||||||
|
description: 'Retrieve the pending requests for the current user to join a cluster group.',
|
||||||
|
history: new HistoryBuilder().added('v3.2.0'),
|
||||||
|
})
|
||||||
|
getClusterGroupRequests(@Auth() auth: AuthDto): Promise<ClusterGroupRequestResponseDto[]> {
|
||||||
|
return this.service.getRequests(auth);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('requests/:id/accept')
|
||||||
|
@Authenticated({ permission: Permission.ClusterGroupRequestCreate })
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Accept a cluster group request',
|
||||||
|
description: 'Join the cluster group the request was created for.',
|
||||||
|
history: new HistoryBuilder().added('v3.2.0'),
|
||||||
|
})
|
||||||
|
acceptClusterGroupRequest(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||||
|
return this.service.acceptRequest(auth, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('requests/:id')
|
||||||
|
@Authenticated({ permission: Permission.ClusterGroupRequestDelete })
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Decline a cluster group request',
|
||||||
|
description: 'Delete a pending request to join a cluster group.',
|
||||||
|
history: new HistoryBuilder().added('v3.2.0'),
|
||||||
|
})
|
||||||
|
deleteClusterGroupRequest(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||||
|
return this.service.deleteRequest(auth, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/requests')
|
||||||
|
@Authenticated({ permission: Permission.ClusterGroupRequestRead })
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Retrieve the requests sent by a cluster group',
|
||||||
|
description: 'Retrieve the pending requests for other users to join the cluster group.',
|
||||||
|
history: new HistoryBuilder().added('v3.2.0'),
|
||||||
|
})
|
||||||
|
getClusterGroupRequestsForGroup(
|
||||||
|
@Auth() auth: AuthDto,
|
||||||
|
@Param() { id }: UUIDParamDto,
|
||||||
|
): Promise<ClusterGroupRequestResponseDto[]> {
|
||||||
|
return this.service.getRequestsForGroup(auth, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/users')
|
||||||
|
@Authenticated({ permission: Permission.ClusterGroupRead })
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Retrieve the users of a cluster group',
|
||||||
|
description: 'Retrieve the users that are a member of the cluster group.',
|
||||||
|
history: new HistoryBuilder().added('v3.2.0'),
|
||||||
|
})
|
||||||
|
getClusterGroupUsers(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserResponseDto[]> {
|
||||||
|
return this.service.getUsers(auth, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/requests')
|
||||||
|
@Authenticated({ permission: Permission.ClusterGroupRequestCreate })
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Create a cluster group request',
|
||||||
|
description: 'Ask another user to join the cluster group of the current user.',
|
||||||
|
history: new HistoryBuilder().added('v3.2.0'),
|
||||||
|
})
|
||||||
|
async createClusterGroupRequest(
|
||||||
|
@Auth() auth: AuthDto,
|
||||||
|
@Param() { id }: UUIDParamDto,
|
||||||
|
@Body() dto: ClusterGroupRequestCreateDto,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
): Promise<ClusterGroupRequestResponseDto> {
|
||||||
|
const { duplicate, value } = await this.service.createRequest(auth, id, dto);
|
||||||
|
res.status(duplicate ? HttpStatus.OK : HttpStatus.CREATED);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/leave')
|
||||||
|
@Authenticated({ permission: Permission.ClusterGroupLeave })
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Leave a cluster group',
|
||||||
|
description: 'Move the current user into a new cluster group of their own.',
|
||||||
|
history: new HistoryBuilder().added('v3.2.0'),
|
||||||
|
})
|
||||||
|
leaveClusterGroup(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||||
|
return this.service.leave(auth, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { AssetMediaController } from 'src/controllers/asset-media.controller';
|
|||||||
import { AssetController } from 'src/controllers/asset.controller';
|
import { AssetController } from 'src/controllers/asset.controller';
|
||||||
import { AuthAdminController } from 'src/controllers/auth-admin.controller';
|
import { AuthAdminController } from 'src/controllers/auth-admin.controller';
|
||||||
import { AuthController } from 'src/controllers/auth.controller';
|
import { AuthController } from 'src/controllers/auth.controller';
|
||||||
|
import { ClusterGroupController } from 'src/controllers/cluster-group.controller';
|
||||||
import { ConfigAdminController } from 'src/controllers/config-admin.controller';
|
import { ConfigAdminController } from 'src/controllers/config-admin.controller';
|
||||||
import { ConfigPublicController } from 'src/controllers/config-public.controller';
|
import { ConfigPublicController } from 'src/controllers/config-public.controller';
|
||||||
import { ConfigUserController } from 'src/controllers/config-user.controller';
|
import { ConfigUserController } from 'src/controllers/config-user.controller';
|
||||||
@@ -52,6 +53,7 @@ export const controllers = [
|
|||||||
AssetMediaController,
|
AssetMediaController,
|
||||||
AuthController,
|
AuthController,
|
||||||
AuthAdminController,
|
AuthAdminController,
|
||||||
|
ClusterGroupController,
|
||||||
ConfigUserController,
|
ConfigUserController,
|
||||||
ConfigAdminController,
|
ConfigAdminController,
|
||||||
ConfigPublicController,
|
ConfigPublicController,
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import { getConfig } from 'src/utils/config';
|
|||||||
|
|
||||||
export interface MoveRequest {
|
export interface MoveRequest {
|
||||||
entityId: string;
|
entityId: string;
|
||||||
|
/** a person is owned, so the owner is needed to save the new path */
|
||||||
|
ownerId?: string;
|
||||||
pathType: PathType;
|
pathType: PathType;
|
||||||
oldPath: string | null;
|
oldPath: string | null;
|
||||||
newPath: string;
|
newPath: string;
|
||||||
@@ -35,6 +37,8 @@ export interface MoveRequest {
|
|||||||
|
|
||||||
export type ThumbnailPathEntity = { id: string; ownerId: string };
|
export type ThumbnailPathEntity = { id: string; ownerId: string };
|
||||||
|
|
||||||
|
export type PersonThumbnailPathEntity = { personGroupId: string; ownerId: string };
|
||||||
|
|
||||||
export type HlsSessionFolder = { ownerId: string; sessionId: string };
|
export type HlsSessionFolder = { ownerId: string; sessionId: string };
|
||||||
|
|
||||||
export type HlsVariantFolder = { ownerId: string; sessionId: string; variantIndex: number };
|
export type HlsVariantFolder = { ownerId: string; sessionId: string; variantIndex: number };
|
||||||
@@ -113,8 +117,8 @@ export class StorageCore {
|
|||||||
return join(StorageCore.getMediaLocation(), folder);
|
return join(StorageCore.getMediaLocation(), folder);
|
||||||
}
|
}
|
||||||
|
|
||||||
static getPersonThumbnailPath(person: ThumbnailPathEntity) {
|
static getPersonThumbnailPath(person: PersonThumbnailPathEntity) {
|
||||||
return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.id}.jpeg`);
|
return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.personGroupId}.jpeg`);
|
||||||
}
|
}
|
||||||
|
|
||||||
static getImagePath(asset: ThumbnailPathEntity, { fileType, format, isEdited }: ImagePathOptions) {
|
static getImagePath(asset: ThumbnailPathEntity, { fileType, format, isEdited }: ImagePathOptions) {
|
||||||
@@ -176,12 +180,13 @@ export class StorageCore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async movePersonFile(person: { id: string; ownerId: string; thumbnailPath: string }, pathType: PersonPathType) {
|
async movePersonFile(person: PersonThumbnailPathEntity & { thumbnailPath: string }, pathType: PersonPathType) {
|
||||||
const { id: entityId, thumbnailPath } = person;
|
const { ownerId, personGroupId, thumbnailPath } = person;
|
||||||
switch (pathType) {
|
switch (pathType) {
|
||||||
case PersonPathType.Face: {
|
case PersonPathType.Face: {
|
||||||
await this.moveFile({
|
await this.moveFile({
|
||||||
entityId,
|
entityId: personGroupId,
|
||||||
|
ownerId,
|
||||||
pathType,
|
pathType,
|
||||||
oldPath: thumbnailPath,
|
oldPath: thumbnailPath,
|
||||||
newPath: StorageCore.getPersonThumbnailPath(person),
|
newPath: StorageCore.getPersonThumbnailPath(person),
|
||||||
@@ -191,7 +196,7 @@ export class StorageCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async moveFile(request: MoveRequest) {
|
async moveFile(request: MoveRequest) {
|
||||||
const { entityId, pathType, oldPath, newPath, assetInfo } = request;
|
const { entityId, ownerId, pathType, oldPath, newPath, assetInfo } = request;
|
||||||
if (!oldPath || oldPath === newPath) {
|
if (!oldPath || oldPath === newPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -264,7 +269,7 @@ export class StorageCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.savePath(pathType, entityId, newPath);
|
await this.savePath(pathType, entityId, newPath, ownerId);
|
||||||
await this.moveRepository.delete(move.id);
|
await this.moveRepository.delete(move.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +322,7 @@ export class StorageCore {
|
|||||||
return { dri, mali };
|
return { dri, mali };
|
||||||
}
|
}
|
||||||
|
|
||||||
private savePath(pathType: PathType, id: string, newPath: string) {
|
private savePath(pathType: PathType, id: string, newPath: string, ownerId?: string) {
|
||||||
switch (pathType) {
|
switch (pathType) {
|
||||||
case AssetPathType.Original: {
|
case AssetPathType.Original: {
|
||||||
return this.assetRepository.update({ id, originalPath: newPath });
|
return this.assetRepository.update({ id, originalPath: newPath });
|
||||||
@@ -333,7 +338,12 @@ export class StorageCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case PersonPathType.Face: {
|
case PersonPathType.Face: {
|
||||||
return this.personRepository.update({ id, thumbnailPath: newPath });
|
if (!ownerId) {
|
||||||
|
this.logger.warn('Unable to save person path without an owner');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.personRepository.update({ ownerId, personGroupId: id, thumbnailPath: newPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
case UserPathType.Profile: {
|
case UserPathType.Profile: {
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ export type User = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type UserAdmin = User & {
|
export type UserAdmin = User & {
|
||||||
|
clusterGroupId: string;
|
||||||
storageLabel: string | null;
|
storageLabel: string | null;
|
||||||
shouldChangePassword: boolean;
|
shouldChangePassword: boolean;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
@@ -241,7 +242,7 @@ export type Exif = Omit<Selectable<AssetExifTable>, 'updatedAt' | 'updateId' | '
|
|||||||
|
|
||||||
export type Person = {
|
export type Person = {
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
id: string;
|
personGroupId: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
updateId: string;
|
updateId: string;
|
||||||
@@ -264,7 +265,7 @@ export type AssetFace = {
|
|||||||
boundingBoxY2: number;
|
boundingBoxY2: number;
|
||||||
imageHeight: number;
|
imageHeight: number;
|
||||||
imageWidth: number;
|
imageWidth: number;
|
||||||
personId: string | null;
|
personGroupId: string | null;
|
||||||
sourceType: SourceType;
|
sourceType: SourceType;
|
||||||
person?: ShallowDehydrateObject<Person> | null;
|
person?: ShallowDehydrateObject<Person> | null;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
@@ -376,6 +377,7 @@ export const columns = {
|
|||||||
userWithPrefix: userWithPrefixColumns,
|
userWithPrefix: userWithPrefixColumns,
|
||||||
userAdmin: [
|
userAdmin: [
|
||||||
...userColumns,
|
...userColumns,
|
||||||
|
'clusterGroupId',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
'deletedAt',
|
'deletedAt',
|
||||||
|
|||||||
@@ -170,8 +170,8 @@ const peopleFromFaces = (faces?: MaybeDehydrated<AssetFace>[]): PersonResponseDt
|
|||||||
const peopleMap: Map<string, PersonResponseDto> = new Map();
|
const peopleMap: Map<string, PersonResponseDto> = new Map();
|
||||||
|
|
||||||
for (const face of faces) {
|
for (const face of faces) {
|
||||||
if (face.person && !peopleMap.has(face.person.id)) {
|
if (face.person && !peopleMap.has(face.person.personGroupId)) {
|
||||||
peopleMap.set(face.person.id, mapPerson(face.person));
|
peopleMap.set(face.person.personGroupId, mapPerson(face.person));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Selectable } from 'kysely';
|
||||||
|
import { createZodDto } from 'nestjs-zod';
|
||||||
|
import { ClusterGroupRequestTable } from 'src/schema/tables/cluster-group-request.table';
|
||||||
|
import { isoDatetimeToDate } from 'src/validation';
|
||||||
|
import z from 'zod';
|
||||||
|
|
||||||
|
const ClusterGroupRequestCreateSchema = z
|
||||||
|
.object({
|
||||||
|
userId: z.uuidv4().describe('User to invite into the cluster group'),
|
||||||
|
})
|
||||||
|
.meta({ id: 'ClusterGroupRequestCreateDto' });
|
||||||
|
|
||||||
|
const ClusterGroupRequestResponseSchema = z
|
||||||
|
.object({
|
||||||
|
id: z.uuidv4().describe('Request ID'),
|
||||||
|
clusterGroupId: z.uuidv4().describe('Cluster group the user is invited to join'),
|
||||||
|
userId: z.uuidv4().describe('User the request was created for'),
|
||||||
|
createdAt: isoDatetimeToDate.describe('Creation date'),
|
||||||
|
})
|
||||||
|
.meta({ id: 'ClusterGroupRequestResponseDto' });
|
||||||
|
|
||||||
|
export class ClusterGroupRequestCreateDto extends createZodDto(ClusterGroupRequestCreateSchema) {}
|
||||||
|
export class ClusterGroupRequestResponseDto extends createZodDto(ClusterGroupRequestResponseSchema) {}
|
||||||
|
|
||||||
|
export function mapClusterGroupRequest(request: Selectable<ClusterGroupRequestTable>): ClusterGroupRequestResponseDto {
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
clusterGroupId: request.clusterGroupId,
|
||||||
|
userId: request.userId,
|
||||||
|
createdAt: request.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -173,7 +173,7 @@ export class PeopleResponseDto extends createZodDto(PeopleResponseSchema) {}
|
|||||||
|
|
||||||
export function mapPerson(person: MaybeDehydrated<Person>): PersonResponseDto {
|
export function mapPerson(person: MaybeDehydrated<Person>): PersonResponseDto {
|
||||||
return {
|
return {
|
||||||
id: person.id,
|
id: person.personGroupId,
|
||||||
name: person.name,
|
name: person.name,
|
||||||
birthDate: asDateString(person.birthDate),
|
birthDate: asDateString(person.birthDate),
|
||||||
thumbnailPath: person.thumbnailPath,
|
thumbnailPath: person.thumbnailPath,
|
||||||
@@ -215,6 +215,6 @@ export function mapFaces(
|
|||||||
): AssetFaceResponseDto {
|
): AssetFaceResponseDto {
|
||||||
return {
|
return {
|
||||||
...mapFacesWithoutPerson(face, edits, assetDimensions),
|
...mapFacesWithoutPerson(face, edits, assetDimensions),
|
||||||
person: face.person?.ownerId === auth.user.id ? mapPerson(face.person) : null,
|
person: face.person ? mapPerson(face.person) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import { User, UserAdmin } from 'src/database';
|
import { User, UserAdmin } from 'src/database';
|
||||||
|
import { HistoryBuilder } from 'src/decorators';
|
||||||
import { pinCodeRegex } from 'src/dtos/auth.dto';
|
import { pinCodeRegex } from 'src/dtos/auth.dto';
|
||||||
import { UserAvatarColor, UserAvatarColorSchema, UserMetadataKey, UserStatusSchema } from 'src/enum';
|
import { UserAvatarColor, UserAvatarColorSchema, UserMetadataKey, UserStatusSchema } from 'src/enum';
|
||||||
import { MaybeDehydrated, UserMetadataItem } from 'src/types';
|
import { MaybeDehydrated, UserMetadataItem } from 'src/types';
|
||||||
@@ -116,6 +117,10 @@ const UserAdminDeleteSchema = z
|
|||||||
export class UserAdminDeleteDto extends createZodDto(UserAdminDeleteSchema) {}
|
export class UserAdminDeleteDto extends createZodDto(UserAdminDeleteSchema) {}
|
||||||
|
|
||||||
const UserAdminResponseSchema = UserResponseSchema.extend({
|
const UserAdminResponseSchema = UserResponseSchema.extend({
|
||||||
|
clusterGroupId: z
|
||||||
|
.uuidv4()
|
||||||
|
.describe('Cluster group the user is a member of')
|
||||||
|
.meta(new HistoryBuilder().added('v3.2.0').getExtensions()),
|
||||||
storageLabel: z.string().nullable().describe('Storage label'),
|
storageLabel: z.string().nullable().describe('Storage label'),
|
||||||
shouldChangePassword: z.boolean().describe('Require password change on next login'),
|
shouldChangePassword: z.boolean().describe('Require password change on next login'),
|
||||||
isAdmin: z.boolean().describe('Is admin user'),
|
isAdmin: z.boolean().describe('Is admin user'),
|
||||||
@@ -139,6 +144,7 @@ export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...mapUser(entity),
|
...mapUser(entity),
|
||||||
|
clusterGroupId: entity.clusterGroupId,
|
||||||
storageLabel: entity.storageLabel,
|
storageLabel: entity.storageLabel,
|
||||||
shouldChangePassword: entity.shouldChangePassword,
|
shouldChangePassword: entity.shouldChangePassword,
|
||||||
isAdmin: entity.isAdmin,
|
isAdmin: entity.isAdmin,
|
||||||
|
|||||||
@@ -161,6 +161,12 @@ export enum Permission {
|
|||||||
BackupUpload = 'backup.upload',
|
BackupUpload = 'backup.upload',
|
||||||
BackupDelete = 'backup.delete',
|
BackupDelete = 'backup.delete',
|
||||||
|
|
||||||
|
ClusterGroupRead = 'clusterGroup.read',
|
||||||
|
ClusterGroupLeave = 'clusterGroup.leave',
|
||||||
|
ClusterGroupRequestCreate = 'clusterGroupRequest.create',
|
||||||
|
ClusterGroupRequestRead = 'clusterGroupRequest.read',
|
||||||
|
ClusterGroupRequestDelete = 'clusterGroupRequest.delete',
|
||||||
|
|
||||||
AdminConfigRead = 'adminConfig.read',
|
AdminConfigRead = 'adminConfig.read',
|
||||||
AdminConfigUpdate = 'adminConfig.update',
|
AdminConfigUpdate = 'adminConfig.update',
|
||||||
|
|
||||||
@@ -1136,6 +1142,7 @@ export enum NotificationType {
|
|||||||
SystemMessage = 'SystemMessage',
|
SystemMessage = 'SystemMessage',
|
||||||
AlbumInvite = 'AlbumInvite',
|
AlbumInvite = 'AlbumInvite',
|
||||||
AlbumUpdate = 'AlbumUpdate',
|
AlbumUpdate = 'AlbumUpdate',
|
||||||
|
ClusterGroupRequest = 'ClusterGroupRequest',
|
||||||
Custom = 'Custom',
|
Custom = 'Custom',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1212,6 +1219,7 @@ export enum ApiTag {
|
|||||||
Memories = 'Memories',
|
Memories = 'Memories',
|
||||||
Notifications = 'Notifications',
|
Notifications = 'Notifications',
|
||||||
NotificationsAdmin = 'Notifications (admin)',
|
NotificationsAdmin = 'Notifications (admin)',
|
||||||
|
ClusterGroups = 'Cluster groups',
|
||||||
Partners = 'Partners',
|
Partners = 'Partners',
|
||||||
People = 'People',
|
People = 'People',
|
||||||
Plugins = 'Plugins',
|
Plugins = 'Plugins',
|
||||||
|
|||||||
@@ -187,13 +187,56 @@ where
|
|||||||
"notification"."id" in ($1)
|
"notification"."id" in ($1)
|
||||||
and "notification"."userId" = $2
|
and "notification"."userId" = $2
|
||||||
|
|
||||||
|
-- AccessRepository.clusterGroup.checkInviteAccess
|
||||||
|
select
|
||||||
|
"cluster_group_request"."clusterGroupId"
|
||||||
|
from
|
||||||
|
"cluster_group_request"
|
||||||
|
where
|
||||||
|
"cluster_group_request"."clusterGroupId" in ($1)
|
||||||
|
and "cluster_group_request"."userId" = $2
|
||||||
|
|
||||||
|
-- AccessRepository.clusterGroup.checkOwnerAccess
|
||||||
|
select
|
||||||
|
"user"."clusterGroupId"
|
||||||
|
from
|
||||||
|
"user"
|
||||||
|
where
|
||||||
|
"user"."clusterGroupId" in ($1)
|
||||||
|
and "user"."id" = $2
|
||||||
|
|
||||||
|
-- AccessRepository.clusterGroupRequest.checkOwnerAccess
|
||||||
|
select
|
||||||
|
"cluster_group_request"."id"
|
||||||
|
from
|
||||||
|
"cluster_group_request"
|
||||||
|
where
|
||||||
|
"cluster_group_request"."id" in ($1)
|
||||||
|
and "cluster_group_request"."userId" = $2
|
||||||
|
|
||||||
|
-- AccessRepository.clusterGroupRequest.checkGroupAccess
|
||||||
|
select
|
||||||
|
"cluster_group_request"."id"
|
||||||
|
from
|
||||||
|
"cluster_group_request"
|
||||||
|
where
|
||||||
|
"cluster_group_request"."id" in ($1)
|
||||||
|
and "cluster_group_request"."clusterGroupId" = (
|
||||||
|
select
|
||||||
|
"user"."clusterGroupId"
|
||||||
|
from
|
||||||
|
"user"
|
||||||
|
where
|
||||||
|
"user"."id" = $2
|
||||||
|
)
|
||||||
|
|
||||||
-- AccessRepository.person.checkOwnerAccess
|
-- AccessRepository.person.checkOwnerAccess
|
||||||
select
|
select
|
||||||
"person"."id"
|
"person"."personGroupId"
|
||||||
from
|
from
|
||||||
"person"
|
"person"
|
||||||
where
|
where
|
||||||
"person"."id" in ($1)
|
"person"."personGroupId" in ($1)
|
||||||
and "person"."ownerId" = $2
|
and "person"."ownerId" = $2
|
||||||
|
|
||||||
-- AccessRepository.person.checkFaceOwnerAccess
|
-- AccessRepository.person.checkFaceOwnerAccess
|
||||||
|
|||||||
@@ -354,9 +354,11 @@ select
|
|||||||
"asset_file"."assetId" = "asset"."id"
|
"asset_file"."assetId" = "asset"."id"
|
||||||
and "asset_file"."type" = $2
|
and "asset_file"."type" = $2
|
||||||
) as agg
|
) as agg
|
||||||
) as "files"
|
) as "files",
|
||||||
|
"user"."clusterGroupId"
|
||||||
from
|
from
|
||||||
"asset"
|
"asset"
|
||||||
|
inner join "user" on "user"."id" = "asset"."ownerId"
|
||||||
where
|
where
|
||||||
"asset"."id" = $3
|
"asset"."id" = $3
|
||||||
|
|
||||||
|
|||||||
@@ -192,7 +192,8 @@ select
|
|||||||
from
|
from
|
||||||
"person"
|
"person"
|
||||||
where
|
where
|
||||||
"asset_face"."personId" = "person"."id"
|
"person"."personGroupId" = "asset_face"."personGroupId"
|
||||||
|
and "person"."ownerId" = "asset"."ownerId"
|
||||||
) as "person" on true
|
) as "person" on true
|
||||||
where
|
where
|
||||||
"asset_face"."assetId" = "asset"."id"
|
"asset_face"."assetId" = "asset"."id"
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
-- NOTE: This file is auto generated by ./sql-generator
|
||||||
|
|
||||||
|
-- ClusterGroupRepository.create
|
||||||
|
insert into
|
||||||
|
"cluster_group"
|
||||||
|
default values
|
||||||
|
returning
|
||||||
|
*
|
||||||
|
|
||||||
|
-- ClusterGroupRepository.hasOtherMembers
|
||||||
|
select
|
||||||
|
"user"."id"
|
||||||
|
from
|
||||||
|
"user"
|
||||||
|
where
|
||||||
|
"user"."clusterGroupId" = $1
|
||||||
|
and "user"."id" != $2
|
||||||
|
and "user"."deletedAt" is null
|
||||||
|
|
||||||
|
-- ClusterGroupRepository.createRequest
|
||||||
|
insert into
|
||||||
|
"cluster_group_request" ("clusterGroupId", "userId")
|
||||||
|
values
|
||||||
|
($1, $2)
|
||||||
|
on conflict ("clusterGroupId", "userId") do update
|
||||||
|
set
|
||||||
|
"clusterGroupId" = $3
|
||||||
|
returning
|
||||||
|
*,
|
||||||
|
(xmax = 0) as "isInserted"
|
||||||
|
|
||||||
|
-- ClusterGroupRepository.getRequest
|
||||||
|
select
|
||||||
|
"cluster_group_request".*
|
||||||
|
from
|
||||||
|
"cluster_group_request"
|
||||||
|
where
|
||||||
|
"cluster_group_request"."id" = $1
|
||||||
|
|
||||||
|
-- ClusterGroupRepository.searchRequests
|
||||||
|
select
|
||||||
|
"cluster_group_request".*
|
||||||
|
from
|
||||||
|
"cluster_group_request"
|
||||||
|
where
|
||||||
|
"cluster_group_request"."userId" = $1
|
||||||
|
and "cluster_group_request"."clusterGroupId" = $2
|
||||||
|
order by
|
||||||
|
"cluster_group_request"."createdAt" asc
|
||||||
|
|
||||||
|
-- ClusterGroupRepository.getUsers
|
||||||
|
select
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"email",
|
||||||
|
"avatarColor",
|
||||||
|
"profileImagePath",
|
||||||
|
"profileChangedAt"
|
||||||
|
from
|
||||||
|
"user"
|
||||||
|
where
|
||||||
|
"user"."clusterGroupId" = $1
|
||||||
|
and "user"."deletedAt" is null
|
||||||
|
order by
|
||||||
|
"user"."id" = $2 desc,
|
||||||
|
"user"."name" asc
|
||||||
|
|
||||||
|
-- ClusterGroupRepository.deleteRequest
|
||||||
|
delete from "cluster_group_request"
|
||||||
|
where
|
||||||
|
"cluster_group_request"."id" = $1
|
||||||
@@ -47,7 +47,8 @@ select
|
|||||||
$1 as "one"
|
$1 as "one"
|
||||||
from
|
from
|
||||||
"asset_face"
|
"asset_face"
|
||||||
inner join "person" on "person"."id" = "asset_face"."personId"
|
inner join "person" on "person"."personGroupId" = "asset_face"."personGroupId"
|
||||||
|
and "person"."ownerId" = "asset"."ownerId"
|
||||||
where
|
where
|
||||||
"asset_face"."assetId" = "asset"."id"
|
"asset_face"."assetId" = "asset"."id"
|
||||||
and "person"."isHidden" = $2
|
and "person"."isHidden" = $2
|
||||||
@@ -86,7 +87,8 @@ select
|
|||||||
$1 as "one"
|
$1 as "one"
|
||||||
from
|
from
|
||||||
"asset_face"
|
"asset_face"
|
||||||
inner join "person" on "person"."id" = "asset_face"."personId"
|
inner join "person" on "person"."personGroupId" = "asset_face"."personGroupId"
|
||||||
|
and "person"."ownerId" = "asset"."ownerId"
|
||||||
where
|
where
|
||||||
"asset_face"."assetId" = "asset"."id"
|
"asset_face"."assetId" = "asset"."id"
|
||||||
and "person"."isHidden" = $2
|
and "person"."isHidden" = $2
|
||||||
|
|||||||
@@ -3,18 +3,53 @@
|
|||||||
-- PersonRepository.reassignFaces
|
-- PersonRepository.reassignFaces
|
||||||
update "asset_face"
|
update "asset_face"
|
||||||
set
|
set
|
||||||
"personId" = $1
|
"personGroupId" = $1
|
||||||
where
|
where
|
||||||
"asset_face"."personId" = $2
|
"asset_face"."personGroupId" = $2
|
||||||
|
|
||||||
-- PersonRepository.delete
|
-- PersonRepository.delete
|
||||||
delete from "person"
|
delete from "person"
|
||||||
where
|
where
|
||||||
"person"."id" in ($1)
|
"ownerId" = $1
|
||||||
|
and "person"."personGroupId" in ($2)
|
||||||
|
returning
|
||||||
|
"personGroupId",
|
||||||
|
"ownerId",
|
||||||
|
"thumbnailPath"
|
||||||
|
|
||||||
|
-- PersonRepository.deleteGroups
|
||||||
|
delete from "person_group"
|
||||||
|
where
|
||||||
|
"person_group"."id" in ($1)
|
||||||
|
|
||||||
|
-- PersonRepository.deleteEmptyGroups
|
||||||
|
delete from "person_group"
|
||||||
|
where
|
||||||
|
not exists (
|
||||||
|
select
|
||||||
|
"person"."personGroupId"
|
||||||
|
from
|
||||||
|
"person"
|
||||||
|
where
|
||||||
|
"person"."personGroupId" = "person_group"."id"
|
||||||
|
)
|
||||||
|
|
||||||
|
-- PersonRepository.deleteOrphanedClusterGroups
|
||||||
|
delete from "cluster_group"
|
||||||
|
where
|
||||||
|
not exists (
|
||||||
|
select
|
||||||
|
"user"."id"
|
||||||
|
from
|
||||||
|
"user"
|
||||||
|
where
|
||||||
|
"user"."clusterGroupId" = "cluster_group"."id"
|
||||||
|
)
|
||||||
|
|
||||||
-- PersonRepository.getFileSamples
|
-- PersonRepository.getFileSamples
|
||||||
select
|
select
|
||||||
"id",
|
"ownerId",
|
||||||
|
"personGroupId",
|
||||||
"thumbnailPath"
|
"thumbnailPath"
|
||||||
from
|
from
|
||||||
"person"
|
"person"
|
||||||
@@ -28,8 +63,9 @@ select
|
|||||||
"person".*
|
"person".*
|
||||||
from
|
from
|
||||||
"person"
|
"person"
|
||||||
inner join "asset_face" on "asset_face"."personId" = "person"."id"
|
inner join "asset_face" on "asset_face"."personGroupId" = "person"."personGroupId"
|
||||||
inner join "asset" on "asset_face"."assetId" = "asset"."id"
|
inner join "asset" on "asset_face"."assetId" = "asset"."id"
|
||||||
|
and "asset"."ownerId" = "person"."ownerId"
|
||||||
and "asset"."visibility" = 'timeline'
|
and "asset"."visibility" = 'timeline'
|
||||||
and "asset"."deletedAt" is null
|
and "asset"."deletedAt" is null
|
||||||
where
|
where
|
||||||
@@ -38,7 +74,8 @@ where
|
|||||||
and "asset_face"."isVisible" is true
|
and "asset_face"."isVisible" is true
|
||||||
and "person"."isHidden" = $2
|
and "person"."isHidden" = $2
|
||||||
group by
|
group by
|
||||||
"person"."id"
|
"person"."ownerId",
|
||||||
|
"person"."personGroupId"
|
||||||
having
|
having
|
||||||
(
|
(
|
||||||
"person"."name" != $3
|
"person"."name" != $3
|
||||||
@@ -72,12 +109,13 @@ select
|
|||||||
"person".*
|
"person".*
|
||||||
from
|
from
|
||||||
"person"
|
"person"
|
||||||
left join "asset_face" on "asset_face"."personId" = "person"."id"
|
left join "asset_face" on "asset_face"."personGroupId" = "person"."personGroupId"
|
||||||
where
|
where
|
||||||
"asset_face"."deletedAt" is null
|
"asset_face"."deletedAt" is null
|
||||||
and "asset_face"."isVisible" is true
|
and "asset_face"."isVisible" is true
|
||||||
group by
|
group by
|
||||||
"person"."id"
|
"person"."ownerId",
|
||||||
|
"person"."personGroupId"
|
||||||
having
|
having
|
||||||
count("asset_face"."assetId") = $1
|
count("asset_face"."assetId") = $1
|
||||||
|
|
||||||
@@ -94,15 +132,16 @@ select
|
|||||||
from
|
from
|
||||||
"person"
|
"person"
|
||||||
where
|
where
|
||||||
"person"."id" = "asset_face"."personId"
|
"person"."personGroupId" = "asset_face"."personGroupId"
|
||||||
|
and "person"."ownerId" = $1
|
||||||
) as obj
|
) as obj
|
||||||
) as "person"
|
) as "person"
|
||||||
from
|
from
|
||||||
"asset_face"
|
"asset_face"
|
||||||
where
|
where
|
||||||
"asset_face"."assetId" = $1
|
"asset_face"."assetId" = $2
|
||||||
and "asset_face"."deletedAt" is null
|
and "asset_face"."deletedAt" is null
|
||||||
and "asset_face"."isVisible" = $2
|
and "asset_face"."isVisible" = $3
|
||||||
order by
|
order by
|
||||||
"asset_face"."boundingBoxX1" asc
|
"asset_face"."boundingBoxX1" asc
|
||||||
|
|
||||||
@@ -119,19 +158,20 @@ select
|
|||||||
from
|
from
|
||||||
"person"
|
"person"
|
||||||
where
|
where
|
||||||
"person"."id" = "asset_face"."personId"
|
"person"."personGroupId" = "asset_face"."personGroupId"
|
||||||
|
and "person"."ownerId" = $1
|
||||||
) as obj
|
) as obj
|
||||||
) as "person"
|
) as "person"
|
||||||
from
|
from
|
||||||
"asset_face"
|
"asset_face"
|
||||||
where
|
where
|
||||||
"asset_face"."id" = $1
|
"asset_face"."id" = $2
|
||||||
and "asset_face"."deletedAt" is null
|
and "asset_face"."deletedAt" is null
|
||||||
|
|
||||||
-- PersonRepository.getFaceForFacialRecognitionJob
|
-- PersonRepository.getFaceForFacialRecognitionJob
|
||||||
select
|
select
|
||||||
"asset_face"."id",
|
"asset_face"."id",
|
||||||
"asset_face"."personId",
|
"asset_face"."personGroupId",
|
||||||
"asset_face"."sourceType",
|
"asset_face"."sourceType",
|
||||||
(
|
(
|
||||||
select
|
select
|
||||||
@@ -141,9 +181,11 @@ select
|
|||||||
select
|
select
|
||||||
"asset"."ownerId",
|
"asset"."ownerId",
|
||||||
"asset"."visibility",
|
"asset"."visibility",
|
||||||
"asset"."fileCreatedAt"
|
"asset"."fileCreatedAt",
|
||||||
|
"user"."clusterGroupId"
|
||||||
from
|
from
|
||||||
"asset"
|
"asset"
|
||||||
|
inner join "user" on "user"."id" = "asset"."ownerId"
|
||||||
where
|
where
|
||||||
"asset"."id" = "asset_face"."assetId"
|
"asset"."id" = "asset_face"."assetId"
|
||||||
) as obj
|
) as obj
|
||||||
@@ -195,16 +237,26 @@ from
|
|||||||
inner join "asset" on "asset_face"."assetId" = "asset"."id"
|
inner join "asset" on "asset_face"."assetId" = "asset"."id"
|
||||||
left join "asset_exif" on "asset_exif"."assetId" = "asset"."id"
|
left join "asset_exif" on "asset_exif"."assetId" = "asset"."id"
|
||||||
where
|
where
|
||||||
"person"."id" = $1
|
"person"."ownerId" = $1
|
||||||
|
and "person"."personGroupId" = $2
|
||||||
and "asset_face"."deletedAt" is null
|
and "asset_face"."deletedAt" is null
|
||||||
|
|
||||||
-- PersonRepository.reassignFace
|
-- PersonRepository.reassignFace
|
||||||
update "asset_face"
|
update "asset_face"
|
||||||
set
|
set
|
||||||
"personId" = $1
|
"personGroupId" = $1
|
||||||
where
|
where
|
||||||
"asset_face"."id" = $2
|
"asset_face"."id" = $2
|
||||||
|
|
||||||
|
-- PersonRepository.getByGroupId
|
||||||
|
select
|
||||||
|
"person".*
|
||||||
|
from
|
||||||
|
"person"
|
||||||
|
where
|
||||||
|
"person"."personGroupId" = $1
|
||||||
|
and "person"."ownerId" = $2
|
||||||
|
|
||||||
-- PersonRepository.getByName
|
-- PersonRepository.getByName
|
||||||
with
|
with
|
||||||
"similarity_threshold" as (
|
"similarity_threshold" as (
|
||||||
@@ -226,7 +278,7 @@ limit
|
|||||||
|
|
||||||
-- PersonRepository.getDistinctNames
|
-- PersonRepository.getDistinctNames
|
||||||
select distinct
|
select distinct
|
||||||
on (lower("person"."name")) "person"."id",
|
on (lower("person"."name")) "person"."personGroupId",
|
||||||
"person"."name"
|
"person"."name"
|
||||||
from
|
from
|
||||||
"person"
|
"person"
|
||||||
@@ -244,10 +296,25 @@ from
|
|||||||
left join "asset" on "asset"."id" = "asset_face"."assetId"
|
left join "asset" on "asset"."id" = "asset_face"."assetId"
|
||||||
and "asset"."visibility" = 'timeline'
|
and "asset"."visibility" = 'timeline'
|
||||||
and "asset"."deletedAt" is null
|
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
|
where
|
||||||
"asset_face"."deletedAt" is null
|
"asset_face"."deletedAt" is null
|
||||||
and "asset_face"."isVisible" is true
|
and "asset_face"."isVisible" is true
|
||||||
and "asset_face"."personId" = $1
|
and "asset_face"."personGroupId" = $3
|
||||||
|
|
||||||
-- PersonRepository.getNumberOfPeople
|
-- PersonRepository.getNumberOfPeople
|
||||||
select
|
select
|
||||||
@@ -267,7 +334,7 @@ where
|
|||||||
from
|
from
|
||||||
"asset_face"
|
"asset_face"
|
||||||
where
|
where
|
||||||
"asset_face"."personId" = "person"."id"
|
"asset_face"."personGroupId" = "person"."personGroupId"
|
||||||
and "asset_face"."deletedAt" is null
|
and "asset_face"."deletedAt" is null
|
||||||
and "asset_face"."isVisible" = $2
|
and "asset_face"."isVisible" = $2
|
||||||
and exists (
|
and exists (
|
||||||
@@ -282,6 +349,164 @@ where
|
|||||||
)
|
)
|
||||||
and "person"."ownerId" = $3
|
and "person"."ownerId" = $3
|
||||||
|
|
||||||
|
-- PersonRepository.createGroup
|
||||||
|
insert into
|
||||||
|
"person_group" ("clusterGroupId")
|
||||||
|
select
|
||||||
|
"user"."clusterGroupId"
|
||||||
|
from
|
||||||
|
"user"
|
||||||
|
where
|
||||||
|
"user"."id" = $1
|
||||||
|
returning
|
||||||
|
*
|
||||||
|
|
||||||
|
-- PersonRepository.reassignCluster
|
||||||
|
begin
|
||||||
|
update "person_group"
|
||||||
|
set
|
||||||
|
"clusterGroupId" = $1
|
||||||
|
where
|
||||||
|
"person_group"."id" in (
|
||||||
|
select
|
||||||
|
"person"."personGroupId"
|
||||||
|
from
|
||||||
|
"person"
|
||||||
|
where
|
||||||
|
"person"."ownerId" = $2
|
||||||
|
)
|
||||||
|
and not exists (
|
||||||
|
select
|
||||||
|
"person"."personGroupId"
|
||||||
|
from
|
||||||
|
"person"
|
||||||
|
where
|
||||||
|
"person"."personGroupId" = "person_group"."id"
|
||||||
|
and "person"."ownerId" != $3
|
||||||
|
)
|
||||||
|
with
|
||||||
|
"shared" as (
|
||||||
|
select distinct
|
||||||
|
"person"."personGroupId" as "oldId"
|
||||||
|
from
|
||||||
|
"person"
|
||||||
|
where
|
||||||
|
"person"."ownerId" = $1
|
||||||
|
and exists (
|
||||||
|
select
|
||||||
|
"other"."personGroupId"
|
||||||
|
from
|
||||||
|
"person" as "other"
|
||||||
|
where
|
||||||
|
"other"."personGroupId" = "person"."personGroupId"
|
||||||
|
and "other"."ownerId" != $2
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"mapping" as materialized (
|
||||||
|
select
|
||||||
|
"shared"."oldId",
|
||||||
|
uuid_generate_v4 () as "newId"
|
||||||
|
from
|
||||||
|
"shared"
|
||||||
|
),
|
||||||
|
"created" as (
|
||||||
|
insert into
|
||||||
|
"person_group" ("id", "clusterGroupId")
|
||||||
|
select
|
||||||
|
"mapping"."newId",
|
||||||
|
$3 as "clusterGroupId"
|
||||||
|
from
|
||||||
|
"mapping"
|
||||||
|
)
|
||||||
|
select
|
||||||
|
"mapping"."oldId",
|
||||||
|
"mapping"."newId"
|
||||||
|
from
|
||||||
|
"mapping"
|
||||||
|
commit
|
||||||
|
|
||||||
|
-- PersonRepository.createGroups
|
||||||
|
insert into
|
||||||
|
"person_group" (
|
||||||
|
"0",
|
||||||
|
"1",
|
||||||
|
"2",
|
||||||
|
"3",
|
||||||
|
"4",
|
||||||
|
"5",
|
||||||
|
"6",
|
||||||
|
"7",
|
||||||
|
"8",
|
||||||
|
"9",
|
||||||
|
"10",
|
||||||
|
"11",
|
||||||
|
"12",
|
||||||
|
"13",
|
||||||
|
"14",
|
||||||
|
"15",
|
||||||
|
"16",
|
||||||
|
"17",
|
||||||
|
"18",
|
||||||
|
"19",
|
||||||
|
"20",
|
||||||
|
"21",
|
||||||
|
"22",
|
||||||
|
"23",
|
||||||
|
"24",
|
||||||
|
"25",
|
||||||
|
"26",
|
||||||
|
"27",
|
||||||
|
"28",
|
||||||
|
"29",
|
||||||
|
"30",
|
||||||
|
"31",
|
||||||
|
"32",
|
||||||
|
"33",
|
||||||
|
"34",
|
||||||
|
"35"
|
||||||
|
)
|
||||||
|
values
|
||||||
|
(
|
||||||
|
$1,
|
||||||
|
$2,
|
||||||
|
$3,
|
||||||
|
$4,
|
||||||
|
$5,
|
||||||
|
$6,
|
||||||
|
$7,
|
||||||
|
$8,
|
||||||
|
$9,
|
||||||
|
$10,
|
||||||
|
$11,
|
||||||
|
$12,
|
||||||
|
$13,
|
||||||
|
$14,
|
||||||
|
$15,
|
||||||
|
$16,
|
||||||
|
$17,
|
||||||
|
$18,
|
||||||
|
$19,
|
||||||
|
$20,
|
||||||
|
$21,
|
||||||
|
$22,
|
||||||
|
$23,
|
||||||
|
$24,
|
||||||
|
$25,
|
||||||
|
$26,
|
||||||
|
$27,
|
||||||
|
$28,
|
||||||
|
$29,
|
||||||
|
$30,
|
||||||
|
$31,
|
||||||
|
$32,
|
||||||
|
$33,
|
||||||
|
$34,
|
||||||
|
$35,
|
||||||
|
$36
|
||||||
|
)
|
||||||
|
returning
|
||||||
|
*
|
||||||
|
|
||||||
-- PersonRepository.refreshFaces
|
-- PersonRepository.refreshFaces
|
||||||
with
|
with
|
||||||
"added_embeddings" as (
|
"added_embeddings" as (
|
||||||
@@ -310,14 +535,15 @@ select
|
|||||||
from
|
from
|
||||||
"person"
|
"person"
|
||||||
where
|
where
|
||||||
"person"."id" = "asset_face"."personId"
|
"person"."personGroupId" = "asset_face"."personGroupId"
|
||||||
|
and "person"."ownerId" = $1
|
||||||
) as obj
|
) as obj
|
||||||
) as "person"
|
) as "person"
|
||||||
from
|
from
|
||||||
"asset_face"
|
"asset_face"
|
||||||
where
|
where
|
||||||
"asset_face"."assetId" in ($1)
|
"asset_face"."assetId" in ($2)
|
||||||
and "asset_face"."personId" in ($2)
|
and "asset_face"."personGroupId" in ($3)
|
||||||
and "asset_face"."deletedAt" is null
|
and "asset_face"."deletedAt" is null
|
||||||
|
|
||||||
-- PersonRepository.getRandomFace
|
-- PersonRepository.getRandomFace
|
||||||
@@ -326,7 +552,7 @@ select
|
|||||||
from
|
from
|
||||||
"asset_face"
|
"asset_face"
|
||||||
where
|
where
|
||||||
"asset_face"."personId" = $1
|
"asset_face"."personGroupId" = $1
|
||||||
and "asset_face"."deletedAt" is null
|
and "asset_face"."deletedAt" is null
|
||||||
and "asset_face"."isVisible" is true
|
and "asset_face"."isVisible" is true
|
||||||
|
|
||||||
@@ -348,15 +574,6 @@ set
|
|||||||
where
|
where
|
||||||
"asset_face"."id" = $2
|
"asset_face"."id" = $2
|
||||||
|
|
||||||
-- PersonRepository.getForPeopleDelete
|
|
||||||
select
|
|
||||||
"id",
|
|
||||||
"thumbnailPath"
|
|
||||||
from
|
|
||||||
"person"
|
|
||||||
where
|
|
||||||
"id" in ($1)
|
|
||||||
|
|
||||||
-- PersonRepository.getForFeatureFaceUpdate
|
-- PersonRepository.getForFeatureFaceUpdate
|
||||||
select
|
select
|
||||||
"asset_face"."id"
|
"asset_face"."id"
|
||||||
@@ -366,4 +583,14 @@ from
|
|||||||
and "asset"."isOffline" = $1
|
and "asset"."isOffline" = $1
|
||||||
where
|
where
|
||||||
"asset_face"."assetId" = $2
|
"asset_face"."assetId" = $2
|
||||||
and "asset_face"."personId" = $3
|
and "asset_face"."personGroupId" = $3
|
||||||
|
|
||||||
|
-- PersonRepository.getForMergePerson
|
||||||
|
select
|
||||||
|
"person".*
|
||||||
|
from
|
||||||
|
"person"
|
||||||
|
where
|
||||||
|
"person"."personGroupId" in ($1)
|
||||||
|
order by
|
||||||
|
"person"."ownerId"
|
||||||
|
|||||||
@@ -218,15 +218,21 @@ with
|
|||||||
"cte" as (
|
"cte" as (
|
||||||
select
|
select
|
||||||
"asset_face"."id",
|
"asset_face"."id",
|
||||||
"asset_face"."personId",
|
"asset_face"."personGroupId",
|
||||||
face_search.embedding <=> $1 as "distance"
|
face_search.embedding <=> $1 as "distance"
|
||||||
from
|
from
|
||||||
"asset_face"
|
"asset_face"
|
||||||
inner join "asset" on "asset"."id" = "asset_face"."assetId"
|
inner join "asset" on "asset"."id" = "asset_face"."assetId"
|
||||||
inner join "face_search" on "face_search"."faceId" = "asset_face"."id"
|
inner join "face_search" on "face_search"."faceId" = "asset_face"."id"
|
||||||
left join "person" on "person"."id" = "asset_face"."personId"
|
|
||||||
where
|
where
|
||||||
"asset"."ownerId" = any ($2::uuid[])
|
"asset"."ownerId" in (
|
||||||
|
select
|
||||||
|
"user"."id"
|
||||||
|
from
|
||||||
|
"user"
|
||||||
|
where
|
||||||
|
"user"."clusterGroupId" = $2
|
||||||
|
)
|
||||||
and "asset"."deletedAt" is null
|
and "asset"."deletedAt" is null
|
||||||
order by
|
order by
|
||||||
"distance"
|
"distance"
|
||||||
@@ -239,7 +245,7 @@ from
|
|||||||
"cte"
|
"cte"
|
||||||
where
|
where
|
||||||
"cte"."distance" <= $4
|
"cte"."distance" <= $4
|
||||||
commit
|
rollback
|
||||||
|
|
||||||
-- SearchRepository.searchPlaces
|
-- SearchRepository.searchPlaces
|
||||||
select
|
select
|
||||||
@@ -849,11 +855,11 @@ where
|
|||||||
"asset_face"."assetId" = "asset"."id"
|
"asset_face"."assetId" = "asset"."id"
|
||||||
and "asset_face"."deletedAt" is null
|
and "asset_face"."deletedAt" is null
|
||||||
and "asset_face"."isVisible" = $2
|
and "asset_face"."isVisible" = $2
|
||||||
and "asset_face"."personId" = any ($3::uuid[])
|
and "asset_face"."personGroupId" = any ($3::uuid[])
|
||||||
group by
|
group by
|
||||||
"asset_face"."assetId"
|
"asset_face"."assetId"
|
||||||
having
|
having
|
||||||
count(distinct "asset_face"."personId") = $4
|
count(distinct "asset_face"."personGroupId") = $4
|
||||||
)
|
)
|
||||||
order by
|
order by
|
||||||
"asset"."fileCreatedAt" desc,
|
"asset"."fileCreatedAt" desc,
|
||||||
@@ -1374,7 +1380,7 @@ where
|
|||||||
"asset_face"."assetId" = "asset"."id"
|
"asset_face"."assetId" = "asset"."id"
|
||||||
and "asset_face"."deletedAt" is null
|
and "asset_face"."deletedAt" is null
|
||||||
and "asset_face"."isVisible" = $3
|
and "asset_face"."isVisible" = $3
|
||||||
and "asset_face"."personId" = any ($4::uuid[])
|
and "asset_face"."personGroupId" = any ($4::uuid[])
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
order by
|
order by
|
||||||
|
|||||||
@@ -536,7 +536,7 @@ order by
|
|||||||
select
|
select
|
||||||
"asset_face"."id",
|
"asset_face"."id",
|
||||||
"assetId",
|
"assetId",
|
||||||
"personId",
|
"personGroupId" as "personId",
|
||||||
"imageWidth",
|
"imageWidth",
|
||||||
"imageHeight",
|
"imageHeight",
|
||||||
"boundingBoxX1",
|
"boundingBoxX1",
|
||||||
@@ -1029,7 +1029,7 @@ order by
|
|||||||
-- SyncRepository.person.getDeletes
|
-- SyncRepository.person.getDeletes
|
||||||
select
|
select
|
||||||
"id",
|
"id",
|
||||||
"personId"
|
"personGroupId" as "personId"
|
||||||
from
|
from
|
||||||
"person_audit" as "person_audit"
|
"person_audit" as "person_audit"
|
||||||
where
|
where
|
||||||
@@ -1041,7 +1041,7 @@ order by
|
|||||||
|
|
||||||
-- SyncRepository.person.getUpserts
|
-- SyncRepository.person.getUpserts
|
||||||
select
|
select
|
||||||
"id",
|
"personGroupId" as "id",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"updatedAt",
|
"updatedAt",
|
||||||
"ownerId",
|
"ownerId",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ select
|
|||||||
"avatarColor",
|
"avatarColor",
|
||||||
"profileImagePath",
|
"profileImagePath",
|
||||||
"profileChangedAt",
|
"profileChangedAt",
|
||||||
|
"clusterGroupId",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"updatedAt",
|
"updatedAt",
|
||||||
"deletedAt",
|
"deletedAt",
|
||||||
@@ -47,6 +48,7 @@ select
|
|||||||
"avatarColor",
|
"avatarColor",
|
||||||
"profileImagePath",
|
"profileImagePath",
|
||||||
"profileChangedAt",
|
"profileChangedAt",
|
||||||
|
"clusterGroupId",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"updatedAt",
|
"updatedAt",
|
||||||
"deletedAt",
|
"deletedAt",
|
||||||
@@ -126,6 +128,7 @@ select
|
|||||||
"avatarColor",
|
"avatarColor",
|
||||||
"profileImagePath",
|
"profileImagePath",
|
||||||
"profileChangedAt",
|
"profileChangedAt",
|
||||||
|
"clusterGroupId",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"updatedAt",
|
"updatedAt",
|
||||||
"deletedAt",
|
"deletedAt",
|
||||||
@@ -165,6 +168,7 @@ select
|
|||||||
"avatarColor",
|
"avatarColor",
|
||||||
"profileImagePath",
|
"profileImagePath",
|
||||||
"profileChangedAt",
|
"profileChangedAt",
|
||||||
|
"clusterGroupId",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"updatedAt",
|
"updatedAt",
|
||||||
"deletedAt",
|
"deletedAt",
|
||||||
@@ -190,6 +194,7 @@ select
|
|||||||
"avatarColor",
|
"avatarColor",
|
||||||
"profileImagePath",
|
"profileImagePath",
|
||||||
"profileChangedAt",
|
"profileChangedAt",
|
||||||
|
"clusterGroupId",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"updatedAt",
|
"updatedAt",
|
||||||
"deletedAt",
|
"deletedAt",
|
||||||
@@ -237,6 +242,7 @@ select
|
|||||||
"avatarColor",
|
"avatarColor",
|
||||||
"profileImagePath",
|
"profileImagePath",
|
||||||
"profileChangedAt",
|
"profileChangedAt",
|
||||||
|
"clusterGroupId",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"updatedAt",
|
"updatedAt",
|
||||||
"deletedAt",
|
"deletedAt",
|
||||||
@@ -275,6 +281,7 @@ select
|
|||||||
"avatarColor",
|
"avatarColor",
|
||||||
"profileImagePath",
|
"profileImagePath",
|
||||||
"profileChangedAt",
|
"profileChangedAt",
|
||||||
|
"clusterGroupId",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"updatedAt",
|
"updatedAt",
|
||||||
"deletedAt",
|
"deletedAt",
|
||||||
|
|||||||
@@ -420,23 +420,97 @@ class MemoryAccess {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ClusterGroupAccess {
|
||||||
|
constructor(private db: Kysely<DB>) {}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
|
||||||
|
@ChunkedSet({ paramIndex: 1 })
|
||||||
|
checkInviteAccess(userId: string, clusterGroupIds: Set<string>) {
|
||||||
|
if (clusterGroupIds.size === 0) {
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.db
|
||||||
|
.selectFrom('cluster_group_request')
|
||||||
|
.select('cluster_group_request.clusterGroupId')
|
||||||
|
.where('cluster_group_request.clusterGroupId', 'in', [...clusterGroupIds])
|
||||||
|
.where('cluster_group_request.userId', '=', userId)
|
||||||
|
.execute()
|
||||||
|
.then((requests) => new Set(requests.map((request) => request.clusterGroupId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
|
||||||
|
@ChunkedSet({ paramIndex: 1 })
|
||||||
|
async checkOwnerAccess(userId: string, clusterGroupIds: Set<string>) {
|
||||||
|
if (clusterGroupIds.size === 0) {
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.db
|
||||||
|
.selectFrom('user')
|
||||||
|
.select('user.clusterGroupId')
|
||||||
|
.where('user.clusterGroupId', 'in', [...clusterGroupIds])
|
||||||
|
.where('user.id', '=', userId)
|
||||||
|
.execute()
|
||||||
|
.then((users) => new Set(users.map((user) => user.clusterGroupId)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ClusterGroupRequestAccess {
|
||||||
|
constructor(private db: Kysely<DB>) {}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
|
||||||
|
@ChunkedSet({ paramIndex: 1 })
|
||||||
|
checkOwnerAccess(userId: string, clusterGroupRequestIds: Set<string>) {
|
||||||
|
if (clusterGroupRequestIds.size === 0) {
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.db
|
||||||
|
.selectFrom('cluster_group_request')
|
||||||
|
.select('cluster_group_request.id')
|
||||||
|
.where('cluster_group_request.id', 'in', [...clusterGroupRequestIds])
|
||||||
|
.where('cluster_group_request.userId', '=', userId)
|
||||||
|
.execute()
|
||||||
|
.then((requests) => new Set(requests.map(({ id }) => id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
|
||||||
|
@ChunkedSet({ paramIndex: 1 })
|
||||||
|
checkGroupAccess(userId: string, clusterGroupRequestIds: Set<string>) {
|
||||||
|
if (clusterGroupRequestIds.size === 0) {
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.db
|
||||||
|
.selectFrom('cluster_group_request')
|
||||||
|
.select('cluster_group_request.id')
|
||||||
|
.where('cluster_group_request.id', 'in', [...clusterGroupRequestIds])
|
||||||
|
.where('cluster_group_request.clusterGroupId', '=', (eb) =>
|
||||||
|
eb.selectFrom('user').select('user.clusterGroupId').where('user.id', '=', userId),
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
.then((requests) => new Set(requests.map(({ id }) => id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class PersonAccess {
|
class PersonAccess {
|
||||||
constructor(private db: Kysely<DB>) {}
|
constructor(private db: Kysely<DB>) {}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
|
||||||
@ChunkedSet({ paramIndex: 1 })
|
@ChunkedSet({ paramIndex: 1 })
|
||||||
async checkOwnerAccess(userId: string, personIds: Set<string>) {
|
async checkOwnerAccess(userId: string, personGroupIds: Set<string>) {
|
||||||
if (personIds.size === 0) {
|
if (personGroupIds.size === 0) {
|
||||||
return new Set<string>();
|
return new Set<string>();
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('person')
|
.selectFrom('person')
|
||||||
.select('person.id')
|
.select('person.personGroupId')
|
||||||
.where('person.id', 'in', [...personIds])
|
.where('person.personGroupId', 'in', [...personGroupIds])
|
||||||
.where('person.ownerId', '=', userId)
|
.where('person.ownerId', '=', userId)
|
||||||
.execute()
|
.execute()
|
||||||
.then((persons) => new Set(persons.map((person) => person.id)));
|
.then((persons) => new Set(persons.map((person) => person.personGroupId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
|
||||||
@@ -526,6 +600,8 @@ export class AccessRepository {
|
|||||||
duplicate: DuplicateAccess;
|
duplicate: DuplicateAccess;
|
||||||
memory: MemoryAccess;
|
memory: MemoryAccess;
|
||||||
notification: NotificationAccess;
|
notification: NotificationAccess;
|
||||||
|
clusterGroup: ClusterGroupAccess;
|
||||||
|
clusterGroupRequest: ClusterGroupRequestAccess;
|
||||||
person: PersonAccess;
|
person: PersonAccess;
|
||||||
partner: PartnerAccess;
|
partner: PartnerAccess;
|
||||||
session: SessionAccess;
|
session: SessionAccess;
|
||||||
@@ -542,6 +618,8 @@ export class AccessRepository {
|
|||||||
this.duplicate = new DuplicateAccess(db);
|
this.duplicate = new DuplicateAccess(db);
|
||||||
this.memory = new MemoryAccess(db);
|
this.memory = new MemoryAccess(db);
|
||||||
this.notification = new NotificationAccess(db);
|
this.notification = new NotificationAccess(db);
|
||||||
|
this.clusterGroup = new ClusterGroupAccess(db);
|
||||||
|
this.clusterGroupRequest = new ClusterGroupRequestAccess(db);
|
||||||
this.person = new PersonAccess(db);
|
this.person = new PersonAccess(db);
|
||||||
this.partner = new PartnerAccess(db);
|
this.partner = new PartnerAccess(db);
|
||||||
this.session = new SessionAccess(db);
|
this.session = new SessionAccess(db);
|
||||||
|
|||||||
@@ -151,6 +151,8 @@ export class AssetJobRepository {
|
|||||||
.select(columns.asset)
|
.select(columns.asset)
|
||||||
.select(withFaces)
|
.select(withFaces)
|
||||||
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
|
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
|
||||||
|
.innerJoin('user', 'user.id', 'asset.ownerId')
|
||||||
|
.select(['user.clusterGroupId'])
|
||||||
.where('asset.id', '=', id)
|
.where('asset.id', '=', id)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
anyUuid,
|
anyUuid,
|
||||||
asUuid,
|
asUuid,
|
||||||
hasPeople,
|
hasPeople,
|
||||||
|
inSharedAlbum,
|
||||||
removeUndefinedKeys,
|
removeUndefinedKeys,
|
||||||
truncatedDate,
|
truncatedDate,
|
||||||
unnest,
|
unnest,
|
||||||
@@ -124,7 +125,7 @@ interface AssetGetByChecksumOptions {
|
|||||||
|
|
||||||
interface GetByIdsRelations {
|
interface GetByIdsRelations {
|
||||||
exifInfo?: boolean;
|
exifInfo?: boolean;
|
||||||
faces?: { person?: boolean; withDeleted?: boolean };
|
faces?: { person?: boolean; withDeleted?: boolean; viewingUserId?: string };
|
||||||
files?: boolean;
|
files?: boolean;
|
||||||
library?: boolean;
|
library?: boolean;
|
||||||
owner?: boolean;
|
owner?: boolean;
|
||||||
@@ -513,12 +514,12 @@ export class AssetRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [[DummyValue.UUID]] })
|
@GenerateSql({ params: [[DummyValue.UUID]] })
|
||||||
@ChunkedArray()
|
@ChunkedArray({ paramIndex: 0 })
|
||||||
getByIdsWithAllRelationsButStacks(ids: string[]) {
|
getByIdsWithAllRelationsButStacks(ids: string[], viewingUserId?: string) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset')
|
.selectFrom('asset')
|
||||||
.selectAll('asset')
|
.selectAll('asset')
|
||||||
.select(withFacesAndPeople)
|
.select(withFacesAndPeople({ viewingUserId }))
|
||||||
.select(withTags)
|
.select(withTags)
|
||||||
.$call(withExif)
|
.$call(withExif)
|
||||||
.where('asset.id', '=', anyUuid(ids))
|
.where('asset.id', '=', anyUuid(ids))
|
||||||
@@ -577,7 +578,11 @@ export class AssetRepository {
|
|||||||
.selectAll('asset')
|
.selectAll('asset')
|
||||||
.where('asset.id', '=', asUuid(id))
|
.where('asset.id', '=', asUuid(id))
|
||||||
.$if(!!exifInfo, withExif)
|
.$if(!!exifInfo, withExif)
|
||||||
.$if(!!faces, (qb) => qb.select(faces?.person ? withFacesAndPeople : withFaces).$narrowType<{ faces: NotNull }>())
|
.$if(!!faces, (qb) =>
|
||||||
|
qb
|
||||||
|
.select(faces?.person ? withFacesAndPeople({ viewingUserId: faces.viewingUserId! }) : withFaces)
|
||||||
|
.$narrowType<{ faces: NotNull }>(),
|
||||||
|
)
|
||||||
.$if(!!library, (qb) => qb.select(withLibrary))
|
.$if(!!library, (qb) => qb.select(withLibrary))
|
||||||
.$if(!!owner, (qb) => qb.select(withOwner))
|
.$if(!!owner, (qb) => qb.select(withOwner))
|
||||||
.$if(!!smartSearch, withSmartSearch)
|
.$if(!!smartSearch, withSmartSearch)
|
||||||
@@ -639,12 +644,12 @@ export class AssetRepository {
|
|||||||
.selectFrom('asset')
|
.selectFrom('asset')
|
||||||
.selectAll('asset')
|
.selectAll('asset')
|
||||||
.$call(withExif)
|
.$call(withExif)
|
||||||
.$call((qb) => qb.select(withFacesAndPeople))
|
.$call((qb) => qb.select(withFaces))
|
||||||
.$call((qb) => qb.select(withEdits))
|
.$call((qb) => qb.select(withEdits))
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.getById(asset.id, { exifInfo: true, faces: { person: true }, edits: true });
|
return this.getById(asset.id, { exifInfo: true, faces: {}, edits: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(asset: { id: string }): Promise<void> {
|
async remove(asset: { id: string }): Promise<void> {
|
||||||
@@ -743,8 +748,8 @@ export class AssetRepository {
|
|||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [{}] })
|
@GenerateSql({ params: [{}, { user: { id: DummyValue.UUID } }] })
|
||||||
async getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]> {
|
async getTimeBuckets(options: TimeBucketOptions, auth: AuthDto): Promise<TimeBucketItem[]> {
|
||||||
return this.db
|
return this.db
|
||||||
.with('asset', (qb) =>
|
.with('asset', (qb) =>
|
||||||
qb
|
qb
|
||||||
@@ -781,7 +786,13 @@ export class AssetRepository {
|
|||||||
)
|
)
|
||||||
.where((eb) => eb.or([eb('asset.stackId', 'is', null), eb(eb.table('stack'), 'is not', null)])),
|
.where((eb) => eb.or([eb('asset.stackId', 'is', null), eb(eb.table('stack'), 'is not', null)])),
|
||||||
)
|
)
|
||||||
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
|
.$if(!!options.userIds, (qb) =>
|
||||||
|
qb.where((eb) => {
|
||||||
|
// TODO this should become a shared `hasAccess` style helper once implement sharing in more places
|
||||||
|
const isOwner = eb('asset.ownerId', '=', anyUuid(options.userIds!));
|
||||||
|
return options.personId ? eb.or([isOwner, inSharedAlbum(eb, auth.user.id)]) : isOwner;
|
||||||
|
}),
|
||||||
|
)
|
||||||
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
|
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
|
||||||
.$if(!!options.assetType, (qb) => qb.where('asset.type', '=', options.assetType!))
|
.$if(!!options.assetType, (qb) => qb.where('asset.type', '=', options.assetType!))
|
||||||
.$if(options.isDuplicate !== undefined, (qb) =>
|
.$if(options.isDuplicate !== undefined, (qb) =>
|
||||||
@@ -867,7 +878,12 @@ export class AssetRepository {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
|
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
|
||||||
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
|
.$if(!!options.userIds, (qb) =>
|
||||||
|
qb.where((eb) => {
|
||||||
|
const isOwner = eb('asset.ownerId', '=', anyUuid(options.userIds!));
|
||||||
|
return options.personId ? eb.or([isOwner, inSharedAlbum(eb, auth.user.id)]) : isOwner;
|
||||||
|
}),
|
||||||
|
)
|
||||||
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
|
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
|
||||||
.$if(!!options.withStacked, (qb) =>
|
.$if(!!options.withStacked, (qb) =>
|
||||||
qb
|
qb
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Insertable, Kysely, sql } from 'kysely';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { columns } from 'src/database';
|
||||||
|
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||||
|
import { DB } from 'src/schema';
|
||||||
|
import { ClusterGroupRequestTable } from 'src/schema/tables/cluster-group-request.table';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ClusterGroupRepository {
|
||||||
|
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||||
|
|
||||||
|
@GenerateSql()
|
||||||
|
create() {
|
||||||
|
return this.db.insertInto('cluster_group').defaultValues().returningAll().executeTakeFirstOrThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [{ clusterGroupId: DummyValue.UUID, userId: DummyValue.UUID }] })
|
||||||
|
async hasOtherMembers({ clusterGroupId, userId }: { clusterGroupId: string; userId: string }): Promise<boolean> {
|
||||||
|
const member = await this.db
|
||||||
|
.selectFrom('user')
|
||||||
|
.select('user.id')
|
||||||
|
.where('user.clusterGroupId', '=', clusterGroupId)
|
||||||
|
.where('user.id', '!=', userId)
|
||||||
|
.where('user.deletedAt', 'is', null)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
return !!member;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [{ clusterGroupId: DummyValue.UUID, userId: DummyValue.UUID }] })
|
||||||
|
createRequest(request: Insertable<ClusterGroupRequestTable>) {
|
||||||
|
return this.db
|
||||||
|
.insertInto('cluster_group_request')
|
||||||
|
.values(request)
|
||||||
|
.onConflict((oc) =>
|
||||||
|
// the update is pointless, but required for the query to return the conflicting row
|
||||||
|
oc.columns(['clusterGroupId', 'userId']).doUpdateSet({ clusterGroupId: request.clusterGroupId }),
|
||||||
|
)
|
||||||
|
.returningAll()
|
||||||
|
.returning(sql<boolean>`(xmax = 0)`.as('isInserted'))
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
|
getRequest(id: string) {
|
||||||
|
return this.db
|
||||||
|
.selectFrom('cluster_group_request')
|
||||||
|
.selectAll('cluster_group_request')
|
||||||
|
.where('cluster_group_request.id', '=', id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [{ userId: DummyValue.UUID, clusterGroupId: DummyValue.UUID }] })
|
||||||
|
searchRequests({ userId, clusterGroupId }: { userId?: string; clusterGroupId?: string } = {}) {
|
||||||
|
return this.db
|
||||||
|
.selectFrom('cluster_group_request')
|
||||||
|
.selectAll('cluster_group_request')
|
||||||
|
.$if(!!userId, (qb) => qb.where('cluster_group_request.userId', '=', userId!))
|
||||||
|
.$if(!!clusterGroupId, (qb) => qb.where('cluster_group_request.clusterGroupId', '=', clusterGroupId!))
|
||||||
|
.orderBy('cluster_group_request.createdAt', 'asc')
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [{ clusterGroupId: DummyValue.UUID, userId: DummyValue.UUID }] })
|
||||||
|
getUsers({ clusterGroupId, userId }: { clusterGroupId: string; userId: string }) {
|
||||||
|
return this.db
|
||||||
|
.selectFrom('user')
|
||||||
|
.select(columns.user)
|
||||||
|
.where('user.clusterGroupId', '=', clusterGroupId)
|
||||||
|
.where('user.deletedAt', 'is', null)
|
||||||
|
.orderBy((eb) => eb('user.id', '=', userId), 'desc')
|
||||||
|
.orderBy('user.name', 'asc')
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
|
async deleteRequest(id: string): Promise<void> {
|
||||||
|
await this.db.deleteFrom('cluster_group_request').where('cluster_group_request.id', '=', id).execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,9 @@ type EventMap = {
|
|||||||
AlbumUpdate: [{ id: string; userIds: string[]; recipientIds: string[] }];
|
AlbumUpdate: [{ id: string; userIds: string[]; recipientIds: string[] }];
|
||||||
AlbumInvite: [{ id: string; userId: string; senderName: string }];
|
AlbumInvite: [{ id: string; userId: string; senderName: string }];
|
||||||
|
|
||||||
|
// cluster group events
|
||||||
|
ClusterGroupRequest: [{ clusterGroupId: string; userId: string; senderName: string }];
|
||||||
|
|
||||||
// asset events
|
// asset events
|
||||||
AssetCreate: [{ asset: Pick<Asset, 'id' | 'ownerId'>; file?: UploadFile }];
|
AssetCreate: [{ asset: Pick<Asset, 'id' | 'ownerId'>; file?: UploadFile }];
|
||||||
AssetTag: [{ assetId: string; userId: string }];
|
AssetTag: [{ assetId: string; userId: string }];
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AppRepository } from 'src/repositories/app.repository';
|
|||||||
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
|
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
|
||||||
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
|
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
|
||||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||||
|
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { CronRepository } from 'src/repositories/cron.repository';
|
import { CronRepository } from 'src/repositories/cron.repository';
|
||||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||||
@@ -83,6 +84,7 @@ export const repositories = [
|
|||||||
NotificationRepository,
|
NotificationRepository,
|
||||||
OAuthRepository,
|
OAuthRepository,
|
||||||
OcrRepository,
|
OcrRepository,
|
||||||
|
ClusterGroupRepository,
|
||||||
PartnerRepository,
|
PartnerRepository,
|
||||||
PersonRepository,
|
PersonRepository,
|
||||||
PluginRepository,
|
PluginRepository,
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ describe(MediaRepository.name, () => {
|
|||||||
const baseFace: AssetFace = {
|
const baseFace: AssetFace = {
|
||||||
id: 'face-1',
|
id: 'face-1',
|
||||||
assetId: 'asset-1',
|
assetId: 'asset-1',
|
||||||
personId: 'person-1',
|
personGroupId: 'person-1',
|
||||||
boundingBoxX1: 100,
|
boundingBoxX1: 100,
|
||||||
boundingBoxY1: 100,
|
boundingBoxY1: 100,
|
||||||
boundingBoxX2: 200,
|
boundingBoxX2: 200,
|
||||||
|
|||||||
@@ -73,7 +73,11 @@ export class MemoryRepository implements IBulkAsset {
|
|||||||
eb.exists(
|
eb.exists(
|
||||||
eb
|
eb
|
||||||
.selectFrom('asset_face')
|
.selectFrom('asset_face')
|
||||||
.innerJoin('person', 'person.id', 'asset_face.personId')
|
.innerJoin('person', (join) =>
|
||||||
|
join
|
||||||
|
.onRef('person.personGroupId', '=', 'asset_face.personGroupId')
|
||||||
|
.onRef('person.ownerId', '=', 'asset.ownerId'),
|
||||||
|
)
|
||||||
.select((eb) => eb.val(1).as('one'))
|
.select((eb) => eb.val(1).as('one'))
|
||||||
.whereRef('asset_face.assetId', '=', 'asset.id')
|
.whereRef('asset_face.assetId', '=', 'asset.id')
|
||||||
.where('person.isHidden', '=', true),
|
.where('person.isHidden', '=', true),
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import { AssetFileType, AssetVisibility, SourceType, UserMetadataKey } from 'src
|
|||||||
import { DB } from 'src/schema';
|
import { DB } from 'src/schema';
|
||||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||||
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
|
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
|
||||||
|
import { PersonGroupTable } from 'src/schema/tables/person-group.table';
|
||||||
import { PersonTable } from 'src/schema/tables/person.table';
|
import { PersonTable } from 'src/schema/tables/person.table';
|
||||||
import { dummy, removeUndefinedKeys, withFilePath } from 'src/utils/database';
|
import { asUuid, dummy, inSharedAlbum, removeUndefinedKeys, withFilePath } from 'src/utils/database';
|
||||||
import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
|
import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
|
||||||
|
|
||||||
export interface PersonSearchOptions {
|
export interface PersonSearchOptions {
|
||||||
@@ -22,19 +23,20 @@ export interface PersonNameSearchOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PersonNameResponse {
|
export interface PersonNameResponse {
|
||||||
id: string;
|
personGroupId: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssetFaceId {
|
export interface AssetFaceId {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
personId: string;
|
personGroupId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateFacesData {
|
export interface UpdateFacesData {
|
||||||
oldPersonId?: string;
|
oldPersonGroupId?: string;
|
||||||
faceIds?: string[];
|
faceIds?: string[];
|
||||||
newPersonId: string;
|
ownerId?: string;
|
||||||
|
newPersonGroupId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PersonStatistics {
|
export interface PersonStatistics {
|
||||||
@@ -53,17 +55,34 @@ export interface GetAllPeopleOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GetAllFacesOptions {
|
export interface GetAllFacesOptions {
|
||||||
personId?: string | null;
|
personGroupId?: string | null;
|
||||||
assetId?: string;
|
assetId?: string;
|
||||||
sourceType?: SourceType;
|
sourceType?: SourceType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UnassignFacesOptions = DeleteFacesOptions;
|
export type UnassignFacesOptions = DeleteFacesOptions;
|
||||||
|
|
||||||
const withPerson = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
|
export type GetFacesOptions = WithPersonOptions & { isVisible?: boolean };
|
||||||
return jsonObjectFrom(
|
|
||||||
eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_face.personId'),
|
/** a person is identified by its owner and the group it belongs to */
|
||||||
).as('person');
|
export type PersonId = { ownerId: string; personGroupId: string };
|
||||||
|
|
||||||
|
export type ReassignCluster = { userId: string; newClusterId: string };
|
||||||
|
|
||||||
|
export type WithPersonOptions = {
|
||||||
|
/** whose version of the person to select */
|
||||||
|
viewingUserId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const withPerson = ({ viewingUserId }: WithPersonOptions) => {
|
||||||
|
return (eb: ExpressionBuilder<DB, 'asset_face'>) =>
|
||||||
|
jsonObjectFrom(
|
||||||
|
eb
|
||||||
|
.selectFrom('person')
|
||||||
|
.selectAll('person')
|
||||||
|
.whereRef('person.personGroupId', '=', 'asset_face.personGroupId')
|
||||||
|
.where('person.ownerId', '=', viewingUserId),
|
||||||
|
).as('person');
|
||||||
};
|
};
|
||||||
|
|
||||||
const withFaceSearch = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
|
const withFaceSearch = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
|
||||||
@@ -76,13 +95,18 @@ const withFaceSearch = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
|
|||||||
export class PersonRepository {
|
export class PersonRepository {
|
||||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||||
|
|
||||||
@GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] })
|
@GenerateSql({ params: [{ oldPersonGroupId: DummyValue.UUID, newPersonGroupId: DummyValue.UUID }] })
|
||||||
async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise<number> {
|
async reassignFaces({ oldPersonGroupId, faceIds, ownerId, newPersonGroupId }: UpdateFacesData): Promise<number> {
|
||||||
const result = await this.db
|
const result = await this.db
|
||||||
.updateTable('asset_face')
|
.updateTable('asset_face')
|
||||||
.set({ personId: newPersonId })
|
.set({ personGroupId: newPersonGroupId })
|
||||||
.$if(!!oldPersonId, (qb) => qb.where('asset_face.personId', '=', oldPersonId!))
|
.$if(!!oldPersonGroupId, (qb) => qb.where('asset_face.personGroupId', '=', oldPersonGroupId!))
|
||||||
.$if(!!faceIds, (qb) => qb.where('asset_face.id', 'in', faceIds!))
|
.$if(!!faceIds, (qb) => qb.where('asset_face.id', 'in', faceIds!))
|
||||||
|
.$if(!!ownerId, (qb) =>
|
||||||
|
qb.where('asset_face.personGroupId', 'in', (eb) =>
|
||||||
|
eb.selectFrom('person').select('person.personGroupId').where('person.ownerId', '=', ownerId!),
|
||||||
|
),
|
||||||
|
)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
return Number(result.numChangedRows ?? 0);
|
return Number(result.numChangedRows ?? 0);
|
||||||
@@ -91,19 +115,64 @@ export class PersonRepository {
|
|||||||
async unassignFaces({ sourceType }: UnassignFacesOptions): Promise<void> {
|
async unassignFaces({ sourceType }: UnassignFacesOptions): Promise<void> {
|
||||||
await this.db
|
await this.db
|
||||||
.updateTable('asset_face')
|
.updateTable('asset_face')
|
||||||
.set({ personId: null })
|
.set({ personGroupId: null })
|
||||||
.where('asset_face.sourceType', '=', sourceType)
|
.where('asset_face.sourceType', '=', sourceType)
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.UUID] })
|
||||||
|
@Chunked()
|
||||||
|
async delete(personGroupIds: string[], ownerId?: string) {
|
||||||
|
if (personGroupIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.db
|
||||||
|
.deleteFrom('person')
|
||||||
|
.$if(!!ownerId, (qb) => qb.where('ownerId', '=', ownerId!))
|
||||||
|
.where('person.personGroupId', 'in', personGroupIds)
|
||||||
|
.returning(['personGroupId', 'ownerId', 'thumbnailPath'])
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [[DummyValue.UUID]] })
|
@GenerateSql({ params: [[DummyValue.UUID]] })
|
||||||
@Chunked()
|
@Chunked()
|
||||||
async delete(ids: string[]): Promise<void> {
|
async deleteGroups(ids: string[]): Promise<void> {
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.db.deleteFrom('person').where('person.id', 'in', ids).execute();
|
await this.db.deleteFrom('person_group').where('person_group.id', 'in', ids).execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql()
|
||||||
|
async deleteEmptyGroups(): Promise<number> {
|
||||||
|
const result = await this.db
|
||||||
|
.deleteFrom('person_group')
|
||||||
|
.where(({ not, exists, selectFrom }) =>
|
||||||
|
not(
|
||||||
|
exists(
|
||||||
|
selectFrom('person')
|
||||||
|
.whereRef('person.personGroupId', '=', 'person_group.id')
|
||||||
|
.select('person.personGroupId'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
return Number(result.numDeletedRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql()
|
||||||
|
async deleteOrphanedClusterGroups(): Promise<number> {
|
||||||
|
const result = await this.db
|
||||||
|
.deleteFrom('cluster_group')
|
||||||
|
.where(({ not, exists, selectFrom }) =>
|
||||||
|
not(exists(selectFrom('user').whereRef('user.clusterGroupId', '=', 'cluster_group.id').select('user.id'))),
|
||||||
|
)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
return Number(result.numDeletedRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteFaces({ sourceType }: DeleteFacesOptions): Promise<void> {
|
async deleteFaces({ sourceType }: DeleteFacesOptions): Promise<void> {
|
||||||
@@ -114,8 +183,8 @@ export class PersonRepository {
|
|||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset_face')
|
.selectFrom('asset_face')
|
||||||
.selectAll('asset_face')
|
.selectAll('asset_face')
|
||||||
.$if(options.personId === null, (qb) => qb.where('asset_face.personId', 'is', null))
|
.$if(options.personGroupId === null, (qb) => qb.where('asset_face.personGroupId', 'is', null))
|
||||||
.$if(!!options.personId, (qb) => qb.where('asset_face.personId', '=', options.personId!))
|
.$if(!!options.personGroupId, (qb) => qb.where('asset_face.personGroupId', '=', options.personGroupId!))
|
||||||
.$if(!!options.sourceType, (qb) => qb.where('asset_face.sourceType', '=', options.sourceType!))
|
.$if(!!options.sourceType, (qb) => qb.where('asset_face.sourceType', '=', options.sourceType!))
|
||||||
.$if(!!options.assetId, (qb) => qb.where('asset_face.assetId', '=', options.assetId!))
|
.$if(!!options.assetId, (qb) => qb.where('asset_face.assetId', '=', options.assetId!))
|
||||||
.where('asset_face.deletedAt', 'is', null)
|
.where('asset_face.deletedAt', 'is', null)
|
||||||
@@ -139,7 +208,7 @@ export class PersonRepository {
|
|||||||
getFileSamples() {
|
getFileSamples() {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('person')
|
.selectFrom('person')
|
||||||
.select(['id', 'thumbnailPath'])
|
.select(['ownerId', 'personGroupId', 'thumbnailPath'])
|
||||||
.where('thumbnailPath', '!=', sql.lit(''))
|
.where('thumbnailPath', '!=', sql.lit(''))
|
||||||
.limit(sql.lit(3))
|
.limit(sql.lit(3))
|
||||||
.execute();
|
.execute();
|
||||||
@@ -150,10 +219,11 @@ export class PersonRepository {
|
|||||||
const items = await this.db
|
const items = await this.db
|
||||||
.selectFrom('person')
|
.selectFrom('person')
|
||||||
.selectAll('person')
|
.selectAll('person')
|
||||||
.innerJoin('asset_face', 'asset_face.personId', 'person.id')
|
.innerJoin('asset_face', 'asset_face.personGroupId', 'person.personGroupId')
|
||||||
.innerJoin('asset', (join) =>
|
.innerJoin('asset', (join) =>
|
||||||
join
|
join
|
||||||
.onRef('asset_face.assetId', '=', 'asset.id')
|
.onRef('asset_face.assetId', '=', 'asset.id')
|
||||||
|
.onRef('asset.ownerId', '=', 'person.ownerId')
|
||||||
.on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
|
.on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
|
||||||
.on('asset.deletedAt', 'is', null),
|
.on('asset.deletedAt', 'is', null),
|
||||||
)
|
)
|
||||||
@@ -178,7 +248,7 @@ export class PersonRepository {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
.groupBy('person.id')
|
.groupBy(['person.ownerId', 'person.personGroupId'])
|
||||||
.$if(!!options?.closestFaceAssetId, (qb) =>
|
.$if(!!options?.closestFaceAssetId, (qb) =>
|
||||||
qb.orderBy((eb) =>
|
qb.orderBy((eb) =>
|
||||||
eb(
|
eb(
|
||||||
@@ -216,22 +286,22 @@ export class PersonRepository {
|
|||||||
return this.db
|
return this.db
|
||||||
.selectFrom('person')
|
.selectFrom('person')
|
||||||
.selectAll('person')
|
.selectAll('person')
|
||||||
.leftJoin('asset_face', 'asset_face.personId', 'person.id')
|
.leftJoin('asset_face', 'asset_face.personGroupId', 'person.personGroupId')
|
||||||
.where('asset_face.deletedAt', 'is', null)
|
.where('asset_face.deletedAt', 'is', null)
|
||||||
.where('asset_face.isVisible', 'is', true)
|
.where('asset_face.isVisible', 'is', true)
|
||||||
.having((eb) => eb.fn.count('asset_face.assetId'), '=', 0)
|
.having((eb) => eb.fn.count('asset_face.assetId'), '=', 0)
|
||||||
.groupBy('person.id')
|
.groupBy(['person.ownerId', 'person.personGroupId'])
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID, { viewingUserId: DummyValue.UUID, isVisible: true }] })
|
||||||
getFaces(assetId: string, options?: { isVisible?: boolean }) {
|
getFaces(assetId: string, options: GetFacesOptions) {
|
||||||
const isVisible = options === undefined ? true : options.isVisible;
|
const { viewingUserId, isVisible } = options;
|
||||||
|
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset_face')
|
.selectFrom('asset_face')
|
||||||
.selectAll('asset_face')
|
.selectAll('asset_face')
|
||||||
.select(withPerson)
|
.select(withPerson({ viewingUserId }))
|
||||||
.where('asset_face.assetId', '=', assetId)
|
.where('asset_face.assetId', '=', assetId)
|
||||||
.where('asset_face.deletedAt', 'is', null)
|
.where('asset_face.deletedAt', 'is', null)
|
||||||
.$if(isVisible !== undefined, (qb) => qb.where('asset_face.isVisible', '=', isVisible!))
|
.$if(isVisible !== undefined, (qb) => qb.where('asset_face.isVisible', '=', isVisible!))
|
||||||
@@ -239,13 +309,13 @@ export class PersonRepository {
|
|||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID, { viewingUserId: DummyValue.UUID }] })
|
||||||
getFaceById(id: string) {
|
getFaceById(id: string, { viewingUserId }: WithPersonOptions) {
|
||||||
// TODO return null instead of find or fail
|
// TODO return null instead of find or fail
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset_face')
|
.selectFrom('asset_face')
|
||||||
.selectAll('asset_face')
|
.selectAll('asset_face')
|
||||||
.select(withPerson)
|
.select(withPerson({ viewingUserId }))
|
||||||
.where('asset_face.id', '=', id)
|
.where('asset_face.id', '=', id)
|
||||||
.where('asset_face.deletedAt', 'is', null)
|
.where('asset_face.deletedAt', 'is', null)
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
@@ -255,12 +325,13 @@ export class PersonRepository {
|
|||||||
getFaceForFacialRecognitionJob(id: string) {
|
getFaceForFacialRecognitionJob(id: string) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset_face')
|
.selectFrom('asset_face')
|
||||||
.select(['asset_face.id', 'asset_face.personId', 'asset_face.sourceType'])
|
.select(['asset_face.id', 'asset_face.personGroupId', 'asset_face.sourceType'])
|
||||||
.select((eb) =>
|
.select((eb) =>
|
||||||
jsonObjectFrom(
|
jsonObjectFrom(
|
||||||
eb
|
eb
|
||||||
.selectFrom('asset')
|
.selectFrom('asset')
|
||||||
.select(['asset.ownerId', 'asset.visibility', 'asset.fileCreatedAt'])
|
.innerJoin('user', 'user.id', 'asset.ownerId')
|
||||||
|
.select(['asset.ownerId', 'asset.visibility', 'asset.fileCreatedAt', 'user.clusterGroupId'])
|
||||||
.whereRef('asset.id', '=', 'asset_face.assetId'),
|
.whereRef('asset.id', '=', 'asset_face.assetId'),
|
||||||
).as('asset'),
|
).as('asset'),
|
||||||
)
|
)
|
||||||
@@ -270,8 +341,8 @@ export class PersonRepository {
|
|||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [{ ownerId: DummyValue.UUID, personGroupId: DummyValue.UUID }] })
|
||||||
getDataForThumbnailGenerationJob(id: string) {
|
getDataForThumbnailGenerationJob({ ownerId, personGroupId }: PersonId) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('person')
|
.selectFrom('person')
|
||||||
.innerJoin('asset_face', 'asset_face.id', 'person.faceAssetId')
|
.innerJoin('asset_face', 'asset_face.id', 'person.faceAssetId')
|
||||||
@@ -290,27 +361,30 @@ export class PersonRepository {
|
|||||||
'asset_exif.orientation as exifOrientation',
|
'asset_exif.orientation as exifOrientation',
|
||||||
])
|
])
|
||||||
.select((eb) => withFilePath(eb, AssetFileType.Preview).as('previewPath'))
|
.select((eb) => withFilePath(eb, AssetFileType.Preview).as('previewPath'))
|
||||||
.where('person.id', '=', id)
|
.where('person.ownerId', '=', ownerId)
|
||||||
|
.where('person.personGroupId', '=', personGroupId)
|
||||||
.where('asset_face.deletedAt', 'is', null)
|
.where('asset_face.deletedAt', 'is', null)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
|
||||||
async reassignFace(assetFaceId: string, newPersonId: string): Promise<number> {
|
async reassignFace(assetFaceId: string, newPersonGroupId: string): Promise<number> {
|
||||||
const result = await this.db
|
const result = await this.db
|
||||||
.updateTable('asset_face')
|
.updateTable('asset_face')
|
||||||
.set({ personId: newPersonId })
|
.set({ personGroupId: newPersonGroupId })
|
||||||
.where('asset_face.id', '=', assetFaceId)
|
.where('asset_face.id', '=', assetFaceId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
return Number(result.numChangedRows ?? 0);
|
return Number(result.numChangedRows ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
getById(personId: string) {
|
@GenerateSql({ params: [{ ownerId: DummyValue.UUID, personGroupId: DummyValue.UUID }] })
|
||||||
|
getByGroupId({ ownerId, personGroupId }: PersonId) {
|
||||||
return this.db //
|
return this.db //
|
||||||
.selectFrom('person')
|
.selectFrom('person')
|
||||||
.selectAll('person')
|
.selectAll('person')
|
||||||
.where('person.id', '=', personId)
|
.where('person.personGroupId', '=', personGroupId)
|
||||||
|
.where('person.ownerId', '=', ownerId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,27 +408,28 @@ export class PersonRepository {
|
|||||||
getDistinctNames(userId: string, { withHidden }: PersonNameSearchOptions): Promise<PersonNameResponse[]> {
|
getDistinctNames(userId: string, { withHidden }: PersonNameSearchOptions): Promise<PersonNameResponse[]> {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('person')
|
.selectFrom('person')
|
||||||
.select(['person.id', 'person.name'])
|
.select(['person.personGroupId', 'person.name'])
|
||||||
.distinctOn((eb) => eb.fn('lower', ['person.name']))
|
.distinctOn((eb) => eb.fn('lower', ['person.name']))
|
||||||
.where((eb) => eb.and([eb('person.ownerId', '=', userId), eb('person.name', '!=', '')]))
|
.where((eb) => eb.and([eb('person.ownerId', '=', userId), eb('person.name', '!=', '')]))
|
||||||
.$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false))
|
.$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false))
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
|
||||||
async getStatistics(personId: string): Promise<PersonStatistics> {
|
async getStatistics(personGroupId: string, userId: string): Promise<PersonStatistics> {
|
||||||
const result = await this.db
|
const result = await this.db
|
||||||
.selectFrom('asset_face')
|
.selectFrom('asset_face')
|
||||||
.leftJoin('asset', (join) =>
|
.leftJoin('asset', (join) =>
|
||||||
join
|
join
|
||||||
.onRef('asset.id', '=', 'asset_face.assetId')
|
.onRef('asset.id', '=', 'asset_face.assetId')
|
||||||
.on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
|
.on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
|
||||||
.on('asset.deletedAt', 'is', null),
|
.on('asset.deletedAt', 'is', null)
|
||||||
|
.on((eb) => eb.or([eb('asset.ownerId', '=', asUuid(userId)), inSharedAlbum(eb, userId)])),
|
||||||
)
|
)
|
||||||
.select((eb) => eb.fn.count(eb.fn('distinct', ['asset.id'])).as('count'))
|
.select((eb) => eb.fn.count(eb.fn('distinct', ['asset.id'])).as('count'))
|
||||||
.where('asset_face.deletedAt', 'is', null)
|
.where('asset_face.deletedAt', 'is', null)
|
||||||
.where('asset_face.isVisible', 'is', true)
|
.where('asset_face.isVisible', 'is', true)
|
||||||
.where('asset_face.personId', '=', personId)
|
.where('asset_face.personGroupId', '=', personGroupId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -371,7 +446,7 @@ export class PersonRepository {
|
|||||||
eb.exists((eb) =>
|
eb.exists((eb) =>
|
||||||
eb
|
eb
|
||||||
.selectFrom('asset_face')
|
.selectFrom('asset_face')
|
||||||
.whereRef('asset_face.personId', '=', 'person.id')
|
.whereRef('asset_face.personGroupId', '=', 'person.personGroupId')
|
||||||
.where('asset_face.deletedAt', 'is', null)
|
.where('asset_face.deletedAt', 'is', null)
|
||||||
.where('asset_face.isVisible', '=', true)
|
.where('asset_face.isVisible', '=', true)
|
||||||
.where((eb) =>
|
.where((eb) =>
|
||||||
@@ -395,13 +470,124 @@ export class PersonRepository {
|
|||||||
return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow();
|
return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
async createAll(people: Insertable<PersonTable>[]): Promise<string[]> {
|
async createAll(people: Insertable<PersonTable>[]) {
|
||||||
if (people.length === 0) {
|
if (people.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await this.db.insertInto('person').values(people).returningAll().execute();
|
return this.db.insertInto('person').values(people).returningAll().execute();
|
||||||
return results.map(({ id }) => id);
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
|
createGroup(ownerId: string) {
|
||||||
|
return this.db
|
||||||
|
.insertInto('person_group')
|
||||||
|
.columns(['clusterGroupId'])
|
||||||
|
.expression((eb) => eb.selectFrom('user').select('user.clusterGroupId').where('user.id', '=', ownerId))
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [{ userId: DummyValue.UUID, newClusterId: DummyValue.UUID }] })
|
||||||
|
async reassignCluster({ userId, newClusterId }: ReassignCluster): Promise<void> {
|
||||||
|
await this.db.transaction().execute(async (trx) => {
|
||||||
|
// a group nobody else has people in moves across as it is
|
||||||
|
await trx
|
||||||
|
.updateTable('person_group')
|
||||||
|
.set({ clusterGroupId: newClusterId })
|
||||||
|
.where('person_group.id', 'in', (eb) =>
|
||||||
|
eb.selectFrom('person').select('person.personGroupId').where('person.ownerId', '=', userId),
|
||||||
|
)
|
||||||
|
.where(({ not, exists, selectFrom }) =>
|
||||||
|
not(
|
||||||
|
exists(
|
||||||
|
selectFrom('person')
|
||||||
|
.select('person.personGroupId')
|
||||||
|
.whereRef('person.personGroupId', '=', 'person_group.id')
|
||||||
|
.where('person.ownerId', '!=', userId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// the rest is shared with someone else, so this user gets a group of their own for each
|
||||||
|
const mapping = await trx
|
||||||
|
.with('shared', (db) =>
|
||||||
|
db
|
||||||
|
.selectFrom('person')
|
||||||
|
.select('person.personGroupId as oldId')
|
||||||
|
.distinct()
|
||||||
|
.where('person.ownerId', '=', userId)
|
||||||
|
.where(({ exists, selectFrom }) =>
|
||||||
|
exists(
|
||||||
|
selectFrom('person as other')
|
||||||
|
.select('other.personGroupId')
|
||||||
|
.whereRef('other.personGroupId', '=', 'person.personGroupId')
|
||||||
|
.where('other.ownerId', '!=', userId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
(cte) => cte('mapping').materialized(),
|
||||||
|
(db) => db.selectFrom('shared').select(['shared.oldId', sql<string>`uuid_generate_v4()`.as('newId')]),
|
||||||
|
)
|
||||||
|
.with('created', (db) =>
|
||||||
|
db
|
||||||
|
.insertInto('person_group')
|
||||||
|
.columns(['id', 'clusterGroupId'])
|
||||||
|
.expression((eb) =>
|
||||||
|
eb.selectFrom('mapping').select(['mapping.newId', sql.val(newClusterId).as('clusterGroupId')]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.selectFrom('mapping')
|
||||||
|
.select(['mapping.oldId', 'mapping.newId'])
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
if (mapping.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldIds = mapping.map(({ oldId }) => oldId);
|
||||||
|
const newIds = mapping.map(({ newId }) => newId);
|
||||||
|
const remapped = sql<{
|
||||||
|
oldId: string;
|
||||||
|
newId: string;
|
||||||
|
}>`(select unnest(${`{${oldIds}}`}::uuid[]) as "oldId", unnest(${`{${newIds}}`}::uuid[]) as "newId")`.as(
|
||||||
|
'mapping',
|
||||||
|
);
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.updateTable('person')
|
||||||
|
.from(remapped)
|
||||||
|
.set((eb) => ({ personGroupId: eb.ref('mapping.newId') }))
|
||||||
|
.whereRef('person.personGroupId', '=', 'mapping.oldId')
|
||||||
|
.where('person.ownerId', '=', userId)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.updateTable('asset_face')
|
||||||
|
.from(remapped)
|
||||||
|
.set((eb) => ({ personGroupId: eb.ref('mapping.newId') }))
|
||||||
|
.whereRef('asset_face.personGroupId', '=', 'mapping.oldId')
|
||||||
|
.where(({ exists, selectFrom }) =>
|
||||||
|
exists(
|
||||||
|
selectFrom('asset')
|
||||||
|
.select('asset.id')
|
||||||
|
.whereRef('asset.id', '=', 'asset_face.assetId')
|
||||||
|
.where('asset.ownerId', '=', userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID, 2] })
|
||||||
|
async createGroups(personGroups: Insertable<PersonGroupTable>[]) {
|
||||||
|
if (personGroups.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.db.insertInto('person_group').values(personGroups).returningAll().execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [[], [], [{ faceId: DummyValue.UUID, embedding: DummyValue.VECTOR }]] })
|
@GenerateSql({ params: [[], [], [{ faceId: DummyValue.UUID, embedding: DummyValue.VECTOR }]] })
|
||||||
@@ -428,11 +614,12 @@ export class PersonRepository {
|
|||||||
await query.selectFrom(dummy).execute();
|
await query.selectFrom(dummy).execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(person: Updateable<PersonTable> & { id: string }) {
|
async update(person: Updateable<PersonTable> & PersonId) {
|
||||||
return this.db
|
return this.db
|
||||||
.updateTable('person')
|
.updateTable('person')
|
||||||
.set(person)
|
.set(person)
|
||||||
.where('person.id', '=', person.id)
|
.where('person.ownerId', '=', person.ownerId)
|
||||||
|
.where('person.personGroupId', '=', person.personGroupId)
|
||||||
.returningAll()
|
.returningAll()
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
@@ -446,7 +633,7 @@ export class PersonRepository {
|
|||||||
.insertInto('person')
|
.insertInto('person')
|
||||||
.values(people)
|
.values(people)
|
||||||
.onConflict((oc) =>
|
.onConflict((oc) =>
|
||||||
oc.column('id').doUpdateSet((eb) =>
|
oc.columns(['ownerId', 'personGroupId']).doUpdateSet((eb) =>
|
||||||
removeUndefinedKeys(
|
removeUndefinedKeys(
|
||||||
{
|
{
|
||||||
name: eb.ref('excluded.name'),
|
name: eb.ref('excluded.name'),
|
||||||
@@ -464,36 +651,38 @@ export class PersonRepository {
|
|||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] })
|
@GenerateSql({
|
||||||
|
params: [[{ assetId: DummyValue.UUID, personGroupId: DummyValue.UUID }], { viewingUserId: DummyValue.UUID }],
|
||||||
|
})
|
||||||
@ChunkedArray()
|
@ChunkedArray()
|
||||||
getFacesByIds(ids: AssetFaceId[]) {
|
getFacesByIds(ids: AssetFaceId[], { viewingUserId }: WithPersonOptions) {
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetIds: string[] = [];
|
const assetIds: string[] = [];
|
||||||
const personIds: string[] = [];
|
const personGroupIds: string[] = [];
|
||||||
for (const { assetId, personId } of ids) {
|
for (const { assetId, personGroupId } of ids) {
|
||||||
assetIds.push(assetId);
|
assetIds.push(assetId);
|
||||||
personIds.push(personId);
|
personGroupIds.push(personGroupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset_face')
|
.selectFrom('asset_face')
|
||||||
.selectAll('asset_face')
|
.selectAll('asset_face')
|
||||||
.select(withPerson)
|
.select(withPerson({ viewingUserId }))
|
||||||
.where('asset_face.assetId', 'in', assetIds)
|
.where('asset_face.assetId', 'in', assetIds)
|
||||||
.where('asset_face.personId', 'in', personIds)
|
.where('asset_face.personGroupId', 'in', personGroupIds)
|
||||||
.where('asset_face.deletedAt', 'is', null)
|
.where('asset_face.deletedAt', 'is', null)
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
getRandomFace(personId: string) {
|
getRandomFace(personGroupId: string) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset_face')
|
.selectFrom('asset_face')
|
||||||
.selectAll('asset_face')
|
.selectAll('asset_face')
|
||||||
.where('asset_face.personId', '=', personId)
|
.where('asset_face.personGroupId', '=', personGroupId)
|
||||||
.where('asset_face.deletedAt', 'is', null)
|
.where('asset_face.deletedAt', 'is', null)
|
||||||
.where('asset_face.isVisible', 'is', true)
|
.where('asset_face.isVisible', 'is', true)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
@@ -532,15 +721,6 @@ export class PersonRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [[DummyValue.UUID]] })
|
|
||||||
@Chunked()
|
|
||||||
getForPeopleDelete(ids: string[]) {
|
|
||||||
if (ids.length === 0) {
|
|
||||||
return Promise.resolve([]);
|
|
||||||
}
|
|
||||||
return this.db.selectFrom('person').select(['id', 'thumbnailPath']).where('id', 'in', ids).execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
@GenerateSql({ params: [[], []] })
|
@GenerateSql({ params: [[], []] })
|
||||||
async updateVisibility(visible: AssetFace[], hidden: AssetFace[]): Promise<void> {
|
async updateVisibility(visible: AssetFace[], hidden: AssetFace[]): Promise<void> {
|
||||||
if (visible.length === 0 && hidden.length === 0) {
|
if (visible.length === 0 && hidden.length === 0) {
|
||||||
@@ -574,14 +754,24 @@ export class PersonRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [{ personId: DummyValue.UUID, assetId: DummyValue.UUID }] })
|
@GenerateSql({ params: [{ personGroupId: DummyValue.UUID, assetId: DummyValue.UUID }] })
|
||||||
getForFeatureFaceUpdate({ personId, assetId }: { personId: string; assetId: string }) {
|
getForFeatureFaceUpdate({ personGroupId, assetId }: { personGroupId: string; assetId: string }) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset_face')
|
.selectFrom('asset_face')
|
||||||
.select('asset_face.id')
|
.select('asset_face.id')
|
||||||
.where('asset_face.assetId', '=', assetId)
|
.where('asset_face.assetId', '=', assetId)
|
||||||
.where('asset_face.personId', '=', personId)
|
.where('asset_face.personGroupId', '=', personGroupId)
|
||||||
.innerJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId').on('asset.isOffline', '=', false))
|
.innerJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId').on('asset.isOffline', '=', false))
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [[DummyValue.UUID]] })
|
||||||
|
getForMergePerson(personGroupIds: string[]) {
|
||||||
|
return this.db
|
||||||
|
.selectFrom('person')
|
||||||
|
.selectAll('person')
|
||||||
|
.where('person.personGroupId', 'in', personGroupIds)
|
||||||
|
.orderBy('person.ownerId')
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export interface SearchOneToOneRelationOptions {
|
|||||||
export interface SearchRelationOptions extends SearchOneToOneRelationOptions {
|
export interface SearchRelationOptions extends SearchOneToOneRelationOptions {
|
||||||
withFaces?: boolean;
|
withFaces?: boolean;
|
||||||
withPeople?: boolean;
|
withPeople?: boolean;
|
||||||
|
/** whose version of the people to select, required when selecting faces or people */
|
||||||
|
viewingUserId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchDateOptions {
|
export interface SearchDateOptions {
|
||||||
@@ -137,6 +139,8 @@ export interface AssetSearchBuilderV3Options {
|
|||||||
filter?: SearchFilter;
|
filter?: SearchFilter;
|
||||||
/** Server-derived ownership scope. Never client-controlled. */
|
/** Server-derived ownership scope. Never client-controlled. */
|
||||||
userIds?: string[];
|
userIds?: string[];
|
||||||
|
/** whose version of the people to select, required when selecting faces or people */
|
||||||
|
viewingUserId?: string;
|
||||||
withExif?: boolean;
|
withExif?: boolean;
|
||||||
withFaces?: boolean;
|
withFaces?: boolean;
|
||||||
withPeople?: boolean;
|
withPeople?: boolean;
|
||||||
@@ -156,11 +160,12 @@ export type SmartSearchOptions = SearchDateOptions &
|
|||||||
SearchUserIdOptions &
|
SearchUserIdOptions &
|
||||||
SearchPeopleOptions &
|
SearchPeopleOptions &
|
||||||
SearchTagOptions &
|
SearchTagOptions &
|
||||||
SearchOcrOptions & { visibility?: AssetVisibility | 'not-locked' };
|
SearchOcrOptions & { visibility?: AssetVisibility | 'not-locked'; viewingUserId?: string };
|
||||||
|
|
||||||
export type LargeAssetSearchOptions = AssetSearchOptions & { minFileSize?: number };
|
export type LargeAssetSearchOptions = AssetSearchOptions & { minFileSize?: number };
|
||||||
|
|
||||||
export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
|
export interface FaceEmbeddingSearch extends Omit<SearchEmbeddingOptions, 'userIds'> {
|
||||||
|
clusterGroupId: string;
|
||||||
hasPerson?: boolean;
|
hasPerson?: boolean;
|
||||||
numResults: number;
|
numResults: number;
|
||||||
maxDistance: number;
|
maxDistance: number;
|
||||||
@@ -170,7 +175,7 @@ export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
|
|||||||
export interface FaceSearchResult {
|
export interface FaceSearchResult {
|
||||||
distance: number;
|
distance: number;
|
||||||
id: string;
|
id: string;
|
||||||
personId: string | null;
|
personGroupId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssetDuplicateResult {
|
export interface AssetDuplicateResult {
|
||||||
@@ -339,7 +344,7 @@ export class SearchRepository {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
searchFaces({ userIds, embedding, numResults, maxDistance, hasPerson, minBirthDate }: FaceEmbeddingSearch) {
|
searchFaces({ clusterGroupId, embedding, numResults, maxDistance, hasPerson, minBirthDate }: FaceEmbeddingSearch) {
|
||||||
if (!z.int().min(1).max(1000).safeParse(numResults).success) {
|
if (!z.int().min(1).max(1000).safeParse(numResults).success) {
|
||||||
throw new Error(`Invalid value for 'numResults': ${numResults}`);
|
throw new Error(`Invalid value for 'numResults': ${numResults}`);
|
||||||
}
|
}
|
||||||
@@ -350,20 +355,29 @@ export class SearchRepository {
|
|||||||
.with('cte', (qb) =>
|
.with('cte', (qb) =>
|
||||||
qb
|
qb
|
||||||
.selectFrom('asset_face')
|
.selectFrom('asset_face')
|
||||||
.select([
|
|
||||||
'asset_face.id',
|
|
||||||
'asset_face.personId',
|
|
||||||
sql<number>`face_search.embedding <=> ${embedding}`.as('distance'),
|
|
||||||
])
|
|
||||||
.innerJoin('asset', 'asset.id', 'asset_face.assetId')
|
.innerJoin('asset', 'asset.id', 'asset_face.assetId')
|
||||||
.innerJoin('face_search', 'face_search.faceId', 'asset_face.id')
|
.innerJoin('face_search', 'face_search.faceId', 'asset_face.id')
|
||||||
.leftJoin('person', 'person.id', 'asset_face.personId')
|
.select([
|
||||||
.where('asset.ownerId', '=', anyUuid(userIds))
|
'asset_face.id',
|
||||||
|
'asset_face.personGroupId',
|
||||||
|
sql<number>`face_search.embedding <=> ${embedding}`.as('distance'),
|
||||||
|
])
|
||||||
|
.where('asset.ownerId', 'in', (eb) =>
|
||||||
|
eb.selectFrom('user').select('user.id').where('user.clusterGroupId', '=', clusterGroupId),
|
||||||
|
)
|
||||||
.where('asset.deletedAt', 'is', null)
|
.where('asset.deletedAt', 'is', null)
|
||||||
.$if(!!hasPerson, (qb) => qb.where('asset_face.personId', 'is not', null))
|
.$if(!!hasPerson, (qb) => qb.where('asset_face.personGroupId', 'is not', null))
|
||||||
.$if(!!minBirthDate, (qb) =>
|
.$if(!!minBirthDate, (qb) =>
|
||||||
qb.where((eb) =>
|
qb.where((eb) =>
|
||||||
eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)]),
|
eb.not(
|
||||||
|
eb.exists(
|
||||||
|
eb
|
||||||
|
.selectFrom('person')
|
||||||
|
.select('person.personGroupId')
|
||||||
|
.whereRef('person.personGroupId', '=', 'asset_face.personGroupId')
|
||||||
|
.where('person.birthDate', '>', minBirthDate!),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy('distance')
|
.orderBy('distance')
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export class SyncRepository {
|
|||||||
partnerAssetExif: PartnerAssetExifsSync;
|
partnerAssetExif: PartnerAssetExifsSync;
|
||||||
partnerStack: PartnerStackSync;
|
partnerStack: PartnerStackSync;
|
||||||
person: PersonSync;
|
person: PersonSync;
|
||||||
|
personGroup: PersonGroupSync;
|
||||||
stack: StackSync;
|
stack: StackSync;
|
||||||
user: UserSync;
|
user: UserSync;
|
||||||
userMetadata: UserMetadataSync;
|
userMetadata: UserMetadataSync;
|
||||||
@@ -89,6 +90,7 @@ export class SyncRepository {
|
|||||||
this.partnerAssetExif = new PartnerAssetExifsSync(this.db);
|
this.partnerAssetExif = new PartnerAssetExifsSync(this.db);
|
||||||
this.partnerStack = new PartnerStackSync(this.db);
|
this.partnerStack = new PartnerStackSync(this.db);
|
||||||
this.person = new PersonSync(this.db);
|
this.person = new PersonSync(this.db);
|
||||||
|
this.personGroup = new PersonGroupSync(this.db);
|
||||||
this.stack = new StackSync(this.db);
|
this.stack = new StackSync(this.db);
|
||||||
this.user = new UserSync(this.db);
|
this.user = new UserSync(this.db);
|
||||||
this.userMetadata = new UserMetadataSync(this.db);
|
this.userMetadata = new UserMetadataSync(this.db);
|
||||||
@@ -422,7 +424,7 @@ class PersonSync extends BaseSync {
|
|||||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||||
getDeletes(options: SyncQueryOptions) {
|
getDeletes(options: SyncQueryOptions) {
|
||||||
return this.auditQuery('person_audit', options)
|
return this.auditQuery('person_audit', options)
|
||||||
.select(['id', 'personId'])
|
.select(['id', 'personGroupId as personId'])
|
||||||
.where('ownerId', '=', options.userId)
|
.where('ownerId', '=', options.userId)
|
||||||
.stream();
|
.stream();
|
||||||
}
|
}
|
||||||
@@ -435,7 +437,7 @@ class PersonSync extends BaseSync {
|
|||||||
getUpserts(options: SyncQueryOptions) {
|
getUpserts(options: SyncQueryOptions) {
|
||||||
return this.upsertQuery('person', options)
|
return this.upsertQuery('person', options)
|
||||||
.select([
|
.select([
|
||||||
'id',
|
'personGroupId as id',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
'ownerId',
|
'ownerId',
|
||||||
@@ -452,6 +454,12 @@ class PersonSync extends BaseSync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class PersonGroupSync extends BaseSync {
|
||||||
|
cleanupAuditTable(daysAgo: number) {
|
||||||
|
return this.auditCleanup('person_group_audit', daysAgo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class AssetFaceSync extends BaseSync {
|
class AssetFaceSync extends BaseSync {
|
||||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||||
getDeletes(options: SyncQueryOptions) {
|
getDeletes(options: SyncQueryOptions) {
|
||||||
@@ -472,7 +480,7 @@ class AssetFaceSync extends BaseSync {
|
|||||||
.select([
|
.select([
|
||||||
'asset_face.id',
|
'asset_face.id',
|
||||||
'assetId',
|
'assetId',
|
||||||
'personId',
|
'personGroupId as personId',
|
||||||
'imageWidth',
|
'imageWidth',
|
||||||
'imageHeight',
|
'imageHeight',
|
||||||
'boundingBoxX1',
|
'boundingBoxX1',
|
||||||
|
|||||||
@@ -212,8 +212,21 @@ export const person_delete_audit = registerFunction({
|
|||||||
language: 'PLPGSQL',
|
language: 'PLPGSQL',
|
||||||
body: `
|
body: `
|
||||||
BEGIN
|
BEGIN
|
||||||
INSERT INTO person_audit ("personId", "ownerId")
|
INSERT INTO person_audit ("personGroupId", "ownerId")
|
||||||
SELECT "id", "ownerId"
|
SELECT "personGroupId", "ownerId"
|
||||||
|
FROM OLD;
|
||||||
|
RETURN NULL;
|
||||||
|
END`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const person_group_delete_audit = registerFunction({
|
||||||
|
name: 'person_group_delete_audit',
|
||||||
|
returnType: 'TRIGGER',
|
||||||
|
language: 'PLPGSQL',
|
||||||
|
body: `
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO person_group_audit ("personGroupId", "clusterGroupId")
|
||||||
|
SELECT "id", "clusterGroupId"
|
||||||
FROM OLD;
|
FROM OLD;
|
||||||
RETURN NULL;
|
RETURN NULL;
|
||||||
END`,
|
END`,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
memory_delete_audit,
|
memory_delete_audit,
|
||||||
partner_delete_audit,
|
partner_delete_audit,
|
||||||
person_delete_audit,
|
person_delete_audit,
|
||||||
|
person_group_delete_audit,
|
||||||
stack_delete_audit,
|
stack_delete_audit,
|
||||||
updated_at,
|
updated_at,
|
||||||
user_delete_audit,
|
user_delete_audit,
|
||||||
@@ -48,6 +49,8 @@ import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table';
|
|||||||
import { AssetOcrAuditTable } from 'src/schema/tables/asset-ocr-audit.table';
|
import { AssetOcrAuditTable } from 'src/schema/tables/asset-ocr-audit.table';
|
||||||
import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table';
|
import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table';
|
||||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||||
|
import { ClusterGroupRequestTable } from 'src/schema/tables/cluster-group-request.table';
|
||||||
|
import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table';
|
||||||
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
|
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
|
||||||
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
|
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
|
||||||
import { IntegrityReportTable } from 'src/schema/tables/integrity-report.table';
|
import { IntegrityReportTable } from 'src/schema/tables/integrity-report.table';
|
||||||
@@ -63,6 +66,8 @@ import { OcrSearchTable } from 'src/schema/tables/ocr-search.table';
|
|||||||
import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table';
|
import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table';
|
||||||
import { PartnerTable } from 'src/schema/tables/partner.table';
|
import { PartnerTable } from 'src/schema/tables/partner.table';
|
||||||
import { PersonAuditTable } from 'src/schema/tables/person-audit.table';
|
import { PersonAuditTable } from 'src/schema/tables/person-audit.table';
|
||||||
|
import { PersonGroupAuditTable } from 'src/schema/tables/person-group-audit.table';
|
||||||
|
import { PersonGroupTable } from 'src/schema/tables/person-group.table';
|
||||||
import { PersonTable } from 'src/schema/tables/person.table';
|
import { PersonTable } from 'src/schema/tables/person.table';
|
||||||
import { PluginMethodTable } from 'src/schema/tables/plugin-method.table';
|
import { PluginMethodTable } from 'src/schema/tables/plugin-method.table';
|
||||||
import { PluginTable } from 'src/schema/tables/plugin.table';
|
import { PluginTable } from 'src/schema/tables/plugin.table';
|
||||||
@@ -116,6 +121,8 @@ export class ImmichDatabase {
|
|||||||
AssetTable,
|
AssetTable,
|
||||||
AssetFileTable,
|
AssetFileTable,
|
||||||
AssetExifTable,
|
AssetExifTable,
|
||||||
|
ClusterGroupTable,
|
||||||
|
ClusterGroupRequestTable,
|
||||||
FaceSearchTable,
|
FaceSearchTable,
|
||||||
GeodataPlacesTable,
|
GeodataPlacesTable,
|
||||||
IntegrityReportTable,
|
IntegrityReportTable,
|
||||||
@@ -132,6 +139,8 @@ export class ImmichDatabase {
|
|||||||
PartnerTable,
|
PartnerTable,
|
||||||
PersonTable,
|
PersonTable,
|
||||||
PersonAuditTable,
|
PersonAuditTable,
|
||||||
|
PersonGroupTable,
|
||||||
|
PersonGroupAuditTable,
|
||||||
SessionTable,
|
SessionTable,
|
||||||
SharedLinkAssetTable,
|
SharedLinkAssetTable,
|
||||||
SharedLinkTable,
|
SharedLinkTable,
|
||||||
@@ -172,6 +181,7 @@ export class ImmichDatabase {
|
|||||||
memory_asset_delete_audit,
|
memory_asset_delete_audit,
|
||||||
stack_delete_audit,
|
stack_delete_audit,
|
||||||
person_delete_audit,
|
person_delete_audit,
|
||||||
|
person_group_delete_audit,
|
||||||
user_metadata_audit,
|
user_metadata_audit,
|
||||||
asset_metadata_audit,
|
asset_metadata_audit,
|
||||||
asset_face_audit,
|
asset_face_audit,
|
||||||
@@ -246,6 +256,11 @@ export interface DB {
|
|||||||
|
|
||||||
person: PersonTable;
|
person: PersonTable;
|
||||||
person_audit: PersonAuditTable;
|
person_audit: PersonAuditTable;
|
||||||
|
person_group: PersonGroupTable;
|
||||||
|
person_group_audit: PersonGroupAuditTable;
|
||||||
|
|
||||||
|
cluster_group: ClusterGroupTable;
|
||||||
|
cluster_group_request: ClusterGroupRequestTable;
|
||||||
|
|
||||||
session: SessionTable;
|
session: SessionTable;
|
||||||
session_sync_checkpoint: SessionSyncCheckpointTable;
|
session_sync_checkpoint: SessionSyncCheckpointTable;
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`CREATE OR REPLACE FUNCTION person_delete_audit()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE PLPGSQL
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO person_audit ("personGroupId", "ownerId")
|
||||||
|
SELECT "personGroupId", "ownerId"
|
||||||
|
FROM OLD;
|
||||||
|
RETURN NULL;
|
||||||
|
END
|
||||||
|
$$;`.execute(db);
|
||||||
|
await sql`CREATE OR REPLACE TRIGGER "person_delete_audit"
|
||||||
|
AFTER DELETE ON "person"
|
||||||
|
REFERENCING OLD TABLE AS "old"
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
WHEN (pg_trigger_depth() <= 1)
|
||||||
|
EXECUTE FUNCTION person_delete_audit();`.execute(db);
|
||||||
|
await sql`CREATE OR REPLACE FUNCTION person_group_delete_audit()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE PLPGSQL
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO person_group_audit ("personGroupId", "clusterGroupId")
|
||||||
|
SELECT "id", "clusterGroupId"
|
||||||
|
FROM OLD;
|
||||||
|
RETURN NULL;
|
||||||
|
END
|
||||||
|
$$;`.execute(db);
|
||||||
|
await sql`CREATE TABLE "cluster_group" (
|
||||||
|
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||||
|
"name" character varying,
|
||||||
|
"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 "cluster_group_pkey" PRIMARY KEY ("id")
|
||||||
|
);`.execute(db);
|
||||||
|
await sql`CREATE INDEX "cluster_group_updateId_idx" ON "cluster_group" ("updateId");`.execute(db);
|
||||||
|
await sql`CREATE OR REPLACE TRIGGER "cluster_group_updatedAt"
|
||||||
|
BEFORE UPDATE ON "cluster_group"
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION updated_at();`.execute(db);
|
||||||
|
|
||||||
|
await sql`ALTER TABLE "user" ADD "clusterGroupId" uuid;`.execute(db);
|
||||||
|
await sql`UPDATE "user" SET "clusterGroupId" = uuid_generate_v4();`.execute(db);
|
||||||
|
await sql`INSERT INTO "cluster_group" ("id") SELECT "clusterGroupId" FROM "user";`.execute(db);
|
||||||
|
await sql`ALTER TABLE "user" ALTER COLUMN "clusterGroupId" SET NOT NULL;`.execute(db);
|
||||||
|
await sql`CREATE INDEX "user_clusterGroupId_idx" ON "user" ("clusterGroupId");`.execute(db);
|
||||||
|
await sql`ALTER TABLE "user" ADD CONSTRAINT "user_clusterGroupId_fkey" FOREIGN KEY ("clusterGroupId") REFERENCES "cluster_group" ("id") ON UPDATE CASCADE ON DELETE NO ACTION;`.execute(db);
|
||||||
|
|
||||||
|
await sql`CREATE TABLE "cluster_group_request" (
|
||||||
|
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||||
|
"clusterGroupId" uuid NOT NULL,
|
||||||
|
"userId" uuid NOT NULL,
|
||||||
|
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT "cluster_group_request_clusterGroupId_fkey" FOREIGN KEY ("clusterGroupId") REFERENCES "cluster_group" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "cluster_group_request_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "cluster_group_request_clusterGroupId_userId_uq" UNIQUE ("clusterGroupId", "userId"),
|
||||||
|
CONSTRAINT "cluster_group_request_pkey" PRIMARY KEY ("id")
|
||||||
|
);`.execute(db);
|
||||||
|
await sql`CREATE INDEX "cluster_group_request_clusterGroupId_idx" ON "cluster_group_request" ("clusterGroupId");`.execute(
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
await sql`CREATE INDEX "cluster_group_request_userId_idx" ON "cluster_group_request" ("userId");`.execute(db);
|
||||||
|
|
||||||
|
await sql`CREATE TABLE "person_group" (
|
||||||
|
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||||
|
"clusterGroupId" uuid NOT NULL,
|
||||||
|
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
"createId" uuid NOT NULL DEFAULT immich_uuid_v7(),
|
||||||
|
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
"updateId" uuid NOT NULL DEFAULT immich_uuid_v7(),
|
||||||
|
CONSTRAINT "person_group_clusterGroupId_fkey" FOREIGN KEY ("clusterGroupId") REFERENCES "cluster_group" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "person_group_pkey" PRIMARY KEY ("id")
|
||||||
|
);`.execute(db);
|
||||||
|
await sql`CREATE INDEX "person_group_clusterGroupId_idx" ON "person_group" ("clusterGroupId");`.execute(db);
|
||||||
|
await sql`CREATE INDEX "person_group_createId_idx" ON "person_group" ("createId");`.execute(db);
|
||||||
|
await sql`CREATE INDEX "person_group_updateId_idx" ON "person_group" ("updateId");`.execute(db);
|
||||||
|
await sql`CREATE OR REPLACE TRIGGER "person_group_delete_audit"
|
||||||
|
AFTER DELETE ON "person_group"
|
||||||
|
REFERENCING OLD TABLE AS "old"
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
WHEN (pg_trigger_depth() = 0)
|
||||||
|
EXECUTE FUNCTION person_group_delete_audit();`.execute(db);
|
||||||
|
await sql`CREATE OR REPLACE TRIGGER "person_group_updatedAt"
|
||||||
|
BEFORE UPDATE ON "person_group"
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION updated_at();`.execute(db);
|
||||||
|
await sql`CREATE TABLE "person_group_audit" (
|
||||||
|
"id" uuid NOT NULL DEFAULT immich_uuid_v7(),
|
||||||
|
"personGroupId" uuid NOT NULL,
|
||||||
|
"clusterGroupId" uuid NOT NULL,
|
||||||
|
"deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
CONSTRAINT "person_group_audit_pkey" PRIMARY KEY ("id")
|
||||||
|
);`.execute(db);
|
||||||
|
await sql`CREATE INDEX "person_group_audit_personGroupId_idx" ON "person_group_audit" ("personGroupId");`.execute(db);
|
||||||
|
await sql`CREATE INDEX "person_group_audit_clusterGroupId_idx" ON "person_group_audit" ("clusterGroupId");`.execute(db);
|
||||||
|
await sql`CREATE INDEX "person_group_audit_deletedAt_idx" ON "person_group_audit" ("deletedAt");`.execute(db);
|
||||||
|
|
||||||
|
await sql`ALTER TABLE "person" ADD "personGroupId" uuid;`.execute(db);
|
||||||
|
await sql`INSERT INTO "person_group" ("id", "clusterGroupId", "createdAt")
|
||||||
|
SELECT "person"."id", "user"."clusterGroupId", "person"."createdAt"
|
||||||
|
FROM "person"
|
||||||
|
INNER JOIN "user" ON "user"."id" = "person"."ownerId";`.execute(db);
|
||||||
|
await sql`UPDATE "person" SET "personGroupId" = "id";`.execute(db);
|
||||||
|
await sql`ALTER TABLE "person" ALTER COLUMN "personGroupId" SET NOT NULL;`.execute(db);
|
||||||
|
await sql`CREATE INDEX "person_personGroupId_idx" ON "person" ("personGroupId");`.execute(db);
|
||||||
|
await sql`ALTER TABLE "person" ADD CONSTRAINT "person_personGroupId_fkey" FOREIGN KEY ("personGroupId") REFERENCES "person_group" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
|
||||||
|
await sql`ALTER TABLE "person_audit" ADD "personGroupId" uuid;`.execute(db);
|
||||||
|
await sql`UPDATE "person_audit" SET "personGroupId" = "personId";`.execute(db);
|
||||||
|
await sql`ALTER TABLE "person_audit" ALTER COLUMN "personGroupId" SET NOT NULL;`.execute(db);
|
||||||
|
await sql`ALTER TABLE "person_audit" DROP COLUMN "personId";`.execute(db);
|
||||||
|
await sql`CREATE INDEX "person_audit_personGroupId_idx" ON "person_audit" ("personGroupId");`.execute(db);
|
||||||
|
|
||||||
|
await sql`ALTER TABLE "asset_face" DROP CONSTRAINT "asset_face_personId_fkey";`.execute(db);
|
||||||
|
await sql`ALTER TABLE "asset_face" RENAME COLUMN "personId" TO "personGroupId";`.execute(db);
|
||||||
|
await sql`UPDATE "asset_face" SET "personGroupId" = "person"."personGroupId"
|
||||||
|
FROM "person"
|
||||||
|
WHERE "person"."id" = "asset_face"."personGroupId";`.execute(db);
|
||||||
|
await sql`ALTER TABLE "asset_face" ADD CONSTRAINT "asset_face_personGroupId_fkey" FOREIGN KEY ("personGroupId") REFERENCES "person_group" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute(
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
await sql`CREATE INDEX "asset_face_personGroupId_assetId_idx" ON "asset_face" ("personGroupId", "assetId");`.execute(
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
await sql`CREATE INDEX "asset_face_personGroupId_assetId_notDeleted_isVisible_idx" ON "asset_face" ("personGroupId", "assetId") WHERE ("deletedAt" IS NULL AND "isVisible" IS TRUE);`.execute(
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
await sql`CREATE INDEX "asset_face_assetId_personGroupId_idx" ON "asset_face" ("assetId", "personGroupId");`.execute(
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
await sql`DROP INDEX "asset_face_assetId_personId_idx";`.execute(db);
|
||||||
|
await sql`DROP INDEX "asset_face_personId_assetId_idx";`.execute(db);
|
||||||
|
await sql`DROP INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx";`.execute(db);
|
||||||
|
|
||||||
|
// a person is identified by its owner and the group it belongs to
|
||||||
|
await sql`ALTER TABLE "person" DROP CONSTRAINT "person_pkey";`.execute(db);
|
||||||
|
await sql`ALTER TABLE "person" DROP COLUMN "id";`.execute(db);
|
||||||
|
await sql`ALTER TABLE "person" ADD CONSTRAINT "person_pkey" PRIMARY KEY ("ownerId", "personGroupId");`.execute(db);
|
||||||
|
await sql`DROP INDEX "person_ownerId_idx";`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"person_delete_audit","sql":"CREATE OR REPLACE FUNCTION person_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person_audit (\\"personGroupId\\", \\"ownerId\\")\\n SELECT \\"personGroupId\\", \\"ownerId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_person_delete_audit';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"trigger","name":"person_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"person_delete_audit\\"\\n AFTER DELETE ON \\"person\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() <= 1)\\n EXECUTE FUNCTION person_delete_audit();"}'::jsonb WHERE "name" = 'trigger_person_delete_audit';`.execute(db);
|
||||||
|
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_person_group_delete_audit', '{"type":"function","name":"person_group_delete_audit","sql":"CREATE OR REPLACE FUNCTION person_group_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person_group_audit (\\"personGroupId\\", \\"clusterGroupId\\")\\n SELECT \\"id\\", \\"clusterGroupId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db);
|
||||||
|
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_cluster_group_updatedAt', '{"type":"trigger","name":"cluster_group_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"cluster_group_updatedAt\\"\\n BEFORE UPDATE ON \\"cluster_group\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db);
|
||||||
|
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_person_group_delete_audit', '{"type":"trigger","name":"person_group_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"person_group_delete_audit\\"\\n AFTER DELETE ON \\"person_group\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION person_group_delete_audit();"}'::jsonb);`.execute(db);
|
||||||
|
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_person_group_updatedAt', '{"type":"trigger","name":"person_group_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"person_group_updatedAt\\"\\n BEFORE UPDATE ON \\"person_group\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db);
|
||||||
|
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_face_personGroupId_assetId_notDeleted_isVisible_idx', '{"type":"index","name":"asset_face_personGroupId_assetId_notDeleted_isVisible_idx","sql":"CREATE INDEX \\"asset_face_personGroupId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"personGroupId\\", \\"assetId\\") WHERE (\\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE);"}'::jsonb);`.execute(db);
|
||||||
|
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_face_personId_assetId_notDeleted_isVisible_idx';`.execute(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`CREATE OR REPLACE FUNCTION person_delete_audit()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE PLPGSQL
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO person_audit ("personId", "ownerId")
|
||||||
|
SELECT "id", "ownerId"
|
||||||
|
FROM OLD;
|
||||||
|
RETURN NULL;
|
||||||
|
END
|
||||||
|
$$;`.execute(db);
|
||||||
|
await sql`CREATE OR REPLACE TRIGGER "person_delete_audit"
|
||||||
|
AFTER DELETE ON "person"
|
||||||
|
REFERENCING OLD TABLE AS "old"
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
WHEN (pg_trigger_depth() = 0)
|
||||||
|
EXECUTE FUNCTION person_delete_audit();`.execute(db);
|
||||||
|
|
||||||
|
await sql`ALTER TABLE "person" DROP CONSTRAINT "person_pkey";`.execute(db);
|
||||||
|
await sql`ALTER TABLE "person" ADD "id" uuid NOT NULL DEFAULT uuid_generate_v4();`.execute(db);
|
||||||
|
await sql`UPDATE "person" SET "id" = "personGroupId";`.execute(db);
|
||||||
|
await sql`ALTER TABLE "person" ADD CONSTRAINT "person_pkey" PRIMARY KEY ("id");`.execute(db);
|
||||||
|
await sql`CREATE INDEX "person_ownerId_idx" ON "person" ("ownerId");`.execute(db);
|
||||||
|
|
||||||
|
await sql`DROP INDEX "asset_face_assetId_personGroupId_idx";`.execute(db);
|
||||||
|
await sql`DROP INDEX "asset_face_personGroupId_assetId_notDeleted_isVisible_idx";`.execute(db);
|
||||||
|
await sql`DROP INDEX "asset_face_personGroupId_assetId_idx";`.execute(db);
|
||||||
|
await sql`ALTER TABLE "asset_face" DROP CONSTRAINT "asset_face_personGroupId_fkey";`.execute(db);
|
||||||
|
await sql`ALTER TABLE "asset_face" RENAME COLUMN "personGroupId" TO "personId";`.execute(db);
|
||||||
|
await sql`UPDATE "asset_face" SET "personId" = "person"."id"
|
||||||
|
FROM "person"
|
||||||
|
WHERE "person"."personGroupId" = "asset_face"."personId";`.execute(db);
|
||||||
|
await sql`ALTER TABLE "asset_face" ADD CONSTRAINT "asset_face_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute(
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
await sql`CREATE INDEX "asset_face_assetId_personId_idx" ON "asset_face" ("assetId", "personId");`.execute(db);
|
||||||
|
await sql`CREATE INDEX "asset_face_personId_assetId_idx" ON "asset_face" ("personId", "assetId");`.execute(db);
|
||||||
|
await sql`CREATE INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx" ON "asset_face" ("personId", "assetId") WHERE ("deletedAt" IS NULL AND "isVisible" IS TRUE);`.execute(
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
|
||||||
|
await sql`ALTER TABLE "person_audit" ADD "personId" uuid;`.execute(db);
|
||||||
|
await sql`UPDATE "person_audit" SET "personId" = "personGroupId";`.execute(db);
|
||||||
|
await sql`ALTER TABLE "person_audit" ALTER COLUMN "personId" SET NOT NULL;`.execute(db);
|
||||||
|
await sql`CREATE INDEX "person_audit_personId_idx" ON "person_audit" ("personId");`.execute(db);
|
||||||
|
await sql`DROP INDEX "person_audit_personGroupId_idx";`.execute(db);
|
||||||
|
await sql`ALTER TABLE "person_audit" DROP COLUMN "personGroupId";`.execute(db);
|
||||||
|
await sql`ALTER TABLE "person" DROP CONSTRAINT "person_personGroupId_fkey";`.execute(db);
|
||||||
|
await sql`DROP INDEX "person_personGroupId_idx";`.execute(db);
|
||||||
|
await sql`ALTER TABLE "person" DROP COLUMN "personGroupId";`.execute(db);
|
||||||
|
await sql`ALTER TABLE "user" DROP CONSTRAINT "user_clusterGroupId_fkey";`.execute(db);
|
||||||
|
await sql`DROP INDEX "user_clusterGroupId_idx";`.execute(db);
|
||||||
|
await sql`ALTER TABLE "user" DROP COLUMN "clusterGroupId";`.execute(db);
|
||||||
|
await sql`DROP TABLE "cluster_group_request";`.execute(db);
|
||||||
|
await sql`DROP TABLE "person_group_audit";`.execute(db);
|
||||||
|
await sql`DROP TABLE "person_group";`.execute(db);
|
||||||
|
await sql`DROP TABLE "cluster_group";`.execute(db);
|
||||||
|
await sql`DROP FUNCTION person_group_delete_audit;`.execute(db);
|
||||||
|
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"person_delete_audit","sql":"CREATE OR REPLACE FUNCTION person_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person_audit (\\"personId\\", \\"ownerId\\")\\n SELECT \\"id\\", \\"ownerId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_person_delete_audit';`.execute(
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"trigger","name":"person_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"person_delete_audit\\"\\n AFTER DELETE ON \\"person\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION person_delete_audit();"}'::jsonb WHERE "name" = 'trigger_person_delete_audit';`.execute(
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_person_group_delete_audit';`.execute(db);
|
||||||
|
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_cluster_group_updatedAt';`.execute(db);
|
||||||
|
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_person_group_delete_audit';`.execute(db);
|
||||||
|
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_person_group_updatedAt';`.execute(db);
|
||||||
|
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_face_personGroupId_assetId_notDeleted_isVisible_idx';`.execute(
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_face_personId_assetId_notDeleted_isVisible_idx', '{"type":"index","name":"asset_face_personId_assetId_notDeleted_isVisible_idx","sql":"CREATE INDEX \\"asset_face_personId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"personId\\", \\"assetId\\") WHERE (\\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE);"}'::jsonb);`.execute(
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import { SourceType } from 'src/enum';
|
|||||||
import { asset_face_source_type } from 'src/schema/enums';
|
import { asset_face_source_type } from 'src/schema/enums';
|
||||||
import { asset_face_audit } from 'src/schema/functions';
|
import { asset_face_audit } from 'src/schema/functions';
|
||||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||||
import { PersonTable } from 'src/schema/tables/person.table';
|
import { PersonGroupTable } from 'src/schema/tables/person-group.table';
|
||||||
|
|
||||||
@Table({ name: 'asset_face' })
|
@Table({ name: 'asset_face' })
|
||||||
@UpdatedAtTrigger('asset_face_updatedAt')
|
@UpdatedAtTrigger('asset_face_updatedAt')
|
||||||
@@ -26,13 +26,13 @@ import { PersonTable } from 'src/schema/tables/person.table';
|
|||||||
when: 'pg_trigger_depth() = 0',
|
when: 'pg_trigger_depth() = 0',
|
||||||
})
|
})
|
||||||
// schemaFromDatabase does not preserve column order
|
// schemaFromDatabase does not preserve column order
|
||||||
@Index({ name: 'asset_face_assetId_personId_idx', columns: ['assetId', 'personId'] })
|
@Index({ name: 'asset_face_assetId_personGroupId_idx', columns: ['assetId', 'personGroupId'] })
|
||||||
@Index({
|
@Index({
|
||||||
name: 'asset_face_personId_assetId_notDeleted_isVisible_idx',
|
name: 'asset_face_personGroupId_assetId_notDeleted_isVisible_idx',
|
||||||
columns: ['personId', 'assetId'],
|
columns: ['personGroupId', 'assetId'],
|
||||||
where: '"deletedAt" IS NULL AND "isVisible" IS TRUE',
|
where: '"deletedAt" IS NULL AND "isVisible" IS TRUE',
|
||||||
})
|
})
|
||||||
@Index({ columns: ['personId', 'assetId'] })
|
@Index({ columns: ['personGroupId', 'assetId'] })
|
||||||
export class AssetFaceTable {
|
export class AssetFaceTable {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id!: Generated<string>;
|
id!: Generated<string>;
|
||||||
@@ -40,19 +40,19 @@ export class AssetFaceTable {
|
|||||||
@ForeignKeyColumn(() => AssetTable, {
|
@ForeignKeyColumn(() => AssetTable, {
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
onUpdate: 'CASCADE',
|
onUpdate: 'CASCADE',
|
||||||
// [assetId, personId] is the PK constraint
|
// [assetId, personGroupId] is the PK constraint
|
||||||
index: false,
|
index: false,
|
||||||
})
|
})
|
||||||
assetId!: string;
|
assetId!: string;
|
||||||
|
|
||||||
@ForeignKeyColumn(() => PersonTable, {
|
@ForeignKeyColumn(() => PersonGroupTable, {
|
||||||
onDelete: 'SET NULL',
|
onDelete: 'SET NULL',
|
||||||
onUpdate: 'CASCADE',
|
onUpdate: 'CASCADE',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
// [personId, assetId] makes this redundant
|
// [personGroupId, assetId] makes this redundant
|
||||||
index: false,
|
index: false,
|
||||||
})
|
})
|
||||||
personId!: string | null;
|
personGroupId!: string | null;
|
||||||
|
|
||||||
@Column({ default: 0, type: 'integer' })
|
@Column({ default: 0, type: 'integer' })
|
||||||
imageWidth!: Generated<number>;
|
imageWidth!: Generated<number>;
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {
|
||||||
|
CreateDateColumn,
|
||||||
|
ForeignKeyColumn,
|
||||||
|
Generated,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Table,
|
||||||
|
Timestamp,
|
||||||
|
Unique,
|
||||||
|
} from '@immich/sql-tools';
|
||||||
|
import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table';
|
||||||
|
import { UserTable } from 'src/schema/tables/user.table';
|
||||||
|
|
||||||
|
@Table('cluster_group_request')
|
||||||
|
@Unique({ columns: ['clusterGroupId', 'userId'] })
|
||||||
|
export class ClusterGroupRequestTable {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id!: Generated<string>;
|
||||||
|
|
||||||
|
@ForeignKeyColumn(() => ClusterGroupTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
|
||||||
|
clusterGroupId!: string;
|
||||||
|
|
||||||
|
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Generated<Timestamp>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Generated,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Table,
|
||||||
|
Timestamp,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from '@immich/sql-tools';
|
||||||
|
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||||
|
|
||||||
|
@Table('cluster_group')
|
||||||
|
@UpdatedAtTrigger('cluster_group_updatedAt')
|
||||||
|
export class ClusterGroupTable {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id!: Generated<string>;
|
||||||
|
|
||||||
|
@Column({ type: 'character varying', nullable: true, default: null })
|
||||||
|
name!: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Generated<Timestamp>;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt!: Generated<Timestamp>;
|
||||||
|
|
||||||
|
@UpdateIdColumn({ index: true })
|
||||||
|
updateId!: Generated<string>;
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ export class PersonAuditTable {
|
|||||||
id!: Generated<string>;
|
id!: Generated<string>;
|
||||||
|
|
||||||
@Column({ type: 'uuid', index: true })
|
@Column({ type: 'uuid', index: true })
|
||||||
personId!: string;
|
personGroupId!: string;
|
||||||
|
|
||||||
@Column({ type: 'uuid', index: true })
|
@Column({ type: 'uuid', index: true })
|
||||||
ownerId!: string;
|
ownerId!: string;
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
|
||||||
|
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||||
|
|
||||||
|
@Table('person_group_audit')
|
||||||
|
export class PersonGroupAuditTable {
|
||||||
|
@PrimaryGeneratedUuidV7Column()
|
||||||
|
id!: Generated<string>;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', index: true })
|
||||||
|
personGroupId!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', index: true })
|
||||||
|
clusterGroupId!: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
|
||||||
|
deletedAt!: Generated<Timestamp>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
AfterDeleteTrigger,
|
||||||
|
CreateDateColumn,
|
||||||
|
ForeignKeyColumn,
|
||||||
|
Generated,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Table,
|
||||||
|
Timestamp,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from '@immich/sql-tools';
|
||||||
|
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||||
|
import { person_group_delete_audit } from 'src/schema/functions';
|
||||||
|
import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table';
|
||||||
|
|
||||||
|
@Table('person_group')
|
||||||
|
@UpdatedAtTrigger('person_group_updatedAt')
|
||||||
|
@AfterDeleteTrigger({
|
||||||
|
scope: 'statement',
|
||||||
|
function: person_group_delete_audit,
|
||||||
|
referencingOldTableAs: 'old',
|
||||||
|
when: 'pg_trigger_depth() = 0',
|
||||||
|
})
|
||||||
|
export class PersonGroupTable {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id!: Generated<string>;
|
||||||
|
|
||||||
|
@ForeignKeyColumn(() => ClusterGroupTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
|
||||||
|
clusterGroupId!: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Generated<Timestamp>;
|
||||||
|
|
||||||
|
@CreateIdColumn({ index: true })
|
||||||
|
createId!: Generated<string>;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt!: Generated<Timestamp>;
|
||||||
|
|
||||||
|
@UpdateIdColumn({ index: true })
|
||||||
|
updateId!: Generated<string>;
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
ForeignKeyColumn,
|
ForeignKeyColumn,
|
||||||
Generated,
|
Generated,
|
||||||
Index,
|
Index,
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
Table,
|
Table,
|
||||||
Timestamp,
|
Timestamp,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
@@ -14,6 +13,7 @@ import {
|
|||||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||||
import { person_delete_audit } from 'src/schema/functions';
|
import { person_delete_audit } from 'src/schema/functions';
|
||||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||||
|
import { PersonGroupTable } from 'src/schema/tables/person-group.table';
|
||||||
import { UserTable } from 'src/schema/tables/user.table';
|
import { UserTable } from 'src/schema/tables/user.table';
|
||||||
|
|
||||||
@Table('person')
|
@Table('person')
|
||||||
@@ -27,12 +27,25 @@ import { UserTable } from 'src/schema/tables/user.table';
|
|||||||
scope: 'statement',
|
scope: 'statement',
|
||||||
function: person_delete_audit,
|
function: person_delete_audit,
|
||||||
referencingOldTableAs: 'old',
|
referencingOldTableAs: 'old',
|
||||||
when: 'pg_trigger_depth() = 0',
|
when: 'pg_trigger_depth() <= 1',
|
||||||
})
|
})
|
||||||
@Check({ name: 'person_birthDate_chk', expression: `"birthDate" <= CURRENT_DATE` })
|
@Check({ name: 'person_birthDate_chk', expression: `"birthDate" <= CURRENT_DATE` })
|
||||||
export class PersonTable {
|
export class PersonTable {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@ForeignKeyColumn(() => UserTable, {
|
||||||
id!: Generated<string>;
|
onDelete: 'CASCADE',
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
primary: true,
|
||||||
|
// [ownerId, personGroupId] is the PK constraint
|
||||||
|
index: false,
|
||||||
|
})
|
||||||
|
ownerId!: string;
|
||||||
|
|
||||||
|
@ForeignKeyColumn(() => PersonGroupTable, {
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
primary: true,
|
||||||
|
})
|
||||||
|
personGroupId!: string;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt!: Generated<Timestamp>;
|
createdAt!: Generated<Timestamp>;
|
||||||
@@ -40,9 +53,6 @@ export class PersonTable {
|
|||||||
@UpdateDateColumn()
|
@UpdateDateColumn()
|
||||||
updatedAt!: Generated<Timestamp>;
|
updatedAt!: Generated<Timestamp>;
|
||||||
|
|
||||||
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
|
|
||||||
ownerId!: string;
|
|
||||||
|
|
||||||
@Column({ default: '' })
|
@Column({ default: '' })
|
||||||
name!: Generated<string>;
|
name!: Generated<string>;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
DeleteDateColumn,
|
DeleteDateColumn,
|
||||||
|
ForeignKeyColumn,
|
||||||
Generated,
|
Generated,
|
||||||
Index,
|
Index,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
@@ -14,6 +15,7 @@ import { ColumnType } from 'kysely';
|
|||||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||||
import { UserAvatarColor, UserStatus } from 'src/enum';
|
import { UserAvatarColor, UserStatus } from 'src/enum';
|
||||||
import { user_delete_audit } from 'src/schema/functions';
|
import { user_delete_audit } from 'src/schema/functions';
|
||||||
|
import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table';
|
||||||
|
|
||||||
@Table('user')
|
@Table('user')
|
||||||
@UpdatedAtTrigger('user_updatedAt')
|
@UpdatedAtTrigger('user_updatedAt')
|
||||||
@@ -82,4 +84,7 @@ export class UserTable {
|
|||||||
|
|
||||||
@UpdateIdColumn({ index: true })
|
@UpdateIdColumn({ index: true })
|
||||||
updateId!: Generated<string>;
|
updateId!: Generated<string>;
|
||||||
|
|
||||||
|
@ForeignKeyColumn(() => ClusterGroupTable, { onUpdate: 'CASCADE', nullable: false })
|
||||||
|
clusterGroupId!: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -326,6 +326,7 @@ describe(AssetService.name, () => {
|
|||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||||
mocks.asset.getById.mockResolvedValueOnce(getForAsset(asset));
|
mocks.asset.getById.mockResolvedValueOnce(getForAsset(asset));
|
||||||
mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset));
|
mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset));
|
||||||
|
mocks.asset.getById.mockResolvedValueOnce(getForAsset(unlinkedAsset));
|
||||||
mocks.asset.update.mockResolvedValueOnce(getForAsset(unlinkedAsset));
|
mocks.asset.update.mockResolvedValueOnce(getForAsset(unlinkedAsset));
|
||||||
|
|
||||||
await sut.update(auth, asset.id, { livePhotoVideoId: null });
|
await sut.update(auth, asset.id, { livePhotoVideoId: null });
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export class AssetService extends BaseService {
|
|||||||
const asset = await this.assetRepository.getById(id, {
|
const asset = await this.assetRepository.getById(id, {
|
||||||
exifInfo: true,
|
exifInfo: true,
|
||||||
owner: true,
|
owner: true,
|
||||||
faces: { person: true },
|
faces: { person: true, viewingUserId: auth.user.id },
|
||||||
stack: { assets: true },
|
stack: { assets: true },
|
||||||
edits: true,
|
edits: true,
|
||||||
tags: true,
|
tags: true,
|
||||||
@@ -85,7 +85,7 @@ export class AssetService extends BaseService {
|
|||||||
delete data.owner;
|
delete data.owner;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.ownerId !== auth.user.id || auth.sharedLink) {
|
if (auth.sharedLink) {
|
||||||
data.people = [];
|
data.people = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ export class AssetService extends BaseService {
|
|||||||
throw new BadRequestException('Asset not found');
|
throw new BadRequestException('Asset not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return mapAsset(asset, { auth });
|
return this.get(auth, id) as Promise<AssetResponseDto>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise<void> {
|
async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise<void> {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { AppRepository } from 'src/repositories/app.repository';
|
|||||||
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
|
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
|
||||||
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
|
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
|
||||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||||
|
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { CronRepository } from 'src/repositories/cron.repository';
|
import { CronRepository } from 'src/repositories/cron.repository';
|
||||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||||
@@ -74,6 +75,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
|
|||||||
AssetRepository,
|
AssetRepository,
|
||||||
AssetEditRepository,
|
AssetEditRepository,
|
||||||
AssetJobRepository,
|
AssetJobRepository,
|
||||||
|
ClusterGroupRepository,
|
||||||
ConfigRepository,
|
ConfigRepository,
|
||||||
CronRepository,
|
CronRepository,
|
||||||
CryptoRepository,
|
CryptoRepository,
|
||||||
@@ -134,6 +136,7 @@ export class BaseService {
|
|||||||
protected assetRepository: AssetRepository,
|
protected assetRepository: AssetRepository,
|
||||||
protected assetEditRepository: AssetEditRepository,
|
protected assetEditRepository: AssetEditRepository,
|
||||||
protected assetJobRepository: AssetJobRepository,
|
protected assetJobRepository: AssetJobRepository,
|
||||||
|
protected clusterGroupRepository: ClusterGroupRepository,
|
||||||
protected configRepository: ConfigRepository,
|
protected configRepository: ConfigRepository,
|
||||||
protected cronRepository: CronRepository,
|
protected cronRepository: CronRepository,
|
||||||
protected cryptoRepository: CryptoRepository,
|
protected cryptoRepository: CryptoRepository,
|
||||||
@@ -203,6 +206,7 @@ export class BaseService {
|
|||||||
ctx.assetRepository,
|
ctx.assetRepository,
|
||||||
ctx.assetEditRepository,
|
ctx.assetEditRepository,
|
||||||
ctx.assetJobRepository,
|
ctx.assetJobRepository,
|
||||||
|
ctx.clusterGroupRepository,
|
||||||
ctx.configRepository,
|
ctx.configRepository,
|
||||||
ctx.cronRepository,
|
ctx.cronRepository,
|
||||||
ctx.cryptoRepository,
|
ctx.cryptoRepository,
|
||||||
@@ -292,7 +296,7 @@ export class BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createUser(dto: Insertable<UserTable> & { email: string }): Promise<UserAdmin> {
|
async createUser(dto: Omit<Insertable<UserTable>, 'clusterGroupId'> & { email: string }): Promise<UserAdmin> {
|
||||||
const exists = await this.userRepository.getByEmail(dto.email);
|
const exists = await this.userRepository.getByEmail(dto.email);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
this.logger.debug('User creation rejected: user already exists');
|
this.logger.debug('User creation rejected: user already exists');
|
||||||
@@ -306,7 +310,7 @@ export class BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload: Insertable<UserTable> = { ...dto };
|
const payload: Omit<Insertable<UserTable>, 'clusterGroupId'> = { ...dto };
|
||||||
if (payload.password) {
|
if (payload.password) {
|
||||||
payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS);
|
payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS);
|
||||||
}
|
}
|
||||||
@@ -314,7 +318,8 @@ export class BaseService {
|
|||||||
payload.storageLabel = sanitize(payload.storageLabel.replaceAll('.', ''));
|
payload.storageLabel = sanitize(payload.storageLabel.replaceAll('.', ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.userRepository.create(payload);
|
const clusterGroup = await this.clusterGroupRepository.create();
|
||||||
|
const user = await this.userRepository.create({ ...payload, clusterGroupId: clusterGroup.id });
|
||||||
|
|
||||||
await this.eventRepository.emit('UserCreate', user);
|
await this.eventRepository.emit('UserCreate', user);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { MaybeDuplicate } from 'src/dtos/activity.dto';
|
||||||
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
|
import {
|
||||||
|
ClusterGroupRequestCreateDto,
|
||||||
|
ClusterGroupRequestResponseDto,
|
||||||
|
mapClusterGroupRequest,
|
||||||
|
} from 'src/dtos/cluster-group.dto';
|
||||||
|
import { mapUser, UserResponseDto } from 'src/dtos/user.dto';
|
||||||
|
import { Permission } from 'src/enum';
|
||||||
|
import { BaseService } from 'src/services/base.service';
|
||||||
|
import { findOrFail } from 'src/utils/misc';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ClusterGroupService extends BaseService {
|
||||||
|
async getRequests(auth: AuthDto): Promise<ClusterGroupRequestResponseDto[]> {
|
||||||
|
const requests = await this.clusterGroupRepository.searchRequests({ userId: auth.user.id });
|
||||||
|
return requests.map((request) => mapClusterGroupRequest(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRequestsForGroup(auth: AuthDto, clusterGroupId: string): Promise<ClusterGroupRequestResponseDto[]> {
|
||||||
|
await this.requireAccess({ auth, permission: Permission.ClusterGroupRead, ids: [clusterGroupId] });
|
||||||
|
|
||||||
|
const requests = await this.clusterGroupRepository.searchRequests({ clusterGroupId });
|
||||||
|
return requests.map((request) => mapClusterGroupRequest(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUsers(auth: AuthDto, clusterGroupId: string): Promise<UserResponseDto[]> {
|
||||||
|
await this.requireAccess({ auth, permission: Permission.ClusterGroupRead, ids: [clusterGroupId] });
|
||||||
|
|
||||||
|
const users = await this.clusterGroupRepository.getUsers({ clusterGroupId, userId: auth.user.id });
|
||||||
|
return users.map((user) => mapUser(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRequest(
|
||||||
|
auth: AuthDto,
|
||||||
|
clusterGroupId: string,
|
||||||
|
{ userId }: ClusterGroupRequestCreateDto,
|
||||||
|
): Promise<MaybeDuplicate<ClusterGroupRequestResponseDto>> {
|
||||||
|
await this.requireAccess({ auth, permission: Permission.ClusterGroupRequestCreate, ids: [clusterGroupId] });
|
||||||
|
|
||||||
|
if (userId === auth.user.id) {
|
||||||
|
throw new BadRequestException('Cannot request to join your own cluster group');
|
||||||
|
}
|
||||||
|
|
||||||
|
await findOrFail(() => this.userRepository.get(userId, {}), 'User');
|
||||||
|
|
||||||
|
const request = await findOrFail(
|
||||||
|
() => this.clusterGroupRepository.createRequest({ clusterGroupId, userId }),
|
||||||
|
'Request',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (request.isInserted) {
|
||||||
|
await this.eventRepository.emit('ClusterGroupRequest', { clusterGroupId, userId, senderName: auth.user.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { duplicate: !request.isInserted, value: mapClusterGroupRequest(request) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async acceptRequest(auth: AuthDto, id: string): Promise<void> {
|
||||||
|
await this.requireAccess({ auth, permission: Permission.ClusterGroupRequestRead, ids: [id] });
|
||||||
|
|
||||||
|
const request = await findOrFail(() => this.clusterGroupRepository.getRequest(id), 'Request');
|
||||||
|
|
||||||
|
await this.personRepository.reassignCluster({ userId: auth.user.id, newClusterId: request.clusterGroupId });
|
||||||
|
await this.userRepository.update(auth.user.id, { clusterGroupId: request.clusterGroupId });
|
||||||
|
await this.clusterGroupRepository.deleteRequest(request.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRequest(auth: AuthDto, id: string): Promise<void> {
|
||||||
|
await this.requireAccess({ auth, permission: Permission.ClusterGroupRequestDelete, ids: [id] });
|
||||||
|
await this.clusterGroupRepository.deleteRequest(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async leave(auth: AuthDto, clusterGroupId: string): Promise<void> {
|
||||||
|
await this.requireAccess({ auth, permission: Permission.ClusterGroupLeave, ids: [clusterGroupId] });
|
||||||
|
|
||||||
|
const hasOtherMembers = await this.clusterGroupRepository.hasOtherMembers({
|
||||||
|
clusterGroupId,
|
||||||
|
userId: auth.user.id,
|
||||||
|
});
|
||||||
|
if (!hasOtherMembers) {
|
||||||
|
throw new BadRequestException('Cannot leave a cluster group without any other members');
|
||||||
|
}
|
||||||
|
|
||||||
|
const clusterGroup = await this.clusterGroupRepository.create();
|
||||||
|
await this.personRepository.reassignCluster({ userId: auth.user.id, newClusterId: clusterGroup.id });
|
||||||
|
await this.userRepository.update(auth.user.id, { clusterGroupId: clusterGroup.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { AssetService } from 'src/services/asset.service';
|
|||||||
import { AuthAdminService } from 'src/services/auth-admin.service';
|
import { AuthAdminService } from 'src/services/auth-admin.service';
|
||||||
import { AuthService } from 'src/services/auth.service';
|
import { AuthService } from 'src/services/auth.service';
|
||||||
import { CliService } from 'src/services/cli.service';
|
import { CliService } from 'src/services/cli.service';
|
||||||
|
import { ClusterGroupService } from 'src/services/cluster-group.service';
|
||||||
import { DatabaseBackupService } from 'src/services/database-backup.service';
|
import { DatabaseBackupService } from 'src/services/database-backup.service';
|
||||||
import { DatabaseService } from 'src/services/database.service';
|
import { DatabaseService } from 'src/services/database.service';
|
||||||
import { DownloadService } from 'src/services/download.service';
|
import { DownloadService } from 'src/services/download.service';
|
||||||
@@ -76,6 +77,7 @@ export const services = [
|
|||||||
NotificationService,
|
NotificationService,
|
||||||
NotificationAdminService,
|
NotificationAdminService,
|
||||||
OcrService,
|
OcrService,
|
||||||
|
ClusterGroupService,
|
||||||
PartnerService,
|
PartnerService,
|
||||||
PersonService,
|
PersonService,
|
||||||
PluginService,
|
PluginService,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ describe(JobService.name, () => {
|
|||||||
jobs: [],
|
jobs: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
item: { name: JobName.PersonGenerateThumbnail, data: { id: 'asset-1' } },
|
item: { name: JobName.PersonGenerateThumbnail, data: { ownerId: 'owner-1', personGroupId: 'person-group-1' } },
|
||||||
jobs: [],
|
jobs: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -90,6 +90,7 @@ describe(JobService.name, () => {
|
|||||||
for (const { item, jobs, stub } of tests) {
|
for (const { item, jobs, stub } of tests) {
|
||||||
it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => {
|
it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => {
|
||||||
if (stub) {
|
if (stub) {
|
||||||
|
mocks.asset.getById.mockResolvedValue(stub[0]);
|
||||||
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue(stub);
|
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue(stub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,11 +124,8 @@ export class JobService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case JobName.PersonGenerateThumbnail: {
|
case JobName.PersonGenerateThumbnail: {
|
||||||
const { id } = item.data;
|
const { ownerId, personGroupId } = item.data;
|
||||||
const person = await this.personRepository.getById(id);
|
this.websocketRepository.clientSend('on_person_thumbnail', ownerId, personGroupId);
|
||||||
if (person) {
|
|
||||||
this.websocketRepository.clientSend('on_person_thumbnail', person.ownerId, person.id);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ describe(MediaService.name, () => {
|
|||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.PersonGenerateThumbnail,
|
name: JobName.PersonGenerateThumbnail,
|
||||||
data: { id: person.id },
|
data: { ownerId: person.ownerId, personGroupId: person.personGroupId },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -129,7 +129,8 @@ describe(MediaService.name, () => {
|
|||||||
{
|
{
|
||||||
name: JobName.PersonGenerateThumbnail,
|
name: JobName.PersonGenerateThumbnail,
|
||||||
data: {
|
data: {
|
||||||
id: person1.id,
|
ownerId: person1.ownerId,
|
||||||
|
personGroupId: person1.personGroupId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -297,7 +298,12 @@ describe(MediaService.name, () => {
|
|||||||
|
|
||||||
expect(mocks.storage.removeEmptyDirs).toHaveBeenCalledTimes(2);
|
expect(mocks.storage.removeEmptyDirs).toHaveBeenCalledTimes(2);
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.AssetFileMigration, data: { id: asset.id } }]);
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.AssetFileMigration, data: { id: asset.id } }]);
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.PersonFileMigration, data: { id: person.id } }]);
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
|
{
|
||||||
|
name: JobName.PersonFileMigration,
|
||||||
|
data: { ownerId: person.ownerId, personGroupId: person.personGroupId },
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1518,24 +1524,25 @@ describe(MediaService.name, () => {
|
|||||||
info: { width: 1000, height: 1000 } as OutputInfo,
|
info: { width: 1000, height: 1000 } as OutputInfo,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.Success);
|
await expect(
|
||||||
|
sut.handleGeneratePersonThumbnail({ ownerId: 'owner-1', personGroupId: 'person-group-1' }),
|
||||||
|
).resolves.toBe(JobStatus.Success);
|
||||||
expect(mocks.media.generateThumbnail).toHaveBeenCalled();
|
expect(mocks.media.generateThumbnail).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip a person not found', async () => {
|
it('should skip a person not found', async () => {
|
||||||
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
|
await sut.handleGeneratePersonThumbnail({ ownerId: 'owner-1', personGroupId: 'person-group-1' });
|
||||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip a person without a face asset id', async () => {
|
it('should skip a person without a face asset id', async () => {
|
||||||
const person = PersonFactory.create({ faceAssetId: null });
|
const person = PersonFactory.create({ faceAssetId: null });
|
||||||
mocks.person.getById.mockResolvedValue(person);
|
await sut.handleGeneratePersonThumbnail({ ownerId: person.ownerId, personGroupId: person.personGroupId });
|
||||||
await sut.handleGeneratePersonThumbnail({ id: person.id });
|
|
||||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip a person with face not found', async () => {
|
it('should skip a person with face not found', async () => {
|
||||||
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
|
await sut.handleGeneratePersonThumbnail({ ownerId: 'owner-1', personGroupId: 'person-group-1' });
|
||||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1548,9 +1555,14 @@ describe(MediaService.name, () => {
|
|||||||
const info = { width: 1000, height: 1000 } as OutputInfo;
|
const info = { width: 1000, height: 1000 } as OutputInfo;
|
||||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||||
|
|
||||||
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
await expect(
|
||||||
|
sut.handleGeneratePersonThumbnail({ ownerId: person.ownerId, personGroupId: person.personGroupId }),
|
||||||
|
).resolves.toBe(JobStatus.Success);
|
||||||
|
|
||||||
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(person.id);
|
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith({
|
||||||
|
ownerId: person.ownerId,
|
||||||
|
personGroupId: person.personGroupId,
|
||||||
|
});
|
||||||
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
|
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
|
||||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailMiddle.originalPath, {
|
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailMiddle.originalPath, {
|
||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
@@ -1581,7 +1593,11 @@ describe(MediaService.name, () => {
|
|||||||
},
|
},
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
);
|
);
|
||||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, thumbnailPath: expect.any(String) });
|
expect(mocks.person.update).toHaveBeenCalledWith({
|
||||||
|
ownerId: person.ownerId,
|
||||||
|
personGroupId: person.personGroupId,
|
||||||
|
thumbnailPath: expect.any(String),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use preview path if video', async () => {
|
it('should use preview path if video', async () => {
|
||||||
@@ -1593,9 +1609,14 @@ describe(MediaService.name, () => {
|
|||||||
const info = { width: 1000, height: 1000 } as OutputInfo;
|
const info = { width: 1000, height: 1000 } as OutputInfo;
|
||||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||||
|
|
||||||
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
await expect(
|
||||||
|
sut.handleGeneratePersonThumbnail({ ownerId: person.ownerId, personGroupId: person.personGroupId }),
|
||||||
|
).resolves.toBe(JobStatus.Success);
|
||||||
|
|
||||||
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(person.id);
|
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith({
|
||||||
|
ownerId: person.ownerId,
|
||||||
|
personGroupId: person.personGroupId,
|
||||||
|
});
|
||||||
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
|
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
|
||||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(expect.any(String), {
|
expect(mocks.media.decodeImage).toHaveBeenCalledWith(expect.any(String), {
|
||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
@@ -1626,7 +1647,11 @@ describe(MediaService.name, () => {
|
|||||||
},
|
},
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
);
|
);
|
||||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, thumbnailPath: expect.any(String) });
|
expect(mocks.person.update).toHaveBeenCalledWith({
|
||||||
|
ownerId: person.ownerId,
|
||||||
|
personGroupId: person.personGroupId,
|
||||||
|
thumbnailPath: expect.any(String),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate a thumbnail without going negative', async () => {
|
it('should generate a thumbnail without going negative', async () => {
|
||||||
@@ -1638,7 +1663,9 @@ describe(MediaService.name, () => {
|
|||||||
const info = { width: 2160, height: 3840 } as OutputInfo;
|
const info = { width: 2160, height: 3840 } as OutputInfo;
|
||||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||||
|
|
||||||
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
await expect(
|
||||||
|
sut.handleGeneratePersonThumbnail({ ownerId: person.ownerId, personGroupId: person.personGroupId }),
|
||||||
|
).resolves.toBe(JobStatus.Success);
|
||||||
|
|
||||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailStart.originalPath, {
|
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailStart.originalPath, {
|
||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
@@ -1681,7 +1708,9 @@ describe(MediaService.name, () => {
|
|||||||
const info = { width: 1000, height: 1000 } as OutputInfo;
|
const info = { width: 1000, height: 1000 } as OutputInfo;
|
||||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||||
|
|
||||||
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
await expect(
|
||||||
|
sut.handleGeneratePersonThumbnail({ ownerId: person.ownerId, personGroupId: person.personGroupId }),
|
||||||
|
).resolves.toBe(JobStatus.Success);
|
||||||
|
|
||||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailEnd.originalPath, {
|
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailEnd.originalPath, {
|
||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
@@ -1724,7 +1753,9 @@ describe(MediaService.name, () => {
|
|||||||
const info = { width: 4624, height: 3080 } as OutputInfo;
|
const info = { width: 4624, height: 3080 } as OutputInfo;
|
||||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||||
|
|
||||||
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
await expect(
|
||||||
|
sut.handleGeneratePersonThumbnail({ ownerId: person.ownerId, personGroupId: person.personGroupId }),
|
||||||
|
).resolves.toBe(JobStatus.Success);
|
||||||
|
|
||||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.negativeCoordinate.originalPath, {
|
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.negativeCoordinate.originalPath, {
|
||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
@@ -1767,7 +1798,9 @@ describe(MediaService.name, () => {
|
|||||||
const info = { width: 4624, height: 3080 } as OutputInfo;
|
const info = { width: 4624, height: 3080 } as OutputInfo;
|
||||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||||
|
|
||||||
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
await expect(
|
||||||
|
sut.handleGeneratePersonThumbnail({ ownerId: person.ownerId, personGroupId: person.personGroupId }),
|
||||||
|
).resolves.toBe(JobStatus.Success);
|
||||||
|
|
||||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.overflowingCoordinate.originalPath, {
|
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.overflowingCoordinate.originalPath, {
|
||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
@@ -1814,7 +1847,9 @@ describe(MediaService.name, () => {
|
|||||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 2160, height: 3840, isTransparent: false });
|
mocks.media.getImageMetadata.mockResolvedValue({ width: 2160, height: 3840, isTransparent: false });
|
||||||
|
|
||||||
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
await expect(
|
||||||
|
sut.handleGeneratePersonThumbnail({ ownerId: person.ownerId, personGroupId: person.personGroupId }),
|
||||||
|
).resolves.toBe(JobStatus.Success);
|
||||||
|
|
||||||
expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath);
|
expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath);
|
||||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(extracted, {
|
expect(mocks.media.decodeImage).toHaveBeenCalledWith(extracted, {
|
||||||
@@ -1857,7 +1892,9 @@ describe(MediaService.name, () => {
|
|||||||
const info = { width: 2160, height: 3840 } as OutputInfo;
|
const info = { width: 2160, height: 3840 } as OutputInfo;
|
||||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||||
|
|
||||||
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
await expect(
|
||||||
|
sut.handleGeneratePersonThumbnail({ ownerId: person.ownerId, personGroupId: person.personGroupId }),
|
||||||
|
).resolves.toBe(JobStatus.Success);
|
||||||
|
|
||||||
expect(mocks.media.extract).not.toHaveBeenCalled();
|
expect(mocks.media.extract).not.toHaveBeenCalled();
|
||||||
expect(mocks.media.generateThumbnail).toHaveBeenCalled();
|
expect(mocks.media.generateThumbnail).toHaveBeenCalled();
|
||||||
@@ -1873,7 +1910,9 @@ describe(MediaService.name, () => {
|
|||||||
const info = { width: 2160, height: 3840 } as OutputInfo;
|
const info = { width: 2160, height: 3840 } as OutputInfo;
|
||||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||||
|
|
||||||
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
await expect(
|
||||||
|
sut.handleGeneratePersonThumbnail({ ownerId: person.ownerId, personGroupId: person.personGroupId }),
|
||||||
|
).resolves.toBe(JobStatus.Success);
|
||||||
|
|
||||||
expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath);
|
expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath);
|
||||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, {
|
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, {
|
||||||
@@ -1897,7 +1936,9 @@ describe(MediaService.name, () => {
|
|||||||
mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg });
|
mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg });
|
||||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 1000, height: 1000, isTransparent: false });
|
mocks.media.getImageMetadata.mockResolvedValue({ width: 1000, height: 1000, isTransparent: false });
|
||||||
|
|
||||||
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
await expect(
|
||||||
|
sut.handleGeneratePersonThumbnail({ ownerId: person.ownerId, personGroupId: person.personGroupId }),
|
||||||
|
).resolves.toBe(JobStatus.Success);
|
||||||
|
|
||||||
expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath);
|
expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath);
|
||||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, {
|
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, {
|
||||||
|
|||||||
@@ -90,16 +90,17 @@ export class MediaService extends BaseService {
|
|||||||
for await (const people of batched(this.personRepository.getAll(force ? undefined : { thumbnailPath: '' }))) {
|
for await (const people of batched(this.personRepository.getAll(force ? undefined : { thumbnailPath: '' }))) {
|
||||||
const jobs: JobItem[] = [];
|
const jobs: JobItem[] = [];
|
||||||
for (const person of people) {
|
for (const person of people) {
|
||||||
|
const { ownerId, personGroupId } = person;
|
||||||
if (!person.faceAssetId) {
|
if (!person.faceAssetId) {
|
||||||
const face = await this.personRepository.getRandomFace(person.id);
|
const face = await this.personRepository.getRandomFace(personGroupId);
|
||||||
if (!face) {
|
if (!face) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.personRepository.update({ id: person.id, faceAssetId: face.id });
|
await this.personRepository.update({ ownerId, personGroupId, faceAssetId: face.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
jobs.push({ name: JobName.PersonGenerateThumbnail, data: { id: person.id } });
|
jobs.push({ name: JobName.PersonGenerateThumbnail, data: { ownerId, personGroupId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.jobRepository.queueAll(jobs);
|
await this.jobRepository.queueAll(jobs);
|
||||||
@@ -124,7 +125,10 @@ export class MediaService extends BaseService {
|
|||||||
|
|
||||||
for await (const people of batched(this.personRepository.getAll())) {
|
for await (const people of batched(this.personRepository.getAll())) {
|
||||||
await this.jobRepository.queueAll(
|
await this.jobRepository.queueAll(
|
||||||
people.map((person) => ({ name: JobName.PersonFileMigration, data: { id: person.id } })),
|
people.map(({ ownerId, personGroupId }) => ({
|
||||||
|
name: JobName.PersonFileMigration,
|
||||||
|
data: { ownerId, personGroupId },
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,19 +390,22 @@ export class MediaService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration })
|
@OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration })
|
||||||
async handleGeneratePersonThumbnail({ id }: JobOf<JobName.PersonGenerateThumbnail>): Promise<JobStatus> {
|
async handleGeneratePersonThumbnail({
|
||||||
|
ownerId,
|
||||||
|
personGroupId,
|
||||||
|
}: JobOf<JobName.PersonGenerateThumbnail>): Promise<JobStatus> {
|
||||||
const { image } = await this.getConfig({ withCache: true });
|
const { image } = await this.getConfig({ withCache: true });
|
||||||
const data = await this.personRepository.getDataForThumbnailGenerationJob(id);
|
const data = await this.personRepository.getDataForThumbnailGenerationJob({ ownerId, personGroupId });
|
||||||
if (!data) {
|
if (!data) {
|
||||||
this.logger.error(`Could not generate person thumbnail for ${id}: missing data`);
|
this.logger.error(`Could not generate person thumbnail for ${personGroupId}: missing data`);
|
||||||
return JobStatus.Failed;
|
return JobStatus.Failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { ownerId, x1, y1, x2, y2, oldWidth, oldHeight, exifOrientation, previewPath, originalPath } = data;
|
const { x1, y1, x2, y2, oldWidth, oldHeight, exifOrientation, previewPath, originalPath } = data;
|
||||||
let inputImage: string | Buffer;
|
let inputImage: string | Buffer;
|
||||||
if (data.type === AssetType.Video) {
|
if (data.type === AssetType.Video) {
|
||||||
if (!previewPath) {
|
if (!previewPath) {
|
||||||
this.logger.error(`Could not generate person thumbnail for video ${id}: missing preview path`);
|
this.logger.error(`Could not generate person thumbnail for video ${personGroupId}: missing preview path`);
|
||||||
return JobStatus.Failed;
|
return JobStatus.Failed;
|
||||||
}
|
}
|
||||||
inputImage = previewPath;
|
inputImage = previewPath;
|
||||||
@@ -416,7 +423,7 @@ export class MediaService extends BaseService {
|
|||||||
orientation: Buffer.isBuffer(inputImage) && exifOrientation ? Number(exifOrientation) : undefined,
|
orientation: Buffer.isBuffer(inputImage) && exifOrientation ? Number(exifOrientation) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const thumbnailPath = StorageCore.getPersonThumbnailPath({ id, ownerId });
|
const thumbnailPath = StorageCore.getPersonThumbnailPath({ ownerId, personGroupId });
|
||||||
this.storageCore.ensureFolders(thumbnailPath);
|
this.storageCore.ensureFolders(thumbnailPath);
|
||||||
|
|
||||||
const thumbnailOptions: GenerateThumbnailOptions = {
|
const thumbnailOptions: GenerateThumbnailOptions = {
|
||||||
@@ -439,7 +446,7 @@ export class MediaService extends BaseService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await this.mediaRepository.generateThumbnail(decodedImage, thumbnailOptions, thumbnailPath);
|
await this.mediaRepository.generateThumbnail(decodedImage, thumbnailOptions, thumbnailPath);
|
||||||
await this.personRepository.update({ id, thumbnailPath });
|
await this.personRepository.update({ ownerId, personGroupId, thumbnailPath });
|
||||||
|
|
||||||
return JobStatus.Success;
|
return JobStatus.Success;
|
||||||
}
|
}
|
||||||
@@ -827,7 +834,7 @@ export class MediaService extends BaseService {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const originalDimensions = getDimensions(asset.exifInfo!);
|
const originalDimensions = getDimensions(asset.exifInfo!);
|
||||||
const assetFaces = await this.personRepository.getFaces(asset.id, {});
|
const assetFaces = await this.personRepository.getFaces(asset.id, { viewingUserId: asset.ownerId });
|
||||||
const ocrData = await this.ocrRepository.getByAssetId(asset.id, {});
|
const ocrData = await this.ocrRepository.getByAssetId(asset.id, {});
|
||||||
|
|
||||||
const faceStatuses = checkFaceVisibility(assetFaces, originalDimensions, cropBox);
|
const faceStatuses = checkFaceVisibility(assetFaces, originalDimensions, cropBox);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { ImmichTags } from 'src/repositories/metadata.repository';
|
import { ImmichTags } from 'src/repositories/metadata.repository';
|
||||||
import { firstDateTime, MetadataService } from 'src/services/metadata.service';
|
import { firstDateTime, MetadataService } from 'src/services/metadata.service';
|
||||||
import { AssetFactory } from 'test/factories/asset.factory';
|
import { AssetFactory } from 'test/factories/asset.factory';
|
||||||
|
import { PersonGroupFactory } from 'test/factories/person-group.factory';
|
||||||
import { PersonFactory } from 'test/factories/person.factory';
|
import { PersonFactory } from 'test/factories/person.factory';
|
||||||
import { videoInfoStub } from 'test/fixtures/media.stub';
|
import { videoInfoStub } from 'test/fixtures/media.stub';
|
||||||
import { tagStub } from 'test/fixtures/tag.stub';
|
import { tagStub } from 'test/fixtures/tag.stub';
|
||||||
@@ -1388,7 +1389,8 @@ describe(MetadataService.name, () => {
|
|||||||
|
|
||||||
mockReadTags(faceTags);
|
mockReadTags(faceTags);
|
||||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||||
mocks.person.createAll.mockResolvedValue([person.id]);
|
mocks.person.createGroups.mockResolvedValue([PersonGroupFactory.create({ id: person.personGroupId })]);
|
||||||
|
mocks.person.createAll.mockResolvedValue([person]);
|
||||||
mocks.person.update.mockResolvedValue(person);
|
mocks.person.update.mockResolvedValue(person);
|
||||||
|
|
||||||
await sut.handleMetadataExtraction({ id: asset.id });
|
await sut.handleMetadataExtraction({ id: asset.id });
|
||||||
@@ -1411,7 +1413,8 @@ describe(MetadataService.name, () => {
|
|||||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||||
mockReadTags(makeFaceTags({ Name: person.name }));
|
mockReadTags(makeFaceTags({ Name: person.name }));
|
||||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||||
mocks.person.createAll.mockResolvedValue([person.id]);
|
mocks.person.createGroups.mockResolvedValue([PersonGroupFactory.create({ id: person.personGroupId })]);
|
||||||
|
mocks.person.createAll.mockResolvedValue([person]);
|
||||||
mocks.person.update.mockResolvedValue(person);
|
mocks.person.update.mockResolvedValue(person);
|
||||||
await sut.handleMetadataExtraction({ id: asset.id });
|
await sut.handleMetadataExtraction({ id: asset.id });
|
||||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||||
@@ -1422,7 +1425,7 @@ describe(MetadataService.name, () => {
|
|||||||
{
|
{
|
||||||
id: 'random-uuid',
|
id: 'random-uuid',
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
personId: 'random-uuid',
|
personGroupId: 'random-uuid',
|
||||||
imageHeight: 100,
|
imageHeight: 100,
|
||||||
imageWidth: 1000,
|
imageWidth: 1000,
|
||||||
boundingBoxX1: 0,
|
boundingBoxX1: 0,
|
||||||
@@ -1435,12 +1438,12 @@ describe(MetadataService.name, () => {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
expect(mocks.person.updateAll).toHaveBeenCalledWith([
|
expect(mocks.person.updateAll).toHaveBeenCalledWith([
|
||||||
{ id: 'random-uuid', ownerId: asset.ownerId, faceAssetId: 'random-uuid' },
|
{ ownerId: asset.ownerId, personGroupId: 'random-uuid', faceAssetId: 'random-uuid' },
|
||||||
]);
|
]);
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.PersonGenerateThumbnail,
|
name: JobName.PersonGenerateThumbnail,
|
||||||
data: { id: person.id },
|
data: { ownerId: asset.ownerId, personGroupId: 'random-uuid' },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -1452,7 +1455,8 @@ describe(MetadataService.name, () => {
|
|||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||||
mockReadTags(makeFaceTags({ Name: person.name }));
|
mockReadTags(makeFaceTags({ Name: person.name }));
|
||||||
mocks.person.getDistinctNames.mockResolvedValue([{ id: person.id, name: person.name }]);
|
mocks.person.getDistinctNames.mockResolvedValue([{ personGroupId: person.personGroupId, name: person.name }]);
|
||||||
|
mocks.person.createGroups.mockResolvedValue([]);
|
||||||
mocks.person.createAll.mockResolvedValue([]);
|
mocks.person.createAll.mockResolvedValue([]);
|
||||||
mocks.person.update.mockResolvedValue(person);
|
mocks.person.update.mockResolvedValue(person);
|
||||||
await sut.handleMetadataExtraction({ id: asset.id });
|
await sut.handleMetadataExtraction({ id: asset.id });
|
||||||
@@ -1464,7 +1468,7 @@ describe(MetadataService.name, () => {
|
|||||||
{
|
{
|
||||||
id: 'random-uuid',
|
id: 'random-uuid',
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
personId: person.id,
|
personGroupId: person.personGroupId,
|
||||||
imageHeight: 100,
|
imageHeight: 100,
|
||||||
imageWidth: 1000,
|
imageWidth: 1000,
|
||||||
boundingBoxX1: 0,
|
boundingBoxX1: 0,
|
||||||
@@ -1540,7 +1544,8 @@ describe(MetadataService.name, () => {
|
|||||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||||
mockReadTags(makeFaceTags({ Name: person.name }, orientation));
|
mockReadTags(makeFaceTags({ Name: person.name }, orientation));
|
||||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||||
mocks.person.createAll.mockResolvedValue([person.id]);
|
mocks.person.createGroups.mockResolvedValue([PersonGroupFactory.create({ id: person.personGroupId })]);
|
||||||
|
mocks.person.createAll.mockResolvedValue([person]);
|
||||||
mocks.person.update.mockResolvedValue(person);
|
mocks.person.update.mockResolvedValue(person);
|
||||||
await sut.handleMetadataExtraction({ id: asset.id });
|
await sut.handleMetadataExtraction({ id: asset.id });
|
||||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||||
@@ -1553,7 +1558,7 @@ describe(MetadataService.name, () => {
|
|||||||
{
|
{
|
||||||
id: 'random-uuid',
|
id: 'random-uuid',
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
personId: 'random-uuid',
|
personGroupId: 'random-uuid',
|
||||||
imageWidth: imgW,
|
imageWidth: imgW,
|
||||||
imageHeight: imgH,
|
imageHeight: imgH,
|
||||||
boundingBoxX1: x1,
|
boundingBoxX1: x1,
|
||||||
@@ -1566,12 +1571,12 @@ describe(MetadataService.name, () => {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
expect(mocks.person.updateAll).toHaveBeenCalledWith([
|
expect(mocks.person.updateAll).toHaveBeenCalledWith([
|
||||||
{ id: 'random-uuid', ownerId: asset.ownerId, faceAssetId: 'random-uuid' },
|
{ ownerId: asset.ownerId, personGroupId: 'random-uuid', faceAssetId: 'random-uuid' },
|
||||||
]);
|
]);
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.PersonGenerateThumbnail,
|
name: JobName.PersonGenerateThumbnail,
|
||||||
data: { id: person.id },
|
data: { ownerId: asset.ownerId, personGroupId: 'random-uuid' },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -893,7 +893,13 @@ export class MetadataService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async applyTaggedFaces(
|
private async applyTaggedFaces(
|
||||||
asset: { id: string; ownerId: string; faces: { id: string; sourceType: SourceType }[]; originalPath: string },
|
asset: {
|
||||||
|
id: string;
|
||||||
|
ownerId: string;
|
||||||
|
clusterGroupId: string;
|
||||||
|
faces: { id: string; sourceType: SourceType }[];
|
||||||
|
originalPath: string;
|
||||||
|
},
|
||||||
tags: ImmichTags,
|
tags: ImmichTags,
|
||||||
) {
|
) {
|
||||||
if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) {
|
if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) {
|
||||||
@@ -902,9 +908,11 @@ export class MetadataService extends BaseService {
|
|||||||
|
|
||||||
const facesToAdd: (Insertable<AssetFaceTable> & { assetId: string })[] = [];
|
const facesToAdd: (Insertable<AssetFaceTable> & { assetId: string })[] = [];
|
||||||
const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true });
|
const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true });
|
||||||
const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id]));
|
const existingNameMap = new Map(
|
||||||
const missing: (Insertable<PersonTable> & { ownerId: string })[] = [];
|
existingNames.map(({ personGroupId, name }) => [name.toLowerCase(), personGroupId]),
|
||||||
const missingWithFaceAsset: { id: string; ownerId: string; faceAssetId: string }[] = [];
|
);
|
||||||
|
const missing: (Insertable<PersonTable> & { name: string; personGroupId: string; clusterGroupId: string })[] = [];
|
||||||
|
const missingWithFaceAsset: { personGroupId: string; ownerId: string; faceAssetId: string }[] = [];
|
||||||
|
|
||||||
const adjustedRegionInfo = this.orientRegionInfo(tags.RegionInfo, tags.Orientation);
|
const adjustedRegionInfo = this.orientRegionInfo(tags.RegionInfo, tags.Orientation);
|
||||||
const imageWidth = adjustedRegionInfo.AppliedToDimensions.W;
|
const imageWidth = adjustedRegionInfo.AppliedToDimensions.W;
|
||||||
@@ -916,7 +924,7 @@ export class MetadataService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loweredName = region.Name.toLowerCase();
|
const loweredName = region.Name.toLowerCase();
|
||||||
const personId = existingNameMap.get(loweredName) || this.cryptoRepository.randomUUID();
|
const personGroupId = existingNameMap.get(loweredName) || this.cryptoRepository.randomUUID();
|
||||||
|
|
||||||
const X = Number(region.Area.X);
|
const X = Number(region.Area.X);
|
||||||
const Y = Number(region.Area.Y);
|
const Y = Number(region.Area.Y);
|
||||||
@@ -925,7 +933,7 @@ export class MetadataService extends BaseService {
|
|||||||
|
|
||||||
const face = {
|
const face = {
|
||||||
id: this.cryptoRepository.randomUUID(),
|
id: this.cryptoRepository.randomUUID(),
|
||||||
personId,
|
personGroupId,
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
imageWidth,
|
imageWidth,
|
||||||
imageHeight,
|
imageHeight,
|
||||||
@@ -938,15 +946,27 @@ export class MetadataService extends BaseService {
|
|||||||
|
|
||||||
facesToAdd.push(face);
|
facesToAdd.push(face);
|
||||||
if (!existingNameMap.has(loweredName)) {
|
if (!existingNameMap.has(loweredName)) {
|
||||||
missing.push({ id: personId, ownerId: asset.ownerId, name: region.Name });
|
missing.push({
|
||||||
missingWithFaceAsset.push({ id: personId, ownerId: asset.ownerId, faceAssetId: face.id });
|
personGroupId,
|
||||||
|
ownerId: asset.ownerId,
|
||||||
|
clusterGroupId: asset.clusterGroupId,
|
||||||
|
name: region.Name,
|
||||||
|
});
|
||||||
|
missingWithFaceAsset.push({ personGroupId, ownerId: asset.ownerId, faceAssetId: face.id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
this.logger.debugFn(() => `Creating missing persons: ${missing.map((p) => `${p.name}/${p.id}`)}`);
|
this.logger.debugFn(() => `Creating missing persons: ${missing.map((p) => `${p.name}/${p.personGroupId}`)}`);
|
||||||
const newPersonIds = await this.personRepository.createAll(missing);
|
await this.personRepository.createGroups(
|
||||||
const jobs = newPersonIds.map((id) => ({ name: JobName.PersonGenerateThumbnail, data: { id } }) as const);
|
missing.map((item) => ({ id: item.personGroupId, clusterGroupId: asset.clusterGroupId })),
|
||||||
|
);
|
||||||
|
await this.personRepository.createAll(missing);
|
||||||
|
|
||||||
|
const jobs = missing.map(
|
||||||
|
({ personGroupId, ownerId }) =>
|
||||||
|
({ name: JobName.PersonGenerateThumbnail, data: { personGroupId, ownerId } }) as const,
|
||||||
|
);
|
||||||
await this.jobRepository.queueAll(jobs);
|
await this.jobRepository.queueAll(jobs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export class NotificationService extends BaseService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([assetId]);
|
const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([assetId], userId);
|
||||||
if (asset) {
|
if (asset) {
|
||||||
this.websocketRepository.clientSend(
|
this.websocketRepository.clientSend(
|
||||||
'on_asset_update',
|
'on_asset_update',
|
||||||
@@ -236,6 +236,20 @@ export class NotificationService extends BaseService {
|
|||||||
await this.jobRepository.queue({ name: JobName.NotifyAlbumInvite, data: { id, recipientId: userId, senderName } });
|
await this.jobRepository.queue({ name: JobName.NotifyAlbumInvite, data: { id, recipientId: userId, senderName } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OnEvent({ name: 'ClusterGroupRequest' })
|
||||||
|
async onClusterGroupRequest({ clusterGroupId, userId, senderName }: ArgOf<'ClusterGroupRequest'>) {
|
||||||
|
const item = await this.notificationRepository.create({
|
||||||
|
userId,
|
||||||
|
type: NotificationType.ClusterGroupRequest,
|
||||||
|
level: NotificationLevel.Info,
|
||||||
|
title: 'Cluster Group Request',
|
||||||
|
description: `${senderName} asked you to join their cluster group`,
|
||||||
|
data: JSON.stringify({ clusterGroupId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.websocketRepository.clientSend('on_notification', userId, mapNotification(item));
|
||||||
|
}
|
||||||
|
|
||||||
@OnEvent({ name: 'SessionDelete' })
|
@OnEvent({ name: 'SessionDelete' })
|
||||||
onSessionDelete({ sessionId }: ArgOf<'SessionDelete'>) {
|
onSessionDelete({ sessionId }: ArgOf<'SessionDelete'>) {
|
||||||
// after the response is sent
|
// after the response is sent
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|||||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||||
import { mapFaces, mapPerson } from 'src/dtos/person.dto';
|
import { mapFaces, mapPerson } from 'src/dtos/person.dto';
|
||||||
import { AssetFileType, CacheControl, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum';
|
import { AssetFileType, CacheControl, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum';
|
||||||
import { FaceSearchResult } from 'src/repositories/search.repository';
|
|
||||||
import { PersonService } from 'src/services/person.service';
|
import { PersonService } from 'src/services/person.service';
|
||||||
import { ImmichFileResponse } from 'src/utils/file';
|
import { ImmichFileResponse } from 'src/utils/file';
|
||||||
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
|
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
|
||||||
import { AssetFactory } from 'test/factories/asset.factory';
|
import { AssetFactory } from 'test/factories/asset.factory';
|
||||||
import { AuthFactory } from 'test/factories/auth.factory';
|
import { AuthFactory } from 'test/factories/auth.factory';
|
||||||
|
import { PersonGroupFactory } from 'test/factories/person-group.factory';
|
||||||
import { PersonFactory } from 'test/factories/person.factory';
|
import { PersonFactory } from 'test/factories/person.factory';
|
||||||
import { UserFactory } from 'test/factories/user.factory';
|
import { UserFactory } from 'test/factories/user.factory';
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
getForAsset,
|
getForAsset,
|
||||||
getForAssetFace,
|
getForAssetFace,
|
||||||
getForDetectedFaces,
|
getForDetectedFaces,
|
||||||
|
getForFaceSearch,
|
||||||
getForFacialRecognitionJob,
|
getForFacialRecognitionJob,
|
||||||
} from 'test/mappers';
|
} from 'test/mappers';
|
||||||
import { newDate, newUuid } from 'test/small.factory';
|
import { newDate, newUuid } from 'test/small.factory';
|
||||||
@@ -49,9 +50,9 @@ describe(PersonService.name, () => {
|
|||||||
total: 2,
|
total: 2,
|
||||||
hidden: 1,
|
hidden: 1,
|
||||||
people: [
|
people: [
|
||||||
expect.objectContaining({ id: person.id, isHidden: false }),
|
expect.objectContaining({ id: person.personGroupId, isHidden: false }),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: hiddenPerson.id,
|
id: hiddenPerson.personGroupId,
|
||||||
isHidden: true,
|
isHidden: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -76,10 +77,10 @@ describe(PersonService.name, () => {
|
|||||||
hidden: 1,
|
hidden: 1,
|
||||||
people: [
|
people: [
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: isFavorite.id,
|
id: isFavorite.personGroupId,
|
||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({ id: person.id, isFavorite: false }),
|
expect.objectContaining({ id: person.personGroupId, isFavorite: false }),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, {
|
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, {
|
||||||
@@ -92,9 +93,9 @@ describe(PersonService.name, () => {
|
|||||||
it('should require person.read permission', async () => {
|
it('should require person.read permission', async () => {
|
||||||
const auth = AuthFactory.create();
|
const auth = AuthFactory.create();
|
||||||
const person = PersonFactory.create();
|
const person = PersonFactory.create();
|
||||||
mocks.person.getById.mockResolvedValue(person);
|
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||||
await expect(sut.getById(auth, person.id)).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.getById(auth, person.personGroupId)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw a bad request when person is not found', async () => {
|
it('should throw a bad request when person is not found', async () => {
|
||||||
@@ -108,11 +109,16 @@ describe(PersonService.name, () => {
|
|||||||
const auth = AuthFactory.create();
|
const auth = AuthFactory.create();
|
||||||
const person = PersonFactory.create();
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.person.getById.mockResolvedValue(person);
|
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
|
||||||
await expect(sut.getById(auth, person.id)).resolves.toEqual(expect.objectContaining({ id: person.id }));
|
await expect(sut.getById(auth, person.personGroupId)).resolves.toEqual(
|
||||||
expect(mocks.person.getById).toHaveBeenCalledWith(person.id);
|
expect.objectContaining({ id: person.personGroupId }),
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
);
|
||||||
|
expect(mocks.person.getByGroupId).toHaveBeenCalledWith({
|
||||||
|
ownerId: auth.user.id,
|
||||||
|
personGroupId: person.personGroupId,
|
||||||
|
});
|
||||||
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,10 +127,10 @@ describe(PersonService.name, () => {
|
|||||||
const auth = AuthFactory.create();
|
const auth = AuthFactory.create();
|
||||||
const person = PersonFactory.create();
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.person.getById.mockResolvedValue(person);
|
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||||
await expect(sut.getThumbnail(auth, person.id)).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.getThumbnail(auth, person.personGroupId)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
|
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error when personId is invalid', async () => {
|
it('should throw an error when personId is invalid', async () => {
|
||||||
@@ -140,27 +146,27 @@ describe(PersonService.name, () => {
|
|||||||
const auth = AuthFactory.create();
|
const auth = AuthFactory.create();
|
||||||
const person = PersonFactory.create({ thumbnailPath: '' });
|
const person = PersonFactory.create({ thumbnailPath: '' });
|
||||||
|
|
||||||
mocks.person.getById.mockResolvedValue(person);
|
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
|
||||||
await expect(sut.getThumbnail(auth, person.id)).rejects.toBeInstanceOf(NotFoundException);
|
await expect(sut.getThumbnail(auth, person.personGroupId)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
|
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should serve the thumbnail', async () => {
|
it('should serve the thumbnail', async () => {
|
||||||
const auth = AuthFactory.create();
|
const auth = AuthFactory.create();
|
||||||
const person = PersonFactory.create();
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.person.getById.mockResolvedValue(person);
|
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
|
||||||
await expect(sut.getThumbnail(auth, person.id)).resolves.toEqual(
|
await expect(sut.getThumbnail(auth, person.personGroupId)).resolves.toEqual(
|
||||||
new ImmichFileResponse({
|
new ImmichFileResponse({
|
||||||
path: person.thumbnailPath,
|
path: person.thumbnailPath,
|
||||||
contentType: 'image/jpeg',
|
contentType: 'image/jpeg',
|
||||||
cacheControl: CacheControl.PrivateWithoutCache,
|
cacheControl: CacheControl.PrivateWithoutCache,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,10 +175,12 @@ describe(PersonService.name, () => {
|
|||||||
const auth = AuthFactory.create();
|
const auth = AuthFactory.create();
|
||||||
const person = PersonFactory.create();
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.person.getById.mockResolvedValue(person);
|
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||||
await expect(sut.update(auth, person.id, { name: 'Person 1' })).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.update(auth, person.personGroupId, { name: 'Person 1' })).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error when personId is invalid', async () => {
|
it('should throw an error when personId is invalid', async () => {
|
||||||
@@ -188,26 +196,32 @@ describe(PersonService.name, () => {
|
|||||||
const auth = AuthFactory.create();
|
const auth = AuthFactory.create();
|
||||||
const person = PersonFactory.create({ name: 'Person 1' });
|
const person = PersonFactory.create({ name: 'Person 1' });
|
||||||
|
|
||||||
|
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||||
mocks.person.update.mockResolvedValue(person);
|
mocks.person.update.mockResolvedValue(person);
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
|
||||||
|
|
||||||
await expect(sut.update(auth, person.id, { name: 'Person 1' })).resolves.toEqual(
|
await expect(sut.update(auth, person.personGroupId, { name: 'Person 1' })).resolves.toEqual(
|
||||||
expect.objectContaining({ id: person.id, name: 'Person 1' }),
|
expect.objectContaining({ id: person.personGroupId, name: 'Person 1' }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, name: 'Person 1' });
|
expect(mocks.person.update).toHaveBeenCalledWith({
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
ownerId: person.ownerId,
|
||||||
|
personGroupId: person.personGroupId,
|
||||||
|
name: 'Person 1',
|
||||||
|
});
|
||||||
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should update a person's date of birth", async () => {
|
it("should update a person's date of birth", async () => {
|
||||||
const auth = AuthFactory.create();
|
const auth = AuthFactory.create();
|
||||||
const person = PersonFactory.create({ birthDate: new Date('1976-06-30') });
|
const person = PersonFactory.create({ birthDate: new Date('1976-06-30') });
|
||||||
|
|
||||||
|
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||||
mocks.person.update.mockResolvedValue(person);
|
mocks.person.update.mockResolvedValue(person);
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
|
||||||
|
|
||||||
await expect(sut.update(auth, person.id, { birthDate: '1976-06-30' })).resolves.toEqual({
|
await expect(sut.update(auth, person.personGroupId, { birthDate: '1976-06-30' })).resolves.toEqual({
|
||||||
id: person.id,
|
id: person.personGroupId,
|
||||||
name: person.name,
|
name: person.name,
|
||||||
birthDate: '1976-06-30',
|
birthDate: '1976-06-30',
|
||||||
thumbnailPath: person.thumbnailPath,
|
thumbnailPath: person.thumbnailPath,
|
||||||
@@ -215,40 +229,54 @@ describe(PersonService.name, () => {
|
|||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
updatedAt: expect.any(String),
|
updatedAt: expect.any(String),
|
||||||
});
|
});
|
||||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, birthDate: '1976-06-30' });
|
expect(mocks.person.update).toHaveBeenCalledWith({
|
||||||
|
ownerId: person.ownerId,
|
||||||
|
personGroupId: person.personGroupId,
|
||||||
|
birthDate: '1976-06-30',
|
||||||
|
});
|
||||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||||
expect(mocks.job.queueAll).not.toHaveBeenCalled();
|
expect(mocks.job.queueAll).not.toHaveBeenCalled();
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update a person visibility', async () => {
|
it('should update a person visibility', async () => {
|
||||||
const auth = AuthFactory.create();
|
const auth = AuthFactory.create();
|
||||||
const person = PersonFactory.create({ isHidden: true });
|
const person = PersonFactory.create({ isHidden: true });
|
||||||
|
|
||||||
|
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||||
mocks.person.update.mockResolvedValue(person);
|
mocks.person.update.mockResolvedValue(person);
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
|
||||||
|
|
||||||
await expect(sut.update(auth, person.id, { isHidden: true })).resolves.toEqual(
|
await expect(sut.update(auth, person.personGroupId, { isHidden: true })).resolves.toEqual(
|
||||||
expect.objectContaining({ isHidden: true }),
|
expect.objectContaining({ isHidden: true }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, isHidden: true });
|
expect(mocks.person.update).toHaveBeenCalledWith({
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
ownerId: person.ownerId,
|
||||||
|
personGroupId: person.personGroupId,
|
||||||
|
isHidden: true,
|
||||||
|
});
|
||||||
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update a person favorite status', async () => {
|
it('should update a person favorite status', async () => {
|
||||||
const auth = AuthFactory.create();
|
const auth = AuthFactory.create();
|
||||||
const person = PersonFactory.create({ isFavorite: true });
|
const person = PersonFactory.create({ isFavorite: true });
|
||||||
|
|
||||||
|
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||||
mocks.person.update.mockResolvedValue(person);
|
mocks.person.update.mockResolvedValue(person);
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
|
||||||
|
|
||||||
await expect(sut.update(auth, person.id, { isFavorite: true })).resolves.toEqual(
|
await expect(sut.update(auth, person.personGroupId, { isFavorite: true })).resolves.toEqual(
|
||||||
expect.objectContaining({ isFavorite: true }),
|
expect.objectContaining({ isFavorite: true }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, isFavorite: true });
|
expect(mocks.person.update).toHaveBeenCalledWith({
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
ownerId: person.ownerId,
|
||||||
|
personGroupId: person.personGroupId,
|
||||||
|
isFavorite: true,
|
||||||
|
});
|
||||||
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should update a person's thumbnailPath", async () => {
|
it("should update a person's thumbnailPath", async () => {
|
||||||
@@ -256,37 +284,44 @@ describe(PersonService.name, () => {
|
|||||||
const auth = AuthFactory.create();
|
const auth = AuthFactory.create();
|
||||||
const person = PersonFactory.create();
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
|
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||||
mocks.person.update.mockResolvedValue(person);
|
mocks.person.update.mockResolvedValue(person);
|
||||||
mocks.person.getForFeatureFaceUpdate.mockResolvedValue(face);
|
mocks.person.getForFeatureFaceUpdate.mockResolvedValue(face);
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([face.assetId]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([face.assetId]));
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
|
||||||
|
|
||||||
await expect(sut.update(auth, person.id, { featureFaceAssetId: face.assetId })).resolves.toEqual(
|
await expect(sut.update(auth, person.personGroupId, { featureFaceAssetId: face.assetId })).resolves.toEqual(
|
||||||
expect.objectContaining({ id: person.id }),
|
expect.objectContaining({ id: person.personGroupId }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, faceAssetId: face.id });
|
expect(mocks.person.update).toHaveBeenCalledWith({
|
||||||
|
ownerId: person.ownerId,
|
||||||
|
personGroupId: person.personGroupId,
|
||||||
|
faceAssetId: face.id,
|
||||||
|
});
|
||||||
expect(mocks.person.getForFeatureFaceUpdate).toHaveBeenCalledWith({
|
expect(mocks.person.getForFeatureFaceUpdate).toHaveBeenCalledWith({
|
||||||
assetId: face.assetId,
|
assetId: face.assetId,
|
||||||
personId: person.id,
|
personGroupId: person.personGroupId,
|
||||||
});
|
});
|
||||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||||
name: JobName.PersonGenerateThumbnail,
|
name: JobName.PersonGenerateThumbnail,
|
||||||
data: { id: person.id },
|
data: { ownerId: person.ownerId, personGroupId: person.personGroupId },
|
||||||
});
|
});
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error when the face feature assetId is invalid', async () => {
|
it('should throw an error when the face feature assetId is invalid', async () => {
|
||||||
const auth = AuthFactory.create();
|
const auth = AuthFactory.create();
|
||||||
const person = PersonFactory.create();
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.person.getById.mockResolvedValue(person);
|
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
|
||||||
|
|
||||||
await expect(sut.update(auth, person.id, { featureFaceAssetId: '-1' })).rejects.toThrow(BadRequestException);
|
await expect(sut.update(auth, person.personGroupId, { featureFaceAssetId: '-1' })).rejects.toThrow(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -320,8 +355,8 @@ describe(PersonService.name, () => {
|
|||||||
const auth = AuthFactory.create();
|
const auth = AuthFactory.create();
|
||||||
const person = PersonFactory.create();
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
|
||||||
mocks.person.getById.mockResolvedValue(person);
|
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||||
mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id]));
|
mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id]));
|
||||||
mocks.person.getFacesByIds.mockResolvedValue([getForAssetFace(face)]);
|
mocks.person.getFacesByIds.mockResolvedValue([getForAssetFace(face)]);
|
||||||
mocks.person.reassignFace.mockResolvedValue(1);
|
mocks.person.reassignFace.mockResolvedValue(1);
|
||||||
@@ -331,15 +366,15 @@ describe(PersonService.name, () => {
|
|||||||
mocks.person.update.mockResolvedValue(person);
|
mocks.person.update.mockResolvedValue(person);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.reassignFaces(auth, person.id, {
|
sut.reassignFaces(auth, person.personGroupId, {
|
||||||
data: [{ personId: person.id, assetId: face.assetId }],
|
data: [{ personId: person.personGroupId, assetId: face.assetId }],
|
||||||
}),
|
}),
|
||||||
).resolves.toBeDefined();
|
).resolves.toBeDefined();
|
||||||
|
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.PersonGenerateThumbnail,
|
name: JobName.PersonGenerateThumbnail,
|
||||||
data: { id: person.id },
|
data: { ownerId: person.ownerId, personGroupId: person.personGroupId },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -381,21 +416,21 @@ describe(PersonService.name, () => {
|
|||||||
const person = PersonFactory.create({ faceAssetId: null });
|
const person = PersonFactory.create({ faceAssetId: null });
|
||||||
const featureFace = AssetFaceFactory.create({
|
const featureFace = AssetFaceFactory.create({
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
personId: person.id,
|
personGroupId: person.personGroupId,
|
||||||
sourceType: SourceType.Manual,
|
sourceType: SourceType.Manual,
|
||||||
});
|
});
|
||||||
|
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
|
||||||
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
|
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
|
||||||
mocks.person.getById.mockResolvedValue(person);
|
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||||
mocks.person.getRandomFace.mockResolvedValue(featureFace);
|
mocks.person.getRandomFace.mockResolvedValue(featureFace);
|
||||||
mocks.person.update.mockResolvedValue({ ...person, faceAssetId: featureFace.id });
|
mocks.person.update.mockResolvedValue({ ...person, faceAssetId: featureFace.id });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.createFace(auth, {
|
sut.createFace(auth, {
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
personId: person.id,
|
personId: person.personGroupId,
|
||||||
imageHeight: 500,
|
imageHeight: 500,
|
||||||
imageWidth: 400,
|
imageWidth: 400,
|
||||||
x: 10,
|
x: 10,
|
||||||
@@ -408,7 +443,7 @@ describe(PersonService.name, () => {
|
|||||||
expect(mocks.asset.getById).toHaveBeenCalledWith(asset.id, { edits: true, exifInfo: true });
|
expect(mocks.asset.getById).toHaveBeenCalledWith(asset.id, { edits: true, exifInfo: true });
|
||||||
expect(mocks.person.createAssetFace).toHaveBeenCalledWith({
|
expect(mocks.person.createAssetFace).toHaveBeenCalledWith({
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
personId: person.id,
|
personGroupId: person.personGroupId,
|
||||||
imageHeight: 500,
|
imageHeight: 500,
|
||||||
imageWidth: 400,
|
imageWidth: 400,
|
||||||
boundingBoxX1: 10,
|
boundingBoxX1: 10,
|
||||||
@@ -417,10 +452,17 @@ describe(PersonService.name, () => {
|
|||||||
boundingBoxY2: 130,
|
boundingBoxY2: 130,
|
||||||
sourceType: SourceType.Manual,
|
sourceType: SourceType.Manual,
|
||||||
});
|
});
|
||||||
expect(mocks.person.getRandomFace).toHaveBeenCalledWith(person.id);
|
expect(mocks.person.getRandomFace).toHaveBeenCalledWith(person.personGroupId);
|
||||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, faceAssetId: featureFace.id });
|
expect(mocks.person.update).toHaveBeenCalledWith({
|
||||||
|
ownerId: person.ownerId,
|
||||||
|
personGroupId: person.personGroupId,
|
||||||
|
faceAssetId: featureFace.id,
|
||||||
|
});
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{ name: JobName.PersonGenerateThumbnail, data: { id: person.id } },
|
{
|
||||||
|
name: JobName.PersonGenerateThumbnail,
|
||||||
|
data: { ownerId: person.ownerId, personGroupId: person.personGroupId },
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -430,14 +472,14 @@ describe(PersonService.name, () => {
|
|||||||
const person = PersonFactory.create({ faceAssetId: newUuid() });
|
const person = PersonFactory.create({ faceAssetId: newUuid() });
|
||||||
|
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
|
||||||
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
|
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
|
||||||
mocks.person.getById.mockResolvedValue(person);
|
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.createFace(auth, {
|
sut.createFace(auth, {
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
personId: person.id,
|
personId: person.personGroupId,
|
||||||
imageHeight: 500,
|
imageHeight: 500,
|
||||||
imageWidth: 400,
|
imageWidth: 400,
|
||||||
x: 10,
|
x: 10,
|
||||||
@@ -459,12 +501,12 @@ describe(PersonService.name, () => {
|
|||||||
const person = PersonFactory.create({ faceAssetId: null });
|
const person = PersonFactory.create({ faceAssetId: null });
|
||||||
|
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set());
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set());
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.createFace(auth, {
|
sut.createFace(auth, {
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
personId: person.id,
|
personId: person.personGroupId,
|
||||||
imageHeight: 500,
|
imageHeight: 500,
|
||||||
imageWidth: 400,
|
imageWidth: 400,
|
||||||
x: 10,
|
x: 10,
|
||||||
@@ -483,11 +525,11 @@ describe(PersonService.name, () => {
|
|||||||
const person = PersonFactory.create();
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create());
|
mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create());
|
||||||
await sut.createNewFeaturePhoto([person.id]);
|
await sut.createNewFeaturePhoto([person]);
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.PersonGenerateThumbnail,
|
name: JobName.PersonGenerateThumbnail,
|
||||||
data: { id: person.id },
|
data: { ownerId: person.ownerId, personGroupId: person.personGroupId },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -498,20 +540,22 @@ describe(PersonService.name, () => {
|
|||||||
const face = AssetFaceFactory.create();
|
const face = AssetFaceFactory.create();
|
||||||
const person = PersonFactory.create();
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
|
||||||
mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id]));
|
mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id]));
|
||||||
mocks.person.getFaceById.mockResolvedValue(getForAssetFace(face));
|
mocks.person.getFaceById.mockResolvedValue(getForAssetFace(face));
|
||||||
mocks.person.reassignFace.mockResolvedValue(1);
|
mocks.person.reassignFace.mockResolvedValue(1);
|
||||||
mocks.person.getById.mockResolvedValue(person);
|
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||||
await expect(sut.reassignFacesById(AuthFactory.create(), person.id, { id: face.id })).resolves.toEqual({
|
await expect(sut.reassignFacesById(AuthFactory.create(), person.personGroupId, { id: face.id })).resolves.toEqual(
|
||||||
birthDate: person.birthDate,
|
{
|
||||||
isHidden: person.isHidden,
|
birthDate: person.birthDate,
|
||||||
isFavorite: person.isFavorite,
|
isHidden: person.isHidden,
|
||||||
id: person.id,
|
isFavorite: person.isFavorite,
|
||||||
name: person.name,
|
id: person.personGroupId,
|
||||||
thumbnailPath: person.thumbnailPath,
|
name: person.name,
|
||||||
updatedAt: expect.any(String),
|
thumbnailPath: person.thumbnailPath,
|
||||||
});
|
updatedAt: expect.any(String),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
expect(mocks.job.queue).not.toHaveBeenCalledWith();
|
expect(mocks.job.queue).not.toHaveBeenCalledWith();
|
||||||
expect(mocks.job.queueAll).not.toHaveBeenCalledWith();
|
expect(mocks.job.queueAll).not.toHaveBeenCalledWith();
|
||||||
@@ -521,12 +565,12 @@ describe(PersonService.name, () => {
|
|||||||
const face = AssetFaceFactory.create();
|
const face = AssetFaceFactory.create();
|
||||||
const person = PersonFactory.create();
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
|
||||||
mocks.person.getFaceById.mockResolvedValue(getForAssetFace(face));
|
mocks.person.getFaceById.mockResolvedValue(getForAssetFace(face));
|
||||||
mocks.person.reassignFace.mockResolvedValue(1);
|
mocks.person.reassignFace.mockResolvedValue(1);
|
||||||
mocks.person.getById.mockResolvedValue(person);
|
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||||
await expect(
|
await expect(
|
||||||
sut.reassignFacesById(AuthFactory.create(), person.id, {
|
sut.reassignFacesById(AuthFactory.create(), person.personGroupId, {
|
||||||
id: face.id,
|
id: face.id,
|
||||||
}),
|
}),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
@@ -537,13 +581,16 @@ describe(PersonService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('createPerson', () => {
|
describe('createPerson', () => {
|
||||||
it('should create a new person', async () => {
|
it('should create a new person in a new group', async () => {
|
||||||
const auth = AuthFactory.create();
|
const auth = AuthFactory.create();
|
||||||
|
const group = PersonGroupFactory.create();
|
||||||
|
|
||||||
mocks.person.create.mockResolvedValue(PersonFactory.create());
|
mocks.person.createGroup.mockResolvedValue(group);
|
||||||
|
mocks.person.create.mockResolvedValue(PersonFactory.create({ personGroupId: group.id }));
|
||||||
await expect(sut.create(auth, {})).resolves.toBeDefined();
|
await expect(sut.create(auth, {})).resolves.toBeDefined();
|
||||||
|
|
||||||
expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: auth.user.id });
|
expect(mocks.person.createGroup).toHaveBeenCalledWith(auth.user.id);
|
||||||
|
expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: auth.user.id, personGroupId: group.id });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -552,10 +599,12 @@ describe(PersonService.name, () => {
|
|||||||
const person = PersonFactory.create();
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
|
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
|
||||||
|
mocks.person.delete.mockResolvedValue([person]);
|
||||||
|
|
||||||
await sut.handlePersonCleanup();
|
await sut.handlePersonCleanup();
|
||||||
|
|
||||||
expect(mocks.person.delete).toHaveBeenCalledWith([person.id]);
|
expect(mocks.person.delete).toHaveBeenCalledWith([person.personGroupId], undefined);
|
||||||
|
expect(mocks.person.deleteEmptyGroups).toHaveBeenCalledWith();
|
||||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
|
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -592,11 +641,13 @@ describe(PersonService.name, () => {
|
|||||||
|
|
||||||
mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset]));
|
mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset]));
|
||||||
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
|
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
|
||||||
|
mocks.person.delete.mockResolvedValue([person]);
|
||||||
|
|
||||||
await sut.handleQueueDetectFaces({ force: true });
|
await sut.handleQueueDetectFaces({ force: true });
|
||||||
|
|
||||||
expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MachineLearning });
|
expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MachineLearning });
|
||||||
expect(mocks.person.delete).toHaveBeenCalledWith([person.id]);
|
expect(mocks.person.delete).toHaveBeenCalledWith([person.personGroupId], undefined);
|
||||||
|
expect(mocks.person.deleteEmptyGroups).toHaveBeenCalledWith();
|
||||||
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true });
|
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true });
|
||||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
|
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
|
||||||
expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true);
|
expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true);
|
||||||
@@ -614,7 +665,7 @@ describe(PersonService.name, () => {
|
|||||||
|
|
||||||
await sut.handleQueueDetectFaces({ force: undefined });
|
await sut.handleQueueDetectFaces({ force: undefined });
|
||||||
|
|
||||||
expect(mocks.person.delete).not.toHaveBeenCalled();
|
expect(mocks.person.deleteGroups).not.toHaveBeenCalled();
|
||||||
expect(mocks.person.deleteFaces).not.toHaveBeenCalled();
|
expect(mocks.person.deleteFaces).not.toHaveBeenCalled();
|
||||||
expect(mocks.person.vacuum).not.toHaveBeenCalled();
|
expect(mocks.person.vacuum).not.toHaveBeenCalled();
|
||||||
expect(mocks.storage.unlink).not.toHaveBeenCalled();
|
expect(mocks.storage.unlink).not.toHaveBeenCalled();
|
||||||
@@ -637,6 +688,7 @@ describe(PersonService.name, () => {
|
|||||||
mocks.person.getAllFaces.mockReturnValue(makeStream([face]));
|
mocks.person.getAllFaces.mockReturnValue(makeStream([face]));
|
||||||
mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset]));
|
mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset]));
|
||||||
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
|
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
|
||||||
|
mocks.person.delete.mockResolvedValue([person]);
|
||||||
mocks.person.deleteFaces.mockResolvedValue();
|
mocks.person.deleteFaces.mockResolvedValue();
|
||||||
|
|
||||||
await sut.handleQueueDetectFaces({ force: true });
|
await sut.handleQueueDetectFaces({ force: true });
|
||||||
@@ -648,7 +700,8 @@ describe(PersonService.name, () => {
|
|||||||
data: { id: asset.id },
|
data: { id: asset.id },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(mocks.person.delete).toHaveBeenCalledWith([person.id]);
|
expect(mocks.person.delete).toHaveBeenCalledWith([person.personGroupId], undefined);
|
||||||
|
expect(mocks.person.deleteEmptyGroups).toHaveBeenCalledWith();
|
||||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
|
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
|
||||||
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true });
|
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true });
|
||||||
});
|
});
|
||||||
@@ -703,7 +756,7 @@ describe(PersonService.name, () => {
|
|||||||
await sut.handleQueueRecognizeFaces({});
|
await sut.handleQueueRecognizeFaces({});
|
||||||
|
|
||||||
expect(mocks.person.getAllFaces).toHaveBeenCalledWith({
|
expect(mocks.person.getAllFaces).toHaveBeenCalledWith({
|
||||||
personId: null,
|
personGroupId: null,
|
||||||
sourceType: SourceType.MachineLearning,
|
sourceType: SourceType.MachineLearning,
|
||||||
});
|
});
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
@@ -769,7 +822,7 @@ describe(PersonService.name, () => {
|
|||||||
expect(mocks.systemMetadata.get).toHaveBeenCalledWith(SystemMetadataKey.FacialRecognitionState);
|
expect(mocks.systemMetadata.get).toHaveBeenCalledWith(SystemMetadataKey.FacialRecognitionState);
|
||||||
expect(mocks.person.getLatestFaceDate).toHaveBeenCalledOnce();
|
expect(mocks.person.getLatestFaceDate).toHaveBeenCalledOnce();
|
||||||
expect(mocks.person.getAllFaces).toHaveBeenCalledWith({
|
expect(mocks.person.getAllFaces).toHaveBeenCalledWith({
|
||||||
personId: null,
|
personGroupId: null,
|
||||||
sourceType: SourceType.MachineLearning,
|
sourceType: SourceType.MachineLearning,
|
||||||
});
|
});
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
@@ -817,6 +870,7 @@ describe(PersonService.name, () => {
|
|||||||
mocks.person.getAll.mockReturnValue(makeStream([face.person!, person]));
|
mocks.person.getAll.mockReturnValue(makeStream([face.person!, person]));
|
||||||
mocks.person.getAllFaces.mockReturnValue(makeStream([face]));
|
mocks.person.getAllFaces.mockReturnValue(makeStream([face]));
|
||||||
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
|
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
|
||||||
|
mocks.person.delete.mockResolvedValue([person]);
|
||||||
mocks.person.unassignFaces.mockResolvedValue();
|
mocks.person.unassignFaces.mockResolvedValue();
|
||||||
|
|
||||||
await sut.handleQueueRecognizeFaces({ force: true });
|
await sut.handleQueueRecognizeFaces({ force: true });
|
||||||
@@ -829,7 +883,8 @@ describe(PersonService.name, () => {
|
|||||||
data: { id: face.id, deferred: false },
|
data: { id: face.id, deferred: false },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(mocks.person.delete).toHaveBeenCalledWith([person.id]);
|
expect(mocks.person.delete).toHaveBeenCalledWith([person.personGroupId], undefined);
|
||||||
|
expect(mocks.person.deleteEmptyGroups).toHaveBeenCalledWith();
|
||||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
|
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
|
||||||
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: false });
|
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: false });
|
||||||
});
|
});
|
||||||
@@ -878,7 +933,7 @@ describe(PersonService.name, () => {
|
|||||||
const face = AssetFaceFactory.create({ assetId: asset.id });
|
const face = AssetFaceFactory.create({ assetId: asset.id });
|
||||||
mocks.crypto.randomUUID.mockReturnValue(face.id);
|
mocks.crypto.randomUUID.mockReturnValue(face.id);
|
||||||
mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face));
|
mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face));
|
||||||
mocks.search.searchFaces.mockResolvedValue([{ ...face, distance: 0.7 }]);
|
mocks.search.searchFaces.mockResolvedValue([getForFaceSearch(face, 0.7)]);
|
||||||
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset));
|
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset));
|
||||||
mocks.person.refreshFaces.mockResolvedValue();
|
mocks.person.refreshFaces.mockResolvedValue();
|
||||||
|
|
||||||
@@ -1014,20 +1069,21 @@ describe(PersonService.name, () => {
|
|||||||
const [noPerson1, noPerson2, primaryFace, face] = [
|
const [noPerson1, noPerson2, primaryFace, face] = [
|
||||||
AssetFaceFactory.create({ assetId: asset.id }),
|
AssetFaceFactory.create({ assetId: asset.id }),
|
||||||
AssetFaceFactory.create(),
|
AssetFaceFactory.create(),
|
||||||
AssetFaceFactory.from().person().build(),
|
AssetFaceFactory.from().person({ ownerId: asset.ownerId }).build(),
|
||||||
AssetFaceFactory.from().person().build(),
|
AssetFaceFactory.from().person({ ownerId: asset.ownerId }).build(),
|
||||||
];
|
];
|
||||||
|
|
||||||
const faces = [
|
const faces = [
|
||||||
{ ...noPerson1, distance: 0 },
|
getForFaceSearch(noPerson1, 0),
|
||||||
{ ...primaryFace, distance: 0.2 },
|
getForFaceSearch(primaryFace, 0.2),
|
||||||
{ ...noPerson2, distance: 0.3 },
|
getForFaceSearch(noPerson2, 0.3),
|
||||||
{ ...face, distance: 0.4 },
|
getForFaceSearch(face, 0.4),
|
||||||
] as FaceSearchResult[];
|
];
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
||||||
mocks.search.searchFaces.mockResolvedValue(faces);
|
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||||
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset));
|
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset));
|
||||||
|
mocks.person.getByGroupId.mockResolvedValue(primaryFace.person!);
|
||||||
mocks.person.create.mockResolvedValue(primaryFace.person!);
|
mocks.person.create.mockResolvedValue(primaryFace.person!);
|
||||||
|
|
||||||
await sut.handleRecognizeFaces({ id: noPerson1.id });
|
await sut.handleRecognizeFaces({ id: noPerson1.id });
|
||||||
@@ -1036,11 +1092,11 @@ describe(PersonService.name, () => {
|
|||||||
expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1);
|
expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||||
faceIds: expect.arrayContaining([noPerson1.id]),
|
faceIds: expect.arrayContaining([noPerson1.id]),
|
||||||
newPersonId: primaryFace.person!.id,
|
newPersonGroupId: primaryFace.person!.personGroupId,
|
||||||
});
|
});
|
||||||
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||||
faceIds: expect.not.arrayContaining([face.id]),
|
faceIds: expect.not.arrayContaining([face.id]),
|
||||||
newPersonId: primaryFace.person!.id,
|
newPersonGroupId: primaryFace.person!.personGroupId,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1048,19 +1104,20 @@ describe(PersonService.name, () => {
|
|||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
const [noPerson, face, faceWithBirthDate] = [
|
const [noPerson, face, faceWithBirthDate] = [
|
||||||
AssetFaceFactory.create({ assetId: asset.id }),
|
AssetFaceFactory.create({ assetId: asset.id }),
|
||||||
AssetFaceFactory.from().person().build(),
|
AssetFaceFactory.from().person({ ownerId: asset.ownerId }).build(),
|
||||||
AssetFaceFactory.from().person({ birthDate: newDate() }).build(),
|
AssetFaceFactory.from().person({ ownerId: asset.ownerId, birthDate: newDate() }).build(),
|
||||||
];
|
];
|
||||||
|
|
||||||
const faces = [
|
const faces = [
|
||||||
{ ...noPerson, distance: 0 },
|
getForFaceSearch(noPerson, 0),
|
||||||
{ ...face, distance: 0.2 },
|
getForFaceSearch(face, 0.2),
|
||||||
{ ...faceWithBirthDate, distance: 0.3 },
|
getForFaceSearch(faceWithBirthDate, 0.3),
|
||||||
] as FaceSearchResult[];
|
];
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
||||||
mocks.search.searchFaces.mockResolvedValue(faces);
|
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||||
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson, asset));
|
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson, asset));
|
||||||
|
mocks.person.getByGroupId.mockResolvedValue(face.person!);
|
||||||
mocks.person.create.mockResolvedValue(face.person!);
|
mocks.person.create.mockResolvedValue(face.person!);
|
||||||
|
|
||||||
await sut.handleRecognizeFaces({ id: noPerson.id });
|
await sut.handleRecognizeFaces({ id: noPerson.id });
|
||||||
@@ -1069,11 +1126,11 @@ describe(PersonService.name, () => {
|
|||||||
expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1);
|
expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||||
faceIds: expect.arrayContaining([noPerson.id]),
|
faceIds: expect.arrayContaining([noPerson.id]),
|
||||||
newPersonId: face.person!.id,
|
newPersonGroupId: face.person!.personGroupId,
|
||||||
});
|
});
|
||||||
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||||
faceIds: expect.not.arrayContaining([face.id]),
|
faceIds: expect.not.arrayContaining([face.id]),
|
||||||
newPersonId: face.person!.id,
|
newPersonGroupId: face.person!.personGroupId,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1081,19 +1138,20 @@ describe(PersonService.name, () => {
|
|||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
const [noPerson, face, faceWithBirthDate] = [
|
const [noPerson, face, faceWithBirthDate] = [
|
||||||
AssetFaceFactory.create({ assetId: asset.id }),
|
AssetFaceFactory.create({ assetId: asset.id }),
|
||||||
AssetFaceFactory.from().person().build(),
|
AssetFaceFactory.from().person({ ownerId: asset.ownerId }).build(),
|
||||||
AssetFaceFactory.from().person({ birthDate: newDate() }).build(),
|
AssetFaceFactory.from().person({ ownerId: asset.ownerId, birthDate: newDate() }).build(),
|
||||||
];
|
];
|
||||||
|
|
||||||
const faces = [
|
const faces = [
|
||||||
{ ...noPerson, distance: 0 },
|
getForFaceSearch(noPerson, 0),
|
||||||
{ ...faceWithBirthDate, distance: 0.2 },
|
getForFaceSearch(faceWithBirthDate, 0.2),
|
||||||
{ ...face, distance: 0.3 },
|
getForFaceSearch(face, 0.3),
|
||||||
] as FaceSearchResult[];
|
];
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
||||||
mocks.search.searchFaces.mockResolvedValue(faces);
|
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||||
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson, asset));
|
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson, asset));
|
||||||
|
mocks.person.getByGroupId.mockResolvedValue(faceWithBirthDate.person!);
|
||||||
mocks.person.create.mockResolvedValue(face.person!);
|
mocks.person.create.mockResolvedValue(face.person!);
|
||||||
|
|
||||||
await sut.handleRecognizeFaces({ id: noPerson.id });
|
await sut.handleRecognizeFaces({ id: noPerson.id });
|
||||||
@@ -1102,11 +1160,11 @@ describe(PersonService.name, () => {
|
|||||||
expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1);
|
expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||||
faceIds: expect.arrayContaining([noPerson.id]),
|
faceIds: expect.arrayContaining([noPerson.id]),
|
||||||
newPersonId: faceWithBirthDate.person!.id,
|
newPersonGroupId: faceWithBirthDate.person!.personGroupId,
|
||||||
});
|
});
|
||||||
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||||
faceIds: expect.not.arrayContaining([face.id]),
|
faceIds: expect.not.arrayContaining([face.id]),
|
||||||
newPersonId: faceWithBirthDate.person!.id,
|
newPersonGroupId: faceWithBirthDate.person!.personGroupId,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1115,32 +1173,64 @@ describe(PersonService.name, () => {
|
|||||||
const [noPerson1, noPerson2] = [AssetFaceFactory.create({ assetId: asset.id }), AssetFaceFactory.create()];
|
const [noPerson1, noPerson2] = [AssetFaceFactory.create({ assetId: asset.id }), AssetFaceFactory.create()];
|
||||||
const person = PersonFactory.create();
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
const faces = [
|
const faces = [getForFaceSearch(noPerson1, 0), getForFaceSearch(noPerson2, 0.3)];
|
||||||
{ ...noPerson1, distance: 0 },
|
|
||||||
{ ...noPerson2, distance: 0.3 },
|
|
||||||
] as FaceSearchResult[];
|
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
||||||
mocks.search.searchFaces.mockResolvedValue(faces);
|
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||||
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset));
|
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset));
|
||||||
|
mocks.person.createGroup.mockResolvedValue(PersonGroupFactory.create({ id: person.personGroupId }));
|
||||||
mocks.person.create.mockResolvedValue(person);
|
mocks.person.create.mockResolvedValue(person);
|
||||||
|
|
||||||
await sut.handleRecognizeFaces({ id: noPerson1.id });
|
await sut.handleRecognizeFaces({ id: noPerson1.id });
|
||||||
|
|
||||||
|
expect(mocks.person.createGroup).toHaveBeenCalledWith(asset.ownerId);
|
||||||
expect(mocks.person.create).toHaveBeenCalledWith({
|
expect(mocks.person.create).toHaveBeenCalledWith({
|
||||||
ownerId: asset.ownerId,
|
ownerId: asset.ownerId,
|
||||||
faceAssetId: noPerson1.id,
|
faceAssetId: noPerson1.id,
|
||||||
|
personGroupId: person.personGroupId,
|
||||||
});
|
});
|
||||||
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||||
faceIds: [noPerson1.id],
|
faceIds: [noPerson1.id],
|
||||||
newPersonId: person.id,
|
newPersonGroupId: person.personGroupId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a person in the matched group when the match belongs to another user', async () => {
|
||||||
|
const asset = AssetFactory.create();
|
||||||
|
const [noPerson, otherOwnerFace] = [
|
||||||
|
AssetFaceFactory.create({ assetId: asset.id }),
|
||||||
|
AssetFaceFactory.from().person().build(),
|
||||||
|
];
|
||||||
|
const person = PersonFactory.create({
|
||||||
|
ownerId: asset.ownerId,
|
||||||
|
personGroupId: otherOwnerFace.person!.personGroupId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const faces = [getForFaceSearch(noPerson, 0), getForFaceSearch(otherOwnerFace, 0.2)];
|
||||||
|
|
||||||
|
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
||||||
|
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||||
|
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson, asset));
|
||||||
|
mocks.person.create.mockResolvedValue(person);
|
||||||
|
|
||||||
|
await sut.handleRecognizeFaces({ id: noPerson.id });
|
||||||
|
|
||||||
|
expect(mocks.person.createGroup).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.person.create).toHaveBeenCalledWith({
|
||||||
|
ownerId: asset.ownerId,
|
||||||
|
faceAssetId: noPerson.id,
|
||||||
|
personGroupId: otherOwnerFace.person!.personGroupId,
|
||||||
|
});
|
||||||
|
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||||
|
faceIds: [noPerson.id],
|
||||||
|
newPersonGroupId: otherOwnerFace.person!.personGroupId,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not queue face with no matches', async () => {
|
it('should not queue face with no matches', async () => {
|
||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
const face = AssetFaceFactory.create({ assetId: asset.id });
|
const face = AssetFaceFactory.create({ assetId: asset.id });
|
||||||
const faces = [{ ...face, distance: 0 }] as FaceSearchResult[];
|
const faces = [getForFaceSearch(face, 0)];
|
||||||
|
|
||||||
mocks.search.searchFaces.mockResolvedValue(faces);
|
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||||
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(face, asset));
|
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(face, asset));
|
||||||
@@ -1158,10 +1248,7 @@ describe(PersonService.name, () => {
|
|||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
const [noPerson1, noPerson2] = [AssetFaceFactory.create({ assetId: asset.id }), AssetFaceFactory.create()];
|
const [noPerson1, noPerson2] = [AssetFaceFactory.create({ assetId: asset.id }), AssetFaceFactory.create()];
|
||||||
|
|
||||||
const faces = [
|
const faces = [getForFaceSearch(noPerson1, 0), getForFaceSearch(noPerson2, 0.4)];
|
||||||
{ ...noPerson1, distance: 0 },
|
|
||||||
{ ...noPerson2, distance: 0.4 },
|
|
||||||
] as FaceSearchResult[];
|
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
|
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
|
||||||
mocks.search.searchFaces.mockResolvedValue(faces);
|
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||||
@@ -1183,10 +1270,7 @@ describe(PersonService.name, () => {
|
|||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
const [noPerson1, noPerson2] = [AssetFaceFactory.create({ assetId: asset.id }), AssetFaceFactory.create()];
|
const [noPerson1, noPerson2] = [AssetFaceFactory.create({ assetId: asset.id }), AssetFaceFactory.create()];
|
||||||
|
|
||||||
const faces = [
|
const faces = [getForFaceSearch(noPerson1, 0), getForFaceSearch(noPerson2, 0.4)];
|
||||||
{ ...noPerson1, distance: 0 },
|
|
||||||
{ ...noPerson2, distance: 0.4 },
|
|
||||||
] as FaceSearchResult[];
|
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
|
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
|
||||||
mocks.search.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]);
|
mocks.search.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]);
|
||||||
@@ -1202,141 +1286,26 @@ describe(PersonService.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('mergePerson', () => {
|
|
||||||
it('should require person.write and person.merge permission', async () => {
|
|
||||||
const auth = AuthFactory.create();
|
|
||||||
const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()];
|
|
||||||
|
|
||||||
mocks.person.getById.mockResolvedValueOnce(person);
|
|
||||||
mocks.person.getById.mockResolvedValueOnce(mergePerson);
|
|
||||||
|
|
||||||
await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).rejects.toBeInstanceOf(
|
|
||||||
BadRequestException,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.person.reassignFaces).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
expect(mocks.person.delete).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should merge two people without smart merge', async () => {
|
|
||||||
const auth = AuthFactory.create();
|
|
||||||
const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()];
|
|
||||||
|
|
||||||
mocks.person.getById.mockResolvedValueOnce(person);
|
|
||||||
mocks.person.getById.mockResolvedValueOnce(mergePerson);
|
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id]));
|
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id]));
|
|
||||||
|
|
||||||
await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).resolves.toEqual([
|
|
||||||
{ id: mergePerson.id, success: true },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
|
||||||
newPersonId: person.id,
|
|
||||||
oldPersonId: mergePerson.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should merge two people with smart merge', async () => {
|
|
||||||
const auth = AuthFactory.create();
|
|
||||||
const [person, mergePerson] = [
|
|
||||||
PersonFactory.create({ name: undefined }),
|
|
||||||
PersonFactory.create({ name: 'Merge person' }),
|
|
||||||
];
|
|
||||||
|
|
||||||
mocks.person.getById.mockResolvedValueOnce(person);
|
|
||||||
mocks.person.getById.mockResolvedValueOnce(mergePerson);
|
|
||||||
mocks.person.update.mockResolvedValue({ ...person, name: mergePerson.name });
|
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id]));
|
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id]));
|
|
||||||
|
|
||||||
await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).resolves.toEqual([
|
|
||||||
{ id: mergePerson.id, success: true },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
|
||||||
newPersonId: person.id,
|
|
||||||
oldPersonId: mergePerson.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mocks.person.update).toHaveBeenCalledWith({
|
|
||||||
id: person.id,
|
|
||||||
name: mergePerson.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw an error when the primary person is not found', async () => {
|
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
|
||||||
|
|
||||||
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf(
|
|
||||||
BadRequestException,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.person.delete).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle invalid merge ids', async () => {
|
|
||||||
const auth = AuthFactory.create();
|
|
||||||
const person = PersonFactory.create();
|
|
||||||
|
|
||||||
mocks.person.getById.mockResolvedValueOnce(person);
|
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id]));
|
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['unknown']));
|
|
||||||
|
|
||||||
await expect(sut.mergePerson(auth, person.id, { ids: ['unknown'] })).resolves.toEqual([
|
|
||||||
{ id: 'unknown', success: false, error: BulkIdErrorReason.NOT_FOUND },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(mocks.person.reassignFaces).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.person.delete).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle an error reassigning faces', async () => {
|
|
||||||
const auth = AuthFactory.create();
|
|
||||||
const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()];
|
|
||||||
|
|
||||||
mocks.person.getById.mockResolvedValueOnce(person);
|
|
||||||
mocks.person.getById.mockResolvedValueOnce(mergePerson);
|
|
||||||
mocks.person.reassignFaces.mockRejectedValue(new Error('update failed'));
|
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id]));
|
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id]));
|
|
||||||
|
|
||||||
await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).resolves.toEqual([
|
|
||||||
{ id: mergePerson.id, success: false, error: BulkIdErrorReason.UNKNOWN },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(mocks.person.delete).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getStatistics', () => {
|
describe('getStatistics', () => {
|
||||||
it('should get correct number of person', async () => {
|
it('should get correct number of person', async () => {
|
||||||
const auth = AuthFactory.create();
|
const auth = AuthFactory.create();
|
||||||
const person = PersonFactory.create();
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.person.getById.mockResolvedValue(person);
|
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||||
mocks.person.getStatistics.mockResolvedValue({ assets: 3 });
|
mocks.person.getStatistics.mockResolvedValue({ assets: 3 });
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
|
||||||
await expect(sut.getStatistics(auth, person.id)).resolves.toEqual({ assets: 3 });
|
await expect(sut.getStatistics(auth, person.personGroupId)).resolves.toEqual({ assets: 3 });
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
expect(mocks.person.getStatistics).toHaveBeenCalledWith(person.personGroupId, auth.user.id);
|
||||||
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should require person.read permission', async () => {
|
it('should require person.read permission', async () => {
|
||||||
const auth = AuthFactory.create();
|
const auth = AuthFactory.create();
|
||||||
const person = PersonFactory.create();
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.person.getById.mockResolvedValue(person);
|
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||||
await expect(sut.getStatistics(auth, person.id)).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.getStatistics(auth, person.personGroupId)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1363,11 +1332,5 @@ describe(PersonService.name, () => {
|
|||||||
it('should not map person if person is null', () => {
|
it('should not map person if person is null', () => {
|
||||||
expect(mapFaces(getForAssetFace(AssetFaceFactory.create()), AuthFactory.create()).person).toBeNull();
|
expect(mapFaces(getForAssetFace(AssetFaceFactory.create()), AuthFactory.create()).person).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not map person if person does not match auth user id', () => {
|
|
||||||
expect(
|
|
||||||
mapFaces(getForAssetFace(AssetFaceFactory.from().person().build()), AuthFactory.create()).person,
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Insertable, Updateable } from 'kysely';
|
import { Insertable, Selectable, Updateable } from 'kysely';
|
||||||
import { Person } from 'src/database';
|
import { Person } from 'src/database';
|
||||||
import { Chunked, OnJob } from 'src/decorators';
|
import { Chunked, OnJob } from 'src/decorators';
|
||||||
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||||
@@ -34,9 +34,10 @@ import {
|
|||||||
VectorIndex,
|
VectorIndex,
|
||||||
} from 'src/enum';
|
} from 'src/enum';
|
||||||
import { BoundingBox } from 'src/repositories/machine-learning.repository';
|
import { BoundingBox } from 'src/repositories/machine-learning.repository';
|
||||||
import { UpdateFacesData } from 'src/repositories/person.repository';
|
import { PersonId, UpdateFacesData } from 'src/repositories/person.repository';
|
||||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||||
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
|
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
|
||||||
|
import { PersonTable } from 'src/schema/tables/person.table';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { JobItem, JobOf } from 'src/types';
|
import { JobItem, JobOf } from 'src/types';
|
||||||
import { getDimensions } from 'src/utils/asset.util';
|
import { getDimensions } from 'src/utils/asset.util';
|
||||||
@@ -45,6 +46,8 @@ import { mimeTypes } from 'src/utils/mime-types';
|
|||||||
import { batched, findOrFail, isFacialRecognitionEnabled } from 'src/utils/misc';
|
import { batched, findOrFail, isFacialRecognitionEnabled } from 'src/utils/misc';
|
||||||
import { Point, transformPoints } from 'src/utils/transform';
|
import { Point, transformPoints } from 'src/utils/transform';
|
||||||
|
|
||||||
|
const personKey = ({ ownerId, personGroupId }: PersonId) => `${ownerId}/${personGroupId}`;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PersonService extends BaseService {
|
export class PersonService extends BaseService {
|
||||||
async getAll(auth: AuthDto, dto: PersonSearchDto): Promise<PeopleResponseDto> {
|
async getAll(auth: AuthDto, dto: PersonSearchDto): Promise<PeopleResponseDto> {
|
||||||
@@ -56,7 +59,10 @@ export class PersonService extends BaseService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (closestPersonId) {
|
if (closestPersonId) {
|
||||||
const person = await this.personRepository.getById(closestPersonId);
|
const person = await this.personRepository.getByGroupId({
|
||||||
|
ownerId: auth.user.id,
|
||||||
|
personGroupId: closestPersonId,
|
||||||
|
});
|
||||||
if (!person?.faceAssetId) {
|
if (!person?.faceAssetId) {
|
||||||
throw new NotFoundException('Person not found');
|
throw new NotFoundException('Person not found');
|
||||||
}
|
}
|
||||||
@@ -76,92 +82,94 @@ export class PersonService extends BaseService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> {
|
async reassignFaces(auth: AuthDto, personGroupId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> {
|
||||||
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personId] });
|
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personGroupId] });
|
||||||
const person = await this.findOrFail(personId);
|
const person = await this.findOrFail(auth, personGroupId);
|
||||||
const result: PersonResponseDto[] = [];
|
const result: PersonResponseDto[] = [];
|
||||||
const changeFeaturePhoto: string[] = [];
|
const changeFeaturePhoto = new Map<string, PersonId>();
|
||||||
for (const data of dto.data) {
|
for (const data of dto.data) {
|
||||||
const faces = await this.personRepository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]);
|
const faces = await this.personRepository.getFacesByIds(
|
||||||
|
[{ personGroupId: data.personId, assetId: data.assetId }],
|
||||||
|
{ viewingUserId: auth.user.id },
|
||||||
|
);
|
||||||
|
|
||||||
for (const face of faces) {
|
for (const face of faces) {
|
||||||
await this.requireAccess({ auth, permission: Permission.PersonCreate, ids: [face.id] });
|
await this.requireAccess({ auth, permission: Permission.PersonCreate, ids: [face.id] });
|
||||||
if (person.faceAssetId === null) {
|
if (person.faceAssetId === null) {
|
||||||
changeFeaturePhoto.push(person.id);
|
changeFeaturePhoto.set(personKey(person), person);
|
||||||
}
|
}
|
||||||
if (face.person && face.person.faceAssetId === face.id) {
|
if (face.person && face.person.faceAssetId === face.id) {
|
||||||
changeFeaturePhoto.push(face.person.id);
|
changeFeaturePhoto.set(personKey(face.person), face.person);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.personRepository.reassignFace(face.id, personId);
|
await this.personRepository.reassignFace(face.id, person.personGroupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
result.push(mapPerson(person));
|
result.push(mapPerson(person));
|
||||||
}
|
}
|
||||||
if (changeFeaturePhoto.length > 0) {
|
if (changeFeaturePhoto.size > 0) {
|
||||||
// Remove duplicates
|
await this.createNewFeaturePhoto(changeFeaturePhoto.values().toArray());
|
||||||
await this.createNewFeaturePhoto([...new Set(changeFeaturePhoto)]);
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise<PersonResponseDto> {
|
async reassignFacesById(auth: AuthDto, personGroupId: string, dto: FaceDto): Promise<PersonResponseDto> {
|
||||||
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personId] });
|
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personGroupId] });
|
||||||
await this.requireAccess({ auth, permission: Permission.PersonCreate, ids: [dto.id] });
|
await this.requireAccess({ auth, permission: Permission.PersonCreate, ids: [dto.id] });
|
||||||
const face = await this.personRepository.getFaceById(dto.id);
|
const face = await this.personRepository.getFaceById(dto.id, { viewingUserId: auth.user.id });
|
||||||
const person = await this.findOrFail(personId);
|
const person = await this.findOrFail(auth, personGroupId);
|
||||||
|
|
||||||
await this.personRepository.reassignFace(face.id, personId);
|
await this.personRepository.reassignFace(face.id, person.personGroupId);
|
||||||
if (person.faceAssetId === null) {
|
if (person.faceAssetId === null) {
|
||||||
await this.createNewFeaturePhoto([person.id]);
|
await this.createNewFeaturePhoto([person]);
|
||||||
}
|
}
|
||||||
if (face.person && face.person.faceAssetId === face.id) {
|
if (face.person && face.person.faceAssetId === face.id) {
|
||||||
await this.createNewFeaturePhoto([face.person.id]);
|
await this.createNewFeaturePhoto([face.person]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return mapPerson(await this.findOrFail(personId));
|
return mapPerson(await this.findOrFail(auth, personGroupId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFacesById(auth: AuthDto, dto: FaceDto): Promise<AssetFaceResponseDto[]> {
|
async getFacesById(auth: AuthDto, dto: FaceDto): Promise<AssetFaceResponseDto[]> {
|
||||||
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.id] });
|
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.id] });
|
||||||
const faces = await this.personRepository.getFaces(dto.id);
|
const faces = await this.personRepository.getFaces(dto.id, { viewingUserId: auth.user.id, isVisible: true });
|
||||||
const asset = await this.assetRepository.getForFaces(dto.id);
|
const asset = await this.assetRepository.getForFaces(dto.id);
|
||||||
const assetDimensions = getDimensions(asset);
|
const assetDimensions = getDimensions(asset);
|
||||||
|
|
||||||
return faces.map((face) => mapFaces(face, auth, asset.edits, assetDimensions));
|
return faces.map((face) => mapFaces(face, auth, asset.edits, assetDimensions));
|
||||||
}
|
}
|
||||||
|
|
||||||
async createNewFeaturePhoto(changeFeaturePhoto: string[]) {
|
async createNewFeaturePhoto(changeFeaturePhoto: PersonId[]) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Changing feature photos for ${changeFeaturePhoto.length} ${changeFeaturePhoto.length > 1 ? 'people' : 'person'}`,
|
`Changing feature photos for ${changeFeaturePhoto.length} ${changeFeaturePhoto.length > 1 ? 'people' : 'person'}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const jobs: JobItem[] = [];
|
const jobs: JobItem[] = [];
|
||||||
for (const personId of changeFeaturePhoto) {
|
for (const { ownerId, personGroupId } of changeFeaturePhoto) {
|
||||||
const assetFace = await this.personRepository.getRandomFace(personId);
|
const assetFace = await this.personRepository.getRandomFace(personGroupId);
|
||||||
|
|
||||||
if (assetFace) {
|
if (assetFace) {
|
||||||
await this.personRepository.update({ id: personId, faceAssetId: assetFace.id });
|
await this.personRepository.update({ ownerId, personGroupId, faceAssetId: assetFace.id });
|
||||||
jobs.push({ name: JobName.PersonGenerateThumbnail, data: { id: personId } });
|
jobs.push({ name: JobName.PersonGenerateThumbnail, data: { ownerId, personGroupId } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.jobRepository.queueAll(jobs);
|
await this.jobRepository.queueAll(jobs);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getById(auth: AuthDto, id: string): Promise<PersonResponseDto> {
|
async getById(auth: AuthDto, personGroupId: string): Promise<PersonResponseDto> {
|
||||||
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] });
|
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [personGroupId] });
|
||||||
return mapPerson(await this.findOrFail(id));
|
return mapPerson(await this.findOrFail(auth, personGroupId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStatistics(auth: AuthDto, id: string): Promise<PersonStatisticsResponseDto> {
|
async getStatistics(auth: AuthDto, personGroupId: string): Promise<PersonStatisticsResponseDto> {
|
||||||
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] });
|
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [personGroupId] });
|
||||||
return this.personRepository.getStatistics(id);
|
return this.personRepository.getStatistics(personGroupId, auth.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getThumbnail(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
|
async getThumbnail(auth: AuthDto, personGroupId: string): Promise<ImmichFileResponse> {
|
||||||
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] });
|
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [personGroupId] });
|
||||||
const person = await this.personRepository.getById(id);
|
const person = await this.personRepository.getByGroupId({ ownerId: auth.user.id, personGroupId });
|
||||||
if (!person || !person.thumbnailPath) {
|
if (!person || !person.thumbnailPath) {
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
@@ -174,8 +182,10 @@ export class PersonService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(auth: AuthDto, dto: PersonCreateDto): Promise<PersonResponseDto> {
|
async create(auth: AuthDto, dto: PersonCreateDto): Promise<PersonResponseDto> {
|
||||||
|
const group = await this.personRepository.createGroup(auth.user.id);
|
||||||
const person = await this.personRepository.create({
|
const person = await this.personRepository.create({
|
||||||
ownerId: auth.user.id,
|
ownerId: auth.user.id,
|
||||||
|
personGroupId: group.id,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
birthDate: dto.birthDate,
|
birthDate: dto.birthDate,
|
||||||
isHidden: dto.isHidden,
|
isHidden: dto.isHidden,
|
||||||
@@ -186,15 +196,16 @@ export class PersonService extends BaseService {
|
|||||||
return mapPerson(person);
|
return mapPerson(person);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
|
async update(auth: AuthDto, personGroupId: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
|
||||||
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [id] });
|
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personGroupId] });
|
||||||
|
|
||||||
|
const { ownerId } = await this.findOrFail(auth, personGroupId);
|
||||||
const { name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite, color } = dto;
|
const { name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite, color } = dto;
|
||||||
// TODO: set by faceId directly
|
// TODO: set by faceId directly
|
||||||
let faceId: string | undefined;
|
let faceId: string | undefined;
|
||||||
if (assetId) {
|
if (assetId) {
|
||||||
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [assetId] });
|
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [assetId] });
|
||||||
const face = await this.personRepository.getForFeatureFaceUpdate({ personId: id, assetId });
|
const face = await this.personRepository.getForFeatureFaceUpdate({ personGroupId, assetId });
|
||||||
if (!face) {
|
if (!face) {
|
||||||
throw new BadRequestException('Invalid assetId for feature face or asset is offline');
|
throw new BadRequestException('Invalid assetId for feature face or asset is offline');
|
||||||
}
|
}
|
||||||
@@ -203,7 +214,8 @@ export class PersonService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const person = await this.personRepository.update({
|
const person = await this.personRepository.update({
|
||||||
id,
|
ownerId,
|
||||||
|
personGroupId,
|
||||||
faceAssetId: faceId,
|
faceAssetId: faceId,
|
||||||
name,
|
name,
|
||||||
birthDate,
|
birthDate,
|
||||||
@@ -213,7 +225,7 @@ export class PersonService extends BaseService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (assetId) {
|
if (assetId) {
|
||||||
await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id } });
|
await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { ownerId, personGroupId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
return mapPerson(person);
|
return mapPerson(person);
|
||||||
@@ -245,21 +257,32 @@ export class PersonService extends BaseService {
|
|||||||
|
|
||||||
async deleteAll(auth: AuthDto, { ids }: BulkIdsDto): Promise<void> {
|
async deleteAll(auth: AuthDto, { ids }: BulkIdsDto): Promise<void> {
|
||||||
await this.requireAccess({ auth, permission: Permission.PersonDelete, ids });
|
await this.requireAccess({ auth, permission: Permission.PersonDelete, ids });
|
||||||
const people = await this.personRepository.getForPeopleDelete(ids);
|
await this.removeAllPersonGroups(ids, auth.user.id);
|
||||||
await this.removeAllPeople(people);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Chunked()
|
@Chunked()
|
||||||
private async removeAllPeople(people: { id: string; thumbnailPath: string }[]) {
|
private async removeAllPersonGroups(groupIds: string[], ownerId?: string) {
|
||||||
|
if (groupIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const people = await this.personRepository.delete(groupIds, ownerId);
|
||||||
await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath)));
|
await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath)));
|
||||||
await this.personRepository.delete(people.map((person) => person.id));
|
await this.personRepository.deleteEmptyGroups();
|
||||||
this.logger.debug(`Deleted ${people.length} people`);
|
this.logger.debug(`Deleted ${groupIds.length} people`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnJob({ name: JobName.PersonCleanup, queue: QueueName.BackgroundTask })
|
@OnJob({ name: JobName.PersonCleanup, queue: QueueName.BackgroundTask })
|
||||||
async handlePersonCleanup(): Promise<JobStatus> {
|
async handlePersonCleanup(): Promise<JobStatus> {
|
||||||
|
// each step can leave the next one something to clean up, so the order matters
|
||||||
const people = await this.personRepository.getAllWithoutFaces();
|
const people = await this.personRepository.getAllWithoutFaces();
|
||||||
await this.removeAllPeople(people);
|
await this.removeAllPersonGroups(people.map((person) => person.personGroupId));
|
||||||
|
|
||||||
|
const personGroups = await this.personRepository.deleteEmptyGroups();
|
||||||
|
const clusterGroups = await this.personRepository.deleteOrphanedClusterGroups();
|
||||||
|
|
||||||
|
this.logger.debug(`Deleted ${personGroups} empty person groups and ${clusterGroups} orphaned cluster groups`);
|
||||||
|
|
||||||
return JobStatus.Success;
|
return JobStatus.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,7 +452,7 @@ export class PersonService extends BaseService {
|
|||||||
const lastRun = new Date().toISOString();
|
const lastRun = new Date().toISOString();
|
||||||
|
|
||||||
const faces = this.personRepository.getAllFaces(
|
const faces = this.personRepository.getAllFaces(
|
||||||
force ? undefined : { personId: null, sourceType: SourceType.MachineLearning },
|
force ? undefined : { personGroupId: null, sourceType: SourceType.MachineLearning },
|
||||||
);
|
);
|
||||||
for await (const batch of batched(faces)) {
|
for await (const batch of batched(faces)) {
|
||||||
await this.jobRepository.queueAll(
|
await this.jobRepository.queueAll(
|
||||||
@@ -465,13 +488,14 @@ export class PersonService extends BaseService {
|
|||||||
return JobStatus.Failed;
|
return JobStatus.Failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (face.personId) {
|
if (face.personGroupId) {
|
||||||
this.logger.debug(`Face ${id} already has a person assigned`);
|
this.logger.debug(`Face ${id} already has a person assigned`);
|
||||||
return JobStatus.Skipped;
|
return JobStatus.Skipped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { ownerId, clusterGroupId } = face.asset;
|
||||||
const matches = await this.searchRepository.searchFaces({
|
const matches = await this.searchRepository.searchFaces({
|
||||||
userIds: [face.asset.ownerId],
|
clusterGroupId,
|
||||||
embedding: face.faceSearch.embedding,
|
embedding: face.faceSearch.embedding,
|
||||||
maxDistance: machineLearning.facialRecognition.maxDistance,
|
maxDistance: machineLearning.facialRecognition.maxDistance,
|
||||||
numResults: machineLearning.facialRecognition.minFaces,
|
numResults: machineLearning.facialRecognition.minFaces,
|
||||||
@@ -495,10 +519,10 @@ export class PersonService extends BaseService {
|
|||||||
return JobStatus.Skipped;
|
return JobStatus.Skipped;
|
||||||
}
|
}
|
||||||
|
|
||||||
let personId = matches.find((match) => match.personId)?.personId;
|
let personGroupId = matches.find((match) => match.personGroupId)?.personGroupId;
|
||||||
if (!personId) {
|
if (!personGroupId) {
|
||||||
const matchWithPerson = await this.searchRepository.searchFaces({
|
const [matchWithPerson] = await this.searchRepository.searchFaces({
|
||||||
userIds: [face.asset.ownerId],
|
clusterGroupId,
|
||||||
embedding: face.faceSearch.embedding,
|
embedding: face.faceSearch.embedding,
|
||||||
maxDistance: machineLearning.facialRecognition.maxDistance,
|
maxDistance: machineLearning.facialRecognition.maxDistance,
|
||||||
numResults: 1,
|
numResults: 1,
|
||||||
@@ -506,29 +530,38 @@ export class PersonService extends BaseService {
|
|||||||
minBirthDate: new Date(face.asset.fileCreatedAt),
|
minBirthDate: new Date(face.asset.fileCreatedAt),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (matchWithPerson.length > 0) {
|
personGroupId = matchWithPerson?.personGroupId ?? undefined;
|
||||||
personId = matchWithPerson[0].personId;
|
}
|
||||||
|
|
||||||
|
if (!personGroupId && isCore) {
|
||||||
|
const group = await this.personRepository.createGroup(ownerId);
|
||||||
|
personGroupId = group.id;
|
||||||
|
this.logger.log(`Created person group ${personGroupId} for face ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (personGroupId) {
|
||||||
|
const person = await this.personRepository.getByGroupId({ ownerId, personGroupId });
|
||||||
|
if (person) {
|
||||||
|
this.logger.debug(`Face ${id} matched person ${person.personGroupId}`);
|
||||||
|
} else {
|
||||||
|
await this.personRepository.create({ ownerId, faceAssetId: face.id, personGroupId });
|
||||||
|
this.logger.log(`Created person for face ${id} in group ${personGroupId}`);
|
||||||
|
await this.jobRepository.queue({
|
||||||
|
name: JobName.PersonGenerateThumbnail,
|
||||||
|
data: { ownerId, personGroupId },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (isCore && !personId) {
|
this.logger.debug(`Assigning face ${id} to person group ${personGroupId}`);
|
||||||
this.logger.log(`Creating new person for face ${id}`);
|
await this.personRepository.reassignFaces({ faceIds: [id], newPersonGroupId: personGroupId });
|
||||||
const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id });
|
|
||||||
await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id: newPerson.id } });
|
|
||||||
personId = newPerson.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (personId) {
|
|
||||||
this.logger.debug(`Assigning face ${id} to person ${personId}`);
|
|
||||||
await this.personRepository.reassignFaces({ faceIds: [id], newPersonId: personId });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return JobStatus.Success;
|
return JobStatus.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnJob({ name: JobName.PersonFileMigration, queue: QueueName.Migration })
|
@OnJob({ name: JobName.PersonFileMigration, queue: QueueName.Migration })
|
||||||
async handlePersonMigration({ id }: JobOf<JobName.PersonFileMigration>): Promise<JobStatus> {
|
async handlePersonMigration({ ownerId, personGroupId }: JobOf<JobName.PersonFileMigration>): Promise<JobStatus> {
|
||||||
const person = await this.personRepository.getById(id);
|
const person = await this.personRepository.getByGroupId({ ownerId, personGroupId });
|
||||||
if (!person) {
|
if (!person) {
|
||||||
return JobStatus.Failed;
|
return JobStatus.Failed;
|
||||||
}
|
}
|
||||||
@@ -538,15 +571,13 @@ export class PersonService extends BaseService {
|
|||||||
return JobStatus.Success;
|
return JobStatus.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
async mergePerson(auth: AuthDto, id: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> {
|
async mergePerson(auth: AuthDto, personGroupId: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> {
|
||||||
const mergeIds = dto.ids;
|
const mergeIds = dto.ids;
|
||||||
if (mergeIds.includes(id)) {
|
if (mergeIds.includes(personGroupId)) {
|
||||||
throw new BadRequestException('Cannot merge a person into themselves');
|
throw new BadRequestException('Cannot merge a person into themselves');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [id] });
|
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personGroupId] });
|
||||||
let primaryPerson = await this.findOrFail(id);
|
|
||||||
const primaryName = primaryPerson.name || primaryPerson.id;
|
|
||||||
|
|
||||||
const results: BulkIdResponseDto[] = [];
|
const results: BulkIdResponseDto[] = [];
|
||||||
|
|
||||||
@@ -556,52 +587,71 @@ export class PersonService extends BaseService {
|
|||||||
ids: mergeIds,
|
ids: mergeIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const mergeId of mergeIds) {
|
let primaryPerson: Selectable<PersonTable> | undefined;
|
||||||
|
|
||||||
|
for (const mergePerson of await this.personRepository.getForMergePerson(mergeIds)) {
|
||||||
|
const mergeId = mergePerson.personGroupId;
|
||||||
const hasAccess = allowedIds.has(mergeId);
|
const hasAccess = allowedIds.has(mergeId);
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NO_PERMISSION });
|
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NO_PERMISSION });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (!primaryPerson || primaryPerson.ownerId !== mergePerson.ownerId) {
|
||||||
const mergePerson = await this.personRepository.getById(mergeId);
|
primaryPerson = await this.personRepository.getByGroupId({ ownerId: mergePerson.ownerId, personGroupId });
|
||||||
if (!mergePerson) {
|
if (!primaryPerson) {
|
||||||
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NOT_FOUND });
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const update: Updateable<Person> & { id: string } = { id: primaryPerson.id };
|
const changes: Updateable<Person> = {};
|
||||||
if (!primaryPerson.name && mergePerson.name) {
|
if (!primaryPerson.name && mergePerson.name) {
|
||||||
update.name = mergePerson.name;
|
changes.name = mergePerson.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!primaryPerson.birthDate && mergePerson.birthDate) {
|
if (!primaryPerson.birthDate && mergePerson.birthDate) {
|
||||||
update.birthDate = mergePerson.birthDate;
|
changes.birthDate = mergePerson.birthDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(update).length > 1) {
|
if (
|
||||||
primaryPerson = await this.personRepository.update(update);
|
(mergePerson.name && mergePerson.name !== primaryPerson.name) ||
|
||||||
}
|
(mergePerson.birthDate && mergePerson.birthDate !== primaryPerson.birthDate)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const mergeName = mergePerson.name || mergePerson.id;
|
if (Object.keys(changes).length > 0) {
|
||||||
const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id };
|
primaryPerson = await this.personRepository.update({
|
||||||
this.logger.log(`Merging ${mergeName} into ${primaryName}`);
|
ownerId: primaryPerson.ownerId,
|
||||||
|
personGroupId: primaryPerson.personGroupId,
|
||||||
|
...changes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergeName = mergePerson.name || mergePerson.personGroupId;
|
||||||
|
const mergeData: UpdateFacesData = {
|
||||||
|
oldPersonGroupId: mergeId,
|
||||||
|
newPersonGroupId: primaryPerson.personGroupId,
|
||||||
|
ownerId: primaryPerson.ownerId,
|
||||||
|
};
|
||||||
|
this.logger.log(`Merging ${mergeName} into ${primaryPerson.name || primaryPerson.personGroupId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
await this.personRepository.reassignFaces(mergeData);
|
await this.personRepository.reassignFaces(mergeData);
|
||||||
await this.removeAllPeople([mergePerson]);
|
await this.removeAllPersonGroups([mergeId], primaryPerson.ownerId);
|
||||||
|
|
||||||
this.logger.log(`Merged ${mergeName} into ${primaryName}`);
|
this.logger.log(`Merged ${mergeName} into ${primaryPerson.name || primaryPerson.personGroupId}`);
|
||||||
results.push({ id: mergeId, success: true });
|
results.push({ id: mergeId, success: true });
|
||||||
} catch (error: Error | any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Unable to merge ${mergeId} into ${id}: ${error}`, error?.stack);
|
this.logger.error(`Unable to merge ${mergeId} into ${personGroupId}: ${error}`, error?.stack);
|
||||||
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.UNKNOWN });
|
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.UNKNOWN });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
private findOrFail(id: string) {
|
private findOrFail(auth: AuthDto, personGroupId: string) {
|
||||||
return findOrFail(() => this.personRepository.getById(id), 'Person');
|
return findOrFail(() => this.personRepository.getByGroupId({ ownerId: auth.user.id, personGroupId }), 'Person');
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO return a asset face response
|
// TODO return a asset face response
|
||||||
@@ -613,7 +663,7 @@ export class PersonService extends BaseService {
|
|||||||
|
|
||||||
const [asset, person] = await Promise.all([
|
const [asset, person] = await Promise.all([
|
||||||
this.assetRepository.getById(dto.assetId, { edits: true, exifInfo: true }),
|
this.assetRepository.getById(dto.assetId, { edits: true, exifInfo: true }),
|
||||||
this.findOrFail(dto.personId),
|
this.findOrFail(auth, dto.personId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
@@ -661,7 +711,7 @@ export class PersonService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.personRepository.createAssetFace({
|
await this.personRepository.createAssetFace({
|
||||||
personId: dto.personId,
|
personGroupId: person.personGroupId,
|
||||||
assetId: dto.assetId,
|
assetId: dto.assetId,
|
||||||
imageHeight: dto.imageHeight,
|
imageHeight: dto.imageHeight,
|
||||||
imageWidth: dto.imageWidth,
|
imageWidth: dto.imageWidth,
|
||||||
@@ -673,7 +723,7 @@ export class PersonService extends BaseService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!person.faceAssetId) {
|
if (!person.faceAssetId) {
|
||||||
await this.createNewFeaturePhoto([person.id]);
|
await this.createNewFeaturePhoto([person]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -250,7 +250,13 @@ describe(SearchService.name, () => {
|
|||||||
);
|
);
|
||||||
expect(mocks.search.searchSmart).toHaveBeenCalledWith(
|
expect(mocks.search.searchSmart).toHaveBeenCalledWith(
|
||||||
{ page: 1, size: 100 },
|
{ page: 1, size: 100 },
|
||||||
{ query: 'test', embedding: '[1, 2, 3]', userIds: [authStub.user1.user.id], visibility: 'not-locked' },
|
{
|
||||||
|
query: 'test',
|
||||||
|
embedding: '[1, 2, 3]',
|
||||||
|
userIds: [authStub.user1.user.id],
|
||||||
|
viewingUserId: authStub.user1.user.id,
|
||||||
|
visibility: 'not-locked',
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -44,12 +44,14 @@ export class SearchService extends BaseService {
|
|||||||
const cities = await this.assetRepository.getAssetIdByCity(auth.user.id, options);
|
const cities = await this.assetRepository.getAssetIdByCity(auth.user.id, options);
|
||||||
const cityAssets = await this.assetRepository.getByIdsWithAllRelationsButStacks(
|
const cityAssets = await this.assetRepository.getByIdsWithAllRelationsButStacks(
|
||||||
cities.items.map(({ data }) => data),
|
cities.items.map(({ data }) => data),
|
||||||
|
auth.user.id,
|
||||||
);
|
);
|
||||||
const cityItems = cityAssets.map((asset) => ({ value: asset.exifInfo!.city!, data: mapAsset(asset, { auth }) }));
|
const cityItems = cityAssets.map((asset) => ({ value: asset.exifInfo!.city!, data: mapAsset(asset, { auth }) }));
|
||||||
|
|
||||||
const recents = await this.assetRepository.getRecentlyCreatedAssetIds(auth.user.id, options.maxFields);
|
const recents = await this.assetRepository.getRecentlyCreatedAssetIds(auth.user.id, options.maxFields);
|
||||||
const recentAssets = await this.assetRepository.getByIdsWithAllRelationsButStacks(
|
const recentAssets = await this.assetRepository.getByIdsWithAllRelationsButStacks(
|
||||||
recents.items.map((item) => item.data),
|
recents.items.map((item) => item.data),
|
||||||
|
auth.user.id,
|
||||||
);
|
);
|
||||||
const recentItems = recentAssets.map((asset) => ({
|
const recentItems = recentAssets.map((asset) => ({
|
||||||
value: asset.createdAt.toISOString(),
|
value: asset.createdAt.toISOString(),
|
||||||
@@ -92,6 +94,7 @@ export class SearchService extends BaseService {
|
|||||||
checksum,
|
checksum,
|
||||||
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
|
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
|
||||||
userIds,
|
userIds,
|
||||||
|
viewingUserId: auth.user.id,
|
||||||
orderDirection: dto.order ?? AssetOrder.Desc,
|
orderDirection: dto.order ?? AssetOrder.Desc,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -109,6 +112,7 @@ export class SearchService extends BaseService {
|
|||||||
...dto,
|
...dto,
|
||||||
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
|
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
|
||||||
userIds,
|
userIds,
|
||||||
|
viewingUserId: auth.user.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +126,7 @@ export class SearchService extends BaseService {
|
|||||||
...dto,
|
...dto,
|
||||||
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
|
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
|
||||||
userIds,
|
userIds,
|
||||||
|
viewingUserId: auth.user.id,
|
||||||
});
|
});
|
||||||
return items.map((item) => mapAsset(item, { auth }));
|
return items.map((item) => mapAsset(item, { auth }));
|
||||||
}
|
}
|
||||||
@@ -136,6 +141,7 @@ export class SearchService extends BaseService {
|
|||||||
...dto,
|
...dto,
|
||||||
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
|
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
|
||||||
userIds,
|
userIds,
|
||||||
|
viewingUserId: auth.user.id,
|
||||||
});
|
});
|
||||||
return items.map((item) => mapAsset(item, { auth }));
|
return items.map((item) => mapAsset(item, { auth }));
|
||||||
}
|
}
|
||||||
@@ -180,6 +186,7 @@ export class SearchService extends BaseService {
|
|||||||
{
|
{
|
||||||
...dto,
|
...dto,
|
||||||
userIds: await userIds,
|
userIds: await userIds,
|
||||||
|
viewingUserId: auth.user.id,
|
||||||
embedding,
|
embedding,
|
||||||
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
|
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ export class SyncService extends BaseService {
|
|||||||
await this.syncRepository.memoryToAsset.cleanupAuditTable(pruneThreshold);
|
await this.syncRepository.memoryToAsset.cleanupAuditTable(pruneThreshold);
|
||||||
await this.syncRepository.partner.cleanupAuditTable(pruneThreshold);
|
await this.syncRepository.partner.cleanupAuditTable(pruneThreshold);
|
||||||
await this.syncRepository.person.cleanupAuditTable(pruneThreshold);
|
await this.syncRepository.person.cleanupAuditTable(pruneThreshold);
|
||||||
|
await this.syncRepository.personGroup.cleanupAuditTable(pruneThreshold);
|
||||||
await this.syncRepository.stack.cleanupAuditTable(pruneThreshold);
|
await this.syncRepository.stack.cleanupAuditTable(pruneThreshold);
|
||||||
await this.syncRepository.user.cleanupAuditTable(pruneThreshold);
|
await this.syncRepository.user.cleanupAuditTable(pruneThreshold);
|
||||||
await this.syncRepository.userMetadata.cleanupAuditTable(pruneThreshold);
|
await this.syncRepository.userMetadata.cleanupAuditTable(pruneThreshold);
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ describe(TimelineService.name, () => {
|
|||||||
await expect(sut.getTimeBuckets(authStub.admin, {})).resolves.toEqual(
|
await expect(sut.getTimeBuckets(authStub.admin, {})).resolves.toEqual(
|
||||||
expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }]),
|
expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }]),
|
||||||
);
|
);
|
||||||
expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith({
|
expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith(
|
||||||
userIds: [authStub.admin.user.id],
|
{
|
||||||
});
|
userIds: [authStub.admin.user.id],
|
||||||
|
},
|
||||||
|
authStub.admin,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should pass bbox options to repository when all bbox fields are provided', async () => {
|
it('should pass bbox options to repository when all bbox fields are provided', async () => {
|
||||||
@@ -36,10 +39,13 @@ describe(TimelineService.name, () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith({
|
expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith(
|
||||||
userIds: [authStub.admin.user.id],
|
{
|
||||||
bbox: { west: -70, south: -30, east: 120, north: 55 },
|
userIds: [authStub.admin.user.id],
|
||||||
});
|
bbox: { west: -70, south: -30, east: 120, north: 55 },
|
||||||
|
},
|
||||||
|
authStub.admin,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class TimelineService extends BaseService {
|
|||||||
async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketsResponseDto[]> {
|
async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketsResponseDto[]> {
|
||||||
await this.timeBucketChecks(auth, dto);
|
await this.timeBucketChecks(auth, dto);
|
||||||
const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto);
|
const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto);
|
||||||
return await this.assetRepository.getTimeBuckets(timeBucketOptions);
|
return await this.assetRepository.getTimeBuckets(timeBucketOptions, auth);
|
||||||
}
|
}
|
||||||
|
|
||||||
// pre-jsonified response
|
// pre-jsonified response
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ describe(UserAdminService.name, () => {
|
|||||||
name: userStub.user1.name,
|
name: userStub.user1.name,
|
||||||
storageLabel: 'label',
|
storageLabel: 'label',
|
||||||
password: expect.anything(),
|
password: expect.anything(),
|
||||||
|
clusterGroupId: expect.any(String),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+7
-2
@@ -215,6 +215,11 @@ export interface IDelayedJob extends IBaseJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type JobSource = 'upload' | 'sidecar-write' | 'copy' | 'edit';
|
export type JobSource = 'upload' | 'sidecar-write' | 'copy' | 'edit';
|
||||||
|
export interface IPersonJob {
|
||||||
|
ownerId: string;
|
||||||
|
personGroupId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IEntityJob extends IBaseJob {
|
export interface IEntityJob extends IBaseJob {
|
||||||
id: string;
|
id: string;
|
||||||
source?: JobSource;
|
source?: JobSource;
|
||||||
@@ -352,7 +357,7 @@ export type JobItem =
|
|||||||
// Migration
|
// Migration
|
||||||
| { name: JobName.FileMigrationQueueAll; data?: IBaseJob }
|
| { name: JobName.FileMigrationQueueAll; data?: IBaseJob }
|
||||||
| { name: JobName.AssetFileMigration; data: IEntityJob }
|
| { name: JobName.AssetFileMigration; data: IEntityJob }
|
||||||
| { name: JobName.PersonFileMigration; data: IEntityJob }
|
| { name: JobName.PersonFileMigration; data: IPersonJob }
|
||||||
|
|
||||||
// Metadata Extraction
|
// Metadata Extraction
|
||||||
| { name: JobName.AssetExtractMetadataQueueAll; data: IBaseJob }
|
| { name: JobName.AssetExtractMetadataQueueAll; data: IBaseJob }
|
||||||
@@ -371,7 +376,7 @@ export type JobItem =
|
|||||||
| { name: JobName.AssetDetectFaces; data: IEntityJob }
|
| { name: JobName.AssetDetectFaces; data: IEntityJob }
|
||||||
| { name: JobName.FacialRecognitionQueueAll; data: INightlyJob }
|
| { name: JobName.FacialRecognitionQueueAll; data: INightlyJob }
|
||||||
| { name: JobName.FacialRecognition; data: IDeferrableJob }
|
| { name: JobName.FacialRecognition; data: IDeferrableJob }
|
||||||
| { name: JobName.PersonGenerateThumbnail; data: IEntityJob }
|
| { name: JobName.PersonGenerateThumbnail; data: IPersonJob }
|
||||||
|
|
||||||
// Smart Search
|
// Smart Search
|
||||||
| { name: JobName.SmartSearchQueueAll; data: IBaseJob }
|
| { name: JobName.SmartSearchQueueAll; data: IBaseJob }
|
||||||
|
|||||||
@@ -299,8 +299,29 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
|
|||||||
return access.person.checkFaceOwnerAccess(auth.user.id, ids);
|
return access.person.checkFaceOwnerAccess(auth.user.id, ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case Permission.ClusterGroupRead: {
|
||||||
|
const isMember = await access.clusterGroup.checkOwnerAccess(auth.user.id, ids);
|
||||||
|
const isInvited = await access.clusterGroup.checkInviteAccess(auth.user.id, setDifference(ids, isMember));
|
||||||
|
return setUnion(isMember, isInvited);
|
||||||
|
}
|
||||||
|
|
||||||
|
case Permission.ClusterGroupLeave:
|
||||||
|
case Permission.ClusterGroupRequestCreate: {
|
||||||
|
return access.clusterGroup.checkOwnerAccess(auth.user.id, ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
case Permission.ClusterGroupRequestDelete: {
|
||||||
|
const isOwner = await access.clusterGroupRequest.checkOwnerAccess(auth.user.id, ids);
|
||||||
|
const isGroupMember = await access.clusterGroupRequest.checkGroupAccess(auth.user.id, ids);
|
||||||
|
return setUnion(isOwner, isGroupMember);
|
||||||
|
}
|
||||||
|
|
||||||
|
case Permission.ClusterGroupRequestRead: {
|
||||||
|
return access.clusterGroupRequest.checkOwnerAccess(auth.user.id, ids);
|
||||||
|
}
|
||||||
|
|
||||||
case Permission.PartnerUpdate: {
|
case Permission.PartnerUpdate: {
|
||||||
return await access.partner.checkUpdateAccess(auth.user.id, ids);
|
return access.partner.checkUpdateAccess(auth.user.id, ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
case Permission.SessionRead:
|
case Permission.SessionRead:
|
||||||
|
|||||||
@@ -235,43 +235,68 @@ export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFile
|
|||||||
.where('asset_file.isEdited', '=', sql.lit(isEdited));
|
.where('asset_file.isEdited', '=', sql.lit(isEdited));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withFacesAndPeople(
|
export type WithFacesAndPeopleOptions = {
|
||||||
eb: ExpressionBuilder<DB, 'asset'>,
|
/** whose version of the person to select */
|
||||||
withHidden?: boolean,
|
viewingUserId?: string;
|
||||||
withDeletedFace?: boolean,
|
withHidden?: boolean;
|
||||||
) {
|
withDeletedFace?: boolean;
|
||||||
return jsonArrayFrom(
|
};
|
||||||
eb
|
|
||||||
.selectFrom('asset_face')
|
export function withFacesAndPeople({ viewingUserId, withHidden, withDeletedFace }: WithFacesAndPeopleOptions) {
|
||||||
.leftJoinLateral(
|
return (eb: ExpressionBuilder<DB, 'asset'>) =>
|
||||||
(eb) =>
|
jsonArrayFrom(
|
||||||
eb.selectFrom('person').selectAll('person').whereRef('asset_face.personId', '=', 'person.id').as('person'),
|
eb
|
||||||
(join) => join.onTrue(),
|
.selectFrom('asset_face')
|
||||||
)
|
.leftJoinLateral(
|
||||||
.selectAll('asset_face')
|
(eb) =>
|
||||||
.select((eb) => eb.table('person').$castTo<ShallowDehydrateObject<Person>>().as('person'))
|
eb
|
||||||
.whereRef('asset_face.assetId', '=', 'asset.id')
|
.selectFrom('person')
|
||||||
.$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null))
|
.selectAll('person')
|
||||||
.$if(!withHidden, (qb) => qb.where('asset_face.isVisible', 'is', true)),
|
.whereRef('person.personGroupId', '=', 'asset_face.personGroupId')
|
||||||
).as('faces');
|
.$if(!viewingUserId, (qb) => qb.whereRef('person.ownerId', '=', 'asset.ownerId'))
|
||||||
|
.$if(!!viewingUserId, (qb) => qb.where('person.ownerId', '=', viewingUserId!))
|
||||||
|
.as('person'),
|
||||||
|
(join) => join.onTrue(),
|
||||||
|
)
|
||||||
|
.selectAll('asset_face')
|
||||||
|
.select((eb) => eb.table('person').$castTo<ShallowDehydrateObject<Person>>().as('person'))
|
||||||
|
.whereRef('asset_face.assetId', '=', 'asset.id')
|
||||||
|
.$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null))
|
||||||
|
.$if(!withHidden, (qb) => qb.where('asset_face.isVisible', 'is', true)),
|
||||||
|
).as('faces');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasPeople<O>(qb: SelectQueryBuilder<DB, 'asset', O>, personIds: string[]) {
|
export function hasPeople<O>(qb: SelectQueryBuilder<DB, 'asset', O>, personGroupIds: string[]) {
|
||||||
return qb.innerJoin(
|
return qb.innerJoin(
|
||||||
(eb) =>
|
(eb) =>
|
||||||
eb
|
eb
|
||||||
.selectFrom('asset_face')
|
.selectFrom('asset_face')
|
||||||
.select('assetId')
|
.select('assetId')
|
||||||
.where('personId', '=', anyUuid(personIds!))
|
.where('personGroupId', '=', anyUuid(personGroupIds!))
|
||||||
.where('deletedAt', 'is', null)
|
.where('deletedAt', 'is', null)
|
||||||
.where('isVisible', 'is', true)
|
.where('isVisible', 'is', true)
|
||||||
.groupBy('assetId')
|
.groupBy('assetId')
|
||||||
.having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length)
|
.having((eb) => eb.fn.count('personGroupId').distinct(), '=', personGroupIds.length)
|
||||||
.as('has_people'),
|
.as('has_people'),
|
||||||
(join) => join.onRef('has_people.assetId', '=', 'asset.id'),
|
(join) => join.onRef('has_people.assetId', '=', 'asset.id'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function inSharedAlbum(eb: ExpressionBuilder<DB, 'asset'>, userId: string) {
|
||||||
|
return eb.exists(
|
||||||
|
eb
|
||||||
|
.selectFrom('album_asset')
|
||||||
|
.select(sql.lit(1).as('exists'))
|
||||||
|
.innerJoin('album', (join) =>
|
||||||
|
join.onRef('album.id', '=', 'album_asset.albumId').on('album.deletedAt', 'is', null),
|
||||||
|
)
|
||||||
|
.innerJoin('album_user', (join) =>
|
||||||
|
join.onRef('album_user.albumId', '=', 'album.id').on('album_user.userId', '=', asUuid(userId)),
|
||||||
|
)
|
||||||
|
.whereRef('album_asset.assetId', '=', 'asset.id'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function inAlbums<O>(qb: SelectQueryBuilder<DB, 'asset', O>, albumIds: string[]) {
|
export function inAlbums<O>(qb: SelectQueryBuilder<DB, 'asset', O>, albumIds: string[]) {
|
||||||
return qb.innerJoin(
|
return qb.innerJoin(
|
||||||
(eb) =>
|
(eb) =>
|
||||||
@@ -509,7 +534,9 @@ export function searchAssetBuilderLegacy(kysely: Kysely<DB>, options: AssetSearc
|
|||||||
)
|
)
|
||||||
.$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null))
|
.$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null))
|
||||||
.$if(!!options.withExif, withExifInner)
|
.$if(!!options.withExif, withExifInner)
|
||||||
.$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople))
|
.$if(!!(options.withFaces || options.withPeople), (qb) =>
|
||||||
|
qb.select(withFacesAndPeople({ viewingUserId: options.viewingUserId! })),
|
||||||
|
)
|
||||||
.$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null));
|
.$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,7 +594,7 @@ function albumIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function personIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) {
|
function personIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) {
|
||||||
const matching = (ids: string[]) => visibleFaces(eb).where('asset_face.personId', '=', anyUuid(ids));
|
const matching = (ids: string[]) => visibleFaces(eb).where('asset_face.personGroupId', '=', anyUuid(ids));
|
||||||
return idsPredicates(eb, filter, {
|
return idsPredicates(eb, filter, {
|
||||||
matchesAny: (ids) => eb.exists(matching(ids)),
|
matchesAny: (ids) => eb.exists(matching(ids)),
|
||||||
matchesAll: (ids) =>
|
matchesAll: (ids) =>
|
||||||
@@ -575,7 +602,7 @@ function personIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) {
|
|||||||
matching(ids)
|
matching(ids)
|
||||||
.select('asset_face.assetId')
|
.select('asset_face.assetId')
|
||||||
.groupBy('asset_face.assetId')
|
.groupBy('asset_face.assetId')
|
||||||
.having((eb) => eb.fn.count('asset_face.personId').distinct(), '=', ids.length),
|
.having((eb) => eb.fn.count('asset_face.personGroupId').distinct(), '=', ids.length),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -773,7 +800,9 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
|
|||||||
.$if(!!options.userIds && options.userIds.length > 0, (qb) =>
|
.$if(!!options.userIds && options.userIds.length > 0, (qb) =>
|
||||||
qb.where('asset.ownerId', '=', anyUuid(options.userIds!)),
|
qb.where('asset.ownerId', '=', anyUuid(options.userIds!)),
|
||||||
)
|
)
|
||||||
.$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople))
|
.$if(!!(options.withFaces || options.withPeople), (qb) =>
|
||||||
|
qb.select(withFacesAndPeople({ viewingUserId: options.viewingUserId! })),
|
||||||
|
)
|
||||||
.$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null))
|
.$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null))
|
||||||
.where((eb) => {
|
.where((eb) => {
|
||||||
const predicates = branchPredicates(eb, filter);
|
const predicates = branchPredicates(eb, filter);
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const createFace = (params: Partial<AssetFace> = {}): AssetFace => ({
|
|||||||
boundingBoxY2: 200,
|
boundingBoxY2: 200,
|
||||||
imageWidth: 1000,
|
imageWidth: 1000,
|
||||||
imageHeight: 1000,
|
imageHeight: 1000,
|
||||||
personId: null,
|
personGroupId: null,
|
||||||
sourceType: SourceType.MachineLearning,
|
sourceType: SourceType.MachineLearning,
|
||||||
person: null,
|
person: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export class AssetFaceFactory {
|
|||||||
imageHeight: 500,
|
imageHeight: 500,
|
||||||
imageWidth: 400,
|
imageWidth: 400,
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
personId: null,
|
personGroupId: null,
|
||||||
sourceType: SourceType.MachineLearning,
|
sourceType: SourceType.MachineLearning,
|
||||||
updatedAt: newDate(),
|
updatedAt: newDate(),
|
||||||
updateId: newUuidV7(),
|
updateId: newUuidV7(),
|
||||||
@@ -37,7 +37,7 @@ export class AssetFaceFactory {
|
|||||||
|
|
||||||
person(dto: PersonLike = {}, builder?: FactoryBuilder<PersonFactory>) {
|
person(dto: PersonLike = {}, builder?: FactoryBuilder<PersonFactory>) {
|
||||||
this.#person = build(PersonFactory.from(dto), builder);
|
this.#person = build(PersonFactory.from(dto), builder);
|
||||||
this.value.personId = this.#person.build().id;
|
this.value.personGroupId = this.#person.build().personGroupId;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Selectable } from 'kysely';
|
||||||
|
import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table';
|
||||||
|
import { ClusterGroupLike } from 'test/factories/types';
|
||||||
|
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
|
||||||
|
|
||||||
|
export class ClusterGroupFactory {
|
||||||
|
private constructor(private readonly value: Selectable<ClusterGroupTable>) {}
|
||||||
|
|
||||||
|
static create(dto: ClusterGroupLike = {}) {
|
||||||
|
return ClusterGroupFactory.from(dto).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
static from(dto: ClusterGroupLike = {}) {
|
||||||
|
return new ClusterGroupFactory({
|
||||||
|
id: newUuid(),
|
||||||
|
name: null,
|
||||||
|
createdAt: newDate(),
|
||||||
|
updatedAt: newDate(),
|
||||||
|
updateId: newUuidV7(),
|
||||||
|
...dto,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
return { ...this.value };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Selectable } from 'kysely';
|
||||||
|
import { PersonGroupTable } from 'src/schema/tables/person-group.table';
|
||||||
|
import { PersonGroupLike } from 'test/factories/types';
|
||||||
|
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
|
||||||
|
|
||||||
|
export class PersonGroupFactory {
|
||||||
|
private constructor(private readonly value: Selectable<PersonGroupTable>) {}
|
||||||
|
|
||||||
|
static create(dto: PersonGroupLike = {}) {
|
||||||
|
return PersonGroupFactory.from(dto).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
static from(dto: PersonGroupLike = {}) {
|
||||||
|
return new PersonGroupFactory({
|
||||||
|
id: newUuid(),
|
||||||
|
clusterGroupId: newUuid(),
|
||||||
|
createdAt: newDate(),
|
||||||
|
createId: newUuidV7(),
|
||||||
|
updatedAt: newDate(),
|
||||||
|
updateId: newUuidV7(),
|
||||||
|
...dto,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
return { ...this.value };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ export class PersonFactory {
|
|||||||
color: null,
|
color: null,
|
||||||
createdAt: newDate(),
|
createdAt: newDate(),
|
||||||
faceAssetId: null,
|
faceAssetId: null,
|
||||||
id: newUuid(),
|
personGroupId: newUuid(),
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
isHidden: false,
|
isHidden: false,
|
||||||
name: 'person',
|
name: 'person',
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
|||||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||||
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
||||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||||
|
import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table';
|
||||||
import { MemoryTable } from 'src/schema/tables/memory.table';
|
import { MemoryTable } from 'src/schema/tables/memory.table';
|
||||||
import { PartnerTable } from 'src/schema/tables/partner.table';
|
import { PartnerTable } from 'src/schema/tables/partner.table';
|
||||||
|
import { PersonGroupTable } from 'src/schema/tables/person-group.table';
|
||||||
import { PersonTable } from 'src/schema/tables/person.table';
|
import { PersonTable } from 'src/schema/tables/person.table';
|
||||||
import { SessionTable } from 'src/schema/tables/session.table';
|
import { SessionTable } from 'src/schema/tables/session.table';
|
||||||
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
|
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
|
||||||
@@ -29,6 +31,8 @@ export type SharedLinkLike = Partial<Selectable<SharedLinkTable>>;
|
|||||||
export type UserLike = Partial<Selectable<UserTable>>;
|
export type UserLike = Partial<Selectable<UserTable>>;
|
||||||
export type AssetFaceLike = Partial<Selectable<AssetFaceTable>>;
|
export type AssetFaceLike = Partial<Selectable<AssetFaceTable>>;
|
||||||
export type PersonLike = Partial<Selectable<PersonTable>>;
|
export type PersonLike = Partial<Selectable<PersonTable>>;
|
||||||
|
export type PersonGroupLike = Partial<Selectable<PersonGroupTable>>;
|
||||||
|
export type ClusterGroupLike = Partial<Selectable<ClusterGroupTable>>;
|
||||||
export type StackLike = Partial<Selectable<StackTable>>;
|
export type StackLike = Partial<Selectable<StackTable>>;
|
||||||
export type MemoryLike = Partial<Selectable<MemoryTable>>;
|
export type MemoryLike = Partial<Selectable<MemoryTable>>;
|
||||||
export type PartnerLike = Partial<Selectable<PartnerTable>>;
|
export type PartnerLike = Partial<Selectable<PartnerTable>>;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export class UserFactory {
|
|||||||
static from(dto: UserLike = {}) {
|
static from(dto: UserLike = {}) {
|
||||||
return new UserFactory({
|
return new UserFactory({
|
||||||
id: newUuid(),
|
id: newUuid(),
|
||||||
|
clusterGroupId: newUuid(),
|
||||||
email: 'test@immich.cloud',
|
email: 'test@immich.cloud',
|
||||||
password: '',
|
password: '',
|
||||||
pinCode: null,
|
pinCode: null,
|
||||||
|
|||||||
Vendored
+2
@@ -6,6 +6,7 @@ export const userStub = {
|
|||||||
admin: <UserAdmin>{
|
admin: <UserAdmin>{
|
||||||
...authStub.admin.user,
|
...authStub.admin.user,
|
||||||
status: UserStatus.Active,
|
status: UserStatus.Active,
|
||||||
|
clusterGroupId: 'cluster-group-id',
|
||||||
profileChangedAt: new Date('2021-01-01'),
|
profileChangedAt: new Date('2021-01-01'),
|
||||||
name: 'admin_name',
|
name: 'admin_name',
|
||||||
id: 'admin_id',
|
id: 'admin_id',
|
||||||
@@ -24,6 +25,7 @@ export const userStub = {
|
|||||||
user1: <UserAdmin>{
|
user1: <UserAdmin>{
|
||||||
...authStub.user1.user,
|
...authStub.user1.user,
|
||||||
status: UserStatus.Active,
|
status: UserStatus.Active,
|
||||||
|
clusterGroupId: 'cluster-group-id',
|
||||||
profileChangedAt: new Date('2021-01-01'),
|
profileChangedAt: new Date('2021-01-01'),
|
||||||
name: 'immich_name',
|
name: 'immich_name',
|
||||||
storageLabel: null,
|
storageLabel: null,
|
||||||
|
|||||||
+21
-3
@@ -1,6 +1,7 @@
|
|||||||
import { Selectable, ShallowDehydrateObject } from 'kysely';
|
import { Selectable, ShallowDehydrateObject } from 'kysely';
|
||||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||||
|
import { FaceSearchResult } from 'src/repositories/search.repository';
|
||||||
import { ActivityTable } from 'src/schema/tables/activity.table';
|
import { ActivityTable } from 'src/schema/tables/activity.table';
|
||||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||||
import { PartnerTable } from 'src/schema/tables/partner.table';
|
import { PartnerTable } from 'src/schema/tables/partner.table';
|
||||||
@@ -12,6 +13,7 @@ import { MemoryFactory } from 'test/factories/memory.factory';
|
|||||||
import { SharedLinkFactory } from 'test/factories/shared-link.factory';
|
import { SharedLinkFactory } from 'test/factories/shared-link.factory';
|
||||||
import { StackFactory } from 'test/factories/stack.factory';
|
import { StackFactory } from 'test/factories/stack.factory';
|
||||||
import { UserFactory } from 'test/factories/user.factory';
|
import { UserFactory } from 'test/factories/user.factory';
|
||||||
|
import { newUuid } from 'test/small.factory';
|
||||||
|
|
||||||
export const getForStorageTemplate = (asset: ReturnType<AssetFactory['build']>) => {
|
export const getForStorageTemplate = (asset: ReturnType<AssetFactory['build']>) => {
|
||||||
return {
|
return {
|
||||||
@@ -54,15 +56,27 @@ export const getAsDetectedFace = (face: ReturnType<AssetFaceFactory['build']>) =
|
|||||||
|
|
||||||
export const getForFacialRecognitionJob = (
|
export const getForFacialRecognitionJob = (
|
||||||
face: ReturnType<AssetFaceFactory['build']>,
|
face: ReturnType<AssetFaceFactory['build']>,
|
||||||
asset: Pick<Selectable<AssetTable>, 'ownerId' | 'visibility' | 'fileCreatedAt'> | null,
|
asset:
|
||||||
|
(Pick<Selectable<AssetTable>, 'ownerId' | 'visibility' | 'fileCreatedAt'> & { clusterGroupId?: string }) | null,
|
||||||
) => ({
|
) => ({
|
||||||
...face,
|
...face,
|
||||||
asset: asset
|
asset: asset
|
||||||
? { ownerId: asset.ownerId, visibility: asset.visibility, fileCreatedAt: asset.fileCreatedAt.toISOString() }
|
? {
|
||||||
|
ownerId: asset.ownerId,
|
||||||
|
clusterGroupId: asset.clusterGroupId ?? newUuid(),
|
||||||
|
visibility: asset.visibility,
|
||||||
|
fileCreatedAt: asset.fileCreatedAt.toISOString(),
|
||||||
|
}
|
||||||
: null,
|
: null,
|
||||||
faceSearch: { faceId: face.id, embedding: '[1, 2, 3, 4]' },
|
faceSearch: { faceId: face.id, embedding: '[1, 2, 3, 4]' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getForFaceSearch = (face: ReturnType<AssetFaceFactory['build']>, distance: number): FaceSearchResult => ({
|
||||||
|
id: face.id,
|
||||||
|
personGroupId: face.personGroupId,
|
||||||
|
distance,
|
||||||
|
});
|
||||||
|
|
||||||
export const getDehydrated = <T extends Record<string, unknown>>(entity: T) => {
|
export const getDehydrated = <T extends Record<string, unknown>>(entity: T) => {
|
||||||
const copiedEntity = structuredClone(entity);
|
const copiedEntity = structuredClone(entity);
|
||||||
for (const [key, value] of Object.entries(copiedEntity)) {
|
for (const [key, value] of Object.entries(copiedEntity)) {
|
||||||
@@ -122,8 +136,12 @@ export const getForMemory = (memory: ReturnType<MemoryFactory['build']>) => ({
|
|||||||
assets: memory.assets.map((asset) => getDehydrated(asset)),
|
assets: memory.assets.map((asset) => getDehydrated(asset)),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getForMetadataExtraction = (asset: ReturnType<AssetFactory['build']>) => ({
|
export const getForMetadataExtraction = (
|
||||||
|
asset: ReturnType<AssetFactory['build']>,
|
||||||
|
{ clusterGroupId }: { clusterGroupId?: string } = {},
|
||||||
|
) => ({
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
|
clusterGroupId: clusterGroupId ?? newUuid(),
|
||||||
checksum: asset.checksum,
|
checksum: asset.checksum,
|
||||||
checksumAlgorithm: asset.checksumAlgorithm,
|
checksumAlgorithm: asset.checksumAlgorithm,
|
||||||
fileCreatedAt: asset.fileCreatedAt,
|
fileCreatedAt: asset.fileCreatedAt,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { ApiKeyRepository } from 'src/repositories/api-key.repository';
|
|||||||
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
|
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
|
||||||
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
|
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
|
||||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||||
|
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { CronRepository } from 'src/repositories/cron.repository';
|
import { CronRepository } from 'src/repositories/cron.repository';
|
||||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||||
@@ -166,7 +167,8 @@ export class MediumTestContext<S extends ClassConstructor<typeof BaseService> =
|
|||||||
}
|
}
|
||||||
|
|
||||||
async newUser(dto: Partial<Insertable<UserTable>> = {}) {
|
async newUser(dto: Partial<Insertable<UserTable>> = {}) {
|
||||||
const user = mediumFactory.userInsert(dto);
|
const clusterGroup = dto.clusterGroupId ? undefined : await this.get(ClusterGroupRepository).create();
|
||||||
|
const user = mediumFactory.userInsert({ ...dto, clusterGroupId: dto.clusterGroupId ?? clusterGroup!.id });
|
||||||
const result = await this.get(UserRepository).create(user);
|
const result = await this.get(UserRepository).create(user);
|
||||||
return { user, result };
|
return { user, result };
|
||||||
}
|
}
|
||||||
@@ -266,8 +268,14 @@ export class MediumTestContext<S extends ClassConstructor<typeof BaseService> =
|
|||||||
}
|
}
|
||||||
|
|
||||||
async newPerson(dto: Partial<Insertable<PersonTable>> & { ownerId: string }) {
|
async newPerson(dto: Partial<Insertable<PersonTable>> & { ownerId: string }) {
|
||||||
const person = mediumFactory.personInsert(dto);
|
const repository = this.get(PersonRepository);
|
||||||
const result = await this.get(PersonRepository).create(person);
|
let personGroupId = dto.personGroupId;
|
||||||
|
if (!personGroupId) {
|
||||||
|
const group = await repository.createGroup(dto.ownerId);
|
||||||
|
personGroupId = group.id;
|
||||||
|
}
|
||||||
|
const person = mediumFactory.personInsert({ ...dto, personGroupId });
|
||||||
|
const result = await repository.create(person);
|
||||||
return { person, result };
|
return { person, result };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,6 +457,7 @@ const newRealRepository = <T extends BaseServiceDeps[number]>(key: T, db: Kysely
|
|||||||
case AssetRepository:
|
case AssetRepository:
|
||||||
case AssetEditRepository:
|
case AssetEditRepository:
|
||||||
case AssetJobRepository:
|
case AssetJobRepository:
|
||||||
|
case ClusterGroupRepository:
|
||||||
case DuplicateRepository:
|
case DuplicateRepository:
|
||||||
case IntegrityRepository:
|
case IntegrityRepository:
|
||||||
case MemoryRepository:
|
case MemoryRepository:
|
||||||
@@ -655,7 +664,7 @@ const assetFaceInsert = (assetFace: Partial<AssetFace> & { assetId: string }) =>
|
|||||||
id: assetFace.id ?? newUuid(),
|
id: assetFace.id ?? newUuid(),
|
||||||
imageHeight: assetFace.imageHeight ?? 10,
|
imageHeight: assetFace.imageHeight ?? 10,
|
||||||
imageWidth: assetFace.imageWidth ?? 10,
|
imageWidth: assetFace.imageWidth ?? 10,
|
||||||
personId: assetFace.personId ?? null,
|
personGroupId: assetFace.personGroupId ?? null,
|
||||||
sourceType: assetFace.sourceType ?? SourceType.MachineLearning,
|
sourceType: assetFace.sourceType ?? SourceType.MachineLearning,
|
||||||
isVisible: assetFace.isVisible ?? true,
|
isVisible: assetFace.isVisible ?? true,
|
||||||
};
|
};
|
||||||
@@ -682,13 +691,12 @@ const assetJobStatusInsert = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const personInsert = (person: Partial<Insertable<PersonTable>> & { ownerId: string }) => {
|
const personInsert = (person: Partial<Insertable<PersonTable>> & { ownerId: string; personGroupId: string }) => {
|
||||||
const defaults = {
|
const defaults = {
|
||||||
birthDate: person.birthDate || null,
|
birthDate: person.birthDate || null,
|
||||||
color: person.color || null,
|
color: person.color || null,
|
||||||
createdAt: person.createdAt || newDate(),
|
createdAt: person.createdAt || newDate(),
|
||||||
faceAssetId: person.faceAssetId || null,
|
faceAssetId: person.faceAssetId || null,
|
||||||
id: person.id || newUuid(),
|
|
||||||
isFavorite: person.isFavorite || false,
|
isFavorite: person.isFavorite || false,
|
||||||
isHidden: person.isHidden || false,
|
isHidden: person.isHidden || false,
|
||||||
name: person.name || 'Test Name',
|
name: person.name || 'Test Name',
|
||||||
@@ -722,7 +730,7 @@ const sessionInsert = ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const userInsert = (user: Partial<Insertable<UserTable>> = {}) => {
|
const userInsert = (user: Partial<Insertable<UserTable>> & { clusterGroupId: string }) => {
|
||||||
const id = user.id || newUuid();
|
const id = user.id || newUuid();
|
||||||
|
|
||||||
const defaults = {
|
const defaults = {
|
||||||
@@ -811,7 +819,7 @@ const loginDetails = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loginResponse = (): LoginResponseDto => {
|
const loginResponse = (): LoginResponseDto => {
|
||||||
const user = userInsert({});
|
const user = userInsert({ clusterGroupId: newUuid() });
|
||||||
return {
|
return {
|
||||||
accessToken: 'access-token',
|
accessToken: 'access-token',
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
|||||||
@@ -23,6 +23,161 @@ beforeAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe(PersonRepository.name, () => {
|
describe(PersonRepository.name, () => {
|
||||||
|
describe('createAll', () => {
|
||||||
|
it('should create people in the groups they were given', async () => {
|
||||||
|
const { ctx, sut } = setup();
|
||||||
|
const [{ user: user1 }, { user: user2 }] = [await ctx.newUser(), await ctx.newUser()];
|
||||||
|
|
||||||
|
const [group1, group2] = await sut.createGroups([
|
||||||
|
{ clusterGroupId: user1.clusterGroupId },
|
||||||
|
{ clusterGroupId: user1.clusterGroupId },
|
||||||
|
]);
|
||||||
|
const group3 = await sut.createGroup(user2.id);
|
||||||
|
|
||||||
|
const people = await sut.createAll([
|
||||||
|
{ ownerId: user1.id, name: 'Alice', personGroupId: group1.id },
|
||||||
|
{ ownerId: user1.id, name: 'Bob', personGroupId: group2.id },
|
||||||
|
{ ownerId: user2.id, name: 'Carol', personGroupId: group3.id },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(people.map(({ personGroupId }) => personGroupId)).toEqual([group1.id, group2.id, group3.id]);
|
||||||
|
|
||||||
|
const groups = await ctx.database
|
||||||
|
.selectFrom('person')
|
||||||
|
.innerJoin('person_group', 'person_group.id', 'person.personGroupId')
|
||||||
|
.innerJoin('user', 'user.id', 'person.ownerId')
|
||||||
|
.select(['person.name', 'person_group.clusterGroupId', 'user.clusterGroupId as ownerClusterGroupId'])
|
||||||
|
.where(
|
||||||
|
'person.personGroupId',
|
||||||
|
'in',
|
||||||
|
people.map(({ personGroupId }) => personGroupId),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
expect(groups).toHaveLength(3);
|
||||||
|
for (const group of groups) {
|
||||||
|
expect(group.clusterGroupId).toBe(group.ownerClusterGroupId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createGroup', () => {
|
||||||
|
it('should create a group in the owner cluster group', async () => {
|
||||||
|
const { ctx, sut } = setup();
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
|
||||||
|
const group = await sut.createGroup(user.id);
|
||||||
|
|
||||||
|
const owner = await ctx.database
|
||||||
|
.selectFrom('person_group')
|
||||||
|
.innerJoin('user', 'user.clusterGroupId', 'person_group.clusterGroupId')
|
||||||
|
.select('user.id')
|
||||||
|
.where('person_group.id', '=', group.id)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
expect(owner.id).toBe(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should put people created with the same group into that group', async () => {
|
||||||
|
const { ctx, sut } = setup(await getKyselyDB());
|
||||||
|
const [{ user: user1 }, { user: user2 }] = [await ctx.newUser(), await ctx.newUser()];
|
||||||
|
|
||||||
|
const group = await sut.createGroup(user1.id);
|
||||||
|
const person1 = await sut.create({ ownerId: user1.id, name: 'Alice', personGroupId: group.id });
|
||||||
|
const person2 = await sut.create({ ownerId: user2.id, name: 'Alice', personGroupId: group.id });
|
||||||
|
|
||||||
|
expect(person1.personGroupId).toBe(group.id);
|
||||||
|
expect(person2.personGroupId).toBe(group.id);
|
||||||
|
|
||||||
|
const groups = await ctx.database.selectFrom('person_group').select('person_group.id').execute();
|
||||||
|
expect(groups.map(({ id }) => id)).toEqual([group.id]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getByGroupId', () => {
|
||||||
|
it('should not return a person owned by another user', async () => {
|
||||||
|
const { ctx, sut } = setup();
|
||||||
|
const [{ user: user1 }, { user: user2 }] = [await ctx.newUser(), await ctx.newUser()];
|
||||||
|
const group = await sut.createGroup(user1.id);
|
||||||
|
|
||||||
|
const person1 = await sut.create({ ownerId: user1.id, name: 'Alice', personGroupId: group.id });
|
||||||
|
const person2 = await ctx.database
|
||||||
|
.insertInto('person')
|
||||||
|
.values({ ownerId: user2.id, name: 'Alice', personGroupId: person1.personGroupId })
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
await expect(sut.getByGroupId({ ownerId: user1.id, personGroupId: person1.personGroupId })).resolves.toEqual(
|
||||||
|
expect.objectContaining({ personGroupId: person1.personGroupId, ownerId: user1.id }),
|
||||||
|
);
|
||||||
|
await expect(sut.getByGroupId({ ownerId: user2.id, personGroupId: person1.personGroupId })).resolves.toEqual(
|
||||||
|
expect.objectContaining({ personGroupId: person2.personGroupId, ownerId: user2.id }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return nothing when the group belongs to another user', async () => {
|
||||||
|
const { ctx, sut } = setup();
|
||||||
|
const [{ user: user1 }, { user: user2 }] = [await ctx.newUser(), await ctx.newUser()];
|
||||||
|
const group = await sut.createGroup(user1.id);
|
||||||
|
|
||||||
|
const person = await sut.create({ ownerId: user1.id, name: 'Alice', personGroupId: group.id });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sut.getByGroupId({ ownerId: user2.id, personGroupId: person.personGroupId }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteEmptyGroups', () => {
|
||||||
|
it('should delete groups that no longer have any people', async () => {
|
||||||
|
const { ctx, sut } = setup(await getKyselyDB());
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
const [keptGroup, emptiedGroup] = await sut.createGroups([
|
||||||
|
{ clusterGroupId: user.clusterGroupId },
|
||||||
|
{ clusterGroupId: user.clusterGroupId },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const kept = await sut.create({ ownerId: user.id, name: 'Alice', personGroupId: keptGroup.id });
|
||||||
|
const emptied = await sut.create({ ownerId: user.id, name: 'Bob', personGroupId: emptiedGroup.id });
|
||||||
|
await ctx.database
|
||||||
|
.deleteFrom('person')
|
||||||
|
.where('person.ownerId', '=', emptied.ownerId)
|
||||||
|
.where('person.personGroupId', '=', emptied.personGroupId)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await expect(sut.deleteEmptyGroups()).resolves.toBe(1);
|
||||||
|
|
||||||
|
const groups = await ctx.database.selectFrom('person_group').select('person_group.id').execute();
|
||||||
|
expect(groups.map(({ id }) => id)).toEqual([kept.personGroupId]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteOrphanedClusterGroups', () => {
|
||||||
|
it('should delete cluster groups that no longer belong to a user, along with their people', async () => {
|
||||||
|
const { ctx, sut } = setup(await getKyselyDB());
|
||||||
|
const [{ user: kept }, { user: removed }] = [await ctx.newUser(), await ctx.newUser()];
|
||||||
|
const keptGroup = await sut.createGroup(kept.id);
|
||||||
|
const removedGroup = await sut.createGroup(removed.id);
|
||||||
|
|
||||||
|
const keptPerson = await sut.create({ ownerId: kept.id, name: 'Alice', personGroupId: keptGroup.id });
|
||||||
|
await sut.create({ ownerId: removed.id, name: 'Bob', personGroupId: removedGroup.id });
|
||||||
|
const { clusterGroupId } = await ctx.database
|
||||||
|
.selectFrom('user')
|
||||||
|
.select('user.clusterGroupId')
|
||||||
|
.where('user.id', '=', kept.id)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
await ctx.database.deleteFrom('user').where('user.id', '=', removed.id).execute();
|
||||||
|
|
||||||
|
await expect(sut.deleteOrphanedClusterGroups()).resolves.toBe(1);
|
||||||
|
|
||||||
|
const clusterGroups = await ctx.database.selectFrom('cluster_group').select('cluster_group.id').execute();
|
||||||
|
expect(clusterGroups.map(({ id }) => id)).toEqual([clusterGroupId]);
|
||||||
|
|
||||||
|
const groups = await ctx.database.selectFrom('person_group').select('person_group.id').execute();
|
||||||
|
expect(groups.map(({ id }) => id)).toEqual([keptPerson.personGroupId]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getDataForThumbnailGenerationJob', () => {
|
describe('getDataForThumbnailGenerationJob', () => {
|
||||||
it('should not return the edited preview path', async () => {
|
it('should not return the edited preview path', async () => {
|
||||||
const { ctx, sut } = setup();
|
const { ctx, sut } = setup();
|
||||||
@@ -33,7 +188,7 @@ describe(PersonRepository.name, () => {
|
|||||||
|
|
||||||
const { assetFace } = await ctx.newAssetFace({
|
const { assetFace } = await ctx.newAssetFace({
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
personId: person.id,
|
personGroupId: person.personGroupId,
|
||||||
boundingBoxX1: 10,
|
boundingBoxX1: 10,
|
||||||
boundingBoxY1: 10,
|
boundingBoxY1: 10,
|
||||||
boundingBoxX2: 90,
|
boundingBoxX2: 90,
|
||||||
@@ -41,7 +196,12 @@ describe(PersonRepository.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// there's a circular dependency between assetFace and person, so we need to update the person after creating the assetFace
|
// there's a circular dependency between assetFace and person, so we need to update the person after creating the assetFace
|
||||||
await ctx.database.updateTable('person').set({ faceAssetId: assetFace.id }).where('id', '=', person.id).execute();
|
await ctx.database
|
||||||
|
.updateTable('person')
|
||||||
|
.set({ faceAssetId: assetFace.id })
|
||||||
|
.where('ownerId', '=', person.ownerId)
|
||||||
|
.where('personGroupId', '=', person.personGroupId)
|
||||||
|
.execute();
|
||||||
|
|
||||||
await ctx.newAssetFile({
|
await ctx.newAssetFile({
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
@@ -56,7 +216,10 @@ describe(PersonRepository.name, () => {
|
|||||||
isEdited: false,
|
isEdited: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await sut.getDataForThumbnailGenerationJob(person.id);
|
const result = await sut.getDataForThumbnailGenerationJob({
|
||||||
|
ownerId: person.ownerId,
|
||||||
|
personGroupId: person.personGroupId,
|
||||||
|
});
|
||||||
|
|
||||||
expect(result).toEqual(
|
expect(result).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { hash } from 'bcrypt';
|
|||||||
import { Kysely } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
import { AuthType } from 'src/enum';
|
import { AuthType } from 'src/enum';
|
||||||
import { AccessRepository } from 'src/repositories/access.repository';
|
import { AccessRepository } from 'src/repositories/access.repository';
|
||||||
|
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||||
@@ -26,6 +27,7 @@ const setup = (db?: Kysely<DB>) => {
|
|||||||
database: db || defaultDatabase,
|
database: db || defaultDatabase,
|
||||||
real: [
|
real: [
|
||||||
AccessRepository,
|
AccessRepository,
|
||||||
|
ClusterGroupRepository,
|
||||||
ConfigRepository,
|
ConfigRepository,
|
||||||
CryptoRepository,
|
CryptoRepository,
|
||||||
DatabaseRepository,
|
DatabaseRepository,
|
||||||
|
|||||||
@@ -0,0 +1,475 @@
|
|||||||
|
import { Kysely } from 'kysely';
|
||||||
|
import { AccessRepository } from 'src/repositories/access.repository';
|
||||||
|
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository';
|
||||||
|
import { EventRepository } from 'src/repositories/event.repository';
|
||||||
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
import { PersonRepository } from 'src/repositories/person.repository';
|
||||||
|
import { UserRepository } from 'src/repositories/user.repository';
|
||||||
|
import { DB } from 'src/schema';
|
||||||
|
import { ClusterGroupService } from 'src/services/cluster-group.service';
|
||||||
|
import { newMediumService } from 'test/medium.factory';
|
||||||
|
import { factory } from 'test/small.factory';
|
||||||
|
import { getKyselyDB } from 'test/utils';
|
||||||
|
|
||||||
|
let defaultDatabase: Kysely<DB>;
|
||||||
|
|
||||||
|
const setup = (db?: Kysely<DB>) => {
|
||||||
|
const ctx = newMediumService(ClusterGroupService, {
|
||||||
|
database: db || defaultDatabase,
|
||||||
|
real: [AccessRepository, ClusterGroupRepository, PersonRepository, UserRepository],
|
||||||
|
mock: [LoggingRepository, EventRepository],
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||||
|
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getClusterGroupId = async (ctx: ReturnType<typeof setup>['ctx'], userId: string) => {
|
||||||
|
const { clusterGroupId } = await ctx.database
|
||||||
|
.selectFrom('user')
|
||||||
|
.select('user.clusterGroupId')
|
||||||
|
.where('user.id', '=', userId)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
return clusterGroupId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPeople = (ctx: ReturnType<typeof setup>['ctx'], ownerId: string) =>
|
||||||
|
ctx.database.selectFrom('person').selectAll('person').where('person.ownerId', '=', ownerId).execute();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
defaultDatabase = await getKyselyDB();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(ClusterGroupService.name, () => {
|
||||||
|
describe('createRequest', () => {
|
||||||
|
it('should create a request for another user', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user: owner } = await ctx.newUser();
|
||||||
|
const { user: invitee } = await ctx.newUser();
|
||||||
|
const auth = factory.auth({ user: owner });
|
||||||
|
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
|
||||||
|
|
||||||
|
const { value: request } = await sut.createRequest(auth, clusterGroupId, { userId: invitee.id });
|
||||||
|
|
||||||
|
expect(request).toEqual(
|
||||||
|
expect.objectContaining({ clusterGroupId, userId: invitee.id, createdAt: expect.any(Date) }),
|
||||||
|
);
|
||||||
|
expect(ctx.getMock(EventRepository).emit).toHaveBeenCalledWith('ClusterGroupRequest', {
|
||||||
|
clusterGroupId,
|
||||||
|
userId: invitee.id,
|
||||||
|
senderName: owner.name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject a cluster group the user is not a member of', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user: owner } = await ctx.newUser();
|
||||||
|
const { user: other } = await ctx.newUser();
|
||||||
|
const auth = factory.auth({ user: owner });
|
||||||
|
const otherClusterGroupId = await getClusterGroupId(ctx, other.id);
|
||||||
|
|
||||||
|
await expect(sut.createRequest(auth, otherClusterGroupId, { userId: other.id })).rejects.toThrow(
|
||||||
|
'Not found or no clusterGroupRequest.create access',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the existing request when it was already created', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user: owner } = await ctx.newUser();
|
||||||
|
const { user: invitee } = await ctx.newUser();
|
||||||
|
const auth = factory.auth({ user: owner });
|
||||||
|
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
|
||||||
|
|
||||||
|
const created = await sut.createRequest(auth, clusterGroupId, { userId: invitee.id });
|
||||||
|
expect(created.duplicate).toBe(false);
|
||||||
|
|
||||||
|
const again = await sut.createRequest(auth, clusterGroupId, { userId: invitee.id });
|
||||||
|
expect(again.duplicate).toBe(true);
|
||||||
|
expect(again.value).toEqual(created.value);
|
||||||
|
|
||||||
|
await expect(sut.getRequests(factory.auth({ user: invitee }))).resolves.toEqual([created.value]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject an unknown user', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user: owner } = await ctx.newUser();
|
||||||
|
const auth = factory.auth({ user: owner });
|
||||||
|
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
|
||||||
|
|
||||||
|
await expect(sut.createRequest(auth, clusterGroupId, { userId: factory.uuid() })).rejects.toThrow(
|
||||||
|
'User not found',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRequests', () => {
|
||||||
|
it('should only return the requests for the current user', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user: owner } = await ctx.newUser();
|
||||||
|
const { user: invitee } = await ctx.newUser();
|
||||||
|
const { user: other } = await ctx.newUser();
|
||||||
|
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
|
||||||
|
|
||||||
|
const { value: request } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, {
|
||||||
|
userId: invitee.id,
|
||||||
|
});
|
||||||
|
await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, { userId: other.id });
|
||||||
|
|
||||||
|
await expect(sut.getRequests(factory.auth({ user: invitee }))).resolves.toEqual([request]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRequestsForGroup', () => {
|
||||||
|
it('should return the requests sent by the cluster group', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user: owner } = await ctx.newUser();
|
||||||
|
const { user: invitee } = await ctx.newUser();
|
||||||
|
const { user: other } = await ctx.newUser();
|
||||||
|
const auth = factory.auth({ user: owner });
|
||||||
|
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
|
||||||
|
|
||||||
|
const { value: request } = await sut.createRequest(auth, clusterGroupId, { userId: invitee.id });
|
||||||
|
|
||||||
|
await expect(sut.getRequestsForGroup(auth, clusterGroupId)).resolves.toEqual([request]);
|
||||||
|
await expect(sut.getRequestsForGroup(factory.auth({ user: other }), clusterGroupId)).rejects.toThrow(
|
||||||
|
'Not found or no clusterGroup.read access',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getUsers', () => {
|
||||||
|
it('should return the members of the cluster group', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user: owner } = await ctx.newUser();
|
||||||
|
const { user: member } = await ctx.newUser();
|
||||||
|
const { user: other } = await ctx.newUser();
|
||||||
|
const auth = factory.auth({ user: owner });
|
||||||
|
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
|
||||||
|
|
||||||
|
const { value: request } = await sut.createRequest(auth, clusterGroupId, { userId: member.id });
|
||||||
|
await sut.acceptRequest(factory.auth({ user: member }), request.id);
|
||||||
|
|
||||||
|
const users = await sut.getUsers(auth, clusterGroupId);
|
||||||
|
expect(users.map(({ id }) => id)).toEqual(expect.arrayContaining([owner.id, member.id]));
|
||||||
|
expect(users.map(({ id }) => id)).not.toContain(other.id);
|
||||||
|
|
||||||
|
await expect(sut.getUsers(factory.auth({ user: other }), clusterGroupId)).rejects.toThrow(
|
||||||
|
'Not found or no clusterGroup.read access',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should let a user with a pending request see the members', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user: owner } = await ctx.newUser();
|
||||||
|
const { user: invitee } = await ctx.newUser();
|
||||||
|
const auth = factory.auth({ user: owner });
|
||||||
|
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
|
||||||
|
|
||||||
|
await expect(sut.getUsers(factory.auth({ user: invitee }), clusterGroupId)).rejects.toThrow(
|
||||||
|
'Not found or no clusterGroup.read access',
|
||||||
|
);
|
||||||
|
|
||||||
|
await sut.createRequest(auth, clusterGroupId, { userId: invitee.id });
|
||||||
|
|
||||||
|
const users = await sut.getUsers(factory.auth({ user: invitee }), clusterGroupId);
|
||||||
|
expect(users.map(({ id }) => id)).toContain(owner.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('acceptRequest', () => {
|
||||||
|
it('should move the user into the cluster group and delete the request', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user: owner } = await ctx.newUser();
|
||||||
|
const { user: invitee } = await ctx.newUser();
|
||||||
|
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
|
||||||
|
|
||||||
|
const { value: request } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, {
|
||||||
|
userId: invitee.id,
|
||||||
|
});
|
||||||
|
await sut.acceptRequest(factory.auth({ user: invitee }), request.id);
|
||||||
|
|
||||||
|
await expect(getClusterGroupId(ctx, invitee.id)).resolves.toBe(clusterGroupId);
|
||||||
|
await expect(sut.getRequests(factory.auth({ user: invitee }))).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not accept a request belonging to someone else', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user: owner } = await ctx.newUser();
|
||||||
|
const { user: invitee } = await ctx.newUser();
|
||||||
|
const { user: other } = await ctx.newUser();
|
||||||
|
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
|
||||||
|
const otherClusterGroupId = await getClusterGroupId(ctx, other.id);
|
||||||
|
|
||||||
|
const { value: request } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, {
|
||||||
|
userId: invitee.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(sut.acceptRequest(factory.auth({ user: other }), request.id)).rejects.toThrow(
|
||||||
|
'Not found or no clusterGroupRequest.read access',
|
||||||
|
);
|
||||||
|
await expect(getClusterGroupId(ctx, other.id)).resolves.toBe(otherClusterGroupId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leave', () => {
|
||||||
|
it('should move the user into a new cluster group', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user: owner } = await ctx.newUser();
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
|
||||||
|
const auth = factory.auth({ user });
|
||||||
|
|
||||||
|
const { value: request } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, {
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
await sut.acceptRequest(auth, request.id);
|
||||||
|
|
||||||
|
await sut.leave(auth, clusterGroupId);
|
||||||
|
|
||||||
|
const newClusterGroupId = await getClusterGroupId(ctx, user.id);
|
||||||
|
expect(newClusterGroupId).not.toBe(clusterGroupId);
|
||||||
|
await expect(
|
||||||
|
ctx.database
|
||||||
|
.selectFrom('cluster_group')
|
||||||
|
.select('cluster_group.id')
|
||||||
|
.where('cluster_group.id', '=', newClusterGroupId)
|
||||||
|
.executeTakeFirst(),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject a cluster group the user is not a member of', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
const { user: other } = await ctx.newUser();
|
||||||
|
const auth = factory.auth({ user });
|
||||||
|
const otherClusterGroupId = await getClusterGroupId(ctx, other.id);
|
||||||
|
|
||||||
|
await expect(sut.leave(auth, otherClusterGroupId)).rejects.toThrow('Not found or no clusterGroup.leave access');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not let the last member leave', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
const auth = factory.auth({ user });
|
||||||
|
const clusterGroupId = await getClusterGroupId(ctx, user.id);
|
||||||
|
|
||||||
|
await expect(sut.leave(auth, clusterGroupId)).rejects.toThrow(
|
||||||
|
'Cannot leave a cluster group without any other members',
|
||||||
|
);
|
||||||
|
await expect(getClusterGroupId(ctx, user.id)).resolves.toBe(clusterGroupId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('joining a cluster group', () => {
|
||||||
|
it('should take the groups of the user along', async () => {
|
||||||
|
const { sut, ctx } = setup(await getKyselyDB());
|
||||||
|
const personRepo = ctx.get(PersonRepository);
|
||||||
|
const { user: owner } = await ctx.newUser();
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
|
||||||
|
|
||||||
|
const { person } = await ctx.newPerson({ ownerId: user.id });
|
||||||
|
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||||
|
const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId });
|
||||||
|
|
||||||
|
const { value: request } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, {
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
await sut.acceptRequest(factory.auth({ user }), request.id);
|
||||||
|
|
||||||
|
// the group keeps its id, it only changes cluster group
|
||||||
|
await expect(personRepo.getByGroupId(person)).resolves.toEqual(
|
||||||
|
expect.objectContaining({ personGroupId: person.personGroupId }),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
ctx.database
|
||||||
|
.selectFrom('person_group')
|
||||||
|
.select('person_group.clusterGroupId')
|
||||||
|
.where('person_group.id', '=', person.personGroupId)
|
||||||
|
.executeTakeFirstOrThrow(),
|
||||||
|
).resolves.toEqual({ clusterGroupId });
|
||||||
|
await expect(
|
||||||
|
ctx.database
|
||||||
|
.selectFrom('asset_face')
|
||||||
|
.select('asset_face.personGroupId')
|
||||||
|
.where('asset_face.id', '=', assetFace.id)
|
||||||
|
.executeTakeFirstOrThrow(),
|
||||||
|
).resolves.toEqual({ personGroupId: person.personGroupId });
|
||||||
|
await expect(getClusterGroupId(ctx, user.id)).resolves.toBe(clusterGroupId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leaving a shared cluster group', () => {
|
||||||
|
it('should recreate the shared groups and take the rest of its own along', async () => {
|
||||||
|
const { sut, ctx } = setup(await getKyselyDB());
|
||||||
|
const personRepo = ctx.get(PersonRepository);
|
||||||
|
const { user: user1 } = await ctx.newUser();
|
||||||
|
const { user: user2 } = await ctx.newUser();
|
||||||
|
const clusterGroupId = await getClusterGroupId(ctx, user1.id);
|
||||||
|
|
||||||
|
const { value: request } = await sut.createRequest(factory.auth({ user: user1 }), clusterGroupId, {
|
||||||
|
userId: user2.id,
|
||||||
|
});
|
||||||
|
await sut.acceptRequest(factory.auth({ user: user2 }), request.id);
|
||||||
|
|
||||||
|
// a group both of them have a person in
|
||||||
|
const { person: shared1 } = await ctx.newPerson({ ownerId: user1.id });
|
||||||
|
await ctx.newPerson({ ownerId: user2.id, personGroupId: shared1.personGroupId });
|
||||||
|
// a group only the leaving user has a person in
|
||||||
|
const { person: only2 } = await ctx.newPerson({ ownerId: user2.id });
|
||||||
|
// a group only the remaining user has a person in
|
||||||
|
const { person: only1 } = await ctx.newPerson({ ownerId: user1.id });
|
||||||
|
|
||||||
|
const { asset: asset2 } = await ctx.newAsset({ ownerId: user2.id });
|
||||||
|
const { assetFace: sharedFace2 } = await ctx.newAssetFace({
|
||||||
|
assetId: asset2.id,
|
||||||
|
personGroupId: shared1.personGroupId,
|
||||||
|
});
|
||||||
|
const { asset: asset1 } = await ctx.newAsset({ ownerId: user1.id });
|
||||||
|
const { assetFace: sharedFace1 } = await ctx.newAssetFace({
|
||||||
|
assetId: asset1.id,
|
||||||
|
personGroupId: shared1.personGroupId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sut.leave(factory.auth({ user: user2 }), clusterGroupId);
|
||||||
|
|
||||||
|
const newClusterGroupId = await getClusterGroupId(ctx, user2.id);
|
||||||
|
expect(newClusterGroupId).not.toBe(clusterGroupId);
|
||||||
|
|
||||||
|
// the shared group is recreated for the leaving user, the one only they had comes along as it is
|
||||||
|
const leaverPeople = await getPeople(ctx, user2.id);
|
||||||
|
const movedShared = leaverPeople.find(({ personGroupId }) => personGroupId !== only2.personGroupId);
|
||||||
|
expect(movedShared).toBeDefined();
|
||||||
|
expect(movedShared!.personGroupId).not.toBe(shared1.personGroupId);
|
||||||
|
await expect(personRepo.getByGroupId(only2)).resolves.toBeDefined();
|
||||||
|
|
||||||
|
// the remaining user is untouched
|
||||||
|
await expect(personRepo.getByGroupId(shared1)).resolves.toBeDefined();
|
||||||
|
await expect(personRepo.getByGroupId(only1)).resolves.toBeDefined();
|
||||||
|
|
||||||
|
const groups = await ctx.database
|
||||||
|
.selectFrom('person_group')
|
||||||
|
.select(['person_group.id', 'person_group.clusterGroupId'])
|
||||||
|
.execute();
|
||||||
|
expect(groups).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ id: shared1.personGroupId, clusterGroupId },
|
||||||
|
{ id: only1.personGroupId, clusterGroupId },
|
||||||
|
{ id: only2.personGroupId, clusterGroupId: newClusterGroupId },
|
||||||
|
{ id: movedShared!.personGroupId, clusterGroupId: newClusterGroupId },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// only the faces on the assets of the leaving user follow the recreated group
|
||||||
|
const faces = await ctx.database
|
||||||
|
.selectFrom('asset_face')
|
||||||
|
.select(['asset_face.id', 'asset_face.personGroupId'])
|
||||||
|
.where('asset_face.id', 'in', [sharedFace1.id, sharedFace2.id])
|
||||||
|
.execute();
|
||||||
|
expect(faces).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ id: sharedFace1.id, personGroupId: shared1.personGroupId },
|
||||||
|
{ id: sharedFace2.id, personGroupId: movedShared!.personGroupId },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should give each shared group its own new group when a user leaves the group', async () => {
|
||||||
|
const { sut, ctx } = setup(await getKyselyDB());
|
||||||
|
const { user: user1 } = await ctx.newUser();
|
||||||
|
const { user: user2 } = await ctx.newUser();
|
||||||
|
const clusterGroupId = await getClusterGroupId(ctx, user1.id);
|
||||||
|
|
||||||
|
const { value: request } = await sut.createRequest(factory.auth({ user: user1 }), clusterGroupId, {
|
||||||
|
userId: user2.id,
|
||||||
|
});
|
||||||
|
await sut.acceptRequest(factory.auth({ user: user2 }), request.id);
|
||||||
|
|
||||||
|
// two groups both users have a person in
|
||||||
|
const { person: sharedA } = await ctx.newPerson({ ownerId: user1.id });
|
||||||
|
const { person: sharedB } = await ctx.newPerson({ ownerId: user1.id });
|
||||||
|
await ctx.newPerson({ ownerId: user2.id, personGroupId: sharedA.personGroupId, name: 'Alice' });
|
||||||
|
await ctx.newPerson({ ownerId: user2.id, personGroupId: sharedB.personGroupId, name: 'Bob' });
|
||||||
|
|
||||||
|
const { asset } = await ctx.newAsset({ ownerId: user2.id });
|
||||||
|
const { assetFace: faceA } = await ctx.newAssetFace({ assetId: asset.id, personGroupId: sharedA.personGroupId });
|
||||||
|
const { assetFace: faceB } = await ctx.newAssetFace({ assetId: asset.id, personGroupId: sharedB.personGroupId });
|
||||||
|
|
||||||
|
await sut.leave(factory.auth({ user: user2 }), clusterGroupId);
|
||||||
|
|
||||||
|
const moved = await getPeople(ctx, user2.id);
|
||||||
|
const movedGroupIds = moved.map(({ personGroupId }) => personGroupId);
|
||||||
|
expect(movedGroupIds).toHaveLength(2);
|
||||||
|
expect(movedGroupIds).not.toContain(sharedA.personGroupId);
|
||||||
|
expect(movedGroupIds).not.toContain(sharedB.personGroupId);
|
||||||
|
expect(new Set(movedGroupIds).size).toBe(2);
|
||||||
|
|
||||||
|
// the group id changes on the way out, so the name is what identifies each person
|
||||||
|
const movedA = moved.find(({ name }) => name === 'Alice');
|
||||||
|
const movedB = moved.find(({ name }) => name === 'Bob');
|
||||||
|
|
||||||
|
const faces = await ctx.database
|
||||||
|
.selectFrom('asset_face')
|
||||||
|
.select(['asset_face.id', 'asset_face.personGroupId'])
|
||||||
|
.where('asset_face.id', 'in', [faceA.id, faceB.id])
|
||||||
|
.execute();
|
||||||
|
expect(faces).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ id: faceA.id, personGroupId: movedA!.personGroupId },
|
||||||
|
{ id: faceB.id, personGroupId: movedB!.personGroupId },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteRequest', () => {
|
||||||
|
it('should let the user it was created for decline it', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user: owner } = await ctx.newUser();
|
||||||
|
const { user: invitee } = await ctx.newUser();
|
||||||
|
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
|
||||||
|
const inviteeClusterGroupId = await getClusterGroupId(ctx, invitee.id);
|
||||||
|
|
||||||
|
const { value: request } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, {
|
||||||
|
userId: invitee.id,
|
||||||
|
});
|
||||||
|
await sut.deleteRequest(factory.auth({ user: invitee }), request.id);
|
||||||
|
|
||||||
|
await expect(sut.getRequests(factory.auth({ user: invitee }))).resolves.toEqual([]);
|
||||||
|
await expect(getClusterGroupId(ctx, invitee.id)).resolves.toBe(inviteeClusterGroupId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should let the cluster group it was created by revoke it', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user: owner } = await ctx.newUser();
|
||||||
|
const { user: invitee } = await ctx.newUser();
|
||||||
|
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
|
||||||
|
|
||||||
|
const { value: request } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, {
|
||||||
|
userId: invitee.id,
|
||||||
|
});
|
||||||
|
await sut.deleteRequest(factory.auth({ user: owner }), request.id);
|
||||||
|
|
||||||
|
await expect(sut.getRequests(factory.auth({ user: invitee }))).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not let an unrelated user delete it', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user: owner } = await ctx.newUser();
|
||||||
|
const { user: invitee } = await ctx.newUser();
|
||||||
|
const { user: other } = await ctx.newUser();
|
||||||
|
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
|
||||||
|
|
||||||
|
const { value: request } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, {
|
||||||
|
userId: invitee.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(sut.deleteRequest(factory.auth({ user: other }), request.id)).rejects.toThrow(
|
||||||
|
'Not found or no clusterGroupRequest.delete access',
|
||||||
|
);
|
||||||
|
await expect(sut.getRequests(factory.auth({ user: invitee }))).resolves.toEqual([request]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Kysely } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
import { AssetEditAction, MirrorAxis } from 'src/dtos/editing.dto';
|
import { AssetEditAction, MirrorAxis } from 'src/dtos/editing.dto';
|
||||||
import { AssetFaceCreateDto } from 'src/dtos/person.dto';
|
import { AssetFaceCreateDto } from 'src/dtos/person.dto';
|
||||||
import { AccessRepository } from 'src/repositories/access.repository';
|
import { AccessRepository } from 'src/repositories/access.repository';
|
||||||
@@ -47,9 +48,11 @@ describe(PersonService.name, () => {
|
|||||||
const auth = factory.auth({ user });
|
const auth = factory.auth({ user });
|
||||||
storageMock.unlink.mockResolvedValue();
|
storageMock.unlink.mockResolvedValue();
|
||||||
|
|
||||||
await expect(personRepo.getById(person.id)).resolves.toEqual(expect.objectContaining({ id: person.id }));
|
await expect(personRepo.getByGroupId(person)).resolves.toEqual(
|
||||||
await expect(sut.delete(auth, person.id)).resolves.toBeUndefined();
|
expect.objectContaining({ personGroupId: person.personGroupId }),
|
||||||
await expect(personRepo.getById(person.id)).resolves.toBeUndefined();
|
);
|
||||||
|
await expect(sut.delete(auth, person.personGroupId)).resolves.toBeUndefined();
|
||||||
|
await expect(personRepo.getByGroupId(person)).resolves.toBeUndefined();
|
||||||
|
|
||||||
expect(storageMock.unlink).toHaveBeenCalledWith(person.thumbnailPath);
|
expect(storageMock.unlink).toHaveBeenCalledWith(person.thumbnailPath);
|
||||||
});
|
});
|
||||||
@@ -73,9 +76,11 @@ describe(PersonService.name, () => {
|
|||||||
const auth = factory.auth({ user });
|
const auth = factory.auth({ user });
|
||||||
storageMock.unlink.mockResolvedValue();
|
storageMock.unlink.mockResolvedValue();
|
||||||
|
|
||||||
await expect(sut.deleteAll(auth, { ids: [person1.id, person2.id] })).resolves.toBeUndefined();
|
await expect(
|
||||||
await expect(personRepo.getById(person1.id)).resolves.toBeUndefined();
|
sut.deleteAll(auth, { ids: [person1.personGroupId, person2.personGroupId] }),
|
||||||
await expect(personRepo.getById(person2.id)).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
|
await expect(personRepo.getByGroupId(person1)).resolves.toBeUndefined();
|
||||||
|
await expect(personRepo.getByGroupId(person2)).resolves.toBeUndefined();
|
||||||
|
|
||||||
expect(storageMock.unlink).toHaveBeenCalledTimes(2);
|
expect(storageMock.unlink).toHaveBeenCalledTimes(2);
|
||||||
expect(storageMock.unlink).toHaveBeenCalledWith(person1.thumbnailPath);
|
expect(storageMock.unlink).toHaveBeenCalledWith(person1.thumbnailPath);
|
||||||
@@ -83,6 +88,100 @@ describe(PersonService.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('mergePerson', () => {
|
||||||
|
it('should merge people of multiple users', 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: person1.personGroupId,
|
||||||
|
});
|
||||||
|
await ctx.newPerson({
|
||||||
|
ownerId: user2.id,
|
||||||
|
personGroupId: person2.personGroupId,
|
||||||
|
});
|
||||||
|
storageMock.unlink.mockResolvedValue();
|
||||||
|
|
||||||
|
const auth = factory.auth({ user: user1 });
|
||||||
|
|
||||||
|
await sut.mergePerson(auth, person1.personGroupId, { ids: [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: person1.personGroupId })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip people with a different name', 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: person1.personGroupId,
|
||||||
|
name: 'Person 1',
|
||||||
|
});
|
||||||
|
await ctx.newPerson({
|
||||||
|
ownerId: user2.id,
|
||||||
|
personGroupId: person2.personGroupId,
|
||||||
|
name: 'Person 2',
|
||||||
|
});
|
||||||
|
storageMock.unlink.mockResolvedValue();
|
||||||
|
|
||||||
|
const auth = factory.auth({ user: user1 });
|
||||||
|
|
||||||
|
await sut.mergePerson(auth, person1.personGroupId, { ids: [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.arrayContaining([
|
||||||
|
expect.objectContaining({ personGroupId: person1.personGroupId }),
|
||||||
|
expect.objectContaining({ personGroupId: person2.personGroupId }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip people with a different birthdate', 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: person1.personGroupId,
|
||||||
|
birthDate: DateTime.now().minus({ years: 1 }).toJSDate(),
|
||||||
|
});
|
||||||
|
await ctx.newPerson({
|
||||||
|
ownerId: user2.id,
|
||||||
|
personGroupId: person2.personGroupId,
|
||||||
|
birthDate: DateTime.now().minus({ years: 2 }).toJSDate(),
|
||||||
|
});
|
||||||
|
storageMock.unlink.mockResolvedValue();
|
||||||
|
|
||||||
|
const auth = factory.auth({ user: user1 });
|
||||||
|
|
||||||
|
await sut.mergePerson(auth, person1.personGroupId, { ids: [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.arrayContaining([
|
||||||
|
expect.objectContaining({ personGroupId: person1.personGroupId }),
|
||||||
|
expect.objectContaining({ personGroupId: person2.personGroupId }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('createFace', () => {
|
describe('createFace', () => {
|
||||||
it('should store and retrieve the face as-is when there are no edits', async () => {
|
it('should store and retrieve the face as-is when there are no edits', async () => {
|
||||||
const { sut, ctx } = setup();
|
const { sut, ctx } = setup();
|
||||||
@@ -101,7 +200,7 @@ describe(PersonService.name, () => {
|
|||||||
y: 50,
|
y: 50,
|
||||||
width: 150,
|
width: 150,
|
||||||
height: 150,
|
height: 150,
|
||||||
personId: person.id,
|
personId: person.personGroupId,
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -114,7 +213,7 @@ describe(PersonService.name, () => {
|
|||||||
await expect(faces).resolves.toEqual(
|
await expect(faces).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
person: expect.objectContaining({ id: person.id }),
|
person: expect.objectContaining({ id: person.personGroupId }),
|
||||||
boundingBoxX1: 50,
|
boundingBoxX1: 50,
|
||||||
boundingBoxY1: 50,
|
boundingBoxY1: 50,
|
||||||
boundingBoxX2: 200,
|
boundingBoxX2: 200,
|
||||||
@@ -155,7 +254,7 @@ describe(PersonService.name, () => {
|
|||||||
y: 0,
|
y: 0,
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 100,
|
height: 100,
|
||||||
personId: person.id,
|
personId: person.personGroupId,
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -168,7 +267,7 @@ describe(PersonService.name, () => {
|
|||||||
await expect(faces).resolves.toEqual(
|
await expect(faces).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
person: expect.objectContaining({ id: person.id }),
|
person: expect.objectContaining({ id: person.personGroupId }),
|
||||||
boundingBoxX1: 0,
|
boundingBoxX1: 0,
|
||||||
boundingBoxY1: 0,
|
boundingBoxY1: 0,
|
||||||
boundingBoxX2: 100,
|
boundingBoxX2: 100,
|
||||||
@@ -186,7 +285,7 @@ describe(PersonService.name, () => {
|
|||||||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
person: expect.objectContaining({ id: person.id }),
|
person: expect.objectContaining({ id: person.personGroupId }),
|
||||||
boundingBoxX1: 50,
|
boundingBoxX1: 50,
|
||||||
boundingBoxY1: 50,
|
boundingBoxY1: 50,
|
||||||
boundingBoxX2: 150,
|
boundingBoxX2: 150,
|
||||||
@@ -224,7 +323,7 @@ describe(PersonService.name, () => {
|
|||||||
y: 50,
|
y: 50,
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
personId: person.id,
|
personId: person.personGroupId,
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -235,7 +334,7 @@ describe(PersonService.name, () => {
|
|||||||
await expect(faces).resolves.toEqual(
|
await expect(faces).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
person: expect.objectContaining({ id: person.id }),
|
person: expect.objectContaining({ id: person.personGroupId }),
|
||||||
boundingBoxX1: expect.closeTo(25, 1),
|
boundingBoxX1: expect.closeTo(25, 1),
|
||||||
boundingBoxY1: expect.closeTo(50, 1),
|
boundingBoxY1: expect.closeTo(50, 1),
|
||||||
boundingBoxX2: expect.closeTo(35, 1),
|
boundingBoxX2: expect.closeTo(35, 1),
|
||||||
@@ -251,7 +350,7 @@ describe(PersonService.name, () => {
|
|||||||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
person: expect.objectContaining({ id: person.id }),
|
person: expect.objectContaining({ id: person.personGroupId }),
|
||||||
boundingBoxX1: 50,
|
boundingBoxX1: 50,
|
||||||
boundingBoxY1: 65,
|
boundingBoxY1: 65,
|
||||||
boundingBoxX2: 60,
|
boundingBoxX2: 60,
|
||||||
@@ -289,7 +388,7 @@ describe(PersonService.name, () => {
|
|||||||
y: 25,
|
y: 25,
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 50,
|
height: 50,
|
||||||
personId: person.id,
|
personId: person.personGroupId,
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -300,7 +399,7 @@ describe(PersonService.name, () => {
|
|||||||
await expect(faces).resolves.toEqual(
|
await expect(faces).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
person: expect.objectContaining({ id: person.id }),
|
person: expect.objectContaining({ id: person.personGroupId }),
|
||||||
boundingBoxX1: 50,
|
boundingBoxX1: 50,
|
||||||
boundingBoxY1: 25,
|
boundingBoxY1: 25,
|
||||||
boundingBoxX2: 150,
|
boundingBoxX2: 150,
|
||||||
@@ -316,7 +415,7 @@ describe(PersonService.name, () => {
|
|||||||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
person: expect.objectContaining({ id: person.id }),
|
person: expect.objectContaining({ id: person.personGroupId }),
|
||||||
boundingBoxX1: 50,
|
boundingBoxX1: 50,
|
||||||
boundingBoxY1: 25,
|
boundingBoxY1: 25,
|
||||||
boundingBoxX2: 150,
|
boundingBoxX2: 150,
|
||||||
@@ -363,7 +462,7 @@ describe(PersonService.name, () => {
|
|||||||
y: 25,
|
y: 25,
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 20,
|
height: 20,
|
||||||
personId: person.id,
|
personId: person.personGroupId,
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -374,7 +473,7 @@ describe(PersonService.name, () => {
|
|||||||
await expect(faces).resolves.toEqual(
|
await expect(faces).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
person: expect.objectContaining({ id: person.id }),
|
person: expect.objectContaining({ id: person.personGroupId }),
|
||||||
boundingBoxX1: expect.closeTo(50, 1),
|
boundingBoxX1: expect.closeTo(50, 1),
|
||||||
boundingBoxY1: expect.closeTo(25, 1),
|
boundingBoxY1: expect.closeTo(25, 1),
|
||||||
boundingBoxX2: expect.closeTo(60, 1),
|
boundingBoxX2: expect.closeTo(60, 1),
|
||||||
@@ -390,7 +489,7 @@ describe(PersonService.name, () => {
|
|||||||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
person: expect.objectContaining({ id: person.id }),
|
person: expect.objectContaining({ id: person.personGroupId }),
|
||||||
boundingBoxX1: 75,
|
boundingBoxX1: 75,
|
||||||
boundingBoxY1: 140,
|
boundingBoxY1: 140,
|
||||||
boundingBoxX2: 95,
|
boundingBoxX2: 95,
|
||||||
@@ -437,7 +536,7 @@ describe(PersonService.name, () => {
|
|||||||
y: 25,
|
y: 25,
|
||||||
width: 75,
|
width: 75,
|
||||||
height: 50,
|
height: 50,
|
||||||
personId: person.id,
|
personId: person.personGroupId,
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -448,7 +547,7 @@ describe(PersonService.name, () => {
|
|||||||
await expect(faces).resolves.toEqual(
|
await expect(faces).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
person: expect.objectContaining({ id: person.id }),
|
person: expect.objectContaining({ id: person.personGroupId }),
|
||||||
boundingBoxX1: 25,
|
boundingBoxX1: 25,
|
||||||
boundingBoxY1: 25,
|
boundingBoxY1: 25,
|
||||||
boundingBoxX2: 100,
|
boundingBoxX2: 100,
|
||||||
@@ -464,7 +563,7 @@ describe(PersonService.name, () => {
|
|||||||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
person: expect.objectContaining({ id: person.id }),
|
person: expect.objectContaining({ id: person.personGroupId }),
|
||||||
boundingBoxX1: 100,
|
boundingBoxX1: 100,
|
||||||
boundingBoxY1: 25,
|
boundingBoxY1: 25,
|
||||||
boundingBoxX2: 175,
|
boundingBoxX2: 175,
|
||||||
@@ -508,7 +607,7 @@ describe(PersonService.name, () => {
|
|||||||
y: 25,
|
y: 25,
|
||||||
width: 15,
|
width: 15,
|
||||||
height: 20,
|
height: 20,
|
||||||
personId: person.id,
|
personId: person.personGroupId,
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -519,7 +618,7 @@ describe(PersonService.name, () => {
|
|||||||
await expect(faces).resolves.toEqual(
|
await expect(faces).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
person: expect.objectContaining({ id: person.id }),
|
person: expect.objectContaining({ id: person.personGroupId }),
|
||||||
boundingBoxX1: expect.closeTo(50, 1),
|
boundingBoxX1: expect.closeTo(50, 1),
|
||||||
boundingBoxY1: expect.closeTo(25, 1),
|
boundingBoxY1: expect.closeTo(25, 1),
|
||||||
boundingBoxX2: expect.closeTo(65, 1),
|
boundingBoxX2: expect.closeTo(65, 1),
|
||||||
@@ -535,7 +634,7 @@ describe(PersonService.name, () => {
|
|||||||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
person: expect.objectContaining({ id: person.id }),
|
person: expect.objectContaining({ id: person.personGroupId }),
|
||||||
boundingBoxX1: 25,
|
boundingBoxX1: 25,
|
||||||
boundingBoxY1: 50,
|
boundingBoxY1: 50,
|
||||||
boundingBoxX2: 45,
|
boundingBoxX2: 45,
|
||||||
@@ -588,7 +687,7 @@ describe(PersonService.name, () => {
|
|||||||
y: 50,
|
y: 50,
|
||||||
width: 75,
|
width: 75,
|
||||||
height: 50,
|
height: 50,
|
||||||
personId: person.id,
|
personId: person.personGroupId,
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -599,7 +698,7 @@ describe(PersonService.name, () => {
|
|||||||
await expect(faces).resolves.toEqual(
|
await expect(faces).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
person: expect.objectContaining({ id: person.id }),
|
person: expect.objectContaining({ id: person.personGroupId }),
|
||||||
boundingBoxX1: 25,
|
boundingBoxX1: 25,
|
||||||
boundingBoxY1: 49,
|
boundingBoxY1: 49,
|
||||||
boundingBoxX2: 99,
|
boundingBoxX2: 99,
|
||||||
@@ -615,7 +714,7 @@ describe(PersonService.name, () => {
|
|||||||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
person: expect.objectContaining({ id: person.id }),
|
person: expect.objectContaining({ id: person.personGroupId }),
|
||||||
boundingBoxX1: 50,
|
boundingBoxX1: 50,
|
||||||
boundingBoxY1: 75,
|
boundingBoxY1: 75,
|
||||||
boundingBoxX2: 100,
|
boundingBoxX2: 100,
|
||||||
@@ -659,7 +758,7 @@ describe(PersonService.name, () => {
|
|||||||
y: 10,
|
y: 10,
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
personId: person.id,
|
personId: person.personGroupId,
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -670,7 +769,7 @@ describe(PersonService.name, () => {
|
|||||||
await expect(faces).resolves.toEqual(
|
await expect(faces).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
person: expect.objectContaining({ id: person.id }),
|
person: expect.objectContaining({ id: person.personGroupId }),
|
||||||
boundingBoxX1: 10,
|
boundingBoxX1: 10,
|
||||||
boundingBoxY1: 10,
|
boundingBoxY1: 10,
|
||||||
boundingBoxX2: 90,
|
boundingBoxX2: 90,
|
||||||
@@ -686,7 +785,7 @@ describe(PersonService.name, () => {
|
|||||||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
person: expect.objectContaining({ id: person.id }),
|
person: expect.objectContaining({ id: person.personGroupId }),
|
||||||
boundingBoxX1: 10,
|
boundingBoxX1: 10,
|
||||||
boundingBoxY1: 10,
|
boundingBoxY1: 10,
|
||||||
boundingBoxX2: 90,
|
boundingBoxX2: 90,
|
||||||
@@ -730,7 +829,7 @@ describe(PersonService.name, () => {
|
|||||||
y: 10,
|
y: 10,
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
personId: person.id,
|
personId: person.personGroupId,
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -741,7 +840,7 @@ describe(PersonService.name, () => {
|
|||||||
await expect(faces).resolves.toEqual(
|
await expect(faces).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
person: expect.objectContaining({ id: person.id }),
|
person: expect.objectContaining({ id: person.personGroupId }),
|
||||||
boundingBoxX1: 110,
|
boundingBoxX1: 110,
|
||||||
boundingBoxY1: 10,
|
boundingBoxY1: 10,
|
||||||
boundingBoxX2: 190,
|
boundingBoxX2: 190,
|
||||||
@@ -757,7 +856,7 @@ describe(PersonService.name, () => {
|
|||||||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
person: expect.objectContaining({ id: person.id }),
|
person: expect.objectContaining({ id: person.personGroupId }),
|
||||||
boundingBoxX1: 10,
|
boundingBoxX1: 10,
|
||||||
boundingBoxY1: 10,
|
boundingBoxY1: 10,
|
||||||
boundingBoxX2: 90,
|
boundingBoxX2: 90,
|
||||||
|
|||||||
@@ -69,11 +69,11 @@ describe(SearchService.name, () => {
|
|||||||
const { user } = await ctx.newUser();
|
const { user } = await ctx.newUser();
|
||||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||||
const { person } = await ctx.newPerson({ ownerId: user.id });
|
const { person } = await ctx.newPerson({ ownerId: user.id });
|
||||||
await ctx.newAssetFace({ assetId: asset.id, personId: person.id });
|
await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId });
|
||||||
|
|
||||||
const auth = factory.auth({ user: { id: user.id } });
|
const auth = factory.auth({ user: { id: user.id } });
|
||||||
|
|
||||||
const result = await sut.searchStatistics(auth, { personIds: [person.id] });
|
const result = await sut.searchStatistics(auth, { personIds: [person.personGroupId] });
|
||||||
|
|
||||||
expect(result).toEqual({ total: 1 });
|
expect(result).toEqual({ total: 1 });
|
||||||
});
|
});
|
||||||
@@ -85,7 +85,7 @@ describe(SearchService.name, () => {
|
|||||||
|
|
||||||
const auth = factory.auth({ user: { id: user.id } });
|
const auth = factory.auth({ user: { id: user.id } });
|
||||||
|
|
||||||
const result = await sut.searchStatistics(auth, { personIds: [person.id] });
|
const result = await sut.searchStatistics(auth, { personIds: [person.personGroupId] });
|
||||||
|
|
||||||
expect(result).toEqual({ total: 0 });
|
expect(result).toEqual({ total: 0 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Kysely } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { ImmichEnvironment, JobName, JobStatus, UserAvatarColor } from 'src/enum';
|
import { ImmichEnvironment, JobName, JobStatus, UserAvatarColor } from 'src/enum';
|
||||||
|
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||||
import { EventRepository } from 'src/repositories/event.repository';
|
import { EventRepository } from 'src/repositories/event.repository';
|
||||||
@@ -12,7 +13,7 @@ import { DB } from 'src/schema';
|
|||||||
import { UserService } from 'src/services/user.service';
|
import { UserService } from 'src/services/user.service';
|
||||||
import { HumanReadableSize } from 'src/utils/bytes';
|
import { HumanReadableSize } from 'src/utils/bytes';
|
||||||
import { mediumFactory, newMediumService } from 'test/medium.factory';
|
import { mediumFactory, newMediumService } from 'test/medium.factory';
|
||||||
import { factory } from 'test/small.factory';
|
import { factory, newUuid } from 'test/small.factory';
|
||||||
import { getKyselyDB } from 'test/utils';
|
import { getKyselyDB } from 'test/utils';
|
||||||
|
|
||||||
const userLicense = {
|
const userLicense = {
|
||||||
@@ -28,7 +29,7 @@ const setup = (db?: Kysely<DB>) => {
|
|||||||
|
|
||||||
return newMediumService(UserService, {
|
return newMediumService(UserService, {
|
||||||
database: db || defaultDatabase,
|
database: db || defaultDatabase,
|
||||||
real: [CryptoRepository, ConfigRepository, SystemMetadataRepository, UserRepository],
|
real: [ClusterGroupRepository, CryptoRepository, ConfigRepository, SystemMetadataRepository, UserRepository],
|
||||||
mock: [LoggingRepository, JobRepository, EventRepository],
|
mock: [LoggingRepository, JobRepository, EventRepository],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -44,7 +45,7 @@ describe(UserService.name, () => {
|
|||||||
it('should create a user', async () => {
|
it('should create a user', async () => {
|
||||||
const { sut, ctx } = setup();
|
const { sut, ctx } = setup();
|
||||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||||
const user = mediumFactory.userInsert();
|
const user = mediumFactory.userInsert({ clusterGroupId: newUuid() });
|
||||||
const created = await sut.createUser({ name: user.name, email: user.email });
|
const created = await sut.createUser({ name: user.name, email: user.email });
|
||||||
expect(created).toEqual(expect.objectContaining({ name: user.name, email: user.email }));
|
expect(created).toEqual(expect.objectContaining({ name: user.name, email: user.email }));
|
||||||
|
|
||||||
@@ -54,7 +55,7 @@ describe(UserService.name, () => {
|
|||||||
it('should reject user with duplicate email', async () => {
|
it('should reject user with duplicate email', async () => {
|
||||||
const { sut, ctx } = setup();
|
const { sut, ctx } = setup();
|
||||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||||
const user = mediumFactory.userInsert();
|
const user = mediumFactory.userInsert({ clusterGroupId: newUuid() });
|
||||||
await expect(sut.createUser({ name: 'Test', email: user.email })).resolves.toMatchObject({ email: user.email });
|
await expect(sut.createUser({ name: 'Test', email: user.email })).resolves.toMatchObject({ email: user.email });
|
||||||
await expect(sut.createUser({ name: 'Test', email: user.email })).rejects.toThrow('Email is not available');
|
await expect(sut.createUser({ name: 'Test', email: user.email })).rejects.toThrow('Email is not available');
|
||||||
});
|
});
|
||||||
@@ -62,7 +63,7 @@ describe(UserService.name, () => {
|
|||||||
it('should not return password', async () => {
|
it('should not return password', async () => {
|
||||||
const { sut, ctx } = setup();
|
const { sut, ctx } = setup();
|
||||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||||
const dto = mediumFactory.userInsert({ password: 'password' });
|
const dto = mediumFactory.userInsert({ clusterGroupId: newUuid(), password: 'password' });
|
||||||
const user = await sut.createUser({ name: 'Test', email: dto.email, password: 'password' });
|
const user = await sut.createUser({ name: 'Test', email: dto.email, password: 'password' });
|
||||||
expect((user as any).password).toBeUndefined();
|
expect((user as any).password).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ describe(SyncEntityType.AssetFaceV2, () => {
|
|||||||
const { auth, ctx } = await setup();
|
const { auth, ctx } = await setup();
|
||||||
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
|
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
|
||||||
const { person } = await ctx.newPerson({ ownerId: auth.user.id });
|
const { person } = await ctx.newPerson({ ownerId: auth.user.id });
|
||||||
const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personId: person.id });
|
const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId });
|
||||||
|
|
||||||
const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]);
|
const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]);
|
||||||
expect(response).toEqual([
|
expect(response).toEqual([
|
||||||
@@ -32,7 +32,7 @@ describe(SyncEntityType.AssetFaceV2, () => {
|
|||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
id: assetFace.id,
|
id: assetFace.id,
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
personId: person.id,
|
personId: person.personGroupId,
|
||||||
imageWidth: assetFace.imageWidth,
|
imageWidth: assetFace.imageWidth,
|
||||||
imageHeight: assetFace.imageHeight,
|
imageHeight: assetFace.imageHeight,
|
||||||
boundingBoxX1: assetFace.boundingBoxX1,
|
boundingBoxX1: assetFace.boundingBoxX1,
|
||||||
@@ -103,7 +103,7 @@ describe(SyncEntityType.AssetFaceV2, () => {
|
|||||||
const { auth, ctx } = await setup();
|
const { auth, ctx } = await setup();
|
||||||
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
|
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
|
||||||
const { person } = await ctx.newPerson({ ownerId: auth.user.id });
|
const { person } = await ctx.newPerson({ ownerId: auth.user.id });
|
||||||
const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personId: person.id });
|
const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId });
|
||||||
|
|
||||||
const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]);
|
const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]);
|
||||||
expect(response).toEqual([
|
expect(response).toEqual([
|
||||||
@@ -112,7 +112,7 @@ describe(SyncEntityType.AssetFaceV2, () => {
|
|||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
id: assetFace.id,
|
id: assetFace.id,
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
personId: person.id,
|
personId: person.personGroupId,
|
||||||
imageWidth: assetFace.imageWidth,
|
imageWidth: assetFace.imageWidth,
|
||||||
imageHeight: assetFace.imageHeight,
|
imageHeight: assetFace.imageHeight,
|
||||||
boundingBoxX1: assetFace.boundingBoxX1,
|
boundingBoxX1: assetFace.boundingBoxX1,
|
||||||
@@ -182,7 +182,7 @@ describe(SyncEntityType.AssetFaceV2, () => {
|
|||||||
const personRepo = ctx.get(PersonRepository);
|
const personRepo = ctx.get(PersonRepository);
|
||||||
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
|
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
|
||||||
const { person } = await ctx.newPerson({ ownerId: auth.user.id });
|
const { person } = await ctx.newPerson({ ownerId: auth.user.id });
|
||||||
const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personId: person.id });
|
const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId });
|
||||||
|
|
||||||
let response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]);
|
let response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]);
|
||||||
expect(response).toEqual([
|
expect(response).toEqual([
|
||||||
@@ -191,7 +191,7 @@ describe(SyncEntityType.AssetFaceV2, () => {
|
|||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
id: assetFace.id,
|
id: assetFace.id,
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
personId: person.id,
|
personId: person.personGroupId,
|
||||||
imageWidth: assetFace.imageWidth,
|
imageWidth: assetFace.imageWidth,
|
||||||
imageHeight: assetFace.imageHeight,
|
imageHeight: assetFace.imageHeight,
|
||||||
boundingBoxX1: assetFace.boundingBoxX1,
|
boundingBoxX1: assetFace.boundingBoxX1,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ describe(SyncEntityType.PersonV1, () => {
|
|||||||
{
|
{
|
||||||
ack: expect.any(String),
|
ack: expect.any(String),
|
||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
id: person.id,
|
id: person.personGroupId,
|
||||||
name: person.name,
|
name: person.name,
|
||||||
isHidden: person.isHidden,
|
isHidden: person.isHidden,
|
||||||
birthDate: person.birthDate,
|
birthDate: person.birthDate,
|
||||||
@@ -50,14 +50,14 @@ describe(SyncEntityType.PersonV1, () => {
|
|||||||
const { auth, ctx } = await setup();
|
const { auth, ctx } = await setup();
|
||||||
const personRepo = ctx.get(PersonRepository);
|
const personRepo = ctx.get(PersonRepository);
|
||||||
const { person } = await ctx.newPerson({ ownerId: auth.user.id });
|
const { person } = await ctx.newPerson({ ownerId: auth.user.id });
|
||||||
await personRepo.delete([person.id]);
|
await personRepo.delete([person.personGroupId], person.ownerId);
|
||||||
|
|
||||||
const response = await ctx.syncStream(auth, [SyncRequestType.PeopleV1]);
|
const response = await ctx.syncStream(auth, [SyncRequestType.PeopleV1]);
|
||||||
expect(response).toEqual([
|
expect(response).toEqual([
|
||||||
{
|
{
|
||||||
ack: expect.any(String),
|
ack: expect.any(String),
|
||||||
data: {
|
data: {
|
||||||
personId: person.id,
|
personId: person.personGroupId,
|
||||||
},
|
},
|
||||||
type: 'PersonDeleteV1',
|
type: 'PersonDeleteV1',
|
||||||
},
|
},
|
||||||
@@ -82,7 +82,7 @@ describe(SyncEntityType.PersonV1, () => {
|
|||||||
]);
|
]);
|
||||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PeopleV1]);
|
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PeopleV1]);
|
||||||
|
|
||||||
await personRepo.delete([person.id]);
|
await personRepo.delete([person.personGroupId], person.ownerId);
|
||||||
|
|
||||||
expect(await ctx.syncStream(auth2, [SyncRequestType.PeopleV1])).toEqual([
|
expect(await ctx.syncStream(auth2, [SyncRequestType.PeopleV1])).toEqual([
|
||||||
expect.objectContaining({ type: SyncEntityType.PersonDeleteV1 }),
|
expect.objectContaining({ type: SyncEntityType.PersonDeleteV1 }),
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
|
|||||||
checkCreateAccess: vitest.fn().mockResolvedValue(new Set()),
|
checkCreateAccess: vitest.fn().mockResolvedValue(new Set()),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
clusterGroup: {
|
||||||
|
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
|
||||||
|
checkInviteAccess: vitest.fn().mockResolvedValue(new Set()),
|
||||||
|
},
|
||||||
|
|
||||||
|
clusterGroupRequest: {
|
||||||
|
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
|
||||||
|
checkGroupAccess: vitest.fn().mockResolvedValue(new Set()),
|
||||||
|
},
|
||||||
|
|
||||||
asset: {
|
asset: {
|
||||||
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
|
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
|
||||||
checkAlbumAccess: vitest.fn().mockResolvedValue(new Set()),
|
checkAlbumAccess: vitest.fn().mockResolvedValue(new Set()),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { AppRepository } from 'src/repositories/app.repository';
|
|||||||
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
|
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
|
||||||
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
|
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
|
||||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||||
|
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { CronRepository } from 'src/repositories/cron.repository';
|
import { CronRepository } from 'src/repositories/cron.repository';
|
||||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||||
@@ -75,6 +76,7 @@ import { AuthService } from 'src/services/auth.service';
|
|||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { RepositoryInterface } from 'src/types';
|
import { RepositoryInterface } from 'src/types';
|
||||||
import { getKyselyConfig } from 'src/utils/database';
|
import { getKyselyConfig } from 'src/utils/database';
|
||||||
|
import { ClusterGroupFactory } from 'test/factories/cluster-group.factory';
|
||||||
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
|
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
|
||||||
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
|
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
|
||||||
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
|
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
|
||||||
@@ -238,6 +240,7 @@ export type ServiceOverrides = {
|
|||||||
asset: AssetRepository;
|
asset: AssetRepository;
|
||||||
assetEdit: AssetEditRepository;
|
assetEdit: AssetEditRepository;
|
||||||
assetJob: AssetJobRepository;
|
assetJob: AssetJobRepository;
|
||||||
|
clusterGroup: ClusterGroupRepository;
|
||||||
config: ConfigRepository;
|
config: ConfigRepository;
|
||||||
cron: CronRepository;
|
cron: CronRepository;
|
||||||
crypto: CryptoRepository;
|
crypto: CryptoRepository;
|
||||||
@@ -321,6 +324,7 @@ export const getMocks = () => {
|
|||||||
asset: newAssetRepositoryMock(),
|
asset: newAssetRepositoryMock(),
|
||||||
assetEdit: automock(AssetEditRepository),
|
assetEdit: automock(AssetEditRepository),
|
||||||
assetJob: automock(AssetJobRepository),
|
assetJob: automock(AssetJobRepository),
|
||||||
|
clusterGroup: automock(ClusterGroupRepository),
|
||||||
app: automock(AppRepository, { strict: false }),
|
app: automock(AppRepository, { strict: false }),
|
||||||
config: newConfigRepositoryMock(),
|
config: newConfigRepositoryMock(),
|
||||||
database: databaseMock,
|
database: databaseMock,
|
||||||
@@ -371,6 +375,9 @@ export const getMocks = () => {
|
|||||||
workflow: automock(WorkflowRepository, { strict: true }),
|
workflow: automock(WorkflowRepository, { strict: true }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// every new user gets a cluster group, which is incidental to most tests
|
||||||
|
mocks.clusterGroup.create.mockResolvedValue(ClusterGroupFactory.create());
|
||||||
|
|
||||||
return mocks;
|
return mocks;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -391,6 +398,7 @@ export const newTestService = <T extends BaseService>(
|
|||||||
overrides.asset || (mocks.asset as As<AssetRepository>),
|
overrides.asset || (mocks.asset as As<AssetRepository>),
|
||||||
overrides.assetEdit || (mocks.assetEdit as As<AssetEditRepository>),
|
overrides.assetEdit || (mocks.assetEdit as As<AssetEditRepository>),
|
||||||
overrides.assetJob || (mocks.assetJob as As<AssetJobRepository>),
|
overrides.assetJob || (mocks.assetJob as As<AssetJobRepository>),
|
||||||
|
overrides.clusterGroup || (mocks.clusterGroup as As<ClusterGroupRepository>),
|
||||||
overrides.config || (mocks.config as As<ConfigRepository> as ConfigRepository),
|
overrides.config || (mocks.config as As<ConfigRepository> as ConfigRepository),
|
||||||
overrides.cron || (mocks.cron as As<CronRepository>),
|
overrides.cron || (mocks.cron as As<CronRepository>),
|
||||||
overrides.crypto || (mocks.crypto as As<CryptoRepository>),
|
overrides.crypto || (mocks.crypto as As<CryptoRepository>),
|
||||||
|
|||||||
@@ -57,45 +57,49 @@
|
|||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !authManager.isSharedLink && isOwner}
|
{#if !authManager.isSharedLink}
|
||||||
<section class="px-4 pt-4 text-sm">
|
<section class="px-4 pt-4 text-sm">
|
||||||
<div class="flex h-10 w-full items-center justify-between">
|
{#if isOwner || visiblePeople.length > 0}
|
||||||
<Text size="small" color="muted">{$t('people')}</Text>
|
<div class="flex h-10 w-full items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<Text size="small" color="muted">{$t('people')}</Text>
|
||||||
{#if people.some((person) => person.isHidden)}
|
<div class="flex items-center gap-2">
|
||||||
<IconButton
|
{#if isOwner}
|
||||||
aria-label={$t('show_hidden_people')}
|
{#if people.some((person) => person.isHidden)}
|
||||||
icon={assetViewerManager.isShowingHiddenPeople ? mdiEyeOff : mdiEye}
|
<IconButton
|
||||||
size="medium"
|
aria-label={$t('show_hidden_people')}
|
||||||
shape="round"
|
icon={assetViewerManager.isShowingHiddenPeople ? mdiEyeOff : mdiEye}
|
||||||
color="secondary"
|
size="medium"
|
||||||
variant="ghost"
|
shape="round"
|
||||||
onclick={() => assetViewerManager.toggleHiddenPeople()}
|
color="secondary"
|
||||||
/>
|
variant="ghost"
|
||||||
{/if}
|
onclick={() => assetViewerManager.toggleHiddenPeople()}
|
||||||
<IconButton
|
/>
|
||||||
aria-label={$t('tag_people')}
|
{/if}
|
||||||
icon={mdiPlus}
|
<IconButton
|
||||||
size="medium"
|
aria-label={$t('tag_people')}
|
||||||
shape="round"
|
icon={mdiPlus}
|
||||||
color="secondary"
|
size="medium"
|
||||||
variant="ghost"
|
shape="round"
|
||||||
onclick={() => assetViewerManager.toggleFaceEditMode()}
|
color="secondary"
|
||||||
/>
|
variant="ghost"
|
||||||
|
onclick={() => assetViewerManager.toggleFaceEditMode()}
|
||||||
|
/>
|
||||||
|
|
||||||
{#if faceManager.data.length > 0}
|
{#if faceManager.data.length > 0}
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label={$t('edit_people')}
|
aria-label={$t('edit_people')}
|
||||||
icon={mdiPencil}
|
icon={mdiPencil}
|
||||||
size="medium"
|
size="medium"
|
||||||
shape="round"
|
shape="round"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onclick={() => assetViewerManager.openEditFacesPanel()}
|
onclick={() => assetViewerManager.openEditFacesPanel()}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
|
|
||||||
<div class="mt-2 grid {visiblePeople.length <= 6 ? 'grid-cols-3 gap-3' : 'grid-cols-4 gap-2'}">
|
<div class="mt-2 grid {visiblePeople.length <= 6 ? 'grid-cols-3 gap-3' : 'grid-cols-4 gap-2'}">
|
||||||
{#each visiblePeople as person (person.id)}
|
{#each visiblePeople as person (person.id)}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { focusTrap } from '$lib/actions/focus-trap';
|
import { focusTrap } from '$lib/actions/focus-trap';
|
||||||
import NotificationItem from '$lib/components/shared-components/navigation-bar/NotificationItem.svelte';
|
import NotificationItem from '$lib/components/shared-components/navigation-bar/NotificationItem.svelte';
|
||||||
|
import { OpenQueryParam } from '$lib/constants';
|
||||||
|
import { Route } from '$lib/route';
|
||||||
import { notificationManager } from '$lib/stores/notification-manager.svelte';
|
import { notificationManager } from '$lib/stores/notification-manager.svelte';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { NotificationType, type NotificationDto } from '@immich/sdk';
|
import { NotificationType, type NotificationDto } from '@immich/sdk';
|
||||||
@@ -50,6 +52,11 @@
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case NotificationType.ClusterGroupRequest: {
|
||||||
|
await goto(Route.userSettings({ isOpen: OpenQueryParam.SHARING }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export enum OpenQueryParam {
|
|||||||
STORAGE_TEMPLATE = 'storage-template',
|
STORAGE_TEMPLATE = 'storage-template',
|
||||||
NOTIFICATIONS = 'notifications',
|
NOTIFICATIONS = 'notifications',
|
||||||
PURCHASE_SETTINGS = 'user-purchase-settings',
|
PURCHASE_SETTINGS = 'user-purchase-settings',
|
||||||
|
SHARING = 'sharing',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const maximumLengthSearchPeople = 100;
|
export const maximumLengthSearchPeople = 100;
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import UserAvatar from '$lib/components/shared-components/UserAvatar.svelte';
|
||||||
|
import { searchUsers, type UserResponseDto } from '@immich/sdk';
|
||||||
|
import { Button, ListButton, LoadingSpinner, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
excludedUserIds: string[];
|
||||||
|
onClose: (users?: UserResponseDto[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { excludedUserIds, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let availableUsers: UserResponseDto[] = $state([]);
|
||||||
|
let selectedUsers: UserResponseDto[] = $state([]);
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
const users = await searchUsers();
|
||||||
|
const excluded = new Set(excludedUserIds);
|
||||||
|
availableUsers = users.filter(({ id }) => !excluded.has(id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectUser = (user: UserResponseDto) => {
|
||||||
|
selectedUsers = selectedUsers.some(({ id }) => id === user.id)
|
||||||
|
? selectedUsers.filter((selectedUser) => selectedUser.id !== user.id)
|
||||||
|
: [...selectedUsers, user];
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal title={$t('add_user')} {onClose} size="small">
|
||||||
|
<ModalBody>
|
||||||
|
{#await loadUsers()}
|
||||||
|
<div class="flex w-full place-content-center place-items-center">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
{:then _}
|
||||||
|
{#if availableUsers.length > 0}
|
||||||
|
<div class="flex max-h-75 immich-scrollbar flex-col gap-2 overflow-y-auto">
|
||||||
|
{#each availableUsers as user (user.id)}
|
||||||
|
<ListButton onclick={() => selectUser(user)} selected={selectedUsers.some(({ id }) => id === user.id)}>
|
||||||
|
<UserAvatar {user} size="md" />
|
||||||
|
<div class="grow text-start">
|
||||||
|
<Text fontWeight="medium">{user.name}</Text>
|
||||||
|
<Text size="tiny" color="muted">{user.email}</Text>
|
||||||
|
</div>
|
||||||
|
</ListButton>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button shape="round" fullWidth onclick={() => onClose(selectedUsers)} disabled={selectedUsers.length === 0}>
|
||||||
|
{$t('add')}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
{:else}
|
||||||
|
<Text color="muted">{$t('partner_page_no_more_users')}</Text>
|
||||||
|
{/if}
|
||||||
|
{/await}
|
||||||
|
</ModalBody>
|
||||||
|
</Modal>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import UserAvatar from '$lib/components/shared-components/UserAvatar.svelte';
|
||||||
|
import { getClusterGroupUsers, type UserResponseDto } from '@immich/sdk';
|
||||||
|
import { Button, HStack, LoadingSpinner, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
clusterGroupId: string;
|
||||||
|
onClose: (result?: 'accept' | 'decline') => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { clusterGroupId, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let users: UserResponseDto[] = $state([]);
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
users = await getClusterGroupUsers({ id: clusterGroupId });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal title={$t('cluster_group')} {onClose} size="small">
|
||||||
|
<ModalBody>
|
||||||
|
{#await loadUsers()}
|
||||||
|
<div class="flex w-full place-content-center place-items-center">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
{:then _}
|
||||||
|
<Text size="small" color="muted">{$t('cluster_group_invite_description')}</Text>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<Text size="small" fontWeight="medium">{$t('users')}</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex max-h-75 immich-scrollbar flex-col gap-4 overflow-y-auto">
|
||||||
|
{#each users as user (user.id)}
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<UserAvatar {user} size="md" />
|
||||||
|
<div class="text-start">
|
||||||
|
<Text fontWeight="medium">{user.name}</Text>
|
||||||
|
<Text size="tiny" color="muted">{user.email}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/await}
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<HStack fullWidth>
|
||||||
|
<Button shape="round" color="danger" fullWidth onclick={() => onClose('decline')}>{$t('decline')}</Button>
|
||||||
|
<Button shape="round" fullWidth onclick={() => onClose('accept')}>{$t('accept')}</Button>
|
||||||
|
</HStack>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import SettingSwitch from '$lib/components/shared-components/settings/SettingSwitch.svelte';
|
|
||||||
import UserAvatar from '$lib/components/shared-components/UserAvatar.svelte';
|
|
||||||
import PartnerSelectionModal from '$lib/modals/PartnerSelectionModal.svelte';
|
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
|
||||||
import {
|
|
||||||
createPartner,
|
|
||||||
getPartners,
|
|
||||||
PartnerDirection,
|
|
||||||
removePartner,
|
|
||||||
updatePartner,
|
|
||||||
type PartnerResponseDto,
|
|
||||||
type UserResponseDto,
|
|
||||||
} from '@immich/sdk';
|
|
||||||
import { Button, Icon, IconButton, modalManager, Text } from '@immich/ui';
|
|
||||||
import { mdiCheck, mdiClose } from '@mdi/js';
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
|
|
||||||
interface PartnerSharing {
|
|
||||||
user: UserResponseDto;
|
|
||||||
sharedByMe: boolean;
|
|
||||||
sharedWithMe: boolean;
|
|
||||||
inTimeline: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let partners: Array<PartnerSharing> = $state([]);
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
await refreshPartners();
|
|
||||||
});
|
|
||||||
|
|
||||||
const refreshPartners = async () => {
|
|
||||||
partners = [];
|
|
||||||
|
|
||||||
const [sharedBy, sharedWith] = await Promise.all([
|
|
||||||
getPartners({ direction: PartnerDirection.SharedBy }),
|
|
||||||
getPartners({ direction: PartnerDirection.SharedWith }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
for (const candidate of sharedBy) {
|
|
||||||
partners = [
|
|
||||||
...partners,
|
|
||||||
{
|
|
||||||
user: candidate,
|
|
||||||
sharedByMe: true,
|
|
||||||
sharedWithMe: false,
|
|
||||||
inTimeline: candidate.inTimeline ?? false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const candidate of sharedWith) {
|
|
||||||
const existIndex = partners.findIndex((p) => candidate.id === p.user.id);
|
|
||||||
|
|
||||||
if (existIndex === -1) {
|
|
||||||
partners = [
|
|
||||||
...partners,
|
|
||||||
{
|
|
||||||
user: candidate,
|
|
||||||
sharedByMe: false,
|
|
||||||
sharedWithMe: true,
|
|
||||||
inTimeline: candidate.inTimeline ?? false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
partners[existIndex].sharedWithMe = true;
|
|
||||||
partners[existIndex].inTimeline = candidate.inTimeline ?? false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemovePartner = async (partner: PartnerResponseDto) => {
|
|
||||||
const isConfirmed = await modalManager.showDialog({
|
|
||||||
title: $t('stop_photo_sharing'),
|
|
||||||
prompt: $t('stop_photo_sharing_description', { values: { partner: partner.name } }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isConfirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await removePartner({ id: partner.id });
|
|
||||||
await refreshPartners();
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, $t('errors.unable_to_remove_partner'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreatePartners = async () => {
|
|
||||||
const users = await modalManager.show(PartnerSelectionModal, {});
|
|
||||||
|
|
||||||
if (!users) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const user of users) {
|
|
||||||
await createPartner({ partnerCreateDto: { sharedWithId: user.id } });
|
|
||||||
}
|
|
||||||
|
|
||||||
await refreshPartners();
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, $t('errors.unable_to_add_partners'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleShowOnTimelineChanged = async (partner: PartnerSharing, inTimeline: boolean) => {
|
|
||||||
try {
|
|
||||||
await updatePartner({ id: partner.user.id, partnerUpdateDto: { inTimeline } });
|
|
||||||
|
|
||||||
partner.inTimeline = inTimeline;
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, $t('errors.unable_to_update_timeline_display_status'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<section class="my-4">
|
|
||||||
{#if partners.length > 0}
|
|
||||||
{#each partners as partner (partner.user.id)}
|
|
||||||
<div class="mt-6 rounded-2xl border border-gray-200 bg-slate-50 p-5 dark:border-gray-800 dark:bg-gray-900">
|
|
||||||
<div class="flex justify-between gap-4 rounded-lg pb-4 transition-all">
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<UserAvatar user={partner.user} size="md" />
|
|
||||||
<div class="text-start">
|
|
||||||
<p class="text-immich-fg dark:text-immich-dark-fg">
|
|
||||||
{partner.user.name}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">
|
|
||||||
{partner.user.email}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if partner.sharedByMe}
|
|
||||||
<IconButton
|
|
||||||
shape="round"
|
|
||||||
color="secondary"
|
|
||||||
variant="ghost"
|
|
||||||
onclick={() => handleRemovePartner(partner.user)}
|
|
||||||
icon={mdiClose}
|
|
||||||
size="small"
|
|
||||||
aria-label={$t('stop_sharing_photos_with_user')}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-immich-dark-gray dark:text-gray-200">
|
|
||||||
<!-- I am sharing my assets with this user -->
|
|
||||||
{#if partner.sharedByMe}
|
|
||||||
<hr class="my-4 border border-gray-200 dark:border-gray-700" />
|
|
||||||
<Text class="my-4" size="small" fontWeight="medium">
|
|
||||||
{$t('shared_with_partner', { values: { partner: partner.user.name } })}
|
|
||||||
</Text>
|
|
||||||
<Text size="tiny" fontWeight="medium"
|
|
||||||
>{$t('partner_can_access', { values: { partner: partner.user.name } })}</Text
|
|
||||||
>
|
|
||||||
<ul class="text-sm">
|
|
||||||
<li class="mt-2 flex place-items-center gap-2 py-1">
|
|
||||||
<Icon icon={mdiCheck} />
|
|
||||||
{$t('partner_can_access_assets')}
|
|
||||||
</li>
|
|
||||||
<li class="flex place-items-center gap-2 py-1">
|
|
||||||
<Icon icon={mdiCheck} />
|
|
||||||
{$t('partner_can_access_location')}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- this user is sharing assets with me -->
|
|
||||||
{#if partner.sharedWithMe}
|
|
||||||
<hr class="my-4 border border-gray-200 dark:border-gray-700" />
|
|
||||||
<Text class="my-4" size="small" fontWeight="medium">
|
|
||||||
{$t('shared_from_partner', { values: { partner: partner.user.name } })}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<SettingSwitch
|
|
||||||
title={$t('show_in_timeline')}
|
|
||||||
subtitle={$t('show_in_timeline_setting_description')}
|
|
||||||
bind:checked={partner.inTimeline}
|
|
||||||
onToggle={(isChecked) => handleShowOnTimelineChanged(partner, isChecked)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="mt-5 flex justify-end">
|
|
||||||
<Button shape="round" size="small" onclick={() => handleCreatePartners()}>{$t('add_partner')}</Button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import SettingSwitch from '$lib/components/shared-components/settings/SettingSwitch.svelte';
|
||||||
|
import UserAvatar from '$lib/components/shared-components/UserAvatar.svelte';
|
||||||
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
|
import ClusterGroupUserSelectionModal from '$lib/modals/ClusterGroupUserSelectionModal.svelte';
|
||||||
|
import ClusterGroupUsersModal from '$lib/modals/ClusterGroupUsersModal.svelte';
|
||||||
|
import PartnerSelectionModal from '$lib/modals/PartnerSelectionModal.svelte';
|
||||||
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
import {
|
||||||
|
acceptClusterGroupRequest,
|
||||||
|
createClusterGroupRequest,
|
||||||
|
createPartner,
|
||||||
|
deleteClusterGroupRequest,
|
||||||
|
getClusterGroupRequests,
|
||||||
|
getClusterGroupRequestsForGroup,
|
||||||
|
getClusterGroupUsers,
|
||||||
|
getMyUser,
|
||||||
|
getPartners,
|
||||||
|
leaveClusterGroup,
|
||||||
|
PartnerDirection,
|
||||||
|
removePartner,
|
||||||
|
searchUsers,
|
||||||
|
updatePartner,
|
||||||
|
type ClusterGroupRequestResponseDto,
|
||||||
|
type PartnerResponseDto,
|
||||||
|
type UserResponseDto,
|
||||||
|
} from '@immich/sdk';
|
||||||
|
import { Button, Card, CardBody, Icon, IconButton, modalManager, Text } from '@immich/ui';
|
||||||
|
import { mdiCheck, mdiClose } from '@mdi/js';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
type PartnerSharing = {
|
||||||
|
user: UserResponseDto;
|
||||||
|
sharedByMe: boolean;
|
||||||
|
sharedWithMe: boolean;
|
||||||
|
inTimeline: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let clusterGroupId: string = $state('');
|
||||||
|
let users: UserResponseDto[] = $state([]);
|
||||||
|
let sentRequests: ClusterGroupRequestResponseDto[] = $state([]);
|
||||||
|
let receivedRequests: ClusterGroupRequestResponseDto[] = $state([]);
|
||||||
|
// a request is sent to someone outside of the group, so they come from elsewhere
|
||||||
|
let candidates: Record<string, UserResponseDto> = $state({});
|
||||||
|
|
||||||
|
let partners: Array<PartnerSharing> = $state([]);
|
||||||
|
|
||||||
|
const canLeave = $derived(users.length > 1);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await Promise.all([refresh(), refreshPartners()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
try {
|
||||||
|
const { clusterGroupId: id } = await getMyUser();
|
||||||
|
clusterGroupId = id;
|
||||||
|
|
||||||
|
const [groupUsers, sent, received, allUsers] = await Promise.all([
|
||||||
|
getClusterGroupUsers({ id }),
|
||||||
|
getClusterGroupRequestsForGroup({ id }),
|
||||||
|
getClusterGroupRequests(),
|
||||||
|
searchUsers(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
users = groupUsers;
|
||||||
|
sentRequests = sent;
|
||||||
|
receivedRequests = received;
|
||||||
|
candidates = Object.fromEntries(allUsers.map((user) => [user.id, user]));
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('errors.unable_to_load_cluster_group'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddUsers = async () => {
|
||||||
|
const excludedUserIds = [...users.map(({ id }) => id), ...sentRequests.map(({ userId }) => userId)];
|
||||||
|
const selected = await modalManager.show(ClusterGroupUserSelectionModal, { excludedUserIds });
|
||||||
|
|
||||||
|
if (!selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const user of selected) {
|
||||||
|
await createClusterGroupRequest({ id: clusterGroupId, clusterGroupRequestCreateDto: { userId: user.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('errors.something_went_wrong'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewGroup = async (request: ClusterGroupRequestResponseDto) => {
|
||||||
|
const result = await modalManager.show(ClusterGroupUsersModal, { clusterGroupId: request.clusterGroupId });
|
||||||
|
if (result === 'accept') {
|
||||||
|
await handleAcceptRequest(request);
|
||||||
|
} else if (result === 'decline') {
|
||||||
|
await handleDeleteRequest(request);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAcceptRequest = async (request: ClusterGroupRequestResponseDto) => {
|
||||||
|
try {
|
||||||
|
await acceptClusterGroupRequest({ id: request.id });
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('errors.something_went_wrong'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteRequest = async (request: ClusterGroupRequestResponseDto) => {
|
||||||
|
try {
|
||||||
|
await deleteClusterGroupRequest({ id: request.id });
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('errors.something_went_wrong'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLeave = async () => {
|
||||||
|
const isConfirmed = await modalManager.showDialog({
|
||||||
|
title: $t('leave_group'),
|
||||||
|
prompt: $t('leave_group_description'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isConfirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await leaveClusterGroup({ id: clusterGroupId });
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('errors.unable_to_leave_cluster_group'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshPartners = async () => {
|
||||||
|
partners = [];
|
||||||
|
|
||||||
|
const [sharedBy, sharedWith] = await Promise.all([
|
||||||
|
getPartners({ direction: PartnerDirection.SharedBy }),
|
||||||
|
getPartners({ direction: PartnerDirection.SharedWith }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const candidate of sharedBy) {
|
||||||
|
partners = [
|
||||||
|
...partners,
|
||||||
|
{
|
||||||
|
user: candidate,
|
||||||
|
sharedByMe: true,
|
||||||
|
sharedWithMe: false,
|
||||||
|
inTimeline: candidate.inTimeline ?? false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of sharedWith) {
|
||||||
|
const existIndex = partners.findIndex((p) => candidate.id === p.user.id);
|
||||||
|
|
||||||
|
if (existIndex === -1) {
|
||||||
|
partners = [
|
||||||
|
...partners,
|
||||||
|
{
|
||||||
|
user: candidate,
|
||||||
|
sharedByMe: false,
|
||||||
|
sharedWithMe: true,
|
||||||
|
inTimeline: candidate.inTimeline ?? false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
partners[existIndex].sharedWithMe = true;
|
||||||
|
partners[existIndex].inTimeline = candidate.inTimeline ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemovePartner = async (partner: PartnerResponseDto) => {
|
||||||
|
const isConfirmed = await modalManager.showDialog({
|
||||||
|
title: $t('stop_photo_sharing'),
|
||||||
|
prompt: $t('stop_photo_sharing_description', { values: { partner: partner.name } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isConfirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await removePartner({ id: partner.id });
|
||||||
|
await refreshPartners();
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('errors.unable_to_remove_partner'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreatePartners = async () => {
|
||||||
|
const users = await modalManager.show(PartnerSelectionModal, {});
|
||||||
|
|
||||||
|
if (!users) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const user of users) {
|
||||||
|
await createPartner({ partnerCreateDto: { sharedWithId: user.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshPartners();
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('errors.unable_to_add_partners'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowOnTimelineChanged = async (partner: PartnerSharing, inTimeline: boolean) => {
|
||||||
|
try {
|
||||||
|
await updatePartner({ id: partner.user.id, partnerUpdateDto: { inTimeline } });
|
||||||
|
|
||||||
|
partner.inTimeline = inTimeline;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('errors.unable_to_update_timeline_display_status'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="my-4">
|
||||||
|
<Text size="large" fontWeight="medium">{$t('cluster_group')}</Text>
|
||||||
|
<Text size="small" color="muted">{$t('cluster_group_description')}</Text>
|
||||||
|
|
||||||
|
<Card class="mt-4">
|
||||||
|
<CardBody>
|
||||||
|
{#each users as user, index (user.id)}
|
||||||
|
<div class="flex items-center justify-between gap-4" class:mt-4={index > 0}>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<UserAvatar {user} size="md" />
|
||||||
|
<div class="text-start">
|
||||||
|
<p class="text-immich-fg dark:text-immich-dark-fg">
|
||||||
|
{user.name}
|
||||||
|
{#if user.id === authManager.user.id}
|
||||||
|
<span class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">({$t('you')})</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if user.id === authManager.user.id && canLeave}
|
||||||
|
<Button shape="round" size="small" color="secondary" onclick={() => handleLeave()}>
|
||||||
|
{$t('leave')}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{#if sentRequests.length > 0 || receivedRequests.length > 0}
|
||||||
|
<div class="mt-4">
|
||||||
|
<Text size="small" fontWeight="medium">{$t('pending')}</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card color="secondary" class="mt-2">
|
||||||
|
<CardBody>
|
||||||
|
{#each receivedRequests as request, index (request.id)}
|
||||||
|
<div class="flex items-center justify-between gap-4" class:mt-4={index > 0}>
|
||||||
|
<Text size="small">{$t('request_received_description')}</Text>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button shape="round" size="small" color="secondary" onclick={() => handleViewGroup(request)}>
|
||||||
|
{$t('view_group')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#each sentRequests as request, index (request.id)}
|
||||||
|
{@const user = candidates[request.userId]}
|
||||||
|
<div class="flex items-center justify-between gap-4" class:mt-4={index > 0 || receivedRequests.length > 0}>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
{#if user}
|
||||||
|
<UserAvatar {user} size="md" />
|
||||||
|
{/if}
|
||||||
|
<div class="text-start">
|
||||||
|
<p class="text-immich-fg dark:text-immich-dark-fg">{user?.name ?? request.userId}</p>
|
||||||
|
<p class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">{user?.email ?? ''}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button shape="round" size="small" color="secondary" onclick={() => handleDeleteRequest(request)}>
|
||||||
|
{$t('cancel')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-5 flex justify-end">
|
||||||
|
<Button shape="round" size="small" onclick={() => handleAddUsers()}>{$t('add_user')}</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="my-4">
|
||||||
|
<Text size="large" fontWeight="medium">{$t('partners')}</Text>
|
||||||
|
|
||||||
|
{#if partners.length > 0}
|
||||||
|
{#each partners as partner (partner.user.id)}
|
||||||
|
<div class="mt-6 rounded-2xl border border-gray-200 bg-slate-50 p-5 dark:border-gray-800 dark:bg-gray-900">
|
||||||
|
<div class="flex justify-between gap-4 rounded-lg pb-4 transition-all">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<UserAvatar user={partner.user} size="md" />
|
||||||
|
<div class="text-start">
|
||||||
|
<p class="text-immich-fg dark:text-immich-dark-fg">
|
||||||
|
{partner.user.name}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">
|
||||||
|
{partner.user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if partner.sharedByMe}
|
||||||
|
<IconButton
|
||||||
|
shape="round"
|
||||||
|
color="secondary"
|
||||||
|
variant="ghost"
|
||||||
|
onclick={() => handleRemovePartner(partner.user)}
|
||||||
|
icon={mdiClose}
|
||||||
|
size="small"
|
||||||
|
aria-label={$t('stop_sharing_photos_with_user')}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-immich-dark-gray dark:text-gray-200">
|
||||||
|
<!-- I am sharing my assets with this user -->
|
||||||
|
{#if partner.sharedByMe}
|
||||||
|
<hr class="my-4 border border-gray-200 dark:border-gray-700" />
|
||||||
|
<Text class="my-4" size="small" fontWeight="medium">
|
||||||
|
{$t('shared_with_partner', { values: { partner: partner.user.name } })}
|
||||||
|
</Text>
|
||||||
|
<Text size="tiny" fontWeight="medium"
|
||||||
|
>{$t('partner_can_access', { values: { partner: partner.user.name } })}</Text
|
||||||
|
>
|
||||||
|
<ul class="text-sm">
|
||||||
|
<li class="mt-2 flex place-items-center gap-2 py-1">
|
||||||
|
<Icon icon={mdiCheck} />
|
||||||
|
{$t('partner_can_access_assets')}
|
||||||
|
</li>
|
||||||
|
<li class="flex place-items-center gap-2 py-1">
|
||||||
|
<Icon icon={mdiCheck} />
|
||||||
|
{$t('partner_can_access_location')}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- this user is sharing assets with me -->
|
||||||
|
{#if partner.sharedWithMe}
|
||||||
|
<hr class="my-4 border border-gray-200 dark:border-gray-700" />
|
||||||
|
<Text class="my-4" size="small" fontWeight="medium">
|
||||||
|
{$t('shared_from_partner', { values: { partner: partner.user.name } })}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<SettingSwitch
|
||||||
|
title={$t('show_in_timeline')}
|
||||||
|
subtitle={$t('show_in_timeline_setting_description')}
|
||||||
|
bind:checked={partner.inTimeline}
|
||||||
|
onToggle={(isChecked) => handleShowOnTimelineChanged(partner, isChecked)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-5 flex justify-end">
|
||||||
|
<Button shape="round" size="small" onclick={() => handleCreatePartners()}>{$t('add_partner')}</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
import ChangePasswordSettings from './ChangePasswordSettings.svelte';
|
import ChangePasswordSettings from './ChangePasswordSettings.svelte';
|
||||||
import DeviceList from './DeviceList.svelte';
|
import DeviceList from './DeviceList.svelte';
|
||||||
import OauthSettings from './OauthSettings.svelte';
|
import OauthSettings from './OauthSettings.svelte';
|
||||||
import PartnerSettings from './PartnerSettings.svelte';
|
import SharingSettings from './SharingSettings.svelte';
|
||||||
import UserApiKeyList from './UserApiKeyList.svelte';
|
import UserApiKeyList from './UserApiKeyList.svelte';
|
||||||
import UserProfileSettings from './UserProfileSettings.svelte';
|
import UserProfileSettings from './UserProfileSettings.svelte';
|
||||||
|
|
||||||
@@ -129,15 +129,6 @@
|
|||||||
<ChangePasswordSettings />
|
<ChangePasswordSettings />
|
||||||
</SettingAccordion>
|
</SettingAccordion>
|
||||||
|
|
||||||
<SettingAccordion
|
|
||||||
icon={mdiAccountGroupOutline}
|
|
||||||
key="partner-sharing"
|
|
||||||
title={$t('partner_sharing')}
|
|
||||||
subtitle={$t('manage_sharing_with_partners')}
|
|
||||||
>
|
|
||||||
<PartnerSettings />
|
|
||||||
</SettingAccordion>
|
|
||||||
|
|
||||||
<SettingAccordion
|
<SettingAccordion
|
||||||
icon={mdiLockSmart}
|
icon={mdiLockSmart}
|
||||||
key="user-pin-code-settings"
|
key="user-pin-code-settings"
|
||||||
@@ -157,3 +148,12 @@
|
|||||||
>
|
>
|
||||||
<UserPurchaseSettings />
|
<UserPurchaseSettings />
|
||||||
</SettingAccordion>
|
</SettingAccordion>
|
||||||
|
|
||||||
|
<SettingAccordion
|
||||||
|
icon={mdiAccountGroupOutline}
|
||||||
|
key={OpenQueryParam.SHARING}
|
||||||
|
title={$t('sharing')}
|
||||||
|
subtitle={$t('manage_sharing_with_other_users')}
|
||||||
|
>
|
||||||
|
<SharingSettings />
|
||||||
|
</SettingAccordion>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Sync } from 'factory.ts';
|
|||||||
|
|
||||||
export const userAdminFactory = Sync.makeFactory<UserAdminResponseDto>({
|
export const userAdminFactory = Sync.makeFactory<UserAdminResponseDto>({
|
||||||
id: Sync.each(() => faker.string.uuid()),
|
id: Sync.each(() => faker.string.uuid()),
|
||||||
|
clusterGroupId: Sync.each(() => faker.string.uuid()),
|
||||||
email: Sync.each(() => faker.internet.email()),
|
email: Sync.each(() => faker.internet.email()),
|
||||||
name: Sync.each(() => faker.person.fullName()),
|
name: Sync.each(() => faker.person.fullName()),
|
||||||
profileImagePath: '',
|
profileImagePath: '',
|
||||||
|
|||||||
Reference in New Issue
Block a user