mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
Merge 0398a30edb into 652ef8a427
This commit is contained in:
commit
3afc5093cc
99 changed files with 4285 additions and 908 deletions
|
|
@ -82,32 +82,32 @@ describe('/people', () => {
|
|||
const asset4 = await utils.createAsset(admin.accessToken);
|
||||
|
||||
await Promise.all([
|
||||
utils.createFace({ assetId: asset1.id, personId: visiblePerson.id }),
|
||||
utils.createFace({ assetId: asset1.id, personId: hiddenPerson.id }),
|
||||
utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }),
|
||||
utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }),
|
||||
utils.createFace({ assetId: asset2.id, personId: multipleAssetsPerson.id }),
|
||||
utils.createFace({ assetId: asset3.id, personId: multipleAssetsPerson.id }), // 4 assets
|
||||
utils.createFace({ assetId: asset1.id, personGroupId: visiblePerson.id }),
|
||||
utils.createFace({ assetId: asset1.id, personGroupId: hiddenPerson.id }),
|
||||
utils.createFace({ assetId: asset1.id, personGroupId: multipleAssetsPerson.id }),
|
||||
utils.createFace({ assetId: asset1.id, personGroupId: multipleAssetsPerson.id }),
|
||||
utils.createFace({ assetId: asset2.id, personGroupId: multipleAssetsPerson.id }),
|
||||
utils.createFace({ assetId: asset3.id, personGroupId: multipleAssetsPerson.id }), // 4 assets
|
||||
// Named persons
|
||||
utils.createFace({ assetId: asset1.id, personId: nameCharliePerson.id }), // 1 asset
|
||||
utils.createFace({ assetId: asset1.id, personId: nameBobPerson.id }),
|
||||
utils.createFace({ assetId: asset2.id, personId: nameBobPerson.id }), // 2 assets
|
||||
utils.createFace({ assetId: asset1.id, personId: nameAlicePerson.id }), // 1 asset
|
||||
utils.createFace({ assetId: asset1.id, personGroupId: nameCharliePerson.id }), // 1 asset
|
||||
utils.createFace({ assetId: asset1.id, personGroupId: nameBobPerson.id }),
|
||||
utils.createFace({ assetId: asset2.id, personGroupId: nameBobPerson.id }), // 2 assets
|
||||
utils.createFace({ assetId: asset1.id, personGroupId: nameAlicePerson.id }), // 1 asset
|
||||
// Null-named person 4 assets
|
||||
utils.createFace({ assetId: asset1.id, personId: nameNullPerson4Assets.id }),
|
||||
utils.createFace({ assetId: asset2.id, personId: nameNullPerson4Assets.id }),
|
||||
utils.createFace({ assetId: asset3.id, personId: nameNullPerson4Assets.id }),
|
||||
utils.createFace({ assetId: asset4.id, personId: nameNullPerson4Assets.id }), // 4 assets
|
||||
utils.createFace({ assetId: asset1.id, personGroupId: nameNullPerson4Assets.id }),
|
||||
utils.createFace({ assetId: asset2.id, personGroupId: nameNullPerson4Assets.id }),
|
||||
utils.createFace({ assetId: asset3.id, personGroupId: nameNullPerson4Assets.id }),
|
||||
utils.createFace({ assetId: asset4.id, personGroupId: nameNullPerson4Assets.id }), // 4 assets
|
||||
// Null-named person 3 assets
|
||||
utils.createFace({ assetId: asset1.id, personId: nameNullPerson3Assets.id }),
|
||||
utils.createFace({ assetId: asset2.id, personId: nameNullPerson3Assets.id }),
|
||||
utils.createFace({ assetId: asset3.id, personId: nameNullPerson3Assets.id }), // 3 assets
|
||||
utils.createFace({ assetId: asset1.id, personGroupId: nameNullPerson3Assets.id }),
|
||||
utils.createFace({ assetId: asset2.id, personGroupId: nameNullPerson3Assets.id }),
|
||||
utils.createFace({ assetId: asset3.id, personGroupId: nameNullPerson3Assets.id }), // 3 assets
|
||||
// Null-named person 1 asset
|
||||
utils.createFace({ assetId: asset3.id, personId: nameNullPerson1Asset.id }),
|
||||
utils.createFace({ assetId: asset3.id, personGroupId: nameNullPerson1Asset.id }),
|
||||
// Favourite People
|
||||
utils.createFace({ assetId: asset1.id, personId: nameFreddyPersonFavourite.id }),
|
||||
utils.createFace({ assetId: asset2.id, personId: nameFreddyPersonFavourite.id }),
|
||||
utils.createFace({ assetId: asset1.id, personId: nameBillPersonFavourite.id }),
|
||||
utils.createFace({ assetId: asset1.id, personGroupId: nameFreddyPersonFavourite.id }),
|
||||
utils.createFace({ assetId: asset2.id, personGroupId: nameFreddyPersonFavourite.id }),
|
||||
utils.createFace({ assetId: asset1.id, personGroupId: nameBillPersonFavourite.id }),
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -184,6 +184,8 @@ export const utils = {
|
|||
'library',
|
||||
'shared_link',
|
||||
'person',
|
||||
'person_group',
|
||||
'cluster_group',
|
||||
'album',
|
||||
'asset',
|
||||
'asset_face',
|
||||
|
|
@ -434,12 +436,12 @@ export const utils = {
|
|||
return person;
|
||||
},
|
||||
|
||||
createFace: async ({ assetId, personId }: { assetId: string; personId: string }) => {
|
||||
createFace: async ({ assetId, personGroupId }: { assetId: string; personGroupId: string }) => {
|
||||
if (!client) {
|
||||
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) => {
|
||||
|
|
@ -447,7 +449,9 @@ export const utils = {
|
|||
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) =>
|
||||
|
|
|
|||
16
i18n/en.json
16
i18n/en.json
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"about": "About",
|
||||
"accept": "Accept",
|
||||
"account": "Account",
|
||||
"account_settings": "Account Settings",
|
||||
"acknowledge": "Acknowledge",
|
||||
|
|
@ -36,6 +37,7 @@
|
|||
"add_to_bottom_bar": "Add to",
|
||||
"add_upload_to_stack": "Add upload to stack",
|
||||
"add_url": "Add URL",
|
||||
"add_user": "Add user",
|
||||
"added_to_archive": "Added to archive",
|
||||
"added_to_favorites": "Added to favorites",
|
||||
"added_to_favorites_count": "Added {count, number} to favorites",
|
||||
|
|
@ -733,6 +735,9 @@
|
|||
"client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate import/removal is available only before login",
|
||||
"client_cert_title": "SSL client certificate [EXPERIMENTAL]",
|
||||
"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_all": "Collapse all",
|
||||
"color": "Color",
|
||||
|
|
@ -830,6 +835,7 @@
|
|||
"date_time_original": "Date/Time Original",
|
||||
"day": "Day",
|
||||
"days": "Days",
|
||||
"decline": "Decline",
|
||||
"deduplicate_all": "Deduplicate All",
|
||||
"default_quality_subtitle": "Quality used when tapping share. Long press the share button to choose each time.",
|
||||
"default_share_quality": "Default share quality",
|
||||
|
|
@ -1023,6 +1029,7 @@
|
|||
"unable_to_create": "Unable to create workflow",
|
||||
"unable_to_create_admin_account": "Unable to create admin account",
|
||||
"unable_to_create_api_key": "Unable to create a new API Key",
|
||||
"unable_to_create_cluster_group_request": "Unable to send request",
|
||||
"unable_to_create_library": "Unable to create library",
|
||||
"unable_to_create_user": "Unable to create user",
|
||||
"unable_to_delete_album": "Unable to delete album",
|
||||
|
|
@ -1040,8 +1047,10 @@
|
|||
"unable_to_get_comments_number": "Unable to get number of comments",
|
||||
"unable_to_get_shared_link": "Failed to get shared link",
|
||||
"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_oauth_account": "Unable to link OAuth account",
|
||||
"unable_to_load_cluster_group": "Unable to load cluster group",
|
||||
"unable_to_log_out_all_devices": "Unable to log out all devices",
|
||||
"unable_to_log_out_device": "Unable to log out device",
|
||||
"unable_to_login_with_oauth": "Unable to login with OAuth",
|
||||
|
|
@ -1259,6 +1268,8 @@
|
|||
"latitude": "Latitude",
|
||||
"leave": "Leave",
|
||||
"leave_album": "Leave album",
|
||||
"leave_group": "Leave group",
|
||||
"leave_group_description": "Your people will no longer be shared with the other members of this group. Are you sure you want to continue?",
|
||||
"lens_model": "Lens model",
|
||||
"less": "Less",
|
||||
"let_others_respond": "Let others respond",
|
||||
|
|
@ -1365,6 +1376,7 @@
|
|||
"manage_media_access_settings": "Open settings",
|
||||
"manage_media_access_subtitle": "Allow the Immich app to manage and move media files.",
|
||||
"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_the_app_settings": "Manage the app settings",
|
||||
"manage_your_account": "Manage your account",
|
||||
|
|
@ -1766,6 +1778,7 @@
|
|||
"removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}",
|
||||
"rename": "Rename",
|
||||
"repository": "Repository",
|
||||
"request_received_description": "You have been invited to another group",
|
||||
"require_password": "Require password",
|
||||
"rescan": "Rescan",
|
||||
"reset": "Reset",
|
||||
|
|
@ -1942,6 +1955,7 @@
|
|||
"shared_album_activity_remove_title": "Delete Activity",
|
||||
"shared_album_section_people_action_error": "Error leaving/removing from album",
|
||||
"shared_album_section_people_title": "PEOPLE",
|
||||
"shared_assets_count": "{count, plural, one {# shared asset} other {# shared assets}}",
|
||||
"shared_by": "Shared by",
|
||||
"shared_by_user": "Shared by {user}",
|
||||
"shared_by_you": "Shared by you",
|
||||
|
|
@ -2248,6 +2262,7 @@
|
|||
"view_all_users": "View all users",
|
||||
"view_asset_owners": "View asset owners",
|
||||
"view_details": "View Details",
|
||||
"view_group": "View group",
|
||||
"view_in_timeline": "View in timeline",
|
||||
"view_link": "View link",
|
||||
"view_name": "View",
|
||||
|
|
@ -2298,6 +2313,7 @@
|
|||
"year": "Year",
|
||||
"years_ago": "{years, plural, one {# year} other {# years}} ago",
|
||||
"yes": "Yes",
|
||||
"you": "You",
|
||||
"you_dont_have_any_shared_links": "You don't have any shared links",
|
||||
"your_wifi_name": "Your Wi-Fi name",
|
||||
"zero_to_clear_rating": "press 0 to clear asset rating",
|
||||
|
|
|
|||
|
|
@ -5660,6 +5660,355 @@
|
|||
"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 for the current user 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"
|
||||
}
|
||||
},
|
||||
"/download/archive": {
|
||||
"post": {
|
||||
"description": "Download a ZIP archive containing the specified assets. The assets must have been previously requested via the \"getDownloadInfo\" endpoint.",
|
||||
|
|
@ -16368,6 +16717,10 @@
|
|||
"name": "Authentication (admin)",
|
||||
"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": "Database Backups (admin)",
|
||||
"description": "Manage backups of the Immich database."
|
||||
|
|
@ -18499,6 +18852,54 @@
|
|||
],
|
||||
"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",
|
||||
"format": "date-time",
|
||||
"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": {
|
||||
"description": "Colorspace",
|
||||
"enum": [
|
||||
|
|
@ -20584,6 +20985,7 @@
|
|||
"SystemMessage",
|
||||
"AlbumInvite",
|
||||
"AlbumUpdate",
|
||||
"ClusterGroupRequest",
|
||||
"Custom"
|
||||
],
|
||||
"type": "string"
|
||||
|
|
@ -21048,6 +21450,11 @@
|
|||
"backup.download",
|
||||
"backup.upload",
|
||||
"backup.delete",
|
||||
"clusterGroup.read",
|
||||
"clusterGroup.leave",
|
||||
"clusterGroupRequest.create",
|
||||
"clusterGroupRequest.read",
|
||||
"clusterGroupRequest.delete",
|
||||
"duplicate.read",
|
||||
"duplicate.delete",
|
||||
"face.create",
|
||||
|
|
@ -21283,10 +21690,24 @@
|
|||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
},
|
||||
"ownedAssets": {
|
||||
"description": "Number of assets owned by the current user",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
},
|
||||
"sharedAssets": {
|
||||
"description": "Number of assets owned by other users, visible through shared albums",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"assets"
|
||||
"assets",
|
||||
"ownedAssets",
|
||||
"sharedAssets"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
|
@ -27499,6 +27920,18 @@
|
|||
"avatarColor": {
|
||||
"$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": {
|
||||
"description": "Creation date",
|
||||
"example": "2024-01-01T00:00:00.000Z",
|
||||
|
|
@ -27591,6 +28024,7 @@
|
|||
},
|
||||
"required": [
|
||||
"avatarColor",
|
||||
"clusterGroupId",
|
||||
"createdAt",
|
||||
"deletedAt",
|
||||
"email",
|
||||
|
|
|
|||
|
|
@ -202,6 +202,8 @@ export type UserLicense = {
|
|||
};
|
||||
export type UserAdminResponseDto = {
|
||||
avatarColor: UserAvatarColor;
|
||||
/** Cluster group the user is a member of */
|
||||
clusterGroupId: string;
|
||||
/** Creation date */
|
||||
createdAt: string;
|
||||
/** Deletion date */
|
||||
|
|
@ -1113,6 +1115,20 @@ export type ValidateAccessTokenResponseDto = {
|
|||
/** Authentication status */
|
||||
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 DownloadArchiveDto = {
|
||||
/** Asset IDs */
|
||||
assetIds: string[];
|
||||
|
|
@ -1521,6 +1537,10 @@ export type AssetFaceUpdateDto = {
|
|||
export type PersonStatisticsResponseDto = {
|
||||
/** Number of assets */
|
||||
assets: number;
|
||||
/** Number of assets owned by the current user */
|
||||
ownedAssets: number;
|
||||
/** Number of assets owned by other users, visible through shared albums */
|
||||
sharedAssets: number;
|
||||
};
|
||||
export type PluginMethodResponseDto = {
|
||||
/** Description */
|
||||
|
|
@ -4671,6 +4691,92 @@ export function validateAccessToken(opts?: Oazapfts.RequestOpts) {
|
|||
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
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Download asset archive
|
||||
*/
|
||||
|
|
@ -7171,6 +7277,7 @@ export enum NotificationType {
|
|||
SystemMessage = "SystemMessage",
|
||||
AlbumInvite = "AlbumInvite",
|
||||
AlbumUpdate = "AlbumUpdate",
|
||||
ClusterGroupRequest = "ClusterGroupRequest",
|
||||
Custom = "Custom"
|
||||
}
|
||||
export enum UserStatus {
|
||||
|
|
@ -7247,6 +7354,11 @@ export enum Permission {
|
|||
BackupDownload = "backup.download",
|
||||
BackupUpload = "backup.upload",
|
||||
BackupDelete = "backup.delete",
|
||||
ClusterGroupRead = "clusterGroup.read",
|
||||
ClusterGroupLeave = "clusterGroup.leave",
|
||||
ClusterGroupRequestCreate = "clusterGroupRequest.create",
|
||||
ClusterGroupRequestRead = "clusterGroupRequest.read",
|
||||
ClusterGroupRequestDelete = "clusterGroupRequest.delete",
|
||||
DuplicateRead = "duplicate.read",
|
||||
DuplicateDelete = "duplicate.delete",
|
||||
FaceCreate = "face.create",
|
||||
|
|
|
|||
|
|
@ -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.Authentication]: 'Endpoints related to user authentication, including OAuth.',
|
||||
[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.DatabaseBackups]: 'Manage backups of the Immich database.',
|
||||
[ApiTag.Deprecated]: 'Deprecated endpoints that are planned for removal in the next major release.',
|
||||
[ApiTag.Download]: 'Endpoints for downloading assets or collections of assets.',
|
||||
|
|
|
|||
107
server/src/controllers/cluster-group.controller.ts
Normal file
107
server/src/controllers/cluster-group.controller.ts
Normal file
|
|
@ -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 for the current user to join a cluster group.',
|
||||
history: new HistoryBuilder().added('v3.2.0'),
|
||||
})
|
||||
deleteClusterGroupRequest(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
return this.service.deleteRequest(auth, id);
|
||||
}
|
||||
|
||||
@Get(':id/requests')
|
||||
@Authenticated({ permission: Permission.ClusterGroupRequestRead })
|
||||
@Endpoint({
|
||||
summary: 'Retrieve the requests sent by a cluster group',
|
||||
description: 'Retrieve the pending requests for other users to join the cluster group.',
|
||||
history: new HistoryBuilder().added('v3.2.0'),
|
||||
})
|
||||
getClusterGroupRequestsForGroup(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
): Promise<ClusterGroupRequestResponseDto[]> {
|
||||
return this.service.getRequestsForGroup(auth, id);
|
||||
}
|
||||
|
||||
@Get(':id/users')
|
||||
@Authenticated({ permission: Permission.ClusterGroupRead })
|
||||
@Endpoint({
|
||||
summary: 'Retrieve the users of a cluster group',
|
||||
description: 'Retrieve the users that are a member of the cluster group.',
|
||||
history: new HistoryBuilder().added('v3.2.0'),
|
||||
})
|
||||
getClusterGroupUsers(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserResponseDto[]> {
|
||||
return this.service.getUsers(auth, id);
|
||||
}
|
||||
|
||||
@Put(':id/requests')
|
||||
@Authenticated({ permission: Permission.ClusterGroupRequestCreate })
|
||||
@Endpoint({
|
||||
summary: 'Create a cluster group request',
|
||||
description: 'Ask another user to join the cluster group of the current user.',
|
||||
history: new HistoryBuilder().added('v3.2.0'),
|
||||
})
|
||||
async createClusterGroupRequest(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Body() dto: ClusterGroupRequestCreateDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<ClusterGroupRequestResponseDto> {
|
||||
const { duplicate, value } = await this.service.createRequest(auth, id, dto);
|
||||
res.status(duplicate ? HttpStatus.OK : HttpStatus.CREATED);
|
||||
return value;
|
||||
}
|
||||
|
||||
@Post(':id/leave')
|
||||
@Authenticated({ permission: Permission.ClusterGroupLeave })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Endpoint({
|
||||
summary: 'Leave a cluster group',
|
||||
description: 'Move the current user into a new cluster group of their own.',
|
||||
history: new HistoryBuilder().added('v3.2.0'),
|
||||
})
|
||||
leaveClusterGroup(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
return this.service.leave(auth, id);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { AssetMediaController } from 'src/controllers/asset-media.controller';
|
|||
import { AssetController } from 'src/controllers/asset.controller';
|
||||
import { AuthAdminController } from 'src/controllers/auth-admin.controller';
|
||||
import { AuthController } from 'src/controllers/auth.controller';
|
||||
import { ClusterGroupController } from 'src/controllers/cluster-group.controller';
|
||||
import { DatabaseBackupController } from 'src/controllers/database-backup.controller';
|
||||
import { DownloadController } from 'src/controllers/download.controller';
|
||||
import { DuplicateController } from 'src/controllers/duplicate.controller';
|
||||
|
|
@ -49,6 +50,7 @@ export const controllers = [
|
|||
AssetMediaController,
|
||||
AuthController,
|
||||
AuthAdminController,
|
||||
ClusterGroupController,
|
||||
DatabaseBackupController,
|
||||
DownloadController,
|
||||
DuplicateController,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ import { getConfig } from 'src/utils/config';
|
|||
|
||||
export interface MoveRequest {
|
||||
entityId: string;
|
||||
/** a person is owned, so the owner is needed to save the new path */
|
||||
ownerId?: string;
|
||||
pathType: PathType;
|
||||
oldPath: string | null;
|
||||
newPath: string;
|
||||
|
|
@ -36,6 +38,8 @@ export interface MoveRequest {
|
|||
|
||||
export type ThumbnailPathEntity = { id: string; ownerId: string };
|
||||
|
||||
export type PersonThumbnailPathEntity = { personGroupId: string; ownerId: string };
|
||||
|
||||
export type HlsSessionFolder = { ownerId: string; sessionId: string };
|
||||
|
||||
export type HlsVariantFolder = { ownerId: string; sessionId: string; variantIndex: number };
|
||||
|
|
@ -114,8 +118,8 @@ export class StorageCore {
|
|||
return join(StorageCore.getMediaLocation(), folder);
|
||||
}
|
||||
|
||||
static getPersonThumbnailPath(person: ThumbnailPathEntity) {
|
||||
return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.id}.jpeg`);
|
||||
static getPersonThumbnailPath(person: PersonThumbnailPathEntity) {
|
||||
return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.personGroupId}.jpeg`);
|
||||
}
|
||||
|
||||
static getImagePath(asset: ThumbnailPathEntity, { fileType, format, isEdited }: ImagePathOptions) {
|
||||
|
|
@ -177,12 +181,13 @@ export class StorageCore {
|
|||
});
|
||||
}
|
||||
|
||||
async movePersonFile(person: { id: string; ownerId: string; thumbnailPath: string }, pathType: PersonPathType) {
|
||||
const { id: entityId, thumbnailPath } = person;
|
||||
async movePersonFile(person: PersonThumbnailPathEntity & { thumbnailPath: string }, pathType: PersonPathType) {
|
||||
const { ownerId, personGroupId, thumbnailPath } = person;
|
||||
switch (pathType) {
|
||||
case PersonPathType.Face: {
|
||||
await this.moveFile({
|
||||
entityId,
|
||||
entityId: personGroupId,
|
||||
ownerId,
|
||||
pathType,
|
||||
oldPath: thumbnailPath,
|
||||
newPath: StorageCore.getPersonThumbnailPath(person),
|
||||
|
|
@ -192,7 +197,7 @@ export class StorageCore {
|
|||
}
|
||||
|
||||
async moveFile(request: MoveRequest) {
|
||||
const { entityId, pathType, oldPath, newPath, assetInfo } = request;
|
||||
const { entityId, ownerId, pathType, oldPath, newPath, assetInfo } = request;
|
||||
if (!oldPath || oldPath === newPath) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -265,7 +270,7 @@ export class StorageCore {
|
|||
}
|
||||
}
|
||||
|
||||
await this.savePath(pathType, entityId, newPath);
|
||||
await this.savePath(pathType, entityId, newPath, ownerId);
|
||||
await this.moveRepository.delete(move.id);
|
||||
}
|
||||
|
||||
|
|
@ -318,7 +323,7 @@ export class StorageCore {
|
|||
return { dri, mali };
|
||||
}
|
||||
|
||||
private savePath(pathType: PathType, id: string, newPath: string) {
|
||||
private savePath(pathType: PathType, id: string, newPath: string, ownerId?: string) {
|
||||
switch (pathType) {
|
||||
case AssetPathType.Original: {
|
||||
return this.assetRepository.update({ id, originalPath: newPath });
|
||||
|
|
@ -334,7 +339,12 @@ export class StorageCore {
|
|||
}
|
||||
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ export type User = {
|
|||
};
|
||||
|
||||
export type UserAdmin = User & {
|
||||
clusterGroupId: string;
|
||||
storageLabel: string | null;
|
||||
shouldChangePassword: boolean;
|
||||
isAdmin: boolean;
|
||||
|
|
@ -241,7 +242,7 @@ export type Exif = Omit<Selectable<AssetExifTable>, 'updatedAt' | 'updateId' | '
|
|||
|
||||
export type Person = {
|
||||
createdAt: Date;
|
||||
id: string;
|
||||
personGroupId: string;
|
||||
ownerId: string;
|
||||
updatedAt: Date;
|
||||
updateId: string;
|
||||
|
|
@ -264,7 +265,7 @@ export type AssetFace = {
|
|||
boundingBoxY2: number;
|
||||
imageHeight: number;
|
||||
imageWidth: number;
|
||||
personId: string | null;
|
||||
personGroupId: string | null;
|
||||
sourceType: SourceType;
|
||||
person?: ShallowDehydrateObject<Person> | null;
|
||||
updatedAt: Date;
|
||||
|
|
@ -376,6 +377,7 @@ export const columns = {
|
|||
userWithPrefix: userWithPrefixColumns,
|
||||
userAdmin: [
|
||||
...userColumns,
|
||||
'clusterGroupId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'deletedAt',
|
||||
|
|
|
|||
|
|
@ -170,8 +170,8 @@ const peopleFromFaces = (faces?: MaybeDehydrated<AssetFace>[]): PersonResponseDt
|
|||
const peopleMap: Map<string, PersonResponseDto> = new Map();
|
||||
|
||||
for (const face of faces) {
|
||||
if (face.person && !peopleMap.has(face.person.id)) {
|
||||
peopleMap.set(face.person.id, mapPerson(face.person));
|
||||
if (face.person && !peopleMap.has(face.person.personGroupId)) {
|
||||
peopleMap.set(face.person.personGroupId, mapPerson(face.person));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
32
server/src/dtos/cluster-group.dto.ts
Normal file
32
server/src/dtos/cluster-group.dto.ts
Normal 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 { asDateTimeString } from 'src/utils/date';
|
||||
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: z.string().meta({ format: 'date-time' }).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: asDateTimeString(request.createdAt),
|
||||
};
|
||||
}
|
||||
|
|
@ -147,6 +147,8 @@ const AssetFaceDeleteSchema = z
|
|||
const PersonStatisticsResponseSchema = z
|
||||
.object({
|
||||
assets: z.int().describe('Number of assets'),
|
||||
ownedAssets: z.int().describe('Number of assets owned by the current user'),
|
||||
sharedAssets: z.int().describe('Number of assets owned by other users, visible through shared albums'),
|
||||
})
|
||||
.meta({ id: 'PersonStatisticsResponseDto' });
|
||||
|
||||
|
|
@ -173,7 +175,7 @@ export class PeopleResponseDto extends createZodDto(PeopleResponseSchema) {}
|
|||
|
||||
export function mapPerson(person: MaybeDehydrated<Person>): PersonResponseDto {
|
||||
return {
|
||||
id: person.id,
|
||||
id: person.personGroupId,
|
||||
name: person.name,
|
||||
birthDate: asDateString(person.birthDate),
|
||||
thumbnailPath: person.thumbnailPath,
|
||||
|
|
@ -215,6 +217,6 @@ export function mapFaces(
|
|||
): AssetFaceResponseDto {
|
||||
return {
|
||||
...mapFacesWithoutPerson(face, edits, assetDimensions),
|
||||
person: face.person?.ownerId === auth.user.id ? mapPerson(face.person) : null,
|
||||
person: face.person ? mapPerson(face.person) : null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { createZodDto } from 'nestjs-zod';
|
||||
import { User, UserAdmin } from 'src/database';
|
||||
import { HistoryBuilder } from 'src/decorators';
|
||||
import { pinCodeRegex } from 'src/dtos/auth.dto';
|
||||
import { UserAvatarColor, UserAvatarColorSchema, UserMetadataKey, UserStatusSchema } from 'src/enum';
|
||||
import { MaybeDehydrated, UserMetadataItem } from 'src/types';
|
||||
|
|
@ -116,6 +117,10 @@ const UserAdminDeleteSchema = z
|
|||
export class UserAdminDeleteDto extends createZodDto(UserAdminDeleteSchema) {}
|
||||
|
||||
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'),
|
||||
shouldChangePassword: z.boolean().describe('Require password change on next login'),
|
||||
isAdmin: z.boolean().describe('Is admin user'),
|
||||
|
|
@ -139,6 +144,7 @@ export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto {
|
|||
|
||||
return {
|
||||
...mapUser(entity),
|
||||
clusterGroupId: entity.clusterGroupId,
|
||||
storageLabel: entity.storageLabel,
|
||||
shouldChangePassword: entity.shouldChangePassword,
|
||||
isAdmin: entity.isAdmin,
|
||||
|
|
|
|||
|
|
@ -160,6 +160,12 @@ export enum Permission {
|
|||
BackupUpload = 'backup.upload',
|
||||
BackupDelete = 'backup.delete',
|
||||
|
||||
ClusterGroupRead = 'clusterGroup.read',
|
||||
ClusterGroupLeave = 'clusterGroup.leave',
|
||||
ClusterGroupRequestCreate = 'clusterGroupRequest.create',
|
||||
ClusterGroupRequestRead = 'clusterGroupRequest.read',
|
||||
ClusterGroupRequestDelete = 'clusterGroupRequest.delete',
|
||||
|
||||
DuplicateRead = 'duplicate.read',
|
||||
DuplicateDelete = 'duplicate.delete',
|
||||
|
||||
|
|
@ -1130,6 +1136,7 @@ export enum NotificationType {
|
|||
SystemMessage = 'SystemMessage',
|
||||
AlbumInvite = 'AlbumInvite',
|
||||
AlbumUpdate = 'AlbumUpdate',
|
||||
ClusterGroupRequest = 'ClusterGroupRequest',
|
||||
Custom = 'Custom',
|
||||
}
|
||||
|
||||
|
|
@ -1190,6 +1197,7 @@ export enum ApiTag {
|
|||
Memories = 'Memories',
|
||||
Notifications = 'Notifications',
|
||||
NotificationsAdmin = 'Notifications (admin)',
|
||||
ClusterGroups = 'Cluster groups',
|
||||
Partners = 'Partners',
|
||||
People = 'People',
|
||||
Plugins = 'Plugins',
|
||||
|
|
|
|||
|
|
@ -187,13 +187,31 @@ where
|
|||
"notification"."id" in ($1)
|
||||
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.person.checkOwnerAccess
|
||||
select
|
||||
"person"."id"
|
||||
"person"."personGroupId"
|
||||
from
|
||||
"person"
|
||||
where
|
||||
"person"."id" in ($1)
|
||||
"person"."personGroupId" in ($1)
|
||||
and "person"."ownerId" = $2
|
||||
|
||||
-- AccessRepository.person.checkFaceOwnerAccess
|
||||
|
|
|
|||
|
|
@ -354,9 +354,11 @@ select
|
|||
"asset_file"."assetId" = "asset"."id"
|
||||
and "asset_file"."type" = $2
|
||||
) as agg
|
||||
) as "files"
|
||||
) as "files",
|
||||
"user"."clusterGroupId"
|
||||
from
|
||||
"asset"
|
||||
inner join "user" on "user"."id" = "asset"."ownerId"
|
||||
where
|
||||
"asset"."id" = $3
|
||||
|
||||
|
|
|
|||
|
|
@ -192,7 +192,8 @@ select
|
|||
from
|
||||
"person"
|
||||
where
|
||||
"asset_face"."personId" = "person"."id"
|
||||
"person"."personGroupId" = "asset_face"."personGroupId"
|
||||
and "person"."ownerId" = $1
|
||||
) as "person" on true
|
||||
where
|
||||
"asset_face"."assetId" = "asset"."id"
|
||||
|
|
@ -224,7 +225,7 @@ from
|
|||
"asset"
|
||||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."id" = any ($1::uuid[])
|
||||
"asset"."id" = any ($2::uuid[])
|
||||
|
||||
-- AssetRepository.deleteAll
|
||||
delete from "asset"
|
||||
|
|
|
|||
94
server/src/queries/cluster.group.repository.sql
Normal file
94
server/src/queries/cluster.group.repository.sql
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- ClusterGroupRepository.create
|
||||
insert into
|
||||
"cluster_group"
|
||||
default values
|
||||
returning
|
||||
*
|
||||
|
||||
-- ClusterGroupRepository.getForUser
|
||||
select
|
||||
"user"."clusterGroupId"
|
||||
from
|
||||
"user"
|
||||
where
|
||||
"user"."id" = $1
|
||||
|
||||
-- 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 nothing
|
||||
returning
|
||||
*
|
||||
|
||||
-- ClusterGroupRepository.getRequest
|
||||
select
|
||||
"cluster_group_request".*
|
||||
from
|
||||
"cluster_group_request"
|
||||
where
|
||||
"cluster_group_request"."id" = $1
|
||||
|
||||
-- ClusterGroupRepository.getRequestFor
|
||||
select
|
||||
"cluster_group_request".*
|
||||
from
|
||||
"cluster_group_request"
|
||||
where
|
||||
"cluster_group_request"."clusterGroupId" = $1
|
||||
and "cluster_group_request"."userId" = $2
|
||||
|
||||
-- ClusterGroupRepository.getRequests
|
||||
select
|
||||
"cluster_group_request".*
|
||||
from
|
||||
"cluster_group_request"
|
||||
where
|
||||
"cluster_group_request"."userId" = $1
|
||||
order by
|
||||
"cluster_group_request"."createdAt" asc
|
||||
|
||||
-- ClusterGroupRepository.getRequestsForGroup
|
||||
select
|
||||
"cluster_group_request".*
|
||||
from
|
||||
"cluster_group_request"
|
||||
where
|
||||
"cluster_group_request"."clusterGroupId" = $1
|
||||
order by
|
||||
"cluster_group_request"."createdAt" asc
|
||||
|
||||
-- ClusterGroupRepository.getUsers
|
||||
select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt"
|
||||
from
|
||||
"user"
|
||||
where
|
||||
"user"."clusterGroupId" = $1
|
||||
and "user"."deletedAt" is null
|
||||
order by
|
||||
"user"."id" = $2 desc,
|
||||
"user"."name" asc
|
||||
|
||||
-- ClusterGroupRepository.deleteRequest
|
||||
delete from "cluster_group_request"
|
||||
where
|
||||
"cluster_group_request"."id" = $1
|
||||
|
|
@ -47,7 +47,8 @@ select
|
|||
$1 as "one"
|
||||
from
|
||||
"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
|
||||
"asset_face"."assetId" = "asset"."id"
|
||||
and "person"."isHidden" = $2
|
||||
|
|
@ -86,7 +87,8 @@ select
|
|||
$1 as "one"
|
||||
from
|
||||
"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
|
||||
"asset_face"."assetId" = "asset"."id"
|
||||
and "person"."isHidden" = $2
|
||||
|
|
|
|||
|
|
@ -3,18 +3,51 @@
|
|||
-- PersonRepository.reassignFaces
|
||||
update "asset_face"
|
||||
set
|
||||
"personId" = $1
|
||||
"personGroupId" = $1
|
||||
where
|
||||
"asset_face"."personId" = $2
|
||||
"asset_face"."personGroupId" = $2
|
||||
|
||||
-- PersonRepository.delete
|
||||
delete from "person"
|
||||
where
|
||||
"person"."id" in ($1)
|
||||
(
|
||||
"person"."ownerId" = $1
|
||||
and "person"."personGroupId" = $2
|
||||
)
|
||||
|
||||
-- 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
|
||||
select
|
||||
"id",
|
||||
"ownerId",
|
||||
"personGroupId",
|
||||
"thumbnailPath"
|
||||
from
|
||||
"person"
|
||||
|
|
@ -28,8 +61,9 @@ select
|
|||
"person".*
|
||||
from
|
||||
"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"
|
||||
and "asset"."ownerId" = "person"."ownerId"
|
||||
and "asset"."visibility" = 'timeline'
|
||||
and "asset"."deletedAt" is null
|
||||
where
|
||||
|
|
@ -38,7 +72,8 @@ where
|
|||
and "asset_face"."isVisible" is true
|
||||
and "person"."isHidden" = $2
|
||||
group by
|
||||
"person"."id"
|
||||
"person"."ownerId",
|
||||
"person"."personGroupId"
|
||||
having
|
||||
(
|
||||
"person"."name" != $3
|
||||
|
|
@ -72,12 +107,13 @@ select
|
|||
"person".*
|
||||
from
|
||||
"person"
|
||||
left join "asset_face" on "asset_face"."personId" = "person"."id"
|
||||
left join "asset_face" on "asset_face"."personGroupId" = "person"."personGroupId"
|
||||
where
|
||||
"asset_face"."deletedAt" is null
|
||||
and "asset_face"."isVisible" is true
|
||||
group by
|
||||
"person"."id"
|
||||
"person"."ownerId",
|
||||
"person"."personGroupId"
|
||||
having
|
||||
count("asset_face"."assetId") = $1
|
||||
|
||||
|
|
@ -94,15 +130,16 @@ select
|
|||
from
|
||||
"person"
|
||||
where
|
||||
"person"."id" = "asset_face"."personId"
|
||||
"person"."personGroupId" = "asset_face"."personGroupId"
|
||||
and "person"."ownerId" = $1
|
||||
) as obj
|
||||
) as "person"
|
||||
from
|
||||
"asset_face"
|
||||
where
|
||||
"asset_face"."assetId" = $1
|
||||
"asset_face"."assetId" = $2
|
||||
and "asset_face"."deletedAt" is null
|
||||
and "asset_face"."isVisible" = $2
|
||||
and "asset_face"."isVisible" = $3
|
||||
order by
|
||||
"asset_face"."boundingBoxX1" asc
|
||||
|
||||
|
|
@ -119,19 +156,20 @@ select
|
|||
from
|
||||
"person"
|
||||
where
|
||||
"person"."id" = "asset_face"."personId"
|
||||
"person"."personGroupId" = "asset_face"."personGroupId"
|
||||
and "person"."ownerId" = $1
|
||||
) as obj
|
||||
) as "person"
|
||||
from
|
||||
"asset_face"
|
||||
where
|
||||
"asset_face"."id" = $1
|
||||
"asset_face"."id" = $2
|
||||
and "asset_face"."deletedAt" is null
|
||||
|
||||
-- PersonRepository.getFaceForFacialRecognitionJob
|
||||
select
|
||||
"asset_face"."id",
|
||||
"asset_face"."personId",
|
||||
"asset_face"."personGroupId",
|
||||
"asset_face"."sourceType",
|
||||
(
|
||||
select
|
||||
|
|
@ -141,9 +179,11 @@ select
|
|||
select
|
||||
"asset"."ownerId",
|
||||
"asset"."visibility",
|
||||
"asset"."fileCreatedAt"
|
||||
"asset"."fileCreatedAt",
|
||||
"user"."clusterGroupId"
|
||||
from
|
||||
"asset"
|
||||
inner join "user" on "user"."id" = "asset"."ownerId"
|
||||
where
|
||||
"asset"."id" = "asset_face"."assetId"
|
||||
) as obj
|
||||
|
|
@ -195,16 +235,26 @@ from
|
|||
inner join "asset" on "asset_face"."assetId" = "asset"."id"
|
||||
left join "asset_exif" on "asset_exif"."assetId" = "asset"."id"
|
||||
where
|
||||
"person"."id" = $1
|
||||
"person"."ownerId" = $1
|
||||
and "person"."personGroupId" = $2
|
||||
and "asset_face"."deletedAt" is null
|
||||
|
||||
-- PersonRepository.reassignFace
|
||||
update "asset_face"
|
||||
set
|
||||
"personId" = $1
|
||||
"personGroupId" = $1
|
||||
where
|
||||
"asset_face"."id" = $2
|
||||
|
||||
-- PersonRepository.getByGroupId
|
||||
select
|
||||
"person".*
|
||||
from
|
||||
"person"
|
||||
where
|
||||
"person"."personGroupId" = $1
|
||||
and "person"."ownerId" = $2
|
||||
|
||||
-- PersonRepository.getByName
|
||||
with
|
||||
"similarity_threshold" as (
|
||||
|
|
@ -226,7 +276,7 @@ limit
|
|||
|
||||
-- PersonRepository.getDistinctNames
|
||||
select distinct
|
||||
on (lower("person"."name")) "person"."id",
|
||||
on (lower("person"."name")) "person"."personGroupId",
|
||||
"person"."name"
|
||||
from
|
||||
"person"
|
||||
|
|
@ -238,16 +288,35 @@ where
|
|||
|
||||
-- PersonRepository.getStatistics
|
||||
select
|
||||
count(distinct ("asset"."id")) as "count"
|
||||
count(distinct ("asset"."id")) as "count",
|
||||
count(distinct ("asset"."id")) filter (
|
||||
where
|
||||
"asset"."ownerId" = $1::uuid
|
||||
) as "ownedCount"
|
||||
from
|
||||
"asset_face"
|
||||
left join "asset" on "asset"."id" = "asset_face"."assetId"
|
||||
and "asset"."visibility" = 'timeline'
|
||||
and "asset"."deletedAt" is null
|
||||
and (
|
||||
"asset"."ownerId" = $2::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" = $3::uuid
|
||||
where
|
||||
"album_asset"."assetId" = "asset"."id"
|
||||
)
|
||||
)
|
||||
where
|
||||
"asset_face"."deletedAt" is null
|
||||
and "asset_face"."isVisible" is true
|
||||
and "asset_face"."personId" = $1
|
||||
and "asset_face"."personGroupId" = $4
|
||||
|
||||
-- PersonRepository.getNumberOfPeople
|
||||
select
|
||||
|
|
@ -267,7 +336,7 @@ where
|
|||
from
|
||||
"asset_face"
|
||||
where
|
||||
"asset_face"."personId" = "person"."id"
|
||||
"asset_face"."personGroupId" = "person"."personGroupId"
|
||||
and "asset_face"."deletedAt" is null
|
||||
and "asset_face"."isVisible" = $2
|
||||
and exists (
|
||||
|
|
@ -282,6 +351,164 @@ where
|
|||
)
|
||||
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
|
||||
with
|
||||
"added_embeddings" as (
|
||||
|
|
@ -310,14 +537,15 @@ select
|
|||
from
|
||||
"person"
|
||||
where
|
||||
"person"."id" = "asset_face"."personId"
|
||||
"person"."personGroupId" = "asset_face"."personGroupId"
|
||||
and "person"."ownerId" = $1
|
||||
) as obj
|
||||
) as "person"
|
||||
from
|
||||
"asset_face"
|
||||
where
|
||||
"asset_face"."assetId" in ($1)
|
||||
and "asset_face"."personId" in ($2)
|
||||
"asset_face"."assetId" in ($2)
|
||||
and "asset_face"."personGroupId" in ($3)
|
||||
and "asset_face"."deletedAt" is null
|
||||
|
||||
-- PersonRepository.getRandomFace
|
||||
|
|
@ -326,7 +554,7 @@ select
|
|||
from
|
||||
"asset_face"
|
||||
where
|
||||
"asset_face"."personId" = $1
|
||||
"asset_face"."personGroupId" = $1
|
||||
and "asset_face"."deletedAt" is null
|
||||
and "asset_face"."isVisible" is true
|
||||
|
||||
|
|
@ -350,12 +578,13 @@ where
|
|||
|
||||
-- PersonRepository.getForPeopleDelete
|
||||
select
|
||||
"id",
|
||||
"thumbnailPath"
|
||||
"person"."ownerId",
|
||||
"person"."personGroupId",
|
||||
"person"."thumbnailPath"
|
||||
from
|
||||
"person"
|
||||
where
|
||||
"id" in ($1)
|
||||
"person"."personGroupId" in ($1)
|
||||
|
||||
-- PersonRepository.getForFeatureFaceUpdate
|
||||
select
|
||||
|
|
@ -366,4 +595,4 @@ from
|
|||
and "asset"."isOffline" = $1
|
||||
where
|
||||
"asset_face"."assetId" = $2
|
||||
and "asset_face"."personId" = $3
|
||||
and "asset_face"."personGroupId" = $3
|
||||
|
|
|
|||
|
|
@ -218,15 +218,21 @@ with
|
|||
"cte" as (
|
||||
select
|
||||
"asset_face"."id",
|
||||
"asset_face"."personId",
|
||||
"asset_face"."personGroupId",
|
||||
face_search.embedding <=> $1 as "distance"
|
||||
from
|
||||
"asset_face"
|
||||
inner join "asset" on "asset"."id" = "asset_face"."assetId"
|
||||
inner join "face_search" on "face_search"."faceId" = "asset_face"."id"
|
||||
left join "person" on "person"."id" = "asset_face"."personId"
|
||||
where
|
||||
"asset"."ownerId" = any ($2::uuid[])
|
||||
"asset"."ownerId" in (
|
||||
select
|
||||
"user"."id"
|
||||
from
|
||||
"user"
|
||||
where
|
||||
"user"."clusterGroupId" = $2
|
||||
)
|
||||
and "asset"."deletedAt" is null
|
||||
order by
|
||||
"distance"
|
||||
|
|
@ -239,7 +245,7 @@ from
|
|||
"cte"
|
||||
where
|
||||
"cte"."distance" <= $4
|
||||
commit
|
||||
rollback
|
||||
|
||||
-- SearchRepository.searchPlaces
|
||||
select
|
||||
|
|
@ -849,11 +855,11 @@ where
|
|||
"asset_face"."assetId" = "asset"."id"
|
||||
and "asset_face"."deletedAt" is null
|
||||
and "asset_face"."isVisible" = $2
|
||||
and "asset_face"."personId" = any ($3::uuid[])
|
||||
and "asset_face"."personGroupId" = any ($3::uuid[])
|
||||
group by
|
||||
"asset_face"."assetId"
|
||||
having
|
||||
count(distinct "asset_face"."personId") = $4
|
||||
count(distinct "asset_face"."personGroupId") = $4
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc,
|
||||
|
|
@ -1374,7 +1380,7 @@ where
|
|||
"asset_face"."assetId" = "asset"."id"
|
||||
and "asset_face"."deletedAt" is null
|
||||
and "asset_face"."isVisible" = $3
|
||||
and "asset_face"."personId" = any ($4::uuid[])
|
||||
and "asset_face"."personGroupId" = any ($4::uuid[])
|
||||
)
|
||||
)
|
||||
order by
|
||||
|
|
|
|||
|
|
@ -536,7 +536,7 @@ order by
|
|||
select
|
||||
"asset_face"."id",
|
||||
"assetId",
|
||||
"personId",
|
||||
"personGroupId" as "personId",
|
||||
"imageWidth",
|
||||
"imageHeight",
|
||||
"boundingBoxX1",
|
||||
|
|
@ -1029,7 +1029,7 @@ order by
|
|||
-- SyncRepository.person.getDeletes
|
||||
select
|
||||
"id",
|
||||
"personId"
|
||||
"personGroupId" as "personId"
|
||||
from
|
||||
"person_audit" as "person_audit"
|
||||
where
|
||||
|
|
@ -1041,7 +1041,7 @@ order by
|
|||
|
||||
-- SyncRepository.person.getUpserts
|
||||
select
|
||||
"id",
|
||||
"personGroupId" as "id",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"ownerId",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ select
|
|||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt",
|
||||
"clusterGroupId",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"deletedAt",
|
||||
|
|
@ -47,6 +48,7 @@ select
|
|||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt",
|
||||
"clusterGroupId",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"deletedAt",
|
||||
|
|
@ -126,6 +128,7 @@ select
|
|||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt",
|
||||
"clusterGroupId",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"deletedAt",
|
||||
|
|
@ -165,6 +168,7 @@ select
|
|||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt",
|
||||
"clusterGroupId",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"deletedAt",
|
||||
|
|
@ -190,6 +194,7 @@ select
|
|||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt",
|
||||
"clusterGroupId",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"deletedAt",
|
||||
|
|
@ -237,6 +242,7 @@ select
|
|||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt",
|
||||
"clusterGroupId",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"deletedAt",
|
||||
|
|
@ -275,6 +281,7 @@ select
|
|||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt",
|
||||
"clusterGroupId",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"deletedAt",
|
||||
|
|
|
|||
|
|
@ -420,23 +420,60 @@ class MemoryAccess {
|
|||
}
|
||||
}
|
||||
|
||||
class ClusterGroupAccess {
|
||||
constructor(private db: Kysely<DB>) {}
|
||||
|
||||
/** a pending request is enough to look at the group being joined */
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
|
||||
@ChunkedSet({ paramIndex: 1 })
|
||||
async 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 PersonAccess {
|
||||
constructor(private db: Kysely<DB>) {}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
|
||||
@ChunkedSet({ paramIndex: 1 })
|
||||
async checkOwnerAccess(userId: string, personIds: Set<string>) {
|
||||
if (personIds.size === 0) {
|
||||
async checkOwnerAccess(userId: string, groupIds: Set<string>) {
|
||||
if (groupIds.size === 0) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
return this.db
|
||||
.selectFrom('person')
|
||||
.select('person.id')
|
||||
.where('person.id', 'in', [...personIds])
|
||||
.select('person.personGroupId')
|
||||
.where('person.personGroupId', 'in', [...groupIds])
|
||||
.where('person.ownerId', '=', userId)
|
||||
.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] })
|
||||
|
|
@ -526,6 +563,7 @@ export class AccessRepository {
|
|||
duplicate: DuplicateAccess;
|
||||
memory: MemoryAccess;
|
||||
notification: NotificationAccess;
|
||||
clusterGroup: ClusterGroupAccess;
|
||||
person: PersonAccess;
|
||||
partner: PartnerAccess;
|
||||
session: SessionAccess;
|
||||
|
|
@ -542,6 +580,7 @@ export class AccessRepository {
|
|||
this.duplicate = new DuplicateAccess(db);
|
||||
this.memory = new MemoryAccess(db);
|
||||
this.notification = new NotificationAccess(db);
|
||||
this.clusterGroup = new ClusterGroupAccess(db);
|
||||
this.person = new PersonAccess(db);
|
||||
this.partner = new PartnerAccess(db);
|
||||
this.session = new SessionAccess(db);
|
||||
|
|
|
|||
|
|
@ -151,6 +151,8 @@ export class AssetJobRepository {
|
|||
.select(columns.asset)
|
||||
.select(withFaces)
|
||||
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
|
||||
.innerJoin('user', 'user.id', 'asset.ownerId')
|
||||
.select(['user.clusterGroupId'])
|
||||
.where('asset.id', '=', id)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import {
|
|||
anyUuid,
|
||||
asUuid,
|
||||
hasPeople,
|
||||
inSharedAlbum,
|
||||
removeUndefinedKeys,
|
||||
truncatedDate,
|
||||
unnest,
|
||||
|
|
@ -124,7 +125,7 @@ interface AssetGetByChecksumOptions {
|
|||
|
||||
interface GetByIdsRelations {
|
||||
exifInfo?: boolean;
|
||||
faces?: { person?: boolean; withDeleted?: boolean };
|
||||
faces?: { person?: boolean; withDeleted?: boolean; viewingUserId?: string };
|
||||
files?: boolean;
|
||||
library?: boolean;
|
||||
owner?: boolean;
|
||||
|
|
@ -510,12 +511,12 @@ export class AssetRepository {
|
|||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID]] })
|
||||
@ChunkedArray()
|
||||
getByIdsWithAllRelationsButStacks(ids: string[]) {
|
||||
@ChunkedArray({ paramIndex: 0 })
|
||||
getByIdsWithAllRelationsButStacks(ids: string[], viewingUserId: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.selectAll('asset')
|
||||
.select(withFacesAndPeople)
|
||||
.select(withFacesAndPeople({ viewingUserId }))
|
||||
.select(withTags)
|
||||
.$call(withExif)
|
||||
.where('asset.id', '=', anyUuid(ids))
|
||||
|
|
@ -574,7 +575,11 @@ export class AssetRepository {
|
|||
.selectAll('asset')
|
||||
.where('asset.id', '=', asUuid(id))
|
||||
.$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(!!owner, (qb) => qb.select(withOwner))
|
||||
.$if(!!smartSearch, withSmartSearch)
|
||||
|
|
@ -636,12 +641,12 @@ export class AssetRepository {
|
|||
.selectFrom('asset')
|
||||
.selectAll('asset')
|
||||
.$call(withExif)
|
||||
.$call((qb) => qb.select(withFacesAndPeople))
|
||||
.$call((qb) => qb.select(withFaces))
|
||||
.$call((qb) => qb.select(withEdits))
|
||||
.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> {
|
||||
|
|
@ -740,8 +745,8 @@ export class AssetRepository {
|
|||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{}] })
|
||||
async getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]> {
|
||||
@GenerateSql({ params: [{}, { user: { id: DummyValue.UUID } }] })
|
||||
async getTimeBuckets(options: TimeBucketOptions, auth: AuthDto): Promise<TimeBucketItem[]> {
|
||||
return this.db
|
||||
.with('asset', (qb) =>
|
||||
qb
|
||||
|
|
@ -778,7 +783,12 @@ export class AssetRepository {
|
|||
)
|
||||
.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) => {
|
||||
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.assetType, (qb) => qb.where('asset.type', '=', options.assetType!))
|
||||
.$if(options.isDuplicate !== undefined, (qb) =>
|
||||
|
|
@ -864,7 +874,12 @@ export class AssetRepository {
|
|||
),
|
||||
)
|
||||
.$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.withStacked, (qb) =>
|
||||
qb
|
||||
|
|
|
|||
111
server/src/repositories/cluster-group.repository.ts
Normal file
111
server/src/repositories/cluster-group.repository.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { Insertable, Kysely } 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: [DummyValue.UUID] })
|
||||
async getForUser(userId: string): Promise<string> {
|
||||
const { clusterGroupId } = await this.db
|
||||
.selectFrom('user')
|
||||
.select('user.clusterGroupId')
|
||||
.where('user.id', '=', userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return clusterGroupId;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
/** returns nothing when the request already existed */
|
||||
@GenerateSql({ params: [{ clusterGroupId: DummyValue.UUID, userId: DummyValue.UUID }] })
|
||||
createRequest(request: Insertable<ClusterGroupRequestTable>) {
|
||||
return this.db
|
||||
.insertInto('cluster_group_request')
|
||||
.values(request)
|
||||
.onConflict((oc) => oc.columns(['clusterGroupId', 'userId']).doNothing())
|
||||
.returningAll()
|
||||
.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: [{ clusterGroupId: DummyValue.UUID, userId: DummyValue.UUID }] })
|
||||
getRequestFor({ clusterGroupId, userId }: { clusterGroupId: string; userId: string }) {
|
||||
return this.db
|
||||
.selectFrom('cluster_group_request')
|
||||
.selectAll('cluster_group_request')
|
||||
.where('cluster_group_request.clusterGroupId', '=', clusterGroupId)
|
||||
.where('cluster_group_request.userId', '=', userId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getRequests(userId: string) {
|
||||
return this.db
|
||||
.selectFrom('cluster_group_request')
|
||||
.selectAll('cluster_group_request')
|
||||
.where('cluster_group_request.userId', '=', userId)
|
||||
.orderBy('cluster_group_request.createdAt', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getRequestsForGroup(clusterGroupId: string) {
|
||||
return this.db
|
||||
.selectFrom('cluster_group_request')
|
||||
.selectAll('cluster_group_request')
|
||||
.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)
|
||||
// the current user comes first, everyone else in alphabetical order
|
||||
.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[] }];
|
||||
AlbumInvite: [{ id: string; userId: string; senderName: string }];
|
||||
|
||||
// cluster group events
|
||||
ClusterGroupRequest: [{ clusterGroupId: string; userId: string; senderName: string }];
|
||||
|
||||
// asset events
|
||||
AssetCreate: [{ asset: Pick<Asset, 'id' | 'ownerId'>; file?: UploadFile }];
|
||||
AssetTag: [{ assetId: string; userId: string }];
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { AppRepository } from 'src/repositories/app.repository';
|
|||
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
|
||||
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { CronRepository } from 'src/repositories/cron.repository';
|
||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||
|
|
@ -83,6 +84,7 @@ export const repositories = [
|
|||
NotificationRepository,
|
||||
OAuthRepository,
|
||||
OcrRepository,
|
||||
ClusterGroupRepository,
|
||||
PartnerRepository,
|
||||
PersonRepository,
|
||||
PluginRepository,
|
||||
|
|
|
|||
|
|
@ -329,7 +329,7 @@ describe(MediaRepository.name, () => {
|
|||
const baseFace: AssetFace = {
|
||||
id: 'face-1',
|
||||
assetId: 'asset-1',
|
||||
personId: 'person-1',
|
||||
personGroupId: 'person-1',
|
||||
boundingBoxX1: 100,
|
||||
boundingBoxY1: 100,
|
||||
boundingBoxX2: 200,
|
||||
|
|
|
|||
|
|
@ -73,7 +73,11 @@ export class MemoryRepository implements IBulkAsset {
|
|||
eb.exists(
|
||||
eb
|
||||
.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'))
|
||||
.whereRef('asset_face.assetId', '=', 'asset.id')
|
||||
.where('person.isHidden', '=', true),
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@ import { AssetFileType, AssetVisibility, SourceType, UserMetadataKey } from 'src
|
|||
import { DB } from 'src/schema';
|
||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.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 { dummy, removeUndefinedKeys, withFilePath } from 'src/utils/database';
|
||||
import { asUuid, dummy, inSharedAlbum, removeUndefinedKeys, withFilePath } from 'src/utils/database';
|
||||
import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
|
||||
|
||||
export interface PersonSearchOptions {
|
||||
|
|
@ -22,23 +23,25 @@ export interface PersonNameSearchOptions {
|
|||
}
|
||||
|
||||
export interface PersonNameResponse {
|
||||
id: string;
|
||||
personGroupId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AssetFaceId {
|
||||
assetId: string;
|
||||
personId: string;
|
||||
personGroupId: string;
|
||||
}
|
||||
|
||||
export interface UpdateFacesData {
|
||||
oldPersonId?: string;
|
||||
oldPersonGroupId?: string;
|
||||
faceIds?: string[];
|
||||
newPersonId: string;
|
||||
newPersonGroupId: string;
|
||||
}
|
||||
|
||||
export interface PersonStatistics {
|
||||
assets: number;
|
||||
ownedAssets: number;
|
||||
sharedAssets: number;
|
||||
}
|
||||
|
||||
export interface DeleteFacesOptions {
|
||||
|
|
@ -53,7 +56,7 @@ export interface GetAllPeopleOptions {
|
|||
}
|
||||
|
||||
export interface GetAllFacesOptions {
|
||||
personId?: string | null;
|
||||
personGroupId?: string | null;
|
||||
assetId?: string;
|
||||
sourceType?: SourceType;
|
||||
}
|
||||
|
|
@ -62,10 +65,27 @@ export type UnassignFacesOptions = DeleteFacesOptions;
|
|||
|
||||
export type SelectFaceOptions = (keyof Selectable<AssetFaceTable>)[];
|
||||
|
||||
const withPerson = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
|
||||
return jsonObjectFrom(
|
||||
eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_face.personId'),
|
||||
).as('person');
|
||||
export type GetFacesOptions = WithPersonOptions & { isVisible?: boolean };
|
||||
|
||||
/** a person is identified by its owner and the group it belongs to */
|
||||
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'>) => {
|
||||
|
|
@ -78,12 +98,12 @@ const withFaceSearch = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
|
|||
export class PersonRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
@GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] })
|
||||
async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise<number> {
|
||||
@GenerateSql({ params: [{ oldPersonGroupId: DummyValue.UUID, newPersonGroupId: DummyValue.UUID }] })
|
||||
async reassignFaces({ oldPersonGroupId, faceIds, newPersonGroupId }: UpdateFacesData): Promise<number> {
|
||||
const result = await this.db
|
||||
.updateTable('asset_face')
|
||||
.set({ personId: newPersonId })
|
||||
.$if(!!oldPersonId, (qb) => qb.where('asset_face.personId', '=', oldPersonId!))
|
||||
.set({ personGroupId: newPersonGroupId })
|
||||
.$if(!!oldPersonGroupId, (qb) => qb.where('asset_face.personGroupId', '=', oldPersonGroupId!))
|
||||
.$if(!!faceIds, (qb) => qb.where('asset_face.id', 'in', faceIds!))
|
||||
.executeTakeFirst();
|
||||
|
||||
|
|
@ -93,19 +113,68 @@ export class PersonRepository {
|
|||
async unassignFaces({ sourceType }: UnassignFacesOptions): Promise<void> {
|
||||
await this.db
|
||||
.updateTable('asset_face')
|
||||
.set({ personId: null })
|
||||
.set({ personGroupId: null })
|
||||
.where('asset_face.sourceType', '=', sourceType)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[{ ownerId: DummyValue.UUID, personGroupId: DummyValue.UUID }]] })
|
||||
@Chunked()
|
||||
async delete(people: PersonId[]): Promise<void> {
|
||||
if (people.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.db
|
||||
.deleteFrom('person')
|
||||
.where((eb) =>
|
||||
eb.or(
|
||||
people.map(({ ownerId, personGroupId }) =>
|
||||
eb.and([eb('person.ownerId', '=', ownerId), eb('person.personGroupId', '=', personGroupId)]),
|
||||
),
|
||||
),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID]] })
|
||||
@Chunked()
|
||||
async delete(ids: string[]): Promise<void> {
|
||||
async deleteGroups(ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) {
|
||||
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> {
|
||||
|
|
@ -116,8 +185,8 @@ export class PersonRepository {
|
|||
return this.db
|
||||
.selectFrom('asset_face')
|
||||
.selectAll('asset_face')
|
||||
.$if(options.personId === null, (qb) => qb.where('asset_face.personId', 'is', null))
|
||||
.$if(!!options.personId, (qb) => qb.where('asset_face.personId', '=', options.personId!))
|
||||
.$if(options.personGroupId === null, (qb) => qb.where('asset_face.personGroupId', 'is', null))
|
||||
.$if(!!options.personGroupId, (qb) => qb.where('asset_face.personGroupId', '=', options.personGroupId!))
|
||||
.$if(!!options.sourceType, (qb) => qb.where('asset_face.sourceType', '=', options.sourceType!))
|
||||
.$if(!!options.assetId, (qb) => qb.where('asset_face.assetId', '=', options.assetId!))
|
||||
.where('asset_face.deletedAt', 'is', null)
|
||||
|
|
@ -141,7 +210,7 @@ export class PersonRepository {
|
|||
getFileSamples() {
|
||||
return this.db
|
||||
.selectFrom('person')
|
||||
.select(['id', 'thumbnailPath'])
|
||||
.select(['ownerId', 'personGroupId', 'thumbnailPath'])
|
||||
.where('thumbnailPath', '!=', sql.lit(''))
|
||||
.limit(sql.lit(3))
|
||||
.execute();
|
||||
|
|
@ -152,10 +221,11 @@ export class PersonRepository {
|
|||
const items = await this.db
|
||||
.selectFrom('person')
|
||||
.selectAll('person')
|
||||
.innerJoin('asset_face', 'asset_face.personId', 'person.id')
|
||||
.innerJoin('asset_face', 'asset_face.personGroupId', 'person.personGroupId')
|
||||
.innerJoin('asset', (join) =>
|
||||
join
|
||||
.onRef('asset_face.assetId', '=', 'asset.id')
|
||||
.onRef('asset.ownerId', '=', 'person.ownerId')
|
||||
.on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
|
||||
.on('asset.deletedAt', 'is', null),
|
||||
)
|
||||
|
|
@ -180,7 +250,7 @@ export class PersonRepository {
|
|||
),
|
||||
]),
|
||||
)
|
||||
.groupBy('person.id')
|
||||
.groupBy(['person.ownerId', 'person.personGroupId'])
|
||||
.$if(!!options?.closestFaceAssetId, (qb) =>
|
||||
qb.orderBy((eb) =>
|
||||
eb(
|
||||
|
|
@ -218,22 +288,22 @@ export class PersonRepository {
|
|||
return this.db
|
||||
.selectFrom('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.isVisible', 'is', true)
|
||||
.having((eb) => eb.fn.count('asset_face.assetId'), '=', 0)
|
||||
.groupBy('person.id')
|
||||
.groupBy(['person.ownerId', 'person.personGroupId'])
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getFaces(assetId: string, options?: { isVisible?: boolean }) {
|
||||
const isVisible = options === undefined ? true : options.isVisible;
|
||||
@GenerateSql({ params: [DummyValue.UUID, { viewingUserId: DummyValue.UUID, isVisible: true }] })
|
||||
getFaces(assetId: string, options: GetFacesOptions) {
|
||||
const { viewingUserId, isVisible } = options;
|
||||
|
||||
return this.db
|
||||
.selectFrom('asset_face')
|
||||
.selectAll('asset_face')
|
||||
.select(withPerson)
|
||||
.select(withPerson({ viewingUserId }))
|
||||
.where('asset_face.assetId', '=', assetId)
|
||||
.where('asset_face.deletedAt', 'is', null)
|
||||
.$if(isVisible !== undefined, (qb) => qb.where('asset_face.isVisible', '=', isVisible!))
|
||||
|
|
@ -241,13 +311,13 @@ export class PersonRepository {
|
|||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getFaceById(id: string) {
|
||||
@GenerateSql({ params: [DummyValue.UUID, { viewingUserId: DummyValue.UUID }] })
|
||||
getFaceById(id: string, { viewingUserId }: WithPersonOptions) {
|
||||
// TODO return null instead of find or fail
|
||||
return this.db
|
||||
.selectFrom('asset_face')
|
||||
.selectAll('asset_face')
|
||||
.select(withPerson)
|
||||
.select(withPerson({ viewingUserId }))
|
||||
.where('asset_face.id', '=', id)
|
||||
.where('asset_face.deletedAt', 'is', null)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
|
@ -257,12 +327,13 @@ export class PersonRepository {
|
|||
getFaceForFacialRecognitionJob(id: string) {
|
||||
return this.db
|
||||
.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) =>
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.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'),
|
||||
).as('asset'),
|
||||
)
|
||||
|
|
@ -272,8 +343,8 @@ export class PersonRepository {
|
|||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getDataForThumbnailGenerationJob(id: string) {
|
||||
@GenerateSql({ params: [{ ownerId: DummyValue.UUID, personGroupId: DummyValue.UUID }] })
|
||||
getDataForThumbnailGenerationJob({ ownerId, personGroupId }: PersonId) {
|
||||
return this.db
|
||||
.selectFrom('person')
|
||||
.innerJoin('asset_face', 'asset_face.id', 'person.faceAssetId')
|
||||
|
|
@ -292,27 +363,30 @@ export class PersonRepository {
|
|||
'asset_exif.orientation as exifOrientation',
|
||||
])
|
||||
.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)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@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
|
||||
.updateTable('asset_face')
|
||||
.set({ personId: newPersonId })
|
||||
.set({ personGroupId: newPersonGroupId })
|
||||
.where('asset_face.id', '=', assetFaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return Number(result.numChangedRows ?? 0);
|
||||
}
|
||||
|
||||
getById(personId: string) {
|
||||
@GenerateSql({ params: [{ ownerId: DummyValue.UUID, personGroupId: DummyValue.UUID }] })
|
||||
getByGroupId({ ownerId, personGroupId }: PersonId) {
|
||||
return this.db //
|
||||
.selectFrom('person')
|
||||
.selectAll('person')
|
||||
.where('person.id', '=', personId)
|
||||
.where('person.personGroupId', '=', personGroupId)
|
||||
.where('person.ownerId', '=', ownerId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
|
|
@ -336,31 +410,43 @@ export class PersonRepository {
|
|||
getDistinctNames(userId: string, { withHidden }: PersonNameSearchOptions): Promise<PersonNameResponse[]> {
|
||||
return this.db
|
||||
.selectFrom('person')
|
||||
.select(['person.id', 'person.name'])
|
||||
.select(['person.personGroupId', 'person.name'])
|
||||
.distinctOn((eb) => eb.fn('lower', ['person.name']))
|
||||
.where((eb) => eb.and([eb('person.ownerId', '=', userId), eb('person.name', '!=', '')]))
|
||||
.$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false))
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getStatistics(personId: string): Promise<PersonStatistics> {
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
|
||||
async getStatistics(personGroupId: string, userId: string): Promise<PersonStatistics> {
|
||||
const result = await this.db
|
||||
.selectFrom('asset_face')
|
||||
.leftJoin('asset', (join) =>
|
||||
join
|
||||
.onRef('asset.id', '=', 'asset_face.assetId')
|
||||
.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'),
|
||||
eb.fn
|
||||
.count(eb.fn('distinct', ['asset.id']))
|
||||
.filterWhere('asset.ownerId', '=', asUuid(userId))
|
||||
.as('ownedCount'),
|
||||
])
|
||||
.where('asset_face.deletedAt', 'is', null)
|
||||
.where('asset_face.isVisible', 'is', true)
|
||||
.where('asset_face.personId', '=', personId)
|
||||
.where('asset_face.personGroupId', '=', personGroupId)
|
||||
.executeTakeFirst();
|
||||
|
||||
const assets = result ? Number(result.count) : 0;
|
||||
const ownedAssets = result ? Number(result.ownedCount) : 0;
|
||||
|
||||
return {
|
||||
assets: result ? Number(result.count) : 0,
|
||||
assets,
|
||||
ownedAssets,
|
||||
sharedAssets: assets - ownedAssets,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -373,7 +459,7 @@ export class PersonRepository {
|
|||
eb.exists((eb) =>
|
||||
eb
|
||||
.selectFrom('asset_face')
|
||||
.whereRef('asset_face.personId', '=', 'person.id')
|
||||
.whereRef('asset_face.personGroupId', '=', 'person.personGroupId')
|
||||
.where('asset_face.deletedAt', 'is', null)
|
||||
.where('asset_face.isVisible', '=', true)
|
||||
.where((eb) =>
|
||||
|
|
@ -397,13 +483,124 @@ export class PersonRepository {
|
|||
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) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results = await this.db.insertInto('person').values(people).returningAll().execute();
|
||||
return results.map(({ id }) => id);
|
||||
return this.db.insertInto('person').values(people).returningAll().execute();
|
||||
}
|
||||
|
||||
@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 }]] })
|
||||
|
|
@ -430,11 +627,12 @@ export class PersonRepository {
|
|||
await query.selectFrom(dummy).execute();
|
||||
}
|
||||
|
||||
async update(person: Updateable<PersonTable> & { id: string }) {
|
||||
async update(person: Updateable<PersonTable> & PersonId) {
|
||||
return this.db
|
||||
.updateTable('person')
|
||||
.set(person)
|
||||
.where('person.id', '=', person.id)
|
||||
.where('person.ownerId', '=', person.ownerId)
|
||||
.where('person.personGroupId', '=', person.personGroupId)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
|
@ -448,7 +646,7 @@ export class PersonRepository {
|
|||
.insertInto('person')
|
||||
.values(people)
|
||||
.onConflict((oc) =>
|
||||
oc.column('id').doUpdateSet((eb) =>
|
||||
oc.columns(['ownerId', 'personGroupId']).doUpdateSet((eb) =>
|
||||
removeUndefinedKeys(
|
||||
{
|
||||
name: eb.ref('excluded.name'),
|
||||
|
|
@ -466,36 +664,38 @@ export class PersonRepository {
|
|||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] })
|
||||
@GenerateSql({
|
||||
params: [[{ assetId: DummyValue.UUID, personGroupId: DummyValue.UUID }], { viewingUserId: DummyValue.UUID }],
|
||||
})
|
||||
@ChunkedArray()
|
||||
getFacesByIds(ids: AssetFaceId[]) {
|
||||
getFacesByIds(ids: AssetFaceId[], { viewingUserId }: WithPersonOptions) {
|
||||
if (ids.length === 0) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
const assetIds: string[] = [];
|
||||
const personIds: string[] = [];
|
||||
for (const { assetId, personId } of ids) {
|
||||
const personGroupIds: string[] = [];
|
||||
for (const { assetId, personGroupId } of ids) {
|
||||
assetIds.push(assetId);
|
||||
personIds.push(personId);
|
||||
personGroupIds.push(personGroupId);
|
||||
}
|
||||
|
||||
return this.db
|
||||
.selectFrom('asset_face')
|
||||
.selectAll('asset_face')
|
||||
.select(withPerson)
|
||||
.select(withPerson({ viewingUserId }))
|
||||
.where('asset_face.assetId', 'in', assetIds)
|
||||
.where('asset_face.personId', 'in', personIds)
|
||||
.where('asset_face.personGroupId', 'in', personGroupIds)
|
||||
.where('asset_face.deletedAt', 'is', null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getRandomFace(personId: string) {
|
||||
getRandomFace(personGroupId: string) {
|
||||
return this.db
|
||||
.selectFrom('asset_face')
|
||||
.selectAll('asset_face')
|
||||
.where('asset_face.personId', '=', personId)
|
||||
.where('asset_face.personGroupId', '=', personGroupId)
|
||||
.where('asset_face.deletedAt', 'is', null)
|
||||
.where('asset_face.isVisible', 'is', true)
|
||||
.executeTakeFirst();
|
||||
|
|
@ -536,11 +736,15 @@ export class PersonRepository {
|
|||
|
||||
@GenerateSql({ params: [[DummyValue.UUID]] })
|
||||
@Chunked()
|
||||
getForPeopleDelete(ids: string[]) {
|
||||
if (ids.length === 0) {
|
||||
getForPeopleDelete(groupIds: string[]) {
|
||||
if (groupIds.length === 0) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return this.db.selectFrom('person').select(['id', 'thumbnailPath']).where('id', 'in', ids).execute();
|
||||
return this.db
|
||||
.selectFrom('person')
|
||||
.select(['person.ownerId', 'person.personGroupId', 'person.thumbnailPath'])
|
||||
.where('person.personGroupId', 'in', groupIds)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[], []] })
|
||||
|
|
@ -576,13 +780,13 @@ export class PersonRepository {
|
|||
});
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ personId: DummyValue.UUID, assetId: DummyValue.UUID }] })
|
||||
getForFeatureFaceUpdate({ personId, assetId }: { personId: string; assetId: string }) {
|
||||
@GenerateSql({ params: [{ personGroupId: DummyValue.UUID, assetId: DummyValue.UUID }] })
|
||||
getForFeatureFaceUpdate({ personGroupId, assetId }: { personGroupId: string; assetId: string }) {
|
||||
return this.db
|
||||
.selectFrom('asset_face')
|
||||
.select('asset_face.id')
|
||||
.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))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ export interface SearchOneToOneRelationOptions {
|
|||
export interface SearchRelationOptions extends SearchOneToOneRelationOptions {
|
||||
withFaces?: boolean;
|
||||
withPeople?: boolean;
|
||||
/** whose version of the people to select, required when selecting faces or people */
|
||||
viewingUserId?: string;
|
||||
}
|
||||
|
||||
export interface SearchDateOptions {
|
||||
|
|
@ -137,6 +139,8 @@ export interface AssetSearchBuilderV3Options {
|
|||
filter?: SearchFilter;
|
||||
/** Server-derived ownership scope. Never client-controlled. */
|
||||
userIds?: string[];
|
||||
/** whose version of the people to select, required when selecting faces or people */
|
||||
viewingUserId?: string;
|
||||
withExif?: boolean;
|
||||
withFaces?: boolean;
|
||||
withPeople?: boolean;
|
||||
|
|
@ -156,13 +160,14 @@ export type SmartSearchOptions = SearchDateOptions &
|
|||
SearchUserIdOptions &
|
||||
SearchPeopleOptions &
|
||||
SearchTagOptions &
|
||||
SearchOcrOptions & { visibility?: AssetVisibility | 'not-locked' };
|
||||
SearchOcrOptions & { visibility?: AssetVisibility | 'not-locked'; viewingUserId?: string };
|
||||
|
||||
export type OcrSearchOptions = SearchDateOptions & SearchOcrOptions;
|
||||
|
||||
export type LargeAssetSearchOptions = AssetSearchOptions & { minFileSize?: number };
|
||||
|
||||
export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
|
||||
export interface FaceEmbeddingSearch extends Omit<SearchEmbeddingOptions, 'userIds'> {
|
||||
clusterGroupId: string;
|
||||
hasPerson?: boolean;
|
||||
numResults: number;
|
||||
maxDistance: number;
|
||||
|
|
@ -172,7 +177,7 @@ export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
|
|||
export interface FaceSearchResult {
|
||||
distance: number;
|
||||
id: string;
|
||||
personId: string | null;
|
||||
personGroupId: string | null;
|
||||
}
|
||||
|
||||
export interface AssetDuplicateResult {
|
||||
|
|
@ -341,7 +346,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) {
|
||||
throw new Error(`Invalid value for 'numResults': ${numResults}`);
|
||||
}
|
||||
|
|
@ -352,20 +357,29 @@ export class SearchRepository {
|
|||
.with('cte', (qb) =>
|
||||
qb
|
||||
.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('face_search', 'face_search.faceId', 'asset_face.id')
|
||||
.leftJoin('person', 'person.id', 'asset_face.personId')
|
||||
.where('asset.ownerId', '=', anyUuid(userIds))
|
||||
.select([
|
||||
'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)
|
||||
.$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) =>
|
||||
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')
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ export class SyncRepository {
|
|||
partnerAssetExif: PartnerAssetExifsSync;
|
||||
partnerStack: PartnerStackSync;
|
||||
person: PersonSync;
|
||||
personGroup: PersonGroupSync;
|
||||
stack: StackSync;
|
||||
user: UserSync;
|
||||
userMetadata: UserMetadataSync;
|
||||
|
|
@ -89,6 +90,7 @@ export class SyncRepository {
|
|||
this.partnerAssetExif = new PartnerAssetExifsSync(this.db);
|
||||
this.partnerStack = new PartnerStackSync(this.db);
|
||||
this.person = new PersonSync(this.db);
|
||||
this.personGroup = new PersonGroupSync(this.db);
|
||||
this.stack = new StackSync(this.db);
|
||||
this.user = new UserSync(this.db);
|
||||
this.userMetadata = new UserMetadataSync(this.db);
|
||||
|
|
@ -422,7 +424,7 @@ class PersonSync extends BaseSync {
|
|||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getDeletes(options: SyncQueryOptions) {
|
||||
return this.auditQuery('person_audit', options)
|
||||
.select(['id', 'personId'])
|
||||
.select(['id', 'personGroupId as personId'])
|
||||
.where('ownerId', '=', options.userId)
|
||||
.stream();
|
||||
}
|
||||
|
|
@ -435,7 +437,7 @@ class PersonSync extends BaseSync {
|
|||
getUpserts(options: SyncQueryOptions) {
|
||||
return this.upsertQuery('person', options)
|
||||
.select([
|
||||
'id',
|
||||
'personGroupId as id',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'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 {
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getDeletes(options: SyncQueryOptions) {
|
||||
|
|
@ -472,7 +480,7 @@ class AssetFaceSync extends BaseSync {
|
|||
.select([
|
||||
'asset_face.id',
|
||||
'assetId',
|
||||
'personId',
|
||||
'personGroupId as personId',
|
||||
'imageWidth',
|
||||
'imageHeight',
|
||||
'boundingBoxX1',
|
||||
|
|
|
|||
|
|
@ -212,8 +212,21 @@ export const person_delete_audit = registerFunction({
|
|||
language: 'PLPGSQL',
|
||||
body: `
|
||||
BEGIN
|
||||
INSERT INTO person_audit ("personId", "ownerId")
|
||||
SELECT "id", "ownerId"
|
||||
INSERT INTO person_audit ("personGroupId", "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;
|
||||
RETURN NULL;
|
||||
END`,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
memory_delete_audit,
|
||||
partner_delete_audit,
|
||||
person_delete_audit,
|
||||
person_group_delete_audit,
|
||||
stack_delete_audit,
|
||||
updated_at,
|
||||
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 { AssetOcrTable } from 'src/schema/tables/asset-ocr.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 { GeodataPlacesTable } from 'src/schema/tables/geodata-places.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 { PartnerTable } from 'src/schema/tables/partner.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 { PluginMethodTable } from 'src/schema/tables/plugin-method.table';
|
||||
import { PluginTable } from 'src/schema/tables/plugin.table';
|
||||
|
|
@ -116,6 +121,8 @@ export class ImmichDatabase {
|
|||
AssetTable,
|
||||
AssetFileTable,
|
||||
AssetExifTable,
|
||||
ClusterGroupTable,
|
||||
ClusterGroupRequestTable,
|
||||
FaceSearchTable,
|
||||
GeodataPlacesTable,
|
||||
IntegrityReportTable,
|
||||
|
|
@ -132,6 +139,8 @@ export class ImmichDatabase {
|
|||
PartnerTable,
|
||||
PersonTable,
|
||||
PersonAuditTable,
|
||||
PersonGroupTable,
|
||||
PersonGroupAuditTable,
|
||||
SessionTable,
|
||||
SharedLinkAssetTable,
|
||||
SharedLinkTable,
|
||||
|
|
@ -172,6 +181,7 @@ export class ImmichDatabase {
|
|||
memory_asset_delete_audit,
|
||||
stack_delete_audit,
|
||||
person_delete_audit,
|
||||
person_group_delete_audit,
|
||||
user_metadata_audit,
|
||||
asset_metadata_audit,
|
||||
asset_face_audit,
|
||||
|
|
@ -246,6 +256,11 @@ export interface DB {
|
|||
|
||||
person: PersonTable;
|
||||
person_audit: PersonAuditTable;
|
||||
person_group: PersonGroupTable;
|
||||
person_group_audit: PersonGroupAuditTable;
|
||||
|
||||
cluster_group: ClusterGroupTable;
|
||||
cluster_group_request: ClusterGroupRequestTable;
|
||||
|
||||
session: SessionTable;
|
||||
session_sync_checkpoint: SessionSyncCheckpointTable;
|
||||
|
|
|
|||
232
server/src/schema/migrations/1786551257709-ClusterGroups.ts
Normal file
232
server/src/schema/migrations/1786551257709-ClusterGroups.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`CREATE OR REPLACE FUNCTION person_delete_audit()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE PLPGSQL
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO person_audit ("personGroupId", "ownerId")
|
||||
SELECT "personGroupId", "ownerId"
|
||||
FROM OLD;
|
||||
RETURN NULL;
|
||||
END
|
||||
$$;`.execute(db);
|
||||
await sql`CREATE OR REPLACE TRIGGER "person_delete_audit"
|
||||
AFTER DELETE ON "person"
|
||||
REFERENCING OLD TABLE AS "old"
|
||||
FOR EACH STATEMENT
|
||||
WHEN (pg_trigger_depth() <= 1)
|
||||
EXECUTE FUNCTION person_delete_audit();`.execute(db);
|
||||
await sql`CREATE OR REPLACE FUNCTION person_group_delete_audit()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE PLPGSQL
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO person_group_audit ("personGroupId", "clusterGroupId")
|
||||
SELECT "id", "clusterGroupId"
|
||||
FROM OLD;
|
||||
RETURN NULL;
|
||||
END
|
||||
$$;`.execute(db);
|
||||
await sql`CREATE TABLE "cluster_group" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"name" character varying,
|
||||
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"updateId" uuid NOT NULL DEFAULT immich_uuid_v7(),
|
||||
CONSTRAINT "cluster_group_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "cluster_group_updateId_idx" ON "cluster_group" ("updateId");`.execute(db);
|
||||
await sql`CREATE OR REPLACE TRIGGER "cluster_group_updatedAt"
|
||||
BEFORE UPDATE ON "cluster_group"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION updated_at();`.execute(db);
|
||||
|
||||
await sql`ALTER TABLE "user" ADD "clusterGroupId" uuid;`.execute(db);
|
||||
await sql`UPDATE "user" SET "clusterGroupId" = uuid_generate_v4();`.execute(db);
|
||||
await sql`INSERT INTO "cluster_group" ("id") SELECT "clusterGroupId" FROM "user";`.execute(db);
|
||||
await sql`ALTER TABLE "user" ALTER COLUMN "clusterGroupId" SET NOT NULL;`.execute(db);
|
||||
await sql`CREATE INDEX "user_clusterGroupId_idx" ON "user" ("clusterGroupId");`.execute(db);
|
||||
await sql`ALTER TABLE "user" ADD CONSTRAINT "user_clusterGroupId_fkey" FOREIGN KEY ("clusterGroupId") REFERENCES "cluster_group" ("id") ON UPDATE CASCADE ON DELETE NO ACTION;`.execute(db);
|
||||
|
||||
await sql`CREATE TABLE "cluster_group_request" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"clusterGroupId" uuid NOT NULL,
|
||||
"userId" uuid NOT NULL,
|
||||
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "cluster_group_request_clusterGroupId_fkey" FOREIGN KEY ("clusterGroupId") REFERENCES "cluster_group" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "cluster_group_request_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "cluster_group_request_clusterGroupId_userId_uq" UNIQUE ("clusterGroupId", "userId"),
|
||||
CONSTRAINT "cluster_group_request_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "cluster_group_request_clusterGroupId_idx" ON "cluster_group_request" ("clusterGroupId");`.execute(
|
||||
db,
|
||||
);
|
||||
await sql`CREATE INDEX "cluster_group_request_userId_idx" ON "cluster_group_request" ("userId");`.execute(db);
|
||||
|
||||
await sql`CREATE TABLE "person_group" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"clusterGroupId" uuid NOT NULL,
|
||||
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"createId" uuid NOT NULL DEFAULT immich_uuid_v7(),
|
||||
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"updateId" uuid NOT NULL DEFAULT immich_uuid_v7(),
|
||||
CONSTRAINT "person_group_clusterGroupId_fkey" FOREIGN KEY ("clusterGroupId") REFERENCES "cluster_group" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "person_group_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "person_group_clusterGroupId_idx" ON "person_group" ("clusterGroupId");`.execute(db);
|
||||
await sql`CREATE INDEX "person_group_createId_idx" ON "person_group" ("createId");`.execute(db);
|
||||
await sql`CREATE INDEX "person_group_updateId_idx" ON "person_group" ("updateId");`.execute(db);
|
||||
await sql`CREATE OR REPLACE TRIGGER "person_group_delete_audit"
|
||||
AFTER DELETE ON "person_group"
|
||||
REFERENCING OLD TABLE AS "old"
|
||||
FOR EACH STATEMENT
|
||||
WHEN (pg_trigger_depth() = 0)
|
||||
EXECUTE FUNCTION person_group_delete_audit();`.execute(db);
|
||||
await sql`CREATE OR REPLACE TRIGGER "person_group_updatedAt"
|
||||
BEFORE UPDATE ON "person_group"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION updated_at();`.execute(db);
|
||||
await sql`CREATE TABLE "person_group_audit" (
|
||||
"id" uuid NOT NULL DEFAULT immich_uuid_v7(),
|
||||
"personGroupId" uuid NOT NULL,
|
||||
"clusterGroupId" uuid NOT NULL,
|
||||
"deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(),
|
||||
CONSTRAINT "person_group_audit_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "person_group_audit_personGroupId_idx" ON "person_group_audit" ("personGroupId");`.execute(db);
|
||||
await sql`CREATE INDEX "person_group_audit_clusterGroupId_idx" ON "person_group_audit" ("clusterGroupId");`.execute(db);
|
||||
await sql`CREATE INDEX "person_group_audit_deletedAt_idx" ON "person_group_audit" ("deletedAt");`.execute(db);
|
||||
|
||||
await sql`ALTER TABLE "person" ADD "personGroupId" uuid;`.execute(db);
|
||||
await sql`INSERT INTO "person_group" ("id", "clusterGroupId", "createdAt")
|
||||
SELECT "person"."id", "user"."clusterGroupId", "person"."createdAt"
|
||||
FROM "person"
|
||||
INNER JOIN "user" ON "user"."id" = "person"."ownerId";`.execute(db);
|
||||
await sql`UPDATE "person" SET "personGroupId" = "id";`.execute(db);
|
||||
await sql`ALTER TABLE "person" ALTER COLUMN "personGroupId" SET NOT NULL;`.execute(db);
|
||||
await sql`CREATE INDEX "person_personGroupId_idx" ON "person" ("personGroupId");`.execute(db);
|
||||
await sql`ALTER TABLE "person" ADD CONSTRAINT "person_personGroupId_fkey" FOREIGN KEY ("personGroupId") REFERENCES "person_group" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(
|
||||
db,
|
||||
);
|
||||
|
||||
await sql`ALTER TABLE "person_audit" ADD "personGroupId" uuid;`.execute(db);
|
||||
await sql`UPDATE "person_audit" SET "personGroupId" = "personId";`.execute(db);
|
||||
await sql`ALTER TABLE "person_audit" ALTER COLUMN "personGroupId" SET NOT NULL;`.execute(db);
|
||||
await sql`ALTER TABLE "person_audit" DROP COLUMN "personId";`.execute(db);
|
||||
await sql`CREATE INDEX "person_audit_personGroupId_idx" ON "person_audit" ("personGroupId");`.execute(db);
|
||||
|
||||
await sql`ALTER TABLE "asset_face" DROP CONSTRAINT "asset_face_personId_fkey";`.execute(db);
|
||||
await sql`ALTER TABLE "asset_face" RENAME COLUMN "personId" TO "personGroupId";`.execute(db);
|
||||
await sql`UPDATE "asset_face" SET "personGroupId" = "person"."personGroupId"
|
||||
FROM "person"
|
||||
WHERE "person"."id" = "asset_face"."personGroupId";`.execute(db);
|
||||
await sql`ALTER TABLE "asset_face" ADD CONSTRAINT "asset_face_personGroupId_fkey" FOREIGN KEY ("personGroupId") REFERENCES "person_group" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute(
|
||||
db,
|
||||
);
|
||||
await sql`CREATE INDEX "asset_face_personGroupId_assetId_idx" ON "asset_face" ("personGroupId", "assetId");`.execute(
|
||||
db,
|
||||
);
|
||||
await sql`CREATE INDEX "asset_face_personGroupId_assetId_notDeleted_isVisible_idx" ON "asset_face" ("personGroupId", "assetId") WHERE ("deletedAt" IS NULL AND "isVisible" IS TRUE);`.execute(
|
||||
db,
|
||||
);
|
||||
await sql`CREATE INDEX "asset_face_assetId_personGroupId_idx" ON "asset_face" ("assetId", "personGroupId");`.execute(
|
||||
db,
|
||||
);
|
||||
await sql`DROP INDEX "asset_face_assetId_personId_idx";`.execute(db);
|
||||
await sql`DROP INDEX "asset_face_personId_assetId_idx";`.execute(db);
|
||||
await sql`DROP INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx";`.execute(db);
|
||||
|
||||
// a person is identified by its owner and the group it belongs to
|
||||
await sql`ALTER TABLE "person" DROP CONSTRAINT "person_pkey";`.execute(db);
|
||||
await sql`ALTER TABLE "person" DROP COLUMN "id";`.execute(db);
|
||||
await sql`ALTER TABLE "person" ADD CONSTRAINT "person_pkey" PRIMARY KEY ("ownerId", "personGroupId");`.execute(db);
|
||||
await sql`DROP INDEX "person_ownerId_idx";`.execute(db);
|
||||
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"person_delete_audit","sql":"CREATE OR REPLACE FUNCTION person_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person_audit (\\"personGroupId\\", \\"ownerId\\")\\n SELECT \\"personGroupId\\", \\"ownerId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_person_delete_audit';`.execute(db);
|
||||
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"trigger","name":"person_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"person_delete_audit\\"\\n AFTER DELETE ON \\"person\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() <= 1)\\n EXECUTE FUNCTION person_delete_audit();"}'::jsonb WHERE "name" = 'trigger_person_delete_audit';`.execute(db);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_person_group_delete_audit', '{"type":"function","name":"person_group_delete_audit","sql":"CREATE OR REPLACE FUNCTION person_group_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person_group_audit (\\"personGroupId\\", \\"clusterGroupId\\")\\n SELECT \\"id\\", \\"clusterGroupId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_cluster_group_updatedAt', '{"type":"trigger","name":"cluster_group_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"cluster_group_updatedAt\\"\\n BEFORE UPDATE ON \\"cluster_group\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_person_group_delete_audit', '{"type":"trigger","name":"person_group_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"person_group_delete_audit\\"\\n AFTER DELETE ON \\"person_group\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION person_group_delete_audit();"}'::jsonb);`.execute(db);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_person_group_updatedAt', '{"type":"trigger","name":"person_group_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"person_group_updatedAt\\"\\n BEFORE UPDATE ON \\"person_group\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_face_personGroupId_assetId_notDeleted_isVisible_idx', '{"type":"index","name":"asset_face_personGroupId_assetId_notDeleted_isVisible_idx","sql":"CREATE INDEX \\"asset_face_personGroupId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"personGroupId\\", \\"assetId\\") WHERE (\\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE);"}'::jsonb);`.execute(db);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_face_personId_assetId_notDeleted_isVisible_idx';`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`CREATE OR REPLACE FUNCTION person_delete_audit()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE PLPGSQL
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO person_audit ("personId", "ownerId")
|
||||
SELECT "id", "ownerId"
|
||||
FROM OLD;
|
||||
RETURN NULL;
|
||||
END
|
||||
$$;`.execute(db);
|
||||
await sql`CREATE OR REPLACE TRIGGER "person_delete_audit"
|
||||
AFTER DELETE ON "person"
|
||||
REFERENCING OLD TABLE AS "old"
|
||||
FOR EACH STATEMENT
|
||||
WHEN (pg_trigger_depth() = 0)
|
||||
EXECUTE FUNCTION person_delete_audit();`.execute(db);
|
||||
|
||||
await sql`ALTER TABLE "person" DROP CONSTRAINT "person_pkey";`.execute(db);
|
||||
await sql`ALTER TABLE "person" ADD "id" uuid NOT NULL DEFAULT uuid_generate_v4();`.execute(db);
|
||||
await sql`UPDATE "person" SET "id" = "personGroupId";`.execute(db);
|
||||
await sql`ALTER TABLE "person" ADD CONSTRAINT "person_pkey" PRIMARY KEY ("id");`.execute(db);
|
||||
await sql`CREATE INDEX "person_ownerId_idx" ON "person" ("ownerId");`.execute(db);
|
||||
|
||||
await sql`DROP INDEX "asset_face_assetId_personGroupId_idx";`.execute(db);
|
||||
await sql`DROP INDEX "asset_face_personGroupId_assetId_notDeleted_isVisible_idx";`.execute(db);
|
||||
await sql`DROP INDEX "asset_face_personGroupId_assetId_idx";`.execute(db);
|
||||
await sql`ALTER TABLE "asset_face" DROP CONSTRAINT "asset_face_personGroupId_fkey";`.execute(db);
|
||||
await sql`ALTER TABLE "asset_face" RENAME COLUMN "personGroupId" TO "personId";`.execute(db);
|
||||
await sql`UPDATE "asset_face" SET "personId" = "person"."id"
|
||||
FROM "person"
|
||||
WHERE "person"."personGroupId" = "asset_face"."personId";`.execute(db);
|
||||
await sql`ALTER TABLE "asset_face" ADD CONSTRAINT "asset_face_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute(
|
||||
db,
|
||||
);
|
||||
await sql`CREATE INDEX "asset_face_assetId_personId_idx" ON "asset_face" ("assetId", "personId");`.execute(db);
|
||||
await sql`CREATE INDEX "asset_face_personId_assetId_idx" ON "asset_face" ("personId", "assetId");`.execute(db);
|
||||
await sql`CREATE INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx" ON "asset_face" ("personId", "assetId") WHERE ("deletedAt" IS NULL AND "isVisible" IS TRUE);`.execute(
|
||||
db,
|
||||
);
|
||||
|
||||
await sql`ALTER TABLE "person_audit" ADD "personId" uuid;`.execute(db);
|
||||
await sql`UPDATE "person_audit" SET "personId" = "personGroupId";`.execute(db);
|
||||
await sql`ALTER TABLE "person_audit" ALTER COLUMN "personId" SET NOT NULL;`.execute(db);
|
||||
await sql`CREATE INDEX "person_audit_personId_idx" ON "person_audit" ("personId");`.execute(db);
|
||||
await sql`DROP INDEX "person_audit_personGroupId_idx";`.execute(db);
|
||||
await sql`ALTER TABLE "person_audit" DROP COLUMN "personGroupId";`.execute(db);
|
||||
await sql`ALTER TABLE "person" DROP CONSTRAINT "person_personGroupId_fkey";`.execute(db);
|
||||
await sql`DROP INDEX "person_personGroupId_idx";`.execute(db);
|
||||
await sql`ALTER TABLE "person" DROP COLUMN "personGroupId";`.execute(db);
|
||||
await sql`ALTER TABLE "user" DROP CONSTRAINT "user_clusterGroupId_fkey";`.execute(db);
|
||||
await sql`DROP INDEX "user_clusterGroupId_idx";`.execute(db);
|
||||
await sql`ALTER TABLE "user" DROP COLUMN "clusterGroupId";`.execute(db);
|
||||
await sql`DROP TABLE "cluster_group_request";`.execute(db);
|
||||
await sql`DROP TABLE "person_group_audit";`.execute(db);
|
||||
await sql`DROP TABLE "person_group";`.execute(db);
|
||||
await sql`DROP TABLE "cluster_group";`.execute(db);
|
||||
await sql`DROP FUNCTION person_group_delete_audit;`.execute(db);
|
||||
|
||||
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"person_delete_audit","sql":"CREATE OR REPLACE FUNCTION person_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person_audit (\\"personId\\", \\"ownerId\\")\\n SELECT \\"id\\", \\"ownerId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_person_delete_audit';`.execute(
|
||||
db,
|
||||
);
|
||||
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"trigger","name":"person_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"person_delete_audit\\"\\n AFTER DELETE ON \\"person\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION person_delete_audit();"}'::jsonb WHERE "name" = 'trigger_person_delete_audit';`.execute(
|
||||
db,
|
||||
);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_person_group_delete_audit';`.execute(db);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_cluster_group_updatedAt';`.execute(db);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_person_group_delete_audit';`.execute(db);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_person_group_updatedAt';`.execute(db);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_face_personGroupId_assetId_notDeleted_isVisible_idx';`.execute(
|
||||
db,
|
||||
);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_face_personId_assetId_notDeleted_isVisible_idx', '{"type":"index","name":"asset_face_personId_assetId_notDeleted_isVisible_idx","sql":"CREATE INDEX \\"asset_face_personId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"personId\\", \\"assetId\\") WHERE (\\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE);"}'::jsonb);`.execute(
|
||||
db,
|
||||
);
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ import { SourceType } from 'src/enum';
|
|||
import { asset_face_source_type } from 'src/schema/enums';
|
||||
import { asset_face_audit } from 'src/schema/functions';
|
||||
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' })
|
||||
@UpdatedAtTrigger('asset_face_updatedAt')
|
||||
|
|
@ -26,13 +26,13 @@ import { PersonTable } from 'src/schema/tables/person.table';
|
|||
when: 'pg_trigger_depth() = 0',
|
||||
})
|
||||
// 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({
|
||||
name: 'asset_face_personId_assetId_notDeleted_isVisible_idx',
|
||||
columns: ['personId', 'assetId'],
|
||||
name: 'asset_face_personGroupId_assetId_notDeleted_isVisible_idx',
|
||||
columns: ['personGroupId', 'assetId'],
|
||||
where: '"deletedAt" IS NULL AND "isVisible" IS TRUE',
|
||||
})
|
||||
@Index({ columns: ['personId', 'assetId'] })
|
||||
@Index({ columns: ['personGroupId', 'assetId'] })
|
||||
export class AssetFaceTable {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: Generated<string>;
|
||||
|
|
@ -40,19 +40,19 @@ export class AssetFaceTable {
|
|||
@ForeignKeyColumn(() => AssetTable, {
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
// [assetId, personId] is the PK constraint
|
||||
// [assetId, personGroupId] is the PK constraint
|
||||
index: false,
|
||||
})
|
||||
assetId!: string;
|
||||
|
||||
@ForeignKeyColumn(() => PersonTable, {
|
||||
@ForeignKeyColumn(() => PersonGroupTable, {
|
||||
onDelete: 'SET NULL',
|
||||
onUpdate: 'CASCADE',
|
||||
nullable: true,
|
||||
// [personId, assetId] makes this redundant
|
||||
// [personGroupId, assetId] makes this redundant
|
||||
index: false,
|
||||
})
|
||||
personId!: string | null;
|
||||
personGroupId!: string | null;
|
||||
|
||||
@Column({ default: 0, type: 'integer' })
|
||||
imageWidth!: Generated<number>;
|
||||
|
|
|
|||
27
server/src/schema/tables/cluster-group-request.table.ts
Normal file
27
server/src/schema/tables/cluster-group-request.table.ts
Normal file
|
|
@ -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>;
|
||||
}
|
||||
29
server/src/schema/tables/cluster-group.table.ts
Normal file
29
server/src/schema/tables/cluster-group.table.ts
Normal file
|
|
@ -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>;
|
||||
|
||||
@Column({ type: 'uuid', index: true })
|
||||
personId!: string;
|
||||
personGroupId!: string;
|
||||
|
||||
@Column({ type: 'uuid', index: true })
|
||||
ownerId!: string;
|
||||
|
|
|
|||
17
server/src/schema/tables/person-group-audit.table.ts
Normal file
17
server/src/schema/tables/person-group-audit.table.ts
Normal file
|
|
@ -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>;
|
||||
}
|
||||
41
server/src/schema/tables/person-group.table.ts
Normal file
41
server/src/schema/tables/person-group.table.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import {
|
||||
AfterDeleteTrigger,
|
||||
CreateDateColumn,
|
||||
ForeignKeyColumn,
|
||||
Generated,
|
||||
PrimaryGeneratedColumn,
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from '@immich/sql-tools';
|
||||
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { person_group_delete_audit } from 'src/schema/functions';
|
||||
import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table';
|
||||
|
||||
@Table('person_group')
|
||||
@UpdatedAtTrigger('person_group_updatedAt')
|
||||
@AfterDeleteTrigger({
|
||||
scope: 'statement',
|
||||
function: person_group_delete_audit,
|
||||
referencingOldTableAs: 'old',
|
||||
when: 'pg_trigger_depth() = 0',
|
||||
})
|
||||
export class PersonGroupTable {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: Generated<string>;
|
||||
|
||||
@ForeignKeyColumn(() => ClusterGroupTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
|
||||
clusterGroupId!: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Generated<Timestamp>;
|
||||
|
||||
@CreateIdColumn({ index: true })
|
||||
createId!: Generated<string>;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Generated<Timestamp>;
|
||||
|
||||
@UpdateIdColumn({ index: true })
|
||||
updateId!: Generated<string>;
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@ import {
|
|||
ForeignKeyColumn,
|
||||
Generated,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
|
|
@ -14,6 +13,7 @@ import {
|
|||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { person_delete_audit } from 'src/schema/functions';
|
||||
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';
|
||||
|
||||
@Table('person')
|
||||
|
|
@ -27,12 +27,25 @@ import { UserTable } from 'src/schema/tables/user.table';
|
|||
scope: 'statement',
|
||||
function: person_delete_audit,
|
||||
referencingOldTableAs: 'old',
|
||||
when: 'pg_trigger_depth() = 0',
|
||||
when: 'pg_trigger_depth() <= 1',
|
||||
})
|
||||
@Check({ name: 'person_birthDate_chk', expression: `"birthDate" <= CURRENT_DATE` })
|
||||
export class PersonTable {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: Generated<string>;
|
||||
@ForeignKeyColumn(() => UserTable, {
|
||||
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()
|
||||
createdAt!: Generated<Timestamp>;
|
||||
|
|
@ -40,9 +53,6 @@ export class PersonTable {
|
|||
@UpdateDateColumn()
|
||||
updatedAt!: Generated<Timestamp>;
|
||||
|
||||
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
|
||||
ownerId!: string;
|
||||
|
||||
@Column({ default: '' })
|
||||
name!: Generated<string>;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
ForeignKeyColumn,
|
||||
Generated,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
|
|
@ -14,6 +15,7 @@ import { ColumnType } from 'kysely';
|
|||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { UserAvatarColor, UserStatus } from 'src/enum';
|
||||
import { user_delete_audit } from 'src/schema/functions';
|
||||
import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table';
|
||||
|
||||
@Table('user')
|
||||
@UpdatedAtTrigger('user_updatedAt')
|
||||
|
|
@ -82,4 +84,7 @@ export class UserTable {
|
|||
|
||||
@UpdateIdColumn({ index: true })
|
||||
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.asset.getById.mockResolvedValueOnce(getForAsset(asset));
|
||||
mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset));
|
||||
mocks.asset.getById.mockResolvedValueOnce(getForAsset(unlinkedAsset));
|
||||
mocks.asset.update.mockResolvedValueOnce(getForAsset(unlinkedAsset));
|
||||
|
||||
await sut.update(auth, asset.id, { livePhotoVideoId: null });
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export class AssetService extends BaseService {
|
|||
const asset = await this.assetRepository.getById(id, {
|
||||
exifInfo: true,
|
||||
owner: true,
|
||||
faces: { person: true },
|
||||
faces: { person: true, viewingUserId: auth.user.id },
|
||||
stack: { assets: true },
|
||||
edits: true,
|
||||
tags: true,
|
||||
|
|
@ -85,7 +85,7 @@ export class AssetService extends BaseService {
|
|||
delete data.owner;
|
||||
}
|
||||
|
||||
if (data.ownerId !== auth.user.id || auth.sharedLink) {
|
||||
if (auth.sharedLink) {
|
||||
data.people = [];
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +124,7 @@ export class AssetService extends BaseService {
|
|||
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> {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { AppRepository } from 'src/repositories/app.repository';
|
|||
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
|
||||
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { CronRepository } from 'src/repositories/cron.repository';
|
||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||
|
|
@ -74,6 +75,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
|
|||
AssetRepository,
|
||||
AssetEditRepository,
|
||||
AssetJobRepository,
|
||||
ClusterGroupRepository,
|
||||
ConfigRepository,
|
||||
CronRepository,
|
||||
CryptoRepository,
|
||||
|
|
@ -134,6 +136,7 @@ export class BaseService {
|
|||
protected assetRepository: AssetRepository,
|
||||
protected assetEditRepository: AssetEditRepository,
|
||||
protected assetJobRepository: AssetJobRepository,
|
||||
protected clusterGroupRepository: ClusterGroupRepository,
|
||||
protected configRepository: ConfigRepository,
|
||||
protected cronRepository: CronRepository,
|
||||
protected cryptoRepository: CryptoRepository,
|
||||
|
|
@ -203,6 +206,7 @@ export class BaseService {
|
|||
ctx.assetRepository,
|
||||
ctx.assetEditRepository,
|
||||
ctx.assetJobRepository,
|
||||
ctx.clusterGroupRepository,
|
||||
ctx.configRepository,
|
||||
ctx.cronRepository,
|
||||
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);
|
||||
if (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) {
|
||||
payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS);
|
||||
}
|
||||
|
|
@ -314,7 +318,8 @@ export class BaseService {
|
|||
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);
|
||||
|
||||
|
|
|
|||
116
server/src/services/cluster-group.service.ts
Normal file
116
server/src/services/cluster-group.service.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
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';
|
||||
|
||||
@Injectable()
|
||||
export class ClusterGroupService extends BaseService {
|
||||
async getRequests(auth: AuthDto): Promise<ClusterGroupRequestResponseDto[]> {
|
||||
const requests = await this.clusterGroupRepository.getRequests(auth.user.id);
|
||||
return requests.map((request) => mapClusterGroupRequest(request));
|
||||
}
|
||||
|
||||
async getRequestsForGroup(auth: AuthDto, clusterGroupId: string): Promise<ClusterGroupRequestResponseDto[]> {
|
||||
await this.requireAccess({ auth, permission: Permission.ClusterGroupRequestRead, ids: [clusterGroupId] });
|
||||
|
||||
const requests = await this.clusterGroupRepository.getRequestsForGroup(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');
|
||||
}
|
||||
|
||||
const user = await this.userRepository.get(userId, {});
|
||||
if (!user) {
|
||||
throw new BadRequestException('Invalid user');
|
||||
}
|
||||
|
||||
const created = await this.clusterGroupRepository.createRequest({ clusterGroupId, userId });
|
||||
const request = created ?? (await this.clusterGroupRepository.getRequestFor({ clusterGroupId, userId }));
|
||||
if (!request) {
|
||||
throw new BadRequestException('Request not found');
|
||||
}
|
||||
|
||||
if (created) {
|
||||
await this.eventRepository.emit('ClusterGroupRequest', { clusterGroupId, userId, senderName: auth.user.name });
|
||||
}
|
||||
|
||||
return { duplicate: !created, value: mapClusterGroupRequest(request) };
|
||||
}
|
||||
|
||||
async acceptRequest(auth: AuthDto, id: string): Promise<void> {
|
||||
const request = await this.clusterGroupRepository.getRequest(id);
|
||||
if (!request || request.userId !== auth.user.id) {
|
||||
throw new BadRequestException('Request not found');
|
||||
}
|
||||
|
||||
const clusterGroupId = await this.clusterGroupRepository.getForUser(auth.user.id);
|
||||
const hasOtherMembers = await this.clusterGroupRepository.hasOtherMembers({
|
||||
clusterGroupId,
|
||||
userId: auth.user.id,
|
||||
});
|
||||
if (hasOtherMembers) {
|
||||
throw new BadRequestException('Leave the current cluster group before joining another one');
|
||||
}
|
||||
|
||||
await this.clusterGroupRepository.deleteRequest(request.id);
|
||||
await this.personRepository.reassignCluster({ userId: auth.user.id, newClusterId: request.clusterGroupId });
|
||||
await this.userRepository.update(auth.user.id, { clusterGroupId: request.clusterGroupId });
|
||||
}
|
||||
|
||||
async deleteRequest(auth: AuthDto, id: string): Promise<void> {
|
||||
const request = await this.clusterGroupRepository.getRequest(id);
|
||||
if (!request) {
|
||||
throw new BadRequestException('Request not found');
|
||||
}
|
||||
|
||||
// the user it was created for declines it, anyone in the cluster group revokes it
|
||||
if (request.userId !== auth.user.id) {
|
||||
await this.requireAccess({
|
||||
auth,
|
||||
permission: Permission.ClusterGroupRequestDelete,
|
||||
ids: [request.clusterGroupId],
|
||||
});
|
||||
}
|
||||
|
||||
await this.clusterGroupRepository.deleteRequest(request.id);
|
||||
}
|
||||
|
||||
async leave(auth: AuthDto, clusterGroupId: string): Promise<void> {
|
||||
await this.requireAccess({ auth, permission: Permission.ClusterGroupLeave, ids: [clusterGroupId] });
|
||||
|
||||
const hasOtherMembers = await this.clusterGroupRepository.hasOtherMembers({
|
||||
clusterGroupId,
|
||||
userId: auth.user.id,
|
||||
});
|
||||
if (!hasOtherMembers) {
|
||||
throw new BadRequestException('Cannot leave a cluster group without any other members');
|
||||
}
|
||||
|
||||
const clusterGroup = await this.clusterGroupRepository.create();
|
||||
await this.personRepository.reassignCluster({ userId: auth.user.id, newClusterId: clusterGroup.id });
|
||||
await this.userRepository.update(auth.user.id, { clusterGroupId: clusterGroup.id });
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { AssetService } from 'src/services/asset.service';
|
|||
import { AuthAdminService } from 'src/services/auth-admin.service';
|
||||
import { AuthService } from 'src/services/auth.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 { DatabaseService } from 'src/services/database.service';
|
||||
import { DownloadService } from 'src/services/download.service';
|
||||
|
|
@ -76,6 +77,7 @@ export const services = [
|
|||
NotificationService,
|
||||
NotificationAdminService,
|
||||
OcrService,
|
||||
ClusterGroupService,
|
||||
PartnerService,
|
||||
PersonService,
|
||||
PluginService,
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ describe(JobService.name, () => {
|
|||
jobs: [],
|
||||
},
|
||||
{
|
||||
item: { name: JobName.PersonGenerateThumbnail, data: { id: 'asset-1' } },
|
||||
item: { name: JobName.PersonGenerateThumbnail, data: { ownerId: 'owner-1', personGroupId: 'person-group-1' } },
|
||||
jobs: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -90,6 +90,7 @@ describe(JobService.name, () => {
|
|||
for (const { item, jobs, stub } of tests) {
|
||||
it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => {
|
||||
if (stub) {
|
||||
mocks.asset.getById.mockResolvedValue(stub[0]);
|
||||
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue(stub);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -124,11 +124,8 @@ export class JobService extends BaseService {
|
|||
}
|
||||
|
||||
case JobName.PersonGenerateThumbnail: {
|
||||
const { id } = item.data;
|
||||
const person = await this.personRepository.getById(id);
|
||||
if (person) {
|
||||
this.websocketRepository.clientSend('on_person_thumbnail', person.ownerId, person.id);
|
||||
}
|
||||
const { ownerId, personGroupId } = item.data;
|
||||
this.websocketRepository.clientSend('on_person_thumbnail', ownerId, personGroupId);
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -172,7 +169,13 @@ export class JobService extends BaseService {
|
|||
break;
|
||||
}
|
||||
|
||||
const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([item.data.id]);
|
||||
const owner = await this.assetRepository.getById(item.data.id);
|
||||
if (!owner) {
|
||||
this.logger.warn(`Could not find asset ${item.data.id} after generating thumbnails`);
|
||||
break;
|
||||
}
|
||||
|
||||
const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([item.data.id], owner.ownerId);
|
||||
if (!asset) {
|
||||
this.logger.warn(`Could not find asset ${item.data.id} after generating thumbnails`);
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ describe(MediaService.name, () => {
|
|||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.PersonGenerateThumbnail,
|
||||
data: { id: person.id },
|
||||
data: { ownerId: person.ownerId, personGroupId: person.personGroupId },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -129,7 +129,8 @@ describe(MediaService.name, () => {
|
|||
{
|
||||
name: JobName.PersonGenerateThumbnail,
|
||||
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.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,
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('should skip a person without a face asset id', async () => {
|
||||
const person = PersonFactory.create({ faceAssetId: null });
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
await sut.handleGeneratePersonThumbnail({ id: person.id });
|
||||
await sut.handleGeneratePersonThumbnail({ ownerId: person.ownerId, personGroupId: person.personGroupId });
|
||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
|
|
@ -1548,9 +1555,14 @@ describe(MediaService.name, () => {
|
|||
const info = { width: 1000, height: 1000 } as OutputInfo;
|
||||
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.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailMiddle.originalPath, {
|
||||
colorspace: Colorspace.P3,
|
||||
|
|
@ -1581,7 +1593,11 @@ describe(MediaService.name, () => {
|
|||
},
|
||||
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 () => {
|
||||
|
|
@ -1593,9 +1609,14 @@ describe(MediaService.name, () => {
|
|||
const info = { width: 1000, height: 1000 } as OutputInfo;
|
||||
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.media.decodeImage).toHaveBeenCalledWith(expect.any(String), {
|
||||
colorspace: Colorspace.P3,
|
||||
|
|
@ -1626,7 +1647,11 @@ describe(MediaService.name, () => {
|
|||
},
|
||||
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 () => {
|
||||
|
|
@ -1638,7 +1663,9 @@ describe(MediaService.name, () => {
|
|||
const info = { width: 2160, height: 3840 } as OutputInfo;
|
||||
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, {
|
||||
colorspace: Colorspace.P3,
|
||||
|
|
@ -1681,7 +1708,9 @@ describe(MediaService.name, () => {
|
|||
const info = { width: 1000, height: 1000 } as OutputInfo;
|
||||
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, {
|
||||
colorspace: Colorspace.P3,
|
||||
|
|
@ -1724,7 +1753,9 @@ describe(MediaService.name, () => {
|
|||
const info = { width: 4624, height: 3080 } as OutputInfo;
|
||||
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, {
|
||||
colorspace: Colorspace.P3,
|
||||
|
|
@ -1767,7 +1798,9 @@ describe(MediaService.name, () => {
|
|||
const info = { width: 4624, height: 3080 } as OutputInfo;
|
||||
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, {
|
||||
colorspace: Colorspace.P3,
|
||||
|
|
@ -1814,7 +1847,9 @@ describe(MediaService.name, () => {
|
|||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||
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.decodeImage).toHaveBeenCalledWith(extracted, {
|
||||
|
|
@ -1857,7 +1892,9 @@ describe(MediaService.name, () => {
|
|||
const info = { width: 2160, height: 3840 } as OutputInfo;
|
||||
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.generateThumbnail).toHaveBeenCalled();
|
||||
|
|
@ -1873,7 +1910,9 @@ describe(MediaService.name, () => {
|
|||
const info = { width: 2160, height: 3840 } as OutputInfo;
|
||||
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.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, {
|
||||
|
|
@ -1897,7 +1936,9 @@ describe(MediaService.name, () => {
|
|||
mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg });
|
||||
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.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, {
|
||||
|
|
|
|||
|
|
@ -91,16 +91,17 @@ export class MediaService extends BaseService {
|
|||
for await (const people of batched(this.personRepository.getAll(force ? undefined : { thumbnailPath: '' }))) {
|
||||
const jobs: JobItem[] = [];
|
||||
for (const person of people) {
|
||||
const { ownerId, personGroupId } = person;
|
||||
if (!person.faceAssetId) {
|
||||
const face = await this.personRepository.getRandomFace(person.id);
|
||||
const face = await this.personRepository.getRandomFace(personGroupId);
|
||||
if (!face) {
|
||||
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);
|
||||
|
|
@ -125,7 +126,10 @@ export class MediaService extends BaseService {
|
|||
|
||||
for await (const people of batched(this.personRepository.getAll())) {
|
||||
await this.jobRepository.queueAll(
|
||||
people.map((person) => ({ name: JobName.PersonFileMigration, data: { id: person.id } })),
|
||||
people.map(({ ownerId, personGroupId }) => ({
|
||||
name: JobName.PersonFileMigration,
|
||||
data: { ownerId, personGroupId },
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -387,19 +391,22 @@ export class MediaService extends BaseService {
|
|||
}
|
||||
|
||||
@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 data = await this.personRepository.getDataForThumbnailGenerationJob(id);
|
||||
const data = await this.personRepository.getDataForThumbnailGenerationJob({ ownerId, personGroupId });
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
if (data.type === AssetType.Video) {
|
||||
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;
|
||||
}
|
||||
inputImage = previewPath;
|
||||
|
|
@ -417,7 +424,7 @@ export class MediaService extends BaseService {
|
|||
orientation: Buffer.isBuffer(inputImage) && exifOrientation ? Number(exifOrientation) : undefined,
|
||||
});
|
||||
|
||||
const thumbnailPath = StorageCore.getPersonThumbnailPath({ id, ownerId });
|
||||
const thumbnailPath = StorageCore.getPersonThumbnailPath({ ownerId, personGroupId });
|
||||
this.storageCore.ensureFolders(thumbnailPath);
|
||||
|
||||
const thumbnailOptions: GenerateThumbnailOptions = {
|
||||
|
|
@ -440,7 +447,7 @@ export class MediaService extends BaseService {
|
|||
};
|
||||
|
||||
await this.mediaRepository.generateThumbnail(decodedImage, thumbnailOptions, thumbnailPath);
|
||||
await this.personRepository.update({ id, thumbnailPath });
|
||||
await this.personRepository.update({ ownerId, personGroupId, thumbnailPath });
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
|
@ -828,7 +835,7 @@ export class MediaService extends BaseService {
|
|||
: undefined;
|
||||
|
||||
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 faceStatuses = checkFaceVisibility(assetFaces, originalDimensions, cropBox);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
import { ImmichTags } from 'src/repositories/metadata.repository';
|
||||
import { firstDateTime, MetadataService } from 'src/services/metadata.service';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { PersonGroupFactory } from 'test/factories/person-group.factory';
|
||||
import { PersonFactory } from 'test/factories/person.factory';
|
||||
import { videoInfoStub } from 'test/fixtures/media.stub';
|
||||
import { tagStub } from 'test/fixtures/tag.stub';
|
||||
|
|
@ -1388,7 +1389,8 @@ describe(MetadataService.name, () => {
|
|||
|
||||
mockReadTags(faceTags);
|
||||
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);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
|
@ -1411,7 +1413,8 @@ describe(MetadataService.name, () => {
|
|||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
mockReadTags(makeFaceTags({ Name: person.name }));
|
||||
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);
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
|
|
@ -1422,7 +1425,7 @@ describe(MetadataService.name, () => {
|
|||
{
|
||||
id: 'random-uuid',
|
||||
assetId: asset.id,
|
||||
personId: 'random-uuid',
|
||||
personGroupId: 'random-uuid',
|
||||
imageHeight: 100,
|
||||
imageWidth: 1000,
|
||||
boundingBoxX1: 0,
|
||||
|
|
@ -1435,12 +1438,12 @@ describe(MetadataService.name, () => {
|
|||
[],
|
||||
);
|
||||
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([
|
||||
{
|
||||
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.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
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.update.mockResolvedValue(person);
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
|
@ -1464,7 +1468,7 @@ describe(MetadataService.name, () => {
|
|||
{
|
||||
id: 'random-uuid',
|
||||
assetId: asset.id,
|
||||
personId: person.id,
|
||||
personGroupId: person.personGroupId,
|
||||
imageHeight: 100,
|
||||
imageWidth: 1000,
|
||||
boundingBoxX1: 0,
|
||||
|
|
@ -1540,7 +1544,8 @@ describe(MetadataService.name, () => {
|
|||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
mockReadTags(makeFaceTags({ Name: person.name }, orientation));
|
||||
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);
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
|
|
@ -1553,7 +1558,7 @@ describe(MetadataService.name, () => {
|
|||
{
|
||||
id: 'random-uuid',
|
||||
assetId: asset.id,
|
||||
personId: 'random-uuid',
|
||||
personGroupId: 'random-uuid',
|
||||
imageWidth: imgW,
|
||||
imageHeight: imgH,
|
||||
boundingBoxX1: x1,
|
||||
|
|
@ -1566,12 +1571,12 @@ describe(MetadataService.name, () => {
|
|||
[],
|
||||
);
|
||||
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([
|
||||
{
|
||||
name: JobName.PersonGenerateThumbnail,
|
||||
data: { id: person.id },
|
||||
data: { ownerId: asset.ownerId, personGroupId: 'random-uuid' },
|
||||
},
|
||||
]);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -893,7 +893,13 @@ export class MetadataService extends BaseService {
|
|||
}
|
||||
|
||||
private async applyTaggedFaces(
|
||||
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,
|
||||
) {
|
||||
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 existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true });
|
||||
const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id]));
|
||||
const missing: (Insertable<PersonTable> & { ownerId: string })[] = [];
|
||||
const missingWithFaceAsset: { id: string; ownerId: string; faceAssetId: string }[] = [];
|
||||
const existingNameMap = new Map(
|
||||
existingNames.map(({ personGroupId, name }) => [name.toLowerCase(), personGroupId]),
|
||||
);
|
||||
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 imageWidth = adjustedRegionInfo.AppliedToDimensions.W;
|
||||
|
|
@ -916,7 +924,7 @@ export class MetadataService extends BaseService {
|
|||
}
|
||||
|
||||
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 Y = Number(region.Area.Y);
|
||||
|
|
@ -925,7 +933,7 @@ export class MetadataService extends BaseService {
|
|||
|
||||
const face = {
|
||||
id: this.cryptoRepository.randomUUID(),
|
||||
personId,
|
||||
personGroupId,
|
||||
assetId: asset.id,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
|
|
@ -938,15 +946,27 @@ export class MetadataService extends BaseService {
|
|||
|
||||
facesToAdd.push(face);
|
||||
if (!existingNameMap.has(loweredName)) {
|
||||
missing.push({ id: personId, ownerId: asset.ownerId, name: region.Name });
|
||||
missingWithFaceAsset.push({ id: personId, ownerId: asset.ownerId, faceAssetId: face.id });
|
||||
missing.push({
|
||||
personGroupId,
|
||||
ownerId: asset.ownerId,
|
||||
clusterGroupId: asset.clusterGroupId,
|
||||
name: region.Name,
|
||||
});
|
||||
missingWithFaceAsset.push({ personGroupId, ownerId: asset.ownerId, faceAssetId: face.id });
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
this.logger.debugFn(() => `Creating missing persons: ${missing.map((p) => `${p.name}/${p.id}`)}`);
|
||||
const newPersonIds = await this.personRepository.createAll(missing);
|
||||
const jobs = newPersonIds.map((id) => ({ name: JobName.PersonGenerateThumbnail, data: { id } }) as const);
|
||||
this.logger.debugFn(() => `Creating missing persons: ${missing.map((p) => `${p.name}/${p.personGroupId}`)}`);
|
||||
await this.personRepository.createGroups(
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ export class NotificationService extends BaseService {
|
|||
return;
|
||||
}
|
||||
|
||||
const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([assetId]);
|
||||
const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([assetId], userId);
|
||||
if (asset) {
|
||||
this.websocketRepository.clientSend(
|
||||
'on_asset_update',
|
||||
|
|
@ -236,6 +236,20 @@ export class NotificationService extends BaseService {
|
|||
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' })
|
||||
onSessionDelete({ sessionId }: ArgOf<'SessionDelete'>) {
|
||||
// after the response is sent
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||
import { mapFaces, mapPerson } from 'src/dtos/person.dto';
|
||||
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 { ImmichFileResponse } from 'src/utils/file';
|
||||
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { PersonGroupFactory } from 'test/factories/person-group.factory';
|
||||
import { PersonFactory } from 'test/factories/person.factory';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
getForAsset,
|
||||
getForAssetFace,
|
||||
getForDetectedFaces,
|
||||
getForFaceSearch,
|
||||
getForFacialRecognitionJob,
|
||||
} from 'test/mappers';
|
||||
import { newDate, newUuid } from 'test/small.factory';
|
||||
|
|
@ -49,9 +50,9 @@ describe(PersonService.name, () => {
|
|||
total: 2,
|
||||
hidden: 1,
|
||||
people: [
|
||||
expect.objectContaining({ id: person.id, isHidden: false }),
|
||||
expect.objectContaining({ id: person.personGroupId, isHidden: false }),
|
||||
expect.objectContaining({
|
||||
id: hiddenPerson.id,
|
||||
id: hiddenPerson.personGroupId,
|
||||
isHidden: true,
|
||||
}),
|
||||
],
|
||||
|
|
@ -76,10 +77,10 @@ describe(PersonService.name, () => {
|
|||
hidden: 1,
|
||||
people: [
|
||||
expect.objectContaining({
|
||||
id: isFavorite.id,
|
||||
id: isFavorite.personGroupId,
|
||||
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, {
|
||||
|
|
@ -92,9 +93,9 @@ describe(PersonService.name, () => {
|
|||
it('should require person.read permission', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
await expect(sut.getById(auth, person.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||
await expect(sut.getById(auth, person.personGroupId)).rejects.toBeInstanceOf(BadRequestException);
|
||||
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 () => {
|
||||
|
|
@ -108,11 +109,16 @@ describe(PersonService.name, () => {
|
|||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
await expect(sut.getById(auth, person.id)).resolves.toEqual(expect.objectContaining({ id: person.id }));
|
||||
expect(mocks.person.getById).toHaveBeenCalledWith(person.id);
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
|
||||
await expect(sut.getById(auth, person.personGroupId)).resolves.toEqual(
|
||||
expect.objectContaining({ id: person.personGroupId }),
|
||||
);
|
||||
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 person = PersonFactory.create();
|
||||
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
await expect(sut.getThumbnail(auth, person.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||
await expect(sut.getThumbnail(auth, person.personGroupId)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
|
||||
});
|
||||
|
||||
it('should throw an error when personId is invalid', async () => {
|
||||
|
|
@ -140,27 +146,27 @@ describe(PersonService.name, () => {
|
|||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create({ thumbnailPath: '' });
|
||||
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
await expect(sut.getThumbnail(auth, person.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
|
||||
await expect(sut.getThumbnail(auth, person.personGroupId)).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
|
||||
});
|
||||
|
||||
it('should serve the thumbnail', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
await expect(sut.getThumbnail(auth, person.id)).resolves.toEqual(
|
||||
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
|
||||
await expect(sut.getThumbnail(auth, person.personGroupId)).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: person.thumbnailPath,
|
||||
contentType: 'image/jpeg',
|
||||
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 person = PersonFactory.create();
|
||||
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
await expect(sut.update(auth, person.id, { name: 'Person 1' })).rejects.toBeInstanceOf(BadRequestException);
|
||||
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||
await expect(sut.update(auth, person.personGroupId, { name: 'Person 1' })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
|
||||
});
|
||||
|
||||
it('should throw an error when personId is invalid', async () => {
|
||||
|
|
@ -188,26 +196,32 @@ describe(PersonService.name, () => {
|
|||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create({ name: 'Person 1' });
|
||||
|
||||
mocks.person.getByGroupId.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(
|
||||
expect.objectContaining({ id: person.id, name: 'Person 1' }),
|
||||
await expect(sut.update(auth, person.personGroupId, { name: 'Person 1' })).resolves.toEqual(
|
||||
expect.objectContaining({ id: person.personGroupId, name: 'Person 1' }),
|
||||
);
|
||||
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, name: 'Person 1' });
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({
|
||||
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 () => {
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create({ birthDate: new Date('1976-06-30') });
|
||||
|
||||
mocks.person.getByGroupId.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({
|
||||
id: person.id,
|
||||
await expect(sut.update(auth, person.personGroupId, { birthDate: '1976-06-30' })).resolves.toEqual({
|
||||
id: person.personGroupId,
|
||||
name: person.name,
|
||||
birthDate: '1976-06-30',
|
||||
thumbnailPath: person.thumbnailPath,
|
||||
|
|
@ -215,40 +229,54 @@ describe(PersonService.name, () => {
|
|||
isFavorite: false,
|
||||
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.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 () => {
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create({ isHidden: true });
|
||||
|
||||
mocks.person.getByGroupId.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(mocks.person.update).toHaveBeenCalledWith({ id: person.id, isHidden: true });
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({
|
||||
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 () => {
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create({ isFavorite: true });
|
||||
|
||||
mocks.person.getByGroupId.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(mocks.person.update).toHaveBeenCalledWith({ id: person.id, isFavorite: true });
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({
|
||||
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 () => {
|
||||
|
|
@ -256,37 +284,44 @@ describe(PersonService.name, () => {
|
|||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||
mocks.person.update.mockResolvedValue(person);
|
||||
mocks.person.getForFeatureFaceUpdate.mockResolvedValue(face);
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([face.assetId]));
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
|
||||
|
||||
await expect(sut.update(auth, person.id, { featureFaceAssetId: face.assetId })).resolves.toEqual(
|
||||
expect.objectContaining({ id: person.id }),
|
||||
await expect(sut.update(auth, person.personGroupId, { featureFaceAssetId: face.assetId })).resolves.toEqual(
|
||||
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({
|
||||
assetId: face.assetId,
|
||||
personId: person.id,
|
||||
personGroupId: person.personGroupId,
|
||||
});
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
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 () => {
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||
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.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 person = PersonFactory.create();
|
||||
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
|
||||
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||
mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id]));
|
||||
mocks.person.getFacesByIds.mockResolvedValue([getForAssetFace(face)]);
|
||||
mocks.person.reassignFace.mockResolvedValue(1);
|
||||
|
|
@ -331,15 +366,15 @@ describe(PersonService.name, () => {
|
|||
mocks.person.update.mockResolvedValue(person);
|
||||
|
||||
await expect(
|
||||
sut.reassignFaces(auth, person.id, {
|
||||
data: [{ personId: person.id, assetId: face.assetId }],
|
||||
sut.reassignFaces(auth, person.personGroupId, {
|
||||
data: [{ personId: person.personGroupId, assetId: face.assetId }],
|
||||
}),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
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 featureFace = AssetFaceFactory.create({
|
||||
assetId: asset.id,
|
||||
personId: person.id,
|
||||
personGroupId: person.personGroupId,
|
||||
sourceType: SourceType.Manual,
|
||||
});
|
||||
|
||||
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.person.getById.mockResolvedValue(person);
|
||||
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||
mocks.person.getRandomFace.mockResolvedValue(featureFace);
|
||||
mocks.person.update.mockResolvedValue({ ...person, faceAssetId: featureFace.id });
|
||||
|
||||
await expect(
|
||||
sut.createFace(auth, {
|
||||
assetId: asset.id,
|
||||
personId: person.id,
|
||||
personId: person.personGroupId,
|
||||
imageHeight: 500,
|
||||
imageWidth: 400,
|
||||
x: 10,
|
||||
|
|
@ -408,7 +443,7 @@ describe(PersonService.name, () => {
|
|||
expect(mocks.asset.getById).toHaveBeenCalledWith(asset.id, { edits: true, exifInfo: true });
|
||||
expect(mocks.person.createAssetFace).toHaveBeenCalledWith({
|
||||
assetId: asset.id,
|
||||
personId: person.id,
|
||||
personGroupId: person.personGroupId,
|
||||
imageHeight: 500,
|
||||
imageWidth: 400,
|
||||
boundingBoxX1: 10,
|
||||
|
|
@ -417,10 +452,17 @@ describe(PersonService.name, () => {
|
|||
boundingBoxY2: 130,
|
||||
sourceType: SourceType.Manual,
|
||||
});
|
||||
expect(mocks.person.getRandomFace).toHaveBeenCalledWith(person.id);
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, faceAssetId: featureFace.id });
|
||||
expect(mocks.person.getRandomFace).toHaveBeenCalledWith(person.personGroupId);
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({
|
||||
ownerId: person.ownerId,
|
||||
personGroupId: person.personGroupId,
|
||||
faceAssetId: featureFace.id,
|
||||
});
|
||||
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() });
|
||||
|
||||
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.person.getById.mockResolvedValue(person);
|
||||
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||
|
||||
await expect(
|
||||
sut.createFace(auth, {
|
||||
assetId: asset.id,
|
||||
personId: person.id,
|
||||
personId: person.personGroupId,
|
||||
imageHeight: 500,
|
||||
imageWidth: 400,
|
||||
x: 10,
|
||||
|
|
@ -459,12 +501,12 @@ describe(PersonService.name, () => {
|
|||
const person = PersonFactory.create({ faceAssetId: null });
|
||||
|
||||
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(
|
||||
sut.createFace(auth, {
|
||||
assetId: asset.id,
|
||||
personId: person.id,
|
||||
personId: person.personGroupId,
|
||||
imageHeight: 500,
|
||||
imageWidth: 400,
|
||||
x: 10,
|
||||
|
|
@ -483,11 +525,11 @@ describe(PersonService.name, () => {
|
|||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create());
|
||||
await sut.createNewFeaturePhoto([person.id]);
|
||||
await sut.createNewFeaturePhoto([person]);
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
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 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.person.getFaceById.mockResolvedValue(getForAssetFace(face));
|
||||
mocks.person.reassignFace.mockResolvedValue(1);
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
await expect(sut.reassignFacesById(AuthFactory.create(), person.id, { id: face.id })).resolves.toEqual({
|
||||
birthDate: person.birthDate,
|
||||
isHidden: person.isHidden,
|
||||
isFavorite: person.isFavorite,
|
||||
id: person.id,
|
||||
name: person.name,
|
||||
thumbnailPath: person.thumbnailPath,
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||
await expect(sut.reassignFacesById(AuthFactory.create(), person.personGroupId, { id: face.id })).resolves.toEqual(
|
||||
{
|
||||
birthDate: person.birthDate,
|
||||
isHidden: person.isHidden,
|
||||
isFavorite: person.isFavorite,
|
||||
id: person.personGroupId,
|
||||
name: person.name,
|
||||
thumbnailPath: person.thumbnailPath,
|
||||
updatedAt: expect.any(String),
|
||||
},
|
||||
);
|
||||
|
||||
expect(mocks.job.queue).not.toHaveBeenCalledWith();
|
||||
expect(mocks.job.queueAll).not.toHaveBeenCalledWith();
|
||||
|
|
@ -521,12 +565,12 @@ describe(PersonService.name, () => {
|
|||
const face = AssetFaceFactory.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.reassignFace.mockResolvedValue(1);
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||
await expect(
|
||||
sut.reassignFacesById(AuthFactory.create(), person.id, {
|
||||
sut.reassignFacesById(AuthFactory.create(), person.personGroupId, {
|
||||
id: face.id,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
|
@ -537,13 +581,16 @@ describe(PersonService.name, () => {
|
|||
});
|
||||
|
||||
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 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();
|
||||
|
||||
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,11 @@ describe(PersonService.name, () => {
|
|||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
|
||||
mocks.person.getForPeopleDelete.mockResolvedValue([person]);
|
||||
|
||||
await sut.handlePersonCleanup();
|
||||
|
||||
expect(mocks.person.delete).toHaveBeenCalledWith([person.id]);
|
||||
expect(mocks.person.deleteGroups).toHaveBeenCalledWith([person.personGroupId]);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
|
||||
});
|
||||
});
|
||||
|
|
@ -592,11 +640,12 @@ describe(PersonService.name, () => {
|
|||
|
||||
mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset]));
|
||||
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
|
||||
mocks.person.getForPeopleDelete.mockResolvedValue([person]);
|
||||
|
||||
await sut.handleQueueDetectFaces({ force: true });
|
||||
|
||||
expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MachineLearning });
|
||||
expect(mocks.person.delete).toHaveBeenCalledWith([person.id]);
|
||||
expect(mocks.person.deleteGroups).toHaveBeenCalledWith([person.personGroupId]);
|
||||
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true });
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
|
||||
expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true);
|
||||
|
|
@ -614,7 +663,7 @@ describe(PersonService.name, () => {
|
|||
|
||||
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.vacuum).not.toHaveBeenCalled();
|
||||
expect(mocks.storage.unlink).not.toHaveBeenCalled();
|
||||
|
|
@ -637,6 +686,7 @@ describe(PersonService.name, () => {
|
|||
mocks.person.getAllFaces.mockReturnValue(makeStream([face]));
|
||||
mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset]));
|
||||
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
|
||||
mocks.person.getForPeopleDelete.mockResolvedValue([person]);
|
||||
mocks.person.deleteFaces.mockResolvedValue();
|
||||
|
||||
await sut.handleQueueDetectFaces({ force: true });
|
||||
|
|
@ -648,7 +698,7 @@ describe(PersonService.name, () => {
|
|||
data: { id: asset.id },
|
||||
},
|
||||
]);
|
||||
expect(mocks.person.delete).toHaveBeenCalledWith([person.id]);
|
||||
expect(mocks.person.deleteGroups).toHaveBeenCalledWith([person.personGroupId]);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
|
||||
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true });
|
||||
});
|
||||
|
|
@ -703,7 +753,7 @@ describe(PersonService.name, () => {
|
|||
await sut.handleQueueRecognizeFaces({});
|
||||
|
||||
expect(mocks.person.getAllFaces).toHaveBeenCalledWith({
|
||||
personId: null,
|
||||
personGroupId: null,
|
||||
sourceType: SourceType.MachineLearning,
|
||||
});
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
|
|
@ -769,7 +819,7 @@ describe(PersonService.name, () => {
|
|||
expect(mocks.systemMetadata.get).toHaveBeenCalledWith(SystemMetadataKey.FacialRecognitionState);
|
||||
expect(mocks.person.getLatestFaceDate).toHaveBeenCalledOnce();
|
||||
expect(mocks.person.getAllFaces).toHaveBeenCalledWith({
|
||||
personId: null,
|
||||
personGroupId: null,
|
||||
sourceType: SourceType.MachineLearning,
|
||||
});
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
|
|
@ -817,6 +867,7 @@ describe(PersonService.name, () => {
|
|||
mocks.person.getAll.mockReturnValue(makeStream([face.person!, person]));
|
||||
mocks.person.getAllFaces.mockReturnValue(makeStream([face]));
|
||||
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
|
||||
mocks.person.getForPeopleDelete.mockResolvedValue([person]);
|
||||
mocks.person.unassignFaces.mockResolvedValue();
|
||||
|
||||
await sut.handleQueueRecognizeFaces({ force: true });
|
||||
|
|
@ -829,7 +880,7 @@ describe(PersonService.name, () => {
|
|||
data: { id: face.id, deferred: false },
|
||||
},
|
||||
]);
|
||||
expect(mocks.person.delete).toHaveBeenCalledWith([person.id]);
|
||||
expect(mocks.person.deleteGroups).toHaveBeenCalledWith([person.personGroupId]);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
|
||||
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: false });
|
||||
});
|
||||
|
|
@ -878,7 +929,7 @@ describe(PersonService.name, () => {
|
|||
const face = AssetFaceFactory.create({ assetId: asset.id });
|
||||
mocks.crypto.randomUUID.mockReturnValue(face.id);
|
||||
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.person.refreshFaces.mockResolvedValue();
|
||||
|
||||
|
|
@ -1014,20 +1065,21 @@ describe(PersonService.name, () => {
|
|||
const [noPerson1, noPerson2, primaryFace, face] = [
|
||||
AssetFaceFactory.create({ assetId: asset.id }),
|
||||
AssetFaceFactory.create(),
|
||||
AssetFaceFactory.from().person().build(),
|
||||
AssetFaceFactory.from().person().build(),
|
||||
AssetFaceFactory.from().person({ ownerId: asset.ownerId }).build(),
|
||||
AssetFaceFactory.from().person({ ownerId: asset.ownerId }).build(),
|
||||
];
|
||||
|
||||
const faces = [
|
||||
{ ...noPerson1, distance: 0 },
|
||||
{ ...primaryFace, distance: 0.2 },
|
||||
{ ...noPerson2, distance: 0.3 },
|
||||
{ ...face, distance: 0.4 },
|
||||
] as FaceSearchResult[];
|
||||
getForFaceSearch(noPerson1, 0),
|
||||
getForFaceSearch(primaryFace, 0.2),
|
||||
getForFaceSearch(noPerson2, 0.3),
|
||||
getForFaceSearch(face, 0.4),
|
||||
];
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
||||
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset));
|
||||
mocks.person.getByGroupId.mockResolvedValue(primaryFace.person!);
|
||||
mocks.person.create.mockResolvedValue(primaryFace.person!);
|
||||
|
||||
await sut.handleRecognizeFaces({ id: noPerson1.id });
|
||||
|
|
@ -1036,11 +1088,11 @@ describe(PersonService.name, () => {
|
|||
expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||
faceIds: expect.arrayContaining([noPerson1.id]),
|
||||
newPersonId: primaryFace.person!.id,
|
||||
newPersonGroupId: primaryFace.person!.personGroupId,
|
||||
});
|
||||
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||
faceIds: expect.not.arrayContaining([face.id]),
|
||||
newPersonId: primaryFace.person!.id,
|
||||
newPersonGroupId: primaryFace.person!.personGroupId,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1048,19 +1100,20 @@ describe(PersonService.name, () => {
|
|||
const asset = AssetFactory.create();
|
||||
const [noPerson, face, faceWithBirthDate] = [
|
||||
AssetFaceFactory.create({ assetId: asset.id }),
|
||||
AssetFaceFactory.from().person().build(),
|
||||
AssetFaceFactory.from().person({ birthDate: newDate() }).build(),
|
||||
AssetFaceFactory.from().person({ ownerId: asset.ownerId }).build(),
|
||||
AssetFaceFactory.from().person({ ownerId: asset.ownerId, birthDate: newDate() }).build(),
|
||||
];
|
||||
|
||||
const faces = [
|
||||
{ ...noPerson, distance: 0 },
|
||||
{ ...face, distance: 0.2 },
|
||||
{ ...faceWithBirthDate, distance: 0.3 },
|
||||
] as FaceSearchResult[];
|
||||
getForFaceSearch(noPerson, 0),
|
||||
getForFaceSearch(face, 0.2),
|
||||
getForFaceSearch(faceWithBirthDate, 0.3),
|
||||
];
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
||||
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson, asset));
|
||||
mocks.person.getByGroupId.mockResolvedValue(face.person!);
|
||||
mocks.person.create.mockResolvedValue(face.person!);
|
||||
|
||||
await sut.handleRecognizeFaces({ id: noPerson.id });
|
||||
|
|
@ -1069,11 +1122,11 @@ describe(PersonService.name, () => {
|
|||
expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||
faceIds: expect.arrayContaining([noPerson.id]),
|
||||
newPersonId: face.person!.id,
|
||||
newPersonGroupId: face.person!.personGroupId,
|
||||
});
|
||||
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||
faceIds: expect.not.arrayContaining([face.id]),
|
||||
newPersonId: face.person!.id,
|
||||
newPersonGroupId: face.person!.personGroupId,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1081,19 +1134,20 @@ describe(PersonService.name, () => {
|
|||
const asset = AssetFactory.create();
|
||||
const [noPerson, face, faceWithBirthDate] = [
|
||||
AssetFaceFactory.create({ assetId: asset.id }),
|
||||
AssetFaceFactory.from().person().build(),
|
||||
AssetFaceFactory.from().person({ birthDate: newDate() }).build(),
|
||||
AssetFaceFactory.from().person({ ownerId: asset.ownerId }).build(),
|
||||
AssetFaceFactory.from().person({ ownerId: asset.ownerId, birthDate: newDate() }).build(),
|
||||
];
|
||||
|
||||
const faces = [
|
||||
{ ...noPerson, distance: 0 },
|
||||
{ ...faceWithBirthDate, distance: 0.2 },
|
||||
{ ...face, distance: 0.3 },
|
||||
] as FaceSearchResult[];
|
||||
getForFaceSearch(noPerson, 0),
|
||||
getForFaceSearch(faceWithBirthDate, 0.2),
|
||||
getForFaceSearch(face, 0.3),
|
||||
];
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
||||
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson, asset));
|
||||
mocks.person.getByGroupId.mockResolvedValue(faceWithBirthDate.person!);
|
||||
mocks.person.create.mockResolvedValue(face.person!);
|
||||
|
||||
await sut.handleRecognizeFaces({ id: noPerson.id });
|
||||
|
|
@ -1102,11 +1156,11 @@ describe(PersonService.name, () => {
|
|||
expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||
faceIds: expect.arrayContaining([noPerson.id]),
|
||||
newPersonId: faceWithBirthDate.person!.id,
|
||||
newPersonGroupId: faceWithBirthDate.person!.personGroupId,
|
||||
});
|
||||
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||
faceIds: expect.not.arrayContaining([face.id]),
|
||||
newPersonId: faceWithBirthDate.person!.id,
|
||||
newPersonGroupId: faceWithBirthDate.person!.personGroupId,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1115,32 +1169,64 @@ describe(PersonService.name, () => {
|
|||
const [noPerson1, noPerson2] = [AssetFaceFactory.create({ assetId: asset.id }), AssetFaceFactory.create()];
|
||||
const person = PersonFactory.create();
|
||||
|
||||
const faces = [
|
||||
{ ...noPerson1, distance: 0 },
|
||||
{ ...noPerson2, distance: 0.3 },
|
||||
] as FaceSearchResult[];
|
||||
const faces = [getForFaceSearch(noPerson1, 0), getForFaceSearch(noPerson2, 0.3)];
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
||||
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset));
|
||||
mocks.person.createGroup.mockResolvedValue(PersonGroupFactory.create({ id: person.personGroupId }));
|
||||
mocks.person.create.mockResolvedValue(person);
|
||||
|
||||
await sut.handleRecognizeFaces({ id: noPerson1.id });
|
||||
|
||||
expect(mocks.person.createGroup).toHaveBeenCalledWith(asset.ownerId);
|
||||
expect(mocks.person.create).toHaveBeenCalledWith({
|
||||
ownerId: asset.ownerId,
|
||||
faceAssetId: noPerson1.id,
|
||||
personGroupId: person.personGroupId,
|
||||
});
|
||||
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||
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 () => {
|
||||
const asset = AssetFactory.create();
|
||||
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.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(face, asset));
|
||||
|
|
@ -1158,10 +1244,7 @@ describe(PersonService.name, () => {
|
|||
const asset = AssetFactory.create();
|
||||
const [noPerson1, noPerson2] = [AssetFaceFactory.create({ assetId: asset.id }), AssetFaceFactory.create()];
|
||||
|
||||
const faces = [
|
||||
{ ...noPerson1, distance: 0 },
|
||||
{ ...noPerson2, distance: 0.4 },
|
||||
] as FaceSearchResult[];
|
||||
const faces = [getForFaceSearch(noPerson1, 0), getForFaceSearch(noPerson2, 0.4)];
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
|
||||
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||
|
|
@ -1183,10 +1266,7 @@ describe(PersonService.name, () => {
|
|||
const asset = AssetFactory.create();
|
||||
const [noPerson1, noPerson2] = [AssetFaceFactory.create({ assetId: asset.id }), AssetFaceFactory.create()];
|
||||
|
||||
const faces = [
|
||||
{ ...noPerson1, distance: 0 },
|
||||
{ ...noPerson2, distance: 0.4 },
|
||||
] as FaceSearchResult[];
|
||||
const faces = [getForFaceSearch(noPerson1, 0), getForFaceSearch(noPerson2, 0.4)];
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
|
||||
mocks.search.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]);
|
||||
|
|
@ -1207,38 +1287,39 @@ describe(PersonService.name, () => {
|
|||
const auth = AuthFactory.create();
|
||||
const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()];
|
||||
|
||||
mocks.person.getById.mockResolvedValueOnce(person);
|
||||
mocks.person.getById.mockResolvedValueOnce(mergePerson);
|
||||
mocks.person.getByGroupId.mockResolvedValueOnce(person);
|
||||
mocks.person.getByGroupId.mockResolvedValueOnce(mergePerson);
|
||||
|
||||
await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(
|
||||
sut.mergePerson(auth, person.personGroupId, { ids: [mergePerson.personGroupId] }),
|
||||
).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]));
|
||||
expect(mocks.person.deleteGroups).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
|
||||
});
|
||||
|
||||
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]));
|
||||
mocks.person.getByGroupId.mockResolvedValueOnce(person);
|
||||
mocks.person.getByGroupId.mockResolvedValueOnce(mergePerson);
|
||||
mocks.person.getForPeopleDelete.mockResolvedValue([mergePerson]);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.personGroupId]));
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.personGroupId]));
|
||||
|
||||
await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).resolves.toEqual([
|
||||
{ id: mergePerson.id, success: true },
|
||||
await expect(sut.mergePerson(auth, person.personGroupId, { ids: [mergePerson.personGroupId] })).resolves.toEqual([
|
||||
{ id: mergePerson.personGroupId, success: true },
|
||||
]);
|
||||
|
||||
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||
newPersonId: person.id,
|
||||
oldPersonId: mergePerson.id,
|
||||
newPersonGroupId: person.personGroupId,
|
||||
oldPersonGroupId: mergePerson.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 merge two people with smart merge', async () => {
|
||||
|
|
@ -1248,27 +1329,29 @@ describe(PersonService.name, () => {
|
|||
PersonFactory.create({ name: 'Merge person' }),
|
||||
];
|
||||
|
||||
mocks.person.getById.mockResolvedValueOnce(person);
|
||||
mocks.person.getById.mockResolvedValueOnce(mergePerson);
|
||||
mocks.person.getByGroupId.mockResolvedValueOnce(person);
|
||||
mocks.person.getByGroupId.mockResolvedValueOnce(mergePerson);
|
||||
mocks.person.getForPeopleDelete.mockResolvedValue([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]));
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.personGroupId]));
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.personGroupId]));
|
||||
|
||||
await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).resolves.toEqual([
|
||||
{ id: mergePerson.id, success: true },
|
||||
await expect(sut.mergePerson(auth, person.personGroupId, { ids: [mergePerson.personGroupId] })).resolves.toEqual([
|
||||
{ id: mergePerson.personGroupId, success: true },
|
||||
]);
|
||||
|
||||
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||
newPersonId: person.id,
|
||||
oldPersonId: mergePerson.id,
|
||||
newPersonGroupId: person.personGroupId,
|
||||
oldPersonGroupId: mergePerson.personGroupId,
|
||||
});
|
||||
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({
|
||||
id: person.id,
|
||||
ownerId: person.ownerId,
|
||||
personGroupId: person.personGroupId,
|
||||
name: mergePerson.name,
|
||||
});
|
||||
|
||||
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 primary person is not found', async () => {
|
||||
|
|
@ -1278,7 +1361,7 @@ describe(PersonService.name, () => {
|
|||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(mocks.person.delete).not.toHaveBeenCalled();
|
||||
expect(mocks.person.deleteGroups).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
|
|
@ -1286,35 +1369,35 @@ describe(PersonService.name, () => {
|
|||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getById.mockResolvedValueOnce(person);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id]));
|
||||
mocks.person.getByGroupId.mockResolvedValueOnce(person);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.personGroupId]));
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['unknown']));
|
||||
|
||||
await expect(sut.mergePerson(auth, person.id, { ids: ['unknown'] })).resolves.toEqual([
|
||||
await expect(sut.mergePerson(auth, person.personGroupId, { 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]));
|
||||
expect(mocks.person.deleteGroups).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
|
||||
});
|
||||
|
||||
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.getByGroupId.mockResolvedValueOnce(person);
|
||||
mocks.person.getByGroupId.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]));
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.personGroupId]));
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.personGroupId]));
|
||||
|
||||
await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).resolves.toEqual([
|
||||
{ id: mergePerson.id, success: false, error: BulkIdErrorReason.UNKNOWN },
|
||||
await expect(sut.mergePerson(auth, person.personGroupId, { ids: [mergePerson.personGroupId] })).resolves.toEqual([
|
||||
{ id: mergePerson.personGroupId, success: false, error: BulkIdErrorReason.UNKNOWN },
|
||||
]);
|
||||
|
||||
expect(mocks.person.delete).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
expect(mocks.person.deleteGroups).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1323,20 +1406,25 @@ describe(PersonService.name, () => {
|
|||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
mocks.person.getStatistics.mockResolvedValue({ assets: 3 });
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
await expect(sut.getStatistics(auth, person.id)).resolves.toEqual({ assets: 3 });
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||
mocks.person.getStatistics.mockResolvedValue({ assets: 3, ownedAssets: 2, sharedAssets: 1 });
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId]));
|
||||
await expect(sut.getStatistics(auth, person.personGroupId)).resolves.toEqual({
|
||||
assets: 3,
|
||||
ownedAssets: 2,
|
||||
sharedAssets: 1,
|
||||
});
|
||||
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 () => {
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
await expect(sut.getStatistics(auth, person.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
mocks.person.getByGroupId.mockResolvedValue(person);
|
||||
await expect(sut.getStatistics(auth, person.personGroupId)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1363,11 +1451,5 @@ describe(PersonService.name, () => {
|
|||
it('should not map person if person is null', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import {
|
|||
VectorIndex,
|
||||
} from 'src/enum';
|
||||
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 { FaceSearchTable } from 'src/schema/tables/face-search.table';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
|
|
@ -45,6 +45,8 @@ import { mimeTypes } from 'src/utils/mime-types';
|
|||
import { batched, findOrFail, isFacialRecognitionEnabled } from 'src/utils/misc';
|
||||
import { Point, transformPoints } from 'src/utils/transform';
|
||||
|
||||
const personKey = ({ ownerId, personGroupId }: PersonId) => `${ownerId}/${personGroupId}`;
|
||||
|
||||
@Injectable()
|
||||
export class PersonService extends BaseService {
|
||||
async getAll(auth: AuthDto, dto: PersonSearchDto): Promise<PeopleResponseDto> {
|
||||
|
|
@ -56,7 +58,10 @@ export class PersonService extends BaseService {
|
|||
};
|
||||
|
||||
if (closestPersonId) {
|
||||
const person = await this.personRepository.getById(closestPersonId);
|
||||
const person = await this.personRepository.getByGroupId({
|
||||
ownerId: auth.user.id,
|
||||
personGroupId: closestPersonId,
|
||||
});
|
||||
if (!person?.faceAssetId) {
|
||||
throw new NotFoundException('Person not found');
|
||||
}
|
||||
|
|
@ -76,92 +81,94 @@ export class PersonService extends BaseService {
|
|||
};
|
||||
}
|
||||
|
||||
async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> {
|
||||
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personId] });
|
||||
const person = await this.findOrFail(personId);
|
||||
async reassignFaces(auth: AuthDto, personGroupId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> {
|
||||
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personGroupId] });
|
||||
const person = await this.findOrFail(auth, personGroupId);
|
||||
const result: PersonResponseDto[] = [];
|
||||
const changeFeaturePhoto: string[] = [];
|
||||
const changeFeaturePhoto = new Map<string, PersonId>();
|
||||
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) {
|
||||
await this.requireAccess({ auth, permission: Permission.PersonCreate, ids: [face.id] });
|
||||
if (person.faceAssetId === null) {
|
||||
changeFeaturePhoto.push(person.id);
|
||||
changeFeaturePhoto.set(personKey(person), person);
|
||||
}
|
||||
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));
|
||||
}
|
||||
if (changeFeaturePhoto.length > 0) {
|
||||
// Remove duplicates
|
||||
await this.createNewFeaturePhoto([...new Set(changeFeaturePhoto)]);
|
||||
if (changeFeaturePhoto.size > 0) {
|
||||
await this.createNewFeaturePhoto(changeFeaturePhoto.values().toArray());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise<PersonResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personId] });
|
||||
async reassignFacesById(auth: AuthDto, personGroupId: string, dto: FaceDto): Promise<PersonResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personGroupId] });
|
||||
await this.requireAccess({ auth, permission: Permission.PersonCreate, ids: [dto.id] });
|
||||
const face = await this.personRepository.getFaceById(dto.id);
|
||||
const person = await this.findOrFail(personId);
|
||||
const face = await this.personRepository.getFaceById(dto.id, { viewingUserId: auth.user.id });
|
||||
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) {
|
||||
await this.createNewFeaturePhoto([person.id]);
|
||||
await this.createNewFeaturePhoto([person]);
|
||||
}
|
||||
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[]> {
|
||||
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 assetDimensions = getDimensions(asset);
|
||||
|
||||
return faces.map((face) => mapFaces(face, auth, asset.edits, assetDimensions));
|
||||
}
|
||||
|
||||
async createNewFeaturePhoto(changeFeaturePhoto: string[]) {
|
||||
async createNewFeaturePhoto(changeFeaturePhoto: PersonId[]) {
|
||||
this.logger.debug(
|
||||
`Changing feature photos for ${changeFeaturePhoto.length} ${changeFeaturePhoto.length > 1 ? 'people' : 'person'}`,
|
||||
);
|
||||
|
||||
const jobs: JobItem[] = [];
|
||||
for (const personId of changeFeaturePhoto) {
|
||||
const assetFace = await this.personRepository.getRandomFace(personId);
|
||||
for (const { ownerId, personGroupId } of changeFeaturePhoto) {
|
||||
const assetFace = await this.personRepository.getRandomFace(personGroupId);
|
||||
|
||||
if (assetFace) {
|
||||
await this.personRepository.update({ id: personId, faceAssetId: assetFace.id });
|
||||
jobs.push({ name: JobName.PersonGenerateThumbnail, data: { id: personId } });
|
||||
await this.personRepository.update({ ownerId, personGroupId, faceAssetId: assetFace.id });
|
||||
jobs.push({ name: JobName.PersonGenerateThumbnail, data: { ownerId, personGroupId } });
|
||||
}
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
}
|
||||
|
||||
async getById(auth: AuthDto, id: string): Promise<PersonResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] });
|
||||
return mapPerson(await this.findOrFail(id));
|
||||
async getById(auth: AuthDto, personGroupId: string): Promise<PersonResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [personGroupId] });
|
||||
return mapPerson(await this.findOrFail(auth, personGroupId));
|
||||
}
|
||||
|
||||
async getStatistics(auth: AuthDto, id: string): Promise<PersonStatisticsResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] });
|
||||
return this.personRepository.getStatistics(id);
|
||||
async getStatistics(auth: AuthDto, personGroupId: string): Promise<PersonStatisticsResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [personGroupId] });
|
||||
return this.personRepository.getStatistics(personGroupId, auth.user.id);
|
||||
}
|
||||
|
||||
async getThumbnail(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
|
||||
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] });
|
||||
const person = await this.personRepository.getById(id);
|
||||
async getThumbnail(auth: AuthDto, personGroupId: string): Promise<ImmichFileResponse> {
|
||||
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [personGroupId] });
|
||||
const person = await this.personRepository.getByGroupId({ ownerId: auth.user.id, personGroupId });
|
||||
if (!person || !person.thumbnailPath) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
|
@ -174,8 +181,10 @@ export class PersonService extends BaseService {
|
|||
}
|
||||
|
||||
async create(auth: AuthDto, dto: PersonCreateDto): Promise<PersonResponseDto> {
|
||||
const group = await this.personRepository.createGroup(auth.user.id);
|
||||
const person = await this.personRepository.create({
|
||||
ownerId: auth.user.id,
|
||||
personGroupId: group.id,
|
||||
name: dto.name,
|
||||
birthDate: dto.birthDate,
|
||||
isHidden: dto.isHidden,
|
||||
|
|
@ -186,15 +195,16 @@ export class PersonService extends BaseService {
|
|||
return mapPerson(person);
|
||||
}
|
||||
|
||||
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [id] });
|
||||
async update(auth: AuthDto, personGroupId: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
|
||||
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;
|
||||
// TODO: set by faceId directly
|
||||
let faceId: string | undefined;
|
||||
if (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) {
|
||||
throw new BadRequestException('Invalid assetId for feature face or asset is offline');
|
||||
}
|
||||
|
|
@ -203,7 +213,8 @@ export class PersonService extends BaseService {
|
|||
}
|
||||
|
||||
const person = await this.personRepository.update({
|
||||
id,
|
||||
ownerId,
|
||||
personGroupId,
|
||||
faceAssetId: faceId,
|
||||
name,
|
||||
birthDate,
|
||||
|
|
@ -213,7 +224,7 @@ export class PersonService extends BaseService {
|
|||
});
|
||||
|
||||
if (assetId) {
|
||||
await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id } });
|
||||
await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { ownerId, personGroupId } });
|
||||
}
|
||||
|
||||
return mapPerson(person);
|
||||
|
|
@ -245,21 +256,39 @@ export class PersonService extends BaseService {
|
|||
|
||||
async deleteAll(auth: AuthDto, { ids }: BulkIdsDto): Promise<void> {
|
||||
await this.requireAccess({ auth, permission: Permission.PersonDelete, ids });
|
||||
const people = await this.personRepository.getForPeopleDelete(ids);
|
||||
await this.removeAllPeople(people);
|
||||
await this.removeAllPersonGroups(ids);
|
||||
}
|
||||
|
||||
@Chunked()
|
||||
private async removeAllPeople(people: { id: string; thumbnailPath: string }[]) {
|
||||
private async removeAllPeople(people: (PersonId & { thumbnailPath: string })[]) {
|
||||
await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath)));
|
||||
await this.personRepository.delete(people.map((person) => person.id));
|
||||
await this.personRepository.delete(people);
|
||||
this.logger.debug(`Deleted ${people.length} people`);
|
||||
}
|
||||
|
||||
@Chunked()
|
||||
private async removeAllPersonGroups(groupIds: string[]) {
|
||||
if (groupIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const people = await this.personRepository.getForPeopleDelete(groupIds);
|
||||
await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath)));
|
||||
await this.personRepository.deleteGroups(groupIds);
|
||||
this.logger.debug(`Deleted ${groupIds.length} people`);
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.PersonCleanup, queue: QueueName.BackgroundTask })
|
||||
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();
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -429,7 +458,7 @@ export class PersonService extends BaseService {
|
|||
const lastRun = new Date().toISOString();
|
||||
|
||||
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)) {
|
||||
await this.jobRepository.queueAll(
|
||||
|
|
@ -465,13 +494,14 @@ export class PersonService extends BaseService {
|
|||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
if (face.personId) {
|
||||
if (face.personGroupId) {
|
||||
this.logger.debug(`Face ${id} already has a person assigned`);
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
const { ownerId, clusterGroupId } = face.asset;
|
||||
const matches = await this.searchRepository.searchFaces({
|
||||
userIds: [face.asset.ownerId],
|
||||
clusterGroupId,
|
||||
embedding: face.faceSearch.embedding,
|
||||
maxDistance: machineLearning.facialRecognition.maxDistance,
|
||||
numResults: machineLearning.facialRecognition.minFaces,
|
||||
|
|
@ -495,10 +525,10 @@ export class PersonService extends BaseService {
|
|||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
let personId = matches.find((match) => match.personId)?.personId;
|
||||
if (!personId) {
|
||||
const matchWithPerson = await this.searchRepository.searchFaces({
|
||||
userIds: [face.asset.ownerId],
|
||||
let personGroupId = matches.find((match) => match.personGroupId)?.personGroupId;
|
||||
if (!personGroupId) {
|
||||
const [matchWithPerson] = await this.searchRepository.searchFaces({
|
||||
clusterGroupId,
|
||||
embedding: face.faceSearch.embedding,
|
||||
maxDistance: machineLearning.facialRecognition.maxDistance,
|
||||
numResults: 1,
|
||||
|
|
@ -506,29 +536,38 @@ export class PersonService extends BaseService {
|
|||
minBirthDate: new Date(face.asset.fileCreatedAt),
|
||||
});
|
||||
|
||||
if (matchWithPerson.length > 0) {
|
||||
personId = matchWithPerson[0].personId;
|
||||
personGroupId = matchWithPerson?.personGroupId ?? undefined;
|
||||
}
|
||||
|
||||
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.log(`Creating new person for face ${id}`);
|
||||
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 });
|
||||
this.logger.debug(`Assigning face ${id} to person group ${personGroupId}`);
|
||||
await this.personRepository.reassignFaces({ faceIds: [id], newPersonGroupId: personGroupId });
|
||||
}
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.PersonFileMigration, queue: QueueName.Migration })
|
||||
async handlePersonMigration({ id }: JobOf<JobName.PersonFileMigration>): Promise<JobStatus> {
|
||||
const person = await this.personRepository.getById(id);
|
||||
async handlePersonMigration({ ownerId, personGroupId }: JobOf<JobName.PersonFileMigration>): Promise<JobStatus> {
|
||||
const person = await this.personRepository.getByGroupId({ ownerId, personGroupId });
|
||||
if (!person) {
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
|
@ -538,15 +577,15 @@ export class PersonService extends BaseService {
|
|||
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;
|
||||
if (mergeIds.includes(id)) {
|
||||
if (mergeIds.includes(personGroupId)) {
|
||||
throw new BadRequestException('Cannot merge a person into themselves');
|
||||
}
|
||||
|
||||
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [id] });
|
||||
let primaryPerson = await this.findOrFail(id);
|
||||
const primaryName = primaryPerson.name || primaryPerson.id;
|
||||
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personGroupId] });
|
||||
let primaryPerson = await this.findOrFail(auth, personGroupId);
|
||||
const primaryName = primaryPerson.name || primaryPerson.personGroupId;
|
||||
|
||||
const results: BulkIdResponseDto[] = [];
|
||||
|
||||
|
|
@ -564,44 +603,48 @@ export class PersonService extends BaseService {
|
|||
}
|
||||
|
||||
try {
|
||||
const mergePerson = await this.personRepository.getById(mergeId);
|
||||
const mergePerson = await this.personRepository.getByGroupId({ ownerId: auth.user.id, personGroupId: mergeId });
|
||||
if (!mergePerson) {
|
||||
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NOT_FOUND });
|
||||
continue;
|
||||
}
|
||||
|
||||
const update: Updateable<Person> & { id: string } = { id: primaryPerson.id };
|
||||
const changes: Updateable<Person> = {};
|
||||
if (!primaryPerson.name && mergePerson.name) {
|
||||
update.name = mergePerson.name;
|
||||
changes.name = mergePerson.name;
|
||||
}
|
||||
|
||||
if (!primaryPerson.birthDate && mergePerson.birthDate) {
|
||||
update.birthDate = mergePerson.birthDate;
|
||||
changes.birthDate = mergePerson.birthDate;
|
||||
}
|
||||
|
||||
if (Object.keys(update).length > 1) {
|
||||
primaryPerson = await this.personRepository.update(update);
|
||||
if (Object.keys(changes).length > 0) {
|
||||
primaryPerson = await this.personRepository.update({
|
||||
ownerId: primaryPerson.ownerId,
|
||||
personGroupId: primaryPerson.personGroupId,
|
||||
...changes,
|
||||
});
|
||||
}
|
||||
|
||||
const mergeName = mergePerson.name || mergePerson.id;
|
||||
const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id };
|
||||
const mergeName = mergePerson.name || mergePerson.personGroupId;
|
||||
const mergeData: UpdateFacesData = { oldPersonGroupId: mergeId, newPersonGroupId: primaryPerson.personGroupId };
|
||||
this.logger.log(`Merging ${mergeName} into ${primaryName}`);
|
||||
|
||||
await this.personRepository.reassignFaces(mergeData);
|
||||
await this.removeAllPeople([mergePerson]);
|
||||
await this.removeAllPersonGroups([mergeId]);
|
||||
|
||||
this.logger.log(`Merged ${mergeName} into ${primaryName}`);
|
||||
results.push({ id: mergeId, success: true });
|
||||
} catch (error: 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 });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private findOrFail(id: string) {
|
||||
return findOrFail(() => this.personRepository.getById(id), 'Person');
|
||||
private findOrFail(auth: AuthDto, personGroupId: string) {
|
||||
return findOrFail(() => this.personRepository.getByGroupId({ ownerId: auth.user.id, personGroupId }), 'Person');
|
||||
}
|
||||
|
||||
// TODO return a asset face response
|
||||
|
|
@ -613,7 +656,7 @@ export class PersonService extends BaseService {
|
|||
|
||||
const [asset, person] = await Promise.all([
|
||||
this.assetRepository.getById(dto.assetId, { edits: true, exifInfo: true }),
|
||||
this.findOrFail(dto.personId),
|
||||
this.findOrFail(auth, dto.personId),
|
||||
]);
|
||||
|
||||
if (!asset) {
|
||||
|
|
@ -661,7 +704,7 @@ export class PersonService extends BaseService {
|
|||
}
|
||||
|
||||
await this.personRepository.createAssetFace({
|
||||
personId: dto.personId,
|
||||
personGroupId: person.personGroupId,
|
||||
assetId: dto.assetId,
|
||||
imageHeight: dto.imageHeight,
|
||||
imageWidth: dto.imageWidth,
|
||||
|
|
@ -673,7 +716,7 @@ export class PersonService extends BaseService {
|
|||
});
|
||||
|
||||
if (!person.faceAssetId) {
|
||||
await this.createNewFeaturePhoto([person.id]);
|
||||
await this.createNewFeaturePhoto([person]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -250,7 +250,13 @@ describe(SearchService.name, () => {
|
|||
);
|
||||
expect(mocks.search.searchSmart).toHaveBeenCalledWith(
|
||||
{ page: 1, size: 100 },
|
||||
{ query: 'test', embedding: '[1, 2, 3]', userIds: [authStub.user1.user.id], visibility: 'not-locked' },
|
||||
{
|
||||
query: 'test',
|
||||
embedding: '[1, 2, 3]',
|
||||
userIds: [authStub.user1.user.id],
|
||||
viewingUserId: authStub.user1.user.id,
|
||||
visibility: 'not-locked',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -44,12 +44,14 @@ export class SearchService extends BaseService {
|
|||
const cities = await this.assetRepository.getAssetIdByCity(auth.user.id, options);
|
||||
const cityAssets = await this.assetRepository.getByIdsWithAllRelationsButStacks(
|
||||
cities.items.map(({ data }) => data),
|
||||
auth.user.id,
|
||||
);
|
||||
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 recentAssets = await this.assetRepository.getByIdsWithAllRelationsButStacks(
|
||||
recents.items.map((item) => item.data),
|
||||
auth.user.id,
|
||||
);
|
||||
const recentItems = recentAssets.map((asset) => ({
|
||||
value: asset.createdAt.toISOString(),
|
||||
|
|
@ -92,6 +94,7 @@ export class SearchService extends BaseService {
|
|||
checksum,
|
||||
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
|
||||
userIds,
|
||||
viewingUserId: auth.user.id,
|
||||
orderDirection: dto.order ?? AssetOrder.Desc,
|
||||
},
|
||||
);
|
||||
|
|
@ -109,6 +112,7 @@ export class SearchService extends BaseService {
|
|||
...dto,
|
||||
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
|
||||
userIds,
|
||||
viewingUserId: auth.user.id,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -122,6 +126,7 @@ export class SearchService extends BaseService {
|
|||
...dto,
|
||||
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
|
||||
userIds,
|
||||
viewingUserId: auth.user.id,
|
||||
});
|
||||
return items.map((item) => mapAsset(item, { auth }));
|
||||
}
|
||||
|
|
@ -136,6 +141,7 @@ export class SearchService extends BaseService {
|
|||
...dto,
|
||||
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
|
||||
userIds,
|
||||
viewingUserId: auth.user.id,
|
||||
});
|
||||
return items.map((item) => mapAsset(item, { auth }));
|
||||
}
|
||||
|
|
@ -180,6 +186,7 @@ export class SearchService extends BaseService {
|
|||
{
|
||||
...dto,
|
||||
userIds: await userIds,
|
||||
viewingUserId: auth.user.id,
|
||||
embedding,
|
||||
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -221,6 +221,7 @@ export class SyncService extends BaseService {
|
|||
await this.syncRepository.memoryToAsset.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.partner.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.person.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.personGroup.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.stack.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.user.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.userMetadata.cleanupAuditTable(pruneThreshold);
|
||||
|
|
|
|||
|
|
@ -19,9 +19,12 @@ describe(TimelineService.name, () => {
|
|||
await expect(sut.getTimeBuckets(authStub.admin, {})).resolves.toEqual(
|
||||
expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }]),
|
||||
);
|
||||
expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith({
|
||||
userIds: [authStub.admin.user.id],
|
||||
});
|
||||
expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith(
|
||||
{
|
||||
userIds: [authStub.admin.user.id],
|
||||
},
|
||||
authStub.admin,
|
||||
);
|
||||
});
|
||||
|
||||
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({
|
||||
userIds: [authStub.admin.user.id],
|
||||
bbox: { west: -70, south: -30, east: 120, north: 55 },
|
||||
});
|
||||
expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith(
|
||||
{
|
||||
userIds: [authStub.admin.user.id],
|
||||
bbox: { west: -70, south: -30, east: 120, north: 55 },
|
||||
},
|
||||
authStub.admin,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export class TimelineService extends BaseService {
|
|||
async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketsResponseDto[]> {
|
||||
await this.timeBucketChecks(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
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ describe(UserAdminService.name, () => {
|
|||
name: userStub.user1.name,
|
||||
storageLabel: 'label',
|
||||
password: expect.anything(),
|
||||
clusterGroupId: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -234,6 +234,11 @@ export interface IDelayedJob extends IBaseJob {
|
|||
}
|
||||
|
||||
export type JobSource = 'upload' | 'sidecar-write' | 'copy' | 'edit';
|
||||
export interface IPersonJob {
|
||||
ownerId: string;
|
||||
personGroupId: string;
|
||||
}
|
||||
|
||||
export interface IEntityJob extends IBaseJob {
|
||||
id: string;
|
||||
source?: JobSource;
|
||||
|
|
@ -385,7 +390,7 @@ export type JobItem =
|
|||
// Migration
|
||||
| { name: JobName.FileMigrationQueueAll; data?: IBaseJob }
|
||||
| { name: JobName.AssetFileMigration; data: IEntityJob }
|
||||
| { name: JobName.PersonFileMigration; data: IEntityJob }
|
||||
| { name: JobName.PersonFileMigration; data: IPersonJob }
|
||||
|
||||
// Metadata Extraction
|
||||
| { name: JobName.AssetExtractMetadataQueueAll; data: IBaseJob }
|
||||
|
|
@ -404,7 +409,7 @@ export type JobItem =
|
|||
| { name: JobName.AssetDetectFaces; data: IEntityJob }
|
||||
| { name: JobName.FacialRecognitionQueueAll; data: INightlyJob }
|
||||
| { name: JobName.FacialRecognition; data: IDeferrableJob }
|
||||
| { name: JobName.PersonGenerateThumbnail; data: IEntityJob }
|
||||
| { name: JobName.PersonGenerateThumbnail; data: IPersonJob }
|
||||
|
||||
// Smart Search
|
||||
| { name: JobName.SmartSearchQueueAll; data: IBaseJob }
|
||||
|
|
|
|||
|
|
@ -299,6 +299,19 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
|
|||
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:
|
||||
case Permission.ClusterGroupRequestDelete:
|
||||
case Permission.ClusterGroupRequestRead: {
|
||||
return await access.clusterGroup.checkOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
case Permission.PartnerUpdate: {
|
||||
return await access.partner.checkUpdateAccess(auth.user.id, ids);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -237,43 +237,67 @@ export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFile
|
|||
.where('asset_file.isEdited', '=', sql.lit(isEdited));
|
||||
}
|
||||
|
||||
export function withFacesAndPeople(
|
||||
eb: ExpressionBuilder<DB, 'asset'>,
|
||||
withHidden?: boolean,
|
||||
withDeletedFace?: boolean,
|
||||
) {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('asset_face')
|
||||
.leftJoinLateral(
|
||||
(eb) =>
|
||||
eb.selectFrom('person').selectAll('person').whereRef('asset_face.personId', '=', 'person.id').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 type WithFacesAndPeopleOptions = {
|
||||
/** whose version of the person to select */
|
||||
viewingUserId: string;
|
||||
withHidden?: boolean;
|
||||
withDeletedFace?: boolean;
|
||||
};
|
||||
|
||||
export function withFacesAndPeople({ viewingUserId, withHidden, withDeletedFace }: WithFacesAndPeopleOptions) {
|
||||
return (eb: ExpressionBuilder<DB, 'asset'>) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('asset_face')
|
||||
.leftJoinLateral(
|
||||
(eb) =>
|
||||
eb
|
||||
.selectFrom('person')
|
||||
.selectAll('person')
|
||||
.whereRef('person.personGroupId', '=', 'asset_face.personGroupId')
|
||||
.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(
|
||||
(eb) =>
|
||||
eb
|
||||
.selectFrom('asset_face')
|
||||
.select('assetId')
|
||||
.where('personId', '=', anyUuid(personIds!))
|
||||
.where('personGroupId', '=', anyUuid(personGroupIds!))
|
||||
.where('deletedAt', 'is', null)
|
||||
.where('isVisible', 'is', true)
|
||||
.groupBy('assetId')
|
||||
.having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length)
|
||||
.having((eb) => eb.fn.count('personGroupId').distinct(), '=', personGroupIds.length)
|
||||
.as('has_people'),
|
||||
(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[]) {
|
||||
return qb.innerJoin(
|
||||
(eb) =>
|
||||
|
|
@ -511,7 +535,9 @@ export function searchAssetBuilderLegacy(kysely: Kysely<DB>, options: AssetSearc
|
|||
)
|
||||
.$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null))
|
||||
.$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));
|
||||
}
|
||||
|
||||
|
|
@ -569,7 +595,7 @@ function albumIdsPredicates(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, {
|
||||
matchesAny: (ids) => eb.exists(matching(ids)),
|
||||
matchesAll: (ids) =>
|
||||
|
|
@ -577,7 +603,7 @@ function personIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) {
|
|||
matching(ids)
|
||||
.select('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),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
|
@ -775,7 +801,9 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
|
|||
.$if(!!options.userIds && options.userIds.length > 0, (qb) =>
|
||||
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))
|
||||
.where((eb) => {
|
||||
const predicates = branchPredicates(eb, filter);
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ const createFace = (params: Partial<AssetFace> = {}): AssetFace => ({
|
|||
boundingBoxY2: 200,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 1000,
|
||||
personId: null,
|
||||
personGroupId: null,
|
||||
sourceType: SourceType.MachineLearning,
|
||||
person: null,
|
||||
updatedAt: new Date(),
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export class AssetFaceFactory {
|
|||
imageHeight: 500,
|
||||
imageWidth: 400,
|
||||
isVisible: true,
|
||||
personId: null,
|
||||
personGroupId: null,
|
||||
sourceType: SourceType.MachineLearning,
|
||||
updatedAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
|
|
@ -37,7 +37,7 @@ export class AssetFaceFactory {
|
|||
|
||||
person(dto: PersonLike = {}, builder?: FactoryBuilder<PersonFactory>) {
|
||||
this.#person = build(PersonFactory.from(dto), builder);
|
||||
this.value.personId = this.#person.build().id;
|
||||
this.value.personGroupId = this.#person.build().personGroupId;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
|
|
|||
27
server/test/factories/cluster-group.factory.ts
Normal file
27
server/test/factories/cluster-group.factory.ts
Normal file
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
28
server/test/factories/person-group.factory.ts
Normal file
28
server/test/factories/person-group.factory.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Selectable } from 'kysely';
|
||||
import { PersonGroupTable } from 'src/schema/tables/person-group.table';
|
||||
import { PersonGroupLike } from 'test/factories/types';
|
||||
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
|
||||
|
||||
export class PersonGroupFactory {
|
||||
private constructor(private readonly value: Selectable<PersonGroupTable>) {}
|
||||
|
||||
static create(dto: PersonGroupLike = {}) {
|
||||
return PersonGroupFactory.from(dto).build();
|
||||
}
|
||||
|
||||
static from(dto: PersonGroupLike = {}) {
|
||||
return new PersonGroupFactory({
|
||||
id: newUuid(),
|
||||
clusterGroupId: newUuid(),
|
||||
createdAt: newDate(),
|
||||
createId: newUuidV7(),
|
||||
updatedAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
...dto,
|
||||
});
|
||||
}
|
||||
|
||||
build() {
|
||||
return { ...this.value };
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ export class PersonFactory {
|
|||
color: null,
|
||||
createdAt: newDate(),
|
||||
faceAssetId: null,
|
||||
id: newUuid(),
|
||||
personGroupId: newUuid(),
|
||||
isFavorite: false,
|
||||
isHidden: false,
|
||||
name: 'person',
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@ import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
|||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||
import { AssetFileTable } from 'src/schema/tables/asset-file.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 { 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 { SessionTable } from 'src/schema/tables/session.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 AssetFaceLike = Partial<Selectable<AssetFaceTable>>;
|
||||
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 MemoryLike = Partial<Selectable<MemoryTable>>;
|
||||
export type PartnerLike = Partial<Selectable<PartnerTable>>;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export class UserFactory {
|
|||
static from(dto: UserLike = {}) {
|
||||
return new UserFactory({
|
||||
id: newUuid(),
|
||||
clusterGroupId: newUuid(),
|
||||
email: 'test@immich.cloud',
|
||||
password: '',
|
||||
pinCode: null,
|
||||
|
|
|
|||
2
server/test/fixtures/user.stub.ts
vendored
2
server/test/fixtures/user.stub.ts
vendored
|
|
@ -6,6 +6,7 @@ export const userStub = {
|
|||
admin: <UserAdmin>{
|
||||
...authStub.admin.user,
|
||||
status: UserStatus.Active,
|
||||
clusterGroupId: 'cluster-group-id',
|
||||
profileChangedAt: new Date('2021-01-01'),
|
||||
name: 'admin_name',
|
||||
id: 'admin_id',
|
||||
|
|
@ -24,6 +25,7 @@ export const userStub = {
|
|||
user1: <UserAdmin>{
|
||||
...authStub.user1.user,
|
||||
status: UserStatus.Active,
|
||||
clusterGroupId: 'cluster-group-id',
|
||||
profileChangedAt: new Date('2021-01-01'),
|
||||
name: 'immich_name',
|
||||
storageLabel: null,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Selectable, ShallowDehydrateObject } from 'kysely';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||
import { FaceSearchResult } from 'src/repositories/search.repository';
|
||||
import { ActivityTable } from 'src/schema/tables/activity.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.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 { StackFactory } from 'test/factories/stack.factory';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
|
||||
export const getForStorageTemplate = (asset: ReturnType<AssetFactory['build']>) => {
|
||||
return {
|
||||
|
|
@ -54,15 +56,27 @@ export const getAsDetectedFace = (face: ReturnType<AssetFaceFactory['build']>) =
|
|||
|
||||
export const getForFacialRecognitionJob = (
|
||||
face: ReturnType<AssetFaceFactory['build']>,
|
||||
asset: Pick<Selectable<AssetTable>, 'ownerId' | 'visibility' | 'fileCreatedAt'> | null,
|
||||
asset:
|
||||
(Pick<Selectable<AssetTable>, 'ownerId' | 'visibility' | 'fileCreatedAt'> & { clusterGroupId?: string }) | null,
|
||||
) => ({
|
||||
...face,
|
||||
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,
|
||||
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) => {
|
||||
const copiedEntity = structuredClone(entity);
|
||||
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)),
|
||||
});
|
||||
|
||||
export const getForMetadataExtraction = (asset: ReturnType<AssetFactory['build']>) => ({
|
||||
export const getForMetadataExtraction = (
|
||||
asset: ReturnType<AssetFactory['build']>,
|
||||
{ clusterGroupId }: { clusterGroupId?: string } = {},
|
||||
) => ({
|
||||
id: asset.id,
|
||||
clusterGroupId: clusterGroupId ?? newUuid(),
|
||||
checksum: asset.checksum,
|
||||
checksumAlgorithm: asset.checksumAlgorithm,
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { AlbumRepository } from 'src/repositories/album.repository';
|
|||
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
|
||||
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { CronRepository } from 'src/repositories/cron.repository';
|
||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||
|
|
@ -163,7 +164,8 @@ export class MediumTestContext<S extends ClassConstructor<typeof BaseService> =
|
|||
}
|
||||
|
||||
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);
|
||||
return { user, result };
|
||||
}
|
||||
|
|
@ -263,8 +265,14 @@ export class MediumTestContext<S extends ClassConstructor<typeof BaseService> =
|
|||
}
|
||||
|
||||
async newPerson(dto: Partial<Insertable<PersonTable>> & { ownerId: string }) {
|
||||
const person = mediumFactory.personInsert(dto);
|
||||
const result = await this.get(PersonRepository).create(person);
|
||||
const repository = this.get(PersonRepository);
|
||||
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 };
|
||||
}
|
||||
|
||||
|
|
@ -445,6 +453,7 @@ const newRealRepository = <T extends BaseServiceDeps[number]>(key: T, db: Kysely
|
|||
case AssetRepository:
|
||||
case AssetEditRepository:
|
||||
case AssetJobRepository:
|
||||
case ClusterGroupRepository:
|
||||
case IntegrityRepository:
|
||||
case MemoryRepository:
|
||||
case NotificationRepository:
|
||||
|
|
@ -648,7 +657,7 @@ const assetFaceInsert = (assetFace: Partial<AssetFace> & { assetId: string }) =>
|
|||
id: assetFace.id ?? newUuid(),
|
||||
imageHeight: assetFace.imageHeight ?? 10,
|
||||
imageWidth: assetFace.imageWidth ?? 10,
|
||||
personId: assetFace.personId ?? null,
|
||||
personGroupId: assetFace.personGroupId ?? null,
|
||||
sourceType: assetFace.sourceType ?? SourceType.MachineLearning,
|
||||
isVisible: assetFace.isVisible ?? true,
|
||||
};
|
||||
|
|
@ -675,13 +684,12 @@ const assetJobStatusInsert = (
|
|||
};
|
||||
};
|
||||
|
||||
const personInsert = (person: Partial<Insertable<PersonTable>> & { ownerId: string }) => {
|
||||
const personInsert = (person: Partial<Insertable<PersonTable>> & { ownerId: string; personGroupId: string }) => {
|
||||
const defaults = {
|
||||
birthDate: person.birthDate || null,
|
||||
color: person.color || null,
|
||||
createdAt: person.createdAt || newDate(),
|
||||
faceAssetId: person.faceAssetId || null,
|
||||
id: person.id || newUuid(),
|
||||
isFavorite: person.isFavorite || false,
|
||||
isHidden: person.isHidden || false,
|
||||
name: person.name || 'Test Name',
|
||||
|
|
@ -715,7 +723,7 @@ const sessionInsert = ({
|
|||
};
|
||||
};
|
||||
|
||||
const userInsert = (user: Partial<Insertable<UserTable>> = {}) => {
|
||||
const userInsert = (user: Partial<Insertable<UserTable>> & { clusterGroupId: string }) => {
|
||||
const id = user.id || newUuid();
|
||||
|
||||
const defaults = {
|
||||
|
|
@ -804,7 +812,7 @@ const loginDetails = () => {
|
|||
};
|
||||
|
||||
const loginResponse = (): LoginResponseDto => {
|
||||
const user = userInsert({});
|
||||
const user = userInsert({ clusterGroupId: newUuid() });
|
||||
return {
|
||||
accessToken: 'access-token',
|
||||
userId: user.id,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,161 @@ beforeAll(async () => {
|
|||
});
|
||||
|
||||
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', () => {
|
||||
it('should not return the edited preview path', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
|
|
@ -33,7 +188,7 @@ describe(PersonRepository.name, () => {
|
|||
|
||||
const { assetFace } = await ctx.newAssetFace({
|
||||
assetId: asset.id,
|
||||
personId: person.id,
|
||||
personGroupId: person.personGroupId,
|
||||
boundingBoxX1: 10,
|
||||
boundingBoxY1: 10,
|
||||
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
|
||||
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({
|
||||
assetId: asset.id,
|
||||
|
|
@ -56,7 +216,10 @@ describe(PersonRepository.name, () => {
|
|||
isEdited: false,
|
||||
});
|
||||
|
||||
const result = await sut.getDataForThumbnailGenerationJob(person.id);
|
||||
const result = await sut.getDataForThumbnailGenerationJob({
|
||||
ownerId: person.ownerId,
|
||||
personGroupId: person.personGroupId,
|
||||
});
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { hash } from 'bcrypt';
|
|||
import { Kysely } from 'kysely';
|
||||
import { AuthType } from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
|
|
@ -26,6 +27,7 @@ const setup = (db?: Kysely<DB>) => {
|
|||
database: db || defaultDatabase,
|
||||
real: [
|
||||
AccessRepository,
|
||||
ClusterGroupRepository,
|
||||
ConfigRepository,
|
||||
CryptoRepository,
|
||||
DatabaseRepository,
|
||||
|
|
|
|||
496
server/test/medium/specs/services/cluster-group.service.spec.ts
Normal file
496
server/test/medium/specs/services/cluster-group.service.spec.ts
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
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(String) }),
|
||||
);
|
||||
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('Invalid user');
|
||||
});
|
||||
});
|
||||
|
||||
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 clusterGroupRequest.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('Request not found');
|
||||
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);
|
||||
});
|
||||
|
||||
it('should require the user to leave their current cluster group first', async () => {
|
||||
const { sut, ctx } = setup(await getKyselyDB());
|
||||
const { user: owner } = await ctx.newUser();
|
||||
const { user } = await ctx.newUser();
|
||||
const { user: third } = await ctx.newUser();
|
||||
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
|
||||
const thirdClusterGroupId = await getClusterGroupId(ctx, third.id);
|
||||
|
||||
// the user joins the first group
|
||||
const { value: first } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, {
|
||||
userId: user.id,
|
||||
});
|
||||
await sut.acceptRequest(factory.auth({ user }), first.id);
|
||||
|
||||
// and is then asked to join another one without leaving
|
||||
const { value: second } = await sut.createRequest(factory.auth({ user: third }), thirdClusterGroupId, {
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
await expect(sut.acceptRequest(factory.auth({ user }), second.id)).rejects.toThrow(
|
||||
'Leave the current cluster group before joining another one',
|
||||
);
|
||||
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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -47,9 +47,11 @@ describe(PersonService.name, () => {
|
|||
const auth = factory.auth({ user });
|
||||
storageMock.unlink.mockResolvedValue();
|
||||
|
||||
await expect(personRepo.getById(person.id)).resolves.toEqual(expect.objectContaining({ id: person.id }));
|
||||
await expect(sut.delete(auth, person.id)).resolves.toBeUndefined();
|
||||
await expect(personRepo.getById(person.id)).resolves.toBeUndefined();
|
||||
await expect(personRepo.getByGroupId(person)).resolves.toEqual(
|
||||
expect.objectContaining({ personGroupId: person.personGroupId }),
|
||||
);
|
||||
await expect(sut.delete(auth, person.personGroupId)).resolves.toBeUndefined();
|
||||
await expect(personRepo.getByGroupId(person)).resolves.toBeUndefined();
|
||||
|
||||
expect(storageMock.unlink).toHaveBeenCalledWith(person.thumbnailPath);
|
||||
});
|
||||
|
|
@ -73,9 +75,11 @@ describe(PersonService.name, () => {
|
|||
const auth = factory.auth({ user });
|
||||
storageMock.unlink.mockResolvedValue();
|
||||
|
||||
await expect(sut.deleteAll(auth, { ids: [person1.id, person2.id] })).resolves.toBeUndefined();
|
||||
await expect(personRepo.getById(person1.id)).resolves.toBeUndefined();
|
||||
await expect(personRepo.getById(person2.id)).resolves.toBeUndefined();
|
||||
await expect(
|
||||
sut.deleteAll(auth, { ids: [person1.personGroupId, person2.personGroupId] }),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(personRepo.getByGroupId(person1)).resolves.toBeUndefined();
|
||||
await expect(personRepo.getByGroupId(person2)).resolves.toBeUndefined();
|
||||
|
||||
expect(storageMock.unlink).toHaveBeenCalledTimes(2);
|
||||
expect(storageMock.unlink).toHaveBeenCalledWith(person1.thumbnailPath);
|
||||
|
|
@ -101,7 +105,7 @@ describe(PersonService.name, () => {
|
|||
y: 50,
|
||||
width: 150,
|
||||
height: 150,
|
||||
personId: person.id,
|
||||
personId: person.personGroupId,
|
||||
assetId: asset.id,
|
||||
};
|
||||
|
||||
|
|
@ -114,7 +118,7 @@ describe(PersonService.name, () => {
|
|||
await expect(faces).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
person: expect.objectContaining({ id: person.personGroupId }),
|
||||
boundingBoxX1: 50,
|
||||
boundingBoxY1: 50,
|
||||
boundingBoxX2: 200,
|
||||
|
|
@ -155,7 +159,7 @@ describe(PersonService.name, () => {
|
|||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
personId: person.id,
|
||||
personId: person.personGroupId,
|
||||
assetId: asset.id,
|
||||
};
|
||||
|
||||
|
|
@ -168,7 +172,7 @@ describe(PersonService.name, () => {
|
|||
await expect(faces).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
person: expect.objectContaining({ id: person.personGroupId }),
|
||||
boundingBoxX1: 0,
|
||||
boundingBoxY1: 0,
|
||||
boundingBoxX2: 100,
|
||||
|
|
@ -186,7 +190,7 @@ describe(PersonService.name, () => {
|
|||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
person: expect.objectContaining({ id: person.personGroupId }),
|
||||
boundingBoxX1: 50,
|
||||
boundingBoxY1: 50,
|
||||
boundingBoxX2: 150,
|
||||
|
|
@ -224,7 +228,7 @@ describe(PersonService.name, () => {
|
|||
y: 50,
|
||||
width: 10,
|
||||
height: 10,
|
||||
personId: person.id,
|
||||
personId: person.personGroupId,
|
||||
assetId: asset.id,
|
||||
};
|
||||
|
||||
|
|
@ -235,7 +239,7 @@ describe(PersonService.name, () => {
|
|||
await expect(faces).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
person: expect.objectContaining({ id: person.personGroupId }),
|
||||
boundingBoxX1: expect.closeTo(25, 1),
|
||||
boundingBoxY1: expect.closeTo(50, 1),
|
||||
boundingBoxX2: expect.closeTo(35, 1),
|
||||
|
|
@ -251,7 +255,7 @@ describe(PersonService.name, () => {
|
|||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
person: expect.objectContaining({ id: person.personGroupId }),
|
||||
boundingBoxX1: 50,
|
||||
boundingBoxY1: 65,
|
||||
boundingBoxX2: 60,
|
||||
|
|
@ -289,7 +293,7 @@ describe(PersonService.name, () => {
|
|||
y: 25,
|
||||
width: 100,
|
||||
height: 50,
|
||||
personId: person.id,
|
||||
personId: person.personGroupId,
|
||||
assetId: asset.id,
|
||||
};
|
||||
|
||||
|
|
@ -300,7 +304,7 @@ describe(PersonService.name, () => {
|
|||
await expect(faces).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
person: expect.objectContaining({ id: person.personGroupId }),
|
||||
boundingBoxX1: 50,
|
||||
boundingBoxY1: 25,
|
||||
boundingBoxX2: 150,
|
||||
|
|
@ -316,7 +320,7 @@ describe(PersonService.name, () => {
|
|||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
person: expect.objectContaining({ id: person.personGroupId }),
|
||||
boundingBoxX1: 50,
|
||||
boundingBoxY1: 25,
|
||||
boundingBoxX2: 150,
|
||||
|
|
@ -363,7 +367,7 @@ describe(PersonService.name, () => {
|
|||
y: 25,
|
||||
width: 10,
|
||||
height: 20,
|
||||
personId: person.id,
|
||||
personId: person.personGroupId,
|
||||
assetId: asset.id,
|
||||
};
|
||||
|
||||
|
|
@ -374,7 +378,7 @@ describe(PersonService.name, () => {
|
|||
await expect(faces).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
person: expect.objectContaining({ id: person.personGroupId }),
|
||||
boundingBoxX1: expect.closeTo(50, 1),
|
||||
boundingBoxY1: expect.closeTo(25, 1),
|
||||
boundingBoxX2: expect.closeTo(60, 1),
|
||||
|
|
@ -390,7 +394,7 @@ describe(PersonService.name, () => {
|
|||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
person: expect.objectContaining({ id: person.personGroupId }),
|
||||
boundingBoxX1: 75,
|
||||
boundingBoxY1: 140,
|
||||
boundingBoxX2: 95,
|
||||
|
|
@ -437,7 +441,7 @@ describe(PersonService.name, () => {
|
|||
y: 25,
|
||||
width: 75,
|
||||
height: 50,
|
||||
personId: person.id,
|
||||
personId: person.personGroupId,
|
||||
assetId: asset.id,
|
||||
};
|
||||
|
||||
|
|
@ -448,7 +452,7 @@ describe(PersonService.name, () => {
|
|||
await expect(faces).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
person: expect.objectContaining({ id: person.personGroupId }),
|
||||
boundingBoxX1: 25,
|
||||
boundingBoxY1: 25,
|
||||
boundingBoxX2: 100,
|
||||
|
|
@ -464,7 +468,7 @@ describe(PersonService.name, () => {
|
|||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
person: expect.objectContaining({ id: person.personGroupId }),
|
||||
boundingBoxX1: 100,
|
||||
boundingBoxY1: 25,
|
||||
boundingBoxX2: 175,
|
||||
|
|
@ -508,7 +512,7 @@ describe(PersonService.name, () => {
|
|||
y: 25,
|
||||
width: 15,
|
||||
height: 20,
|
||||
personId: person.id,
|
||||
personId: person.personGroupId,
|
||||
assetId: asset.id,
|
||||
};
|
||||
|
||||
|
|
@ -519,7 +523,7 @@ describe(PersonService.name, () => {
|
|||
await expect(faces).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
person: expect.objectContaining({ id: person.personGroupId }),
|
||||
boundingBoxX1: expect.closeTo(50, 1),
|
||||
boundingBoxY1: expect.closeTo(25, 1),
|
||||
boundingBoxX2: expect.closeTo(65, 1),
|
||||
|
|
@ -535,7 +539,7 @@ describe(PersonService.name, () => {
|
|||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
person: expect.objectContaining({ id: person.personGroupId }),
|
||||
boundingBoxX1: 25,
|
||||
boundingBoxY1: 50,
|
||||
boundingBoxX2: 45,
|
||||
|
|
@ -588,7 +592,7 @@ describe(PersonService.name, () => {
|
|||
y: 50,
|
||||
width: 75,
|
||||
height: 50,
|
||||
personId: person.id,
|
||||
personId: person.personGroupId,
|
||||
assetId: asset.id,
|
||||
};
|
||||
|
||||
|
|
@ -599,7 +603,7 @@ describe(PersonService.name, () => {
|
|||
await expect(faces).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
person: expect.objectContaining({ id: person.personGroupId }),
|
||||
boundingBoxX1: 25,
|
||||
boundingBoxY1: 49,
|
||||
boundingBoxX2: 99,
|
||||
|
|
@ -615,7 +619,7 @@ describe(PersonService.name, () => {
|
|||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
person: expect.objectContaining({ id: person.personGroupId }),
|
||||
boundingBoxX1: 50,
|
||||
boundingBoxY1: 75,
|
||||
boundingBoxX2: 100,
|
||||
|
|
@ -659,7 +663,7 @@ describe(PersonService.name, () => {
|
|||
y: 10,
|
||||
width: 80,
|
||||
height: 80,
|
||||
personId: person.id,
|
||||
personId: person.personGroupId,
|
||||
assetId: asset.id,
|
||||
};
|
||||
|
||||
|
|
@ -670,7 +674,7 @@ describe(PersonService.name, () => {
|
|||
await expect(faces).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
person: expect.objectContaining({ id: person.personGroupId }),
|
||||
boundingBoxX1: 10,
|
||||
boundingBoxY1: 10,
|
||||
boundingBoxX2: 90,
|
||||
|
|
@ -686,7 +690,7 @@ describe(PersonService.name, () => {
|
|||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
person: expect.objectContaining({ id: person.personGroupId }),
|
||||
boundingBoxX1: 10,
|
||||
boundingBoxY1: 10,
|
||||
boundingBoxX2: 90,
|
||||
|
|
@ -730,7 +734,7 @@ describe(PersonService.name, () => {
|
|||
y: 10,
|
||||
width: 80,
|
||||
height: 80,
|
||||
personId: person.id,
|
||||
personId: person.personGroupId,
|
||||
assetId: asset.id,
|
||||
};
|
||||
|
||||
|
|
@ -741,7 +745,7 @@ describe(PersonService.name, () => {
|
|||
await expect(faces).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
person: expect.objectContaining({ id: person.personGroupId }),
|
||||
boundingBoxX1: 110,
|
||||
boundingBoxY1: 10,
|
||||
boundingBoxX2: 190,
|
||||
|
|
@ -757,7 +761,7 @@ describe(PersonService.name, () => {
|
|||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
person: expect.objectContaining({ id: person.personGroupId }),
|
||||
boundingBoxX1: 10,
|
||||
boundingBoxY1: 10,
|
||||
boundingBoxX2: 90,
|
||||
|
|
|
|||
|
|
@ -69,11 +69,11 @@ describe(SearchService.name, () => {
|
|||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ 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 result = await sut.searchStatistics(auth, { personIds: [person.id] });
|
||||
const result = await sut.searchStatistics(auth, { personIds: [person.personGroupId] });
|
||||
|
||||
expect(result).toEqual({ total: 1 });
|
||||
});
|
||||
|
|
@ -85,7 +85,7 @@ describe(SearchService.name, () => {
|
|||
|
||||
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 });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Kysely } from 'kysely';
|
||||
import { DateTime } from 'luxon';
|
||||
import { ImmichEnvironment, JobName, JobStatus, UserAvatarColor } from 'src/enum';
|
||||
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { CryptoRepository } from 'src/repositories/crypto.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 { HumanReadableSize } from 'src/utils/bytes';
|
||||
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';
|
||||
|
||||
const userLicense = {
|
||||
|
|
@ -28,7 +29,7 @@ const setup = (db?: Kysely<DB>) => {
|
|||
|
||||
return newMediumService(UserService, {
|
||||
database: db || defaultDatabase,
|
||||
real: [CryptoRepository, ConfigRepository, SystemMetadataRepository, UserRepository],
|
||||
real: [ClusterGroupRepository, CryptoRepository, ConfigRepository, SystemMetadataRepository, UserRepository],
|
||||
mock: [LoggingRepository, JobRepository, EventRepository],
|
||||
});
|
||||
};
|
||||
|
|
@ -44,7 +45,7 @@ describe(UserService.name, () => {
|
|||
it('should create a user', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
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 });
|
||||
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 () => {
|
||||
const { sut, ctx } = setup();
|
||||
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 })).rejects.toThrow('Email is not available');
|
||||
});
|
||||
|
|
@ -62,7 +63,7 @@ describe(UserService.name, () => {
|
|||
it('should not return password', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
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' });
|
||||
expect((user as any).password).toBeUndefined();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ describe(SyncEntityType.AssetFaceV2, () => {
|
|||
const { auth, ctx } = await setup();
|
||||
const { asset } = await ctx.newAsset({ 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]);
|
||||
expect(response).toEqual([
|
||||
|
|
@ -32,7 +32,7 @@ describe(SyncEntityType.AssetFaceV2, () => {
|
|||
data: expect.objectContaining({
|
||||
id: assetFace.id,
|
||||
assetId: asset.id,
|
||||
personId: person.id,
|
||||
personId: person.personGroupId,
|
||||
imageWidth: assetFace.imageWidth,
|
||||
imageHeight: assetFace.imageHeight,
|
||||
boundingBoxX1: assetFace.boundingBoxX1,
|
||||
|
|
@ -103,7 +103,7 @@ describe(SyncEntityType.AssetFaceV2, () => {
|
|||
const { auth, ctx } = await setup();
|
||||
const { asset } = await ctx.newAsset({ 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]);
|
||||
expect(response).toEqual([
|
||||
|
|
@ -112,7 +112,7 @@ describe(SyncEntityType.AssetFaceV2, () => {
|
|||
data: expect.objectContaining({
|
||||
id: assetFace.id,
|
||||
assetId: asset.id,
|
||||
personId: person.id,
|
||||
personId: person.personGroupId,
|
||||
imageWidth: assetFace.imageWidth,
|
||||
imageHeight: assetFace.imageHeight,
|
||||
boundingBoxX1: assetFace.boundingBoxX1,
|
||||
|
|
@ -182,7 +182,7 @@ describe(SyncEntityType.AssetFaceV2, () => {
|
|||
const personRepo = ctx.get(PersonRepository);
|
||||
const { asset } = await ctx.newAsset({ 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]);
|
||||
expect(response).toEqual([
|
||||
|
|
@ -191,7 +191,7 @@ describe(SyncEntityType.AssetFaceV2, () => {
|
|||
data: expect.objectContaining({
|
||||
id: assetFace.id,
|
||||
assetId: asset.id,
|
||||
personId: person.id,
|
||||
personId: person.personGroupId,
|
||||
imageWidth: assetFace.imageWidth,
|
||||
imageHeight: assetFace.imageHeight,
|
||||
boundingBoxX1: assetFace.boundingBoxX1,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ describe(SyncEntityType.PersonV1, () => {
|
|||
{
|
||||
ack: expect.any(String),
|
||||
data: expect.objectContaining({
|
||||
id: person.id,
|
||||
id: person.personGroupId,
|
||||
name: person.name,
|
||||
isHidden: person.isHidden,
|
||||
birthDate: person.birthDate,
|
||||
|
|
@ -50,14 +50,14 @@ describe(SyncEntityType.PersonV1, () => {
|
|||
const { auth, ctx } = await setup();
|
||||
const personRepo = ctx.get(PersonRepository);
|
||||
const { person } = await ctx.newPerson({ ownerId: auth.user.id });
|
||||
await personRepo.delete([person.id]);
|
||||
await personRepo.delete([{ ownerId: person.ownerId, personGroupId: person.personGroupId }]);
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.PeopleV1]);
|
||||
expect(response).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
data: {
|
||||
personId: person.id,
|
||||
personId: person.personGroupId,
|
||||
},
|
||||
type: 'PersonDeleteV1',
|
||||
},
|
||||
|
|
@ -82,7 +82,7 @@ describe(SyncEntityType.PersonV1, () => {
|
|||
]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PeopleV1]);
|
||||
|
||||
await personRepo.delete([person.id]);
|
||||
await personRepo.delete([{ ownerId: person.ownerId, personGroupId: person.personGroupId }]);
|
||||
|
||||
expect(await ctx.syncStream(auth2, [SyncRequestType.PeopleV1])).toEqual([
|
||||
expect.objectContaining({ type: SyncEntityType.PersonDeleteV1 }),
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
|
|||
checkCreateAccess: vitest.fn().mockResolvedValue(new Set()),
|
||||
},
|
||||
|
||||
clusterGroup: {
|
||||
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
|
||||
checkInviteAccess: vitest.fn().mockResolvedValue(new Set()),
|
||||
},
|
||||
|
||||
asset: {
|
||||
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
|
||||
checkAlbumAccess: vitest.fn().mockResolvedValue(new Set()),
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { AppRepository } from 'src/repositories/app.repository';
|
|||
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
|
||||
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { CronRepository } from 'src/repositories/cron.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 { RepositoryInterface } from 'src/types';
|
||||
import { getKyselyConfig } from 'src/utils/database';
|
||||
import { ClusterGroupFactory } from 'test/factories/cluster-group.factory';
|
||||
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
|
||||
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
|
||||
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
|
||||
|
|
@ -236,6 +238,7 @@ export type ServiceOverrides = {
|
|||
asset: AssetRepository;
|
||||
assetEdit: AssetEditRepository;
|
||||
assetJob: AssetJobRepository;
|
||||
clusterGroup: ClusterGroupRepository;
|
||||
config: ConfigRepository;
|
||||
cron: CronRepository;
|
||||
crypto: CryptoRepository;
|
||||
|
|
@ -319,6 +322,7 @@ export const getMocks = () => {
|
|||
asset: newAssetRepositoryMock(),
|
||||
assetEdit: automock(AssetEditRepository),
|
||||
assetJob: automock(AssetJobRepository),
|
||||
clusterGroup: automock(ClusterGroupRepository),
|
||||
app: automock(AppRepository, { strict: false }),
|
||||
config: newConfigRepositoryMock(),
|
||||
database: databaseMock,
|
||||
|
|
@ -369,6 +373,9 @@ export const getMocks = () => {
|
|||
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;
|
||||
};
|
||||
|
||||
|
|
@ -389,6 +396,7 @@ export const newTestService = <T extends BaseService>(
|
|||
overrides.asset || (mocks.asset as As<AssetRepository>),
|
||||
overrides.assetEdit || (mocks.assetEdit as As<AssetEditRepository>),
|
||||
overrides.assetJob || (mocks.assetJob as As<AssetJobRepository>),
|
||||
overrides.clusterGroup || (mocks.clusterGroup as As<ClusterGroupRepository>),
|
||||
overrides.config || (mocks.config as As<ConfigRepository> as ConfigRepository),
|
||||
overrides.cron || (mocks.cron as As<CronRepository>),
|
||||
overrides.crypto || (mocks.crypto as As<CryptoRepository>),
|
||||
|
|
|
|||
|
|
@ -57,45 +57,49 @@
|
|||
);
|
||||
</script>
|
||||
|
||||
{#if !authManager.isSharedLink && isOwner}
|
||||
{#if !authManager.isSharedLink}
|
||||
<section class="px-4 pt-4 text-sm">
|
||||
<div class="flex h-10 w-full items-center justify-between">
|
||||
<Text size="small" color="muted">{$t('people')}</Text>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if people.some((person) => person.isHidden)}
|
||||
<IconButton
|
||||
aria-label={$t('show_hidden_people')}
|
||||
icon={assetViewerManager.isShowingHiddenPeople ? mdiEyeOff : mdiEye}
|
||||
size="medium"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={() => assetViewerManager.toggleHiddenPeople()}
|
||||
/>
|
||||
{/if}
|
||||
<IconButton
|
||||
aria-label={$t('tag_people')}
|
||||
icon={mdiPlus}
|
||||
size="medium"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={() => assetViewerManager.toggleFaceEditMode()}
|
||||
/>
|
||||
{#if isOwner || visiblePeople.length > 0}
|
||||
<div class="flex h-10 w-full items-center justify-between">
|
||||
<Text size="small" color="muted">{$t('people')}</Text>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if isOwner}
|
||||
{#if people.some((person) => person.isHidden)}
|
||||
<IconButton
|
||||
aria-label={$t('show_hidden_people')}
|
||||
icon={assetViewerManager.isShowingHiddenPeople ? mdiEyeOff : mdiEye}
|
||||
size="medium"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={() => assetViewerManager.toggleHiddenPeople()}
|
||||
/>
|
||||
{/if}
|
||||
<IconButton
|
||||
aria-label={$t('tag_people')}
|
||||
icon={mdiPlus}
|
||||
size="medium"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={() => assetViewerManager.toggleFaceEditMode()}
|
||||
/>
|
||||
|
||||
{#if faceManager.data.length > 0}
|
||||
<IconButton
|
||||
aria-label={$t('edit_people')}
|
||||
icon={mdiPencil}
|
||||
size="medium"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={() => assetViewerManager.openEditFacesPanel()}
|
||||
/>
|
||||
{/if}
|
||||
{#if faceManager.data.length > 0}
|
||||
<IconButton
|
||||
aria-label={$t('edit_people')}
|
||||
icon={mdiPencil}
|
||||
size="medium"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={() => assetViewerManager.openEditFacesPanel()}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-2 grid {visiblePeople.length <= 6 ? 'grid-cols-3 gap-3' : 'grid-cols-4 gap-2'}">
|
||||
{#each visiblePeople as person (person.id)}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { focusTrap } from '$lib/actions/focus-trap';
|
||||
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 { handleError } from '$lib/utils/handle-error';
|
||||
import { NotificationType, type NotificationDto } from '@immich/sdk';
|
||||
|
|
@ -50,6 +52,11 @@
|
|||
break;
|
||||
}
|
||||
|
||||
case NotificationType.ClusterGroupRequest: {
|
||||
await goto(Route.userSettings({ isOpen: OpenQueryParam.SHARING }));
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export enum OpenQueryParam {
|
|||
STORAGE_TEMPLATE = 'storage-template',
|
||||
NOTIFICATIONS = 'notifications',
|
||||
PURCHASE_SETTINGS = 'user-purchase-settings',
|
||||
SHARING = 'sharing',
|
||||
}
|
||||
|
||||
export const maximumLengthSearchPeople = 100;
|
||||
|
|
|
|||
60
web/src/lib/modals/ClusterGroupUserSelectionModal.svelte
Normal file
60
web/src/lib/modals/ClusterGroupUserSelectionModal.svelte
Normal file
|
|
@ -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';
|
||||
|
||||
interface 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>
|
||||
54
web/src/lib/modals/ClusterGroupUsersModal.svelte
Normal file
54
web/src/lib/modals/ClusterGroupUsersModal.svelte
Normal file
|
|
@ -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>
|
||||
|
|
@ -63,6 +63,7 @@
|
|||
let { data }: Props = $props();
|
||||
|
||||
let numberOfAssets = $derived(data.statistics.assets);
|
||||
let numberOfSharedAssets = $derived(data.statistics.sharedAssets);
|
||||
let person = $derived(data.person);
|
||||
let thumbnailData = $derived(getPeopleThumbnailUrl(person));
|
||||
|
||||
|
|
@ -395,6 +396,9 @@
|
|||
<p class="w-40 truncate font-medium sm:w-72">{person.name || $t('add_a_name')}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{$t('assets_count', { values: { count: numberOfAssets } })}
|
||||
{#if numberOfSharedAssets > 0}
|
||||
· {$t('shared_assets_count', { values: { count: numberOfSharedAssets } })}
|
||||
{/if}
|
||||
</p>
|
||||
{#if person.birthDate}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
379
web/src/routes/(user)/user-settings/SharingSettings.svelte
Normal file
379
web/src/routes/(user)/user-settings/SharingSettings.svelte
Normal file
|
|
@ -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 ClusterGroupUserSelectionModal from '$lib/modals/ClusterGroupUserSelectionModal.svelte';
|
||||
import ClusterGroupUsersModal from '$lib/modals/ClusterGroupUsersModal.svelte';
|
||||
import PartnerSelectionModal from '$lib/modals/PartnerSelectionModal.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.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';
|
||||
|
||||
interface 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.unable_to_create_cluster_group_request'));
|
||||
}
|
||||
};
|
||||
|
||||
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 DeviceList from './DeviceList.svelte';
|
||||
import OauthSettings from './OauthSettings.svelte';
|
||||
import PartnerSettings from './PartnerSettings.svelte';
|
||||
import SharingSettings from './SharingSettings.svelte';
|
||||
import UserApiKeyList from './UserApiKeyList.svelte';
|
||||
import UserProfileSettings from './UserProfileSettings.svelte';
|
||||
|
||||
|
|
@ -129,15 +129,6 @@
|
|||
<ChangePasswordSettings />
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
icon={mdiAccountGroupOutline}
|
||||
key="partner-sharing"
|
||||
title={$t('partner_sharing')}
|
||||
subtitle={$t('manage_sharing_with_partners')}
|
||||
>
|
||||
<PartnerSettings />
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
icon={mdiLockSmart}
|
||||
key="user-pin-code-settings"
|
||||
|
|
@ -157,3 +148,12 @@
|
|||
>
|
||||
<UserPurchaseSettings />
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
icon={mdiAccountGroupOutline}
|
||||
key={OpenQueryParam.SHARING}
|
||||
title={$t('sharing')}
|
||||
subtitle={$t('manage_sharing_with_other_users')}
|
||||
>
|
||||
<SharingSettings />
|
||||
</SettingAccordion>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export const userFactory = Sync.makeFactory<UserResponseDto>({
|
|||
|
||||
export const userAdminFactory = Sync.makeFactory<UserAdminResponseDto>({
|
||||
id: Sync.each(() => faker.string.uuid()),
|
||||
clusterGroupId: Sync.each(() => faker.string.uuid()),
|
||||
email: Sync.each(() => faker.internet.email()),
|
||||
name: Sync.each(() => faker.person.fullName()),
|
||||
profileImagePath: '',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue