feat: cluster groups (#30739)

Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
This commit is contained in:
Jason Rasmussen
2026-08-20 20:03:00 +02:00
committed by GitHub
co-authored by Daniel Dietzler
parent f88fb628ff
commit 11b1aa5ecf
100 changed files with 4299 additions and 1006 deletions
+1 -1
View File
@@ -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);
+21 -21
View File
@@ -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
View File
@@ -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) =>
+14
View File
@@ -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",
+1 -1
View File
@@ -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},
+422
View File
@@ -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",
+108
View File
@@ -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",
+2
View File
@@ -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);
}
}
+2
View File
@@ -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,
+19 -9
View File
@@ -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: {
+4 -2
View File
@@ -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',
+2 -2
View File
@@ -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));
} }
} }
+32
View File
@@ -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,
};
}
+2 -2
View File
@@ -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,
}; };
} }
+6
View File
@@ -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,
+8
View File
@@ -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',
+45 -2
View File
@@ -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
+3 -1
View File
@@ -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
+2 -1
View File
@@ -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
+4 -2
View File
@@ -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
+261 -34
View File
@@ -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"
+13 -7
View File
@@ -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
+3 -3
View File
@@ -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",
+7
View File
@@ -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",
+83 -5
View File
@@ -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();
} }
+27 -11
View File
@@ -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 }];
+2
View File
@@ -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,
+5 -1
View File
@@ -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),
+263 -73
View File
@@ -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();
}
} }
+27 -13
View File
@@ -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')
+11 -3
View File
@@ -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',
+15 -2
View File
@@ -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`,
+15
View File
@@ -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,
);
}
+9 -9
View File
@@ -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>;
}
+17 -7
View File
@@ -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>;
+5
View File
@@ -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 });
+3 -3
View File
@@ -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> {
+8 -3
View File
@@ -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 });
}
}
+2
View File
@@ -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,
+2 -1
View File
@@ -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);
} }
+2 -5
View File
@@ -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;
} }
+63 -22
View File
@@ -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, {
+19 -12
View File
@@ -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);
+16 -11
View File
@@ -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' },
}, },
]); ]);
}, },
+31 -11
View File
@@ -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);
} }
+15 -1
View File
@@ -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
+232 -269
View File
@@ -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();
});
}); });
}); });
+152 -102
View File
@@ -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]);
} }
} }
+7 -1
View File
@@ -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',
},
); );
}); });
+7
View File
@@ -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'),
}, },
+1
View File
@@ -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);
+13 -7
View File
@@ -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,
);
}); });
}); });
+1 -1
View File
@@ -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
View File
@@ -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 }
+22 -1
View File
@@ -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:
+55 -26
View File
@@ -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);
+1 -1
View File
@@ -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(),
+2 -2
View File
@@ -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 };
}
}
+1 -1
View File
@@ -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',
+4
View File
@@ -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>>;
+1
View File
@@ -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,
+2
View File
@@ -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
View File
@@ -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,
+16 -8
View File
@@ -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()),
+8
View File
@@ -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;
} }
+1
View File
@@ -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: '',