diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 3124dd0609f77..0be265b6a6019 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -184,6 +184,8 @@ export const utils = { 'library', 'shared_link', 'person', + 'person_group', + 'cluster_group', 'album', 'asset', 'asset_face', @@ -439,7 +441,10 @@ export const utils = { return; } - await client.query('INSERT INTO asset_face ("assetId", "personId") VALUES ($1, $2)', [assetId, personId]); + await client.query( + 'INSERT INTO asset_face ("assetId", "personId") SELECT $1, "id" FROM "person" WHERE "personGroupId" = $2', + [assetId, personId], + ); }, setPersonThumbnail: async (personId: string) => { @@ -447,7 +452,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) => diff --git a/i18n/en.json b/i18n/en.json index 536b7677ac0d8..4b9e25c077340 100644 --- a/i18n/en.json +++ b/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", @@ -2245,6 +2258,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", @@ -2288,6 +2302,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", diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index bc3bf82094a27..f05c53942654b 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -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.", @@ -16270,6 +16619,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." @@ -18401,6 +18754,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": [ @@ -20486,6 +20887,7 @@ "SystemMessage", "AlbumInvite", "AlbumUpdate", + "ClusterGroupRequest", "Custom" ], "type": "string" @@ -20950,6 +21352,11 @@ "backup.download", "backup.upload", "backup.delete", + "clusterGroup.read", + "clusterGroup.leave", + "clusterGroupRequest.create", + "clusterGroupRequest.read", + "clusterGroupRequest.delete", "duplicate.read", "duplicate.delete", "face.create", @@ -27400,6 +27807,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", @@ -27492,6 +27911,7 @@ }, "required": [ "avatarColor", + "clusterGroupId", "createdAt", "deletedAt", "email", diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 3ac958ce2d962..6ed3a22b8d663 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -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[]; @@ -4649,6 +4665,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 */ @@ -7127,6 +7229,7 @@ export enum NotificationType { SystemMessage = "SystemMessage", AlbumInvite = "AlbumInvite", AlbumUpdate = "AlbumUpdate", + ClusterGroupRequest = "ClusterGroupRequest", Custom = "Custom" } export enum UserStatus { @@ -7203,6 +7306,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", diff --git a/server/src/constants.ts b/server/src/constants.ts index 815a400ed80ae..0cdd94265e6ca 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -150,6 +150,8 @@ export const endpointTags: Record = { [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.', diff --git a/server/src/controllers/cluster-group.controller.ts b/server/src/controllers/cluster-group.controller.ts new file mode 100644 index 0000000000000..88aa688cfd962 --- /dev/null +++ b/server/src/controllers/cluster-group.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return this.service.leave(auth, id); + } +} diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index e7a01643abdc3..1d393ec89121b 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -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, diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 185ab95e6233e..8674ebe7e65b7 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -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: { diff --git a/server/src/database.ts b/server/src/database.ts index 100ba451e768a..2d04b138258dd 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -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, '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 | null; updatedAt: Date; @@ -376,6 +377,7 @@ export const columns = { userWithPrefix: userWithPrefixColumns, userAdmin: [ ...userColumns, + 'clusterGroupId', 'createdAt', 'updatedAt', 'deletedAt', diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index c54ce1cbab315..eaa05b40dc248 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -170,8 +170,8 @@ const peopleFromFaces = (faces?: MaybeDehydrated[]): PersonResponseDt const peopleMap: Map = 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)); } } diff --git a/server/src/dtos/cluster-group.dto.ts b/server/src/dtos/cluster-group.dto.ts new file mode 100644 index 0000000000000..cc53626838fdd --- /dev/null +++ b/server/src/dtos/cluster-group.dto.ts @@ -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): ClusterGroupRequestResponseDto { + return { + id: request.id, + clusterGroupId: request.clusterGroupId, + userId: request.userId, + createdAt: asDateTimeString(request.createdAt), + }; +} diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index fa56ded1765de..0379b6f3b0f6d 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -173,7 +173,7 @@ export class PeopleResponseDto extends createZodDto(PeopleResponseSchema) {} export function mapPerson(person: MaybeDehydrated): PersonResponseDto { return { - id: person.id, + id: person.personGroupId, name: person.name, birthDate: asDateString(person.birthDate), thumbnailPath: person.thumbnailPath, @@ -215,6 +215,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, }; } diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 528163e57ca78..2a15e43976ad7 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -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, diff --git a/server/src/enum.ts b/server/src/enum.ts index 0d29244e09da8..a11f45536b859 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -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', @@ -1129,6 +1135,7 @@ export enum NotificationType { SystemMessage = 'SystemMessage', AlbumInvite = 'AlbumInvite', AlbumUpdate = 'AlbumUpdate', + ClusterGroupRequest = 'ClusterGroupRequest', Custom = 'Custom', } @@ -1189,6 +1196,7 @@ export enum ApiTag { Memories = 'Memories', Notifications = 'Notifications', NotificationsAdmin = 'Notifications (admin)', + ClusterGroups = 'Cluster groups', Partners = 'Partners', People = 'People', Plugins = 'Plugins', diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index 94d3b4d003cc1..317cff8748d26 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -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 diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index aa046039130a1..96af89edb56a4 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -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 diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index cc694dd63a91e..acc686a480d3a 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -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" diff --git a/server/src/queries/cluster.group.repository.sql b/server/src/queries/cluster.group.repository.sql new file mode 100644 index 0000000000000..57e8c647b25c1 --- /dev/null +++ b/server/src/queries/cluster.group.repository.sql @@ -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 diff --git a/server/src/queries/memory.repository.sql b/server/src/queries/memory.repository.sql index 44339cbcd9851..7730813a5fb6e 100644 --- a/server/src/queries/memory.repository.sql +++ b/server/src/queries/memory.repository.sql @@ -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 diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index a2f3f6444290d..90067594a881c 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -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" @@ -247,7 +297,7 @@ from where "asset_face"."deletedAt" is null and "asset_face"."isVisible" is true - and "asset_face"."personId" = $1 + and "asset_face"."personGroupId" = $1 -- PersonRepository.getNumberOfPeople select @@ -267,7 +317,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 +332,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 +518,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 +535,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 +559,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 +576,4 @@ from and "asset"."isOffline" = $1 where "asset_face"."assetId" = $2 - and "asset_face"."personId" = $3 + and "asset_face"."personGroupId" = $3 diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index efd7236bb5cba..abd10e6221bc2 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -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 diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 69f7981bf3b31..15c5a73f2ca3c 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -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", diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index c5a4f139a7041..de5cfea138637 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -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", diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index b315538bf9b80..52d15d2572fe9 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -420,23 +420,60 @@ class MemoryAccess { } } +class ClusterGroupAccess { + constructor(private db: Kysely) {} + + /** 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) { + if (clusterGroupIds.size === 0) { + return new Set(); + } + + 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) { + if (clusterGroupIds.size === 0) { + return new Set(); + } + + 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) {} @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) - async checkOwnerAccess(userId: string, personIds: Set) { - if (personIds.size === 0) { + async checkOwnerAccess(userId: string, groupIds: Set) { + if (groupIds.size === 0) { return new Set(); } 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); diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index bab0c44a41c87..5c7924a2086c5 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -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(); } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index add3f0a24d4d4..e9a27af73a592 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -124,7 +124,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 +510,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 +574,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 +640,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 { diff --git a/server/src/repositories/cluster-group.repository.ts b/server/src/repositories/cluster-group.repository.ts new file mode 100644 index 0000000000000..542838438eced --- /dev/null +++ b/server/src/repositories/cluster-group.repository.ts @@ -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) {} + + @GenerateSql() + create() { + return this.db.insertInto('cluster_group').defaultValues().returningAll().executeTakeFirstOrThrow(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async getForUser(userId: string): Promise { + 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 { + 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) { + 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 { + await this.db.deleteFrom('cluster_group_request').where('cluster_group_request.id', '=', id).execute(); + } +} diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 416f823952296..564d76b5996c8 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -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; file?: UploadFile }]; AssetTag: [{ assetId: string }]; diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 534a71b6d6e5b..15ec7c353a980 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -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, diff --git a/server/src/repositories/media.repository.spec.ts b/server/src/repositories/media.repository.spec.ts index e8106c0ff97df..08475b4404b50 100644 --- a/server/src/repositories/media.repository.spec.ts +++ b/server/src/repositories/media.repository.spec.ts @@ -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, diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts index 09aa5ad880d4b..9e34efdf3f999 100644 --- a/server/src/repositories/memory.repository.ts +++ b/server/src/repositories/memory.repository.ts @@ -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), diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 0db03a18c71ca..f6fa35e4b07db 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -8,6 +8,7 @@ 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 { paginationHelper, PaginationOptions } from 'src/utils/pagination'; @@ -22,19 +23,19 @@ 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 { @@ -53,7 +54,7 @@ export interface GetAllPeopleOptions { } export interface GetAllFacesOptions { - personId?: string | null; + personGroupId?: string | null; assetId?: string; sourceType?: SourceType; } @@ -62,10 +63,27 @@ export type UnassignFacesOptions = DeleteFacesOptions; export type SelectFaceOptions = (keyof Selectable)[]; -const withPerson = (eb: ExpressionBuilder) => { - 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) => + jsonObjectFrom( + eb + .selectFrom('person') + .selectAll('person') + .whereRef('person.personGroupId', '=', 'asset_face.personGroupId') + .where('person.ownerId', '=', viewingUserId), + ).as('person'); }; const withFaceSearch = (eb: ExpressionBuilder) => { @@ -78,12 +96,12 @@ const withFaceSearch = (eb: ExpressionBuilder) => { export class PersonRepository { constructor(@InjectKysely() private db: Kysely) {} - @GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] }) - async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise { + @GenerateSql({ params: [{ oldPersonGroupId: DummyValue.UUID, newPersonGroupId: DummyValue.UUID }] }) + async reassignFaces({ oldPersonGroupId, faceIds, newPersonGroupId }: UpdateFacesData): Promise { 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 +111,68 @@ export class PersonRepository { async unassignFaces({ sourceType }: UnassignFacesOptions): Promise { 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 { + 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 { + async deleteGroups(ids: string[]): Promise { 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 { + 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 { + 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 { @@ -116,8 +183,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 +208,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 +219,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 +248,7 @@ export class PersonRepository { ), ]), ) - .groupBy('person.id') + .groupBy(['person.ownerId', 'person.personGroupId']) .$if(!!options?.closestFaceAssetId, (qb) => qb.orderBy((eb) => eb( @@ -218,22 +286,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 +309,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 +325,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 +341,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 +361,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 { + async reassignFace(assetFaceId: string, newPersonGroupId: string): Promise { 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,7 +408,7 @@ export class PersonRepository { getDistinctNames(userId: string, { withHidden }: PersonNameSearchOptions): Promise { 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)) @@ -344,7 +416,7 @@ export class PersonRepository { } @GenerateSql({ params: [DummyValue.UUID] }) - async getStatistics(personId: string): Promise { + async getStatistics(personGroupId: string): Promise { const result = await this.db .selectFrom('asset_face') .leftJoin('asset', (join) => @@ -356,7 +428,7 @@ export class PersonRepository { .select((eb) => eb.fn.count(eb.fn('distinct', ['asset.id'])).as('count')) .where('asset_face.deletedAt', 'is', null) .where('asset_face.isVisible', 'is', true) - .where('asset_face.personId', '=', personId) + .where('asset_face.personGroupId', '=', personGroupId) .executeTakeFirst(); return { @@ -373,7 +445,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 +469,124 @@ export class PersonRepository { return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow(); } - async createAll(people: Insertable[]): Promise { + async createAll(people: Insertable[]) { 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 { + 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`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[]) { + 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 +613,12 @@ export class PersonRepository { await query.selectFrom(dummy).execute(); } - async update(person: Updateable & { id: string }) { + async update(person: Updateable & 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 +632,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 +650,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 +722,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 +766,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(); } diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 70ba09ef2cf79..571d384d5cda4 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -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 { + 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') + .innerJoin('asset', 'asset.id', 'asset_face.assetId') + .innerJoin('face_search', 'face_search.faceId', 'asset_face.id') .select([ 'asset_face.id', - 'asset_face.personId', + 'asset_face.personGroupId', sql`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)) + .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') diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index eca8d18e66152..a8359cac5a031 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -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', diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index 2ee61d82dd57a..531c9b81bf44f 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -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`, diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 171fefb50f733..548107a522954 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -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'; @@ -115,6 +120,8 @@ export class ImmichDatabase { AssetTable, AssetFileTable, AssetExifTable, + ClusterGroupTable, + ClusterGroupRequestTable, FaceSearchTable, GeodataPlacesTable, IntegrityReportTable, @@ -131,6 +138,8 @@ export class ImmichDatabase { PartnerTable, PersonTable, PersonAuditTable, + PersonGroupTable, + PersonGroupAuditTable, SessionTable, SharedLinkAssetTable, SharedLinkTable, @@ -171,6 +180,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, @@ -245,6 +255,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; diff --git a/server/src/schema/migrations/1786551257709-ClusterGroups.ts b/server/src/schema/migrations/1786551257709-ClusterGroups.ts new file mode 100644 index 0000000000000..13b95762d8221 --- /dev/null +++ b/server/src/schema/migrations/1786551257709-ClusterGroups.ts @@ -0,0 +1,232 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + 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): Promise { + 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, + ); +} diff --git a/server/src/schema/tables/asset-face.table.ts b/server/src/schema/tables/asset-face.table.ts index b67e5e5dac550..9832f99ce8938 100644 --- a/server/src/schema/tables/asset-face.table.ts +++ b/server/src/schema/tables/asset-face.table.ts @@ -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; @@ -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; diff --git a/server/src/schema/tables/cluster-group-request.table.ts b/server/src/schema/tables/cluster-group-request.table.ts new file mode 100644 index 0000000000000..d85ab06184df4 --- /dev/null +++ b/server/src/schema/tables/cluster-group-request.table.ts @@ -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; + + @ForeignKeyColumn(() => ClusterGroupTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) + clusterGroupId!: string; + + @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) + userId!: string; + + @CreateDateColumn() + createdAt!: Generated; +} diff --git a/server/src/schema/tables/cluster-group.table.ts b/server/src/schema/tables/cluster-group.table.ts new file mode 100644 index 0000000000000..79c7d91db5de1 --- /dev/null +++ b/server/src/schema/tables/cluster-group.table.ts @@ -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; + + @Column({ type: 'character varying', nullable: true, default: null }) + name!: string | null; + + @CreateDateColumn() + createdAt!: Generated; + + @UpdateDateColumn() + updatedAt!: Generated; + + @UpdateIdColumn({ index: true }) + updateId!: Generated; +} diff --git a/server/src/schema/tables/person-audit.table.ts b/server/src/schema/tables/person-audit.table.ts index 4fb55f1744639..4045c2917b5a9 100644 --- a/server/src/schema/tables/person-audit.table.ts +++ b/server/src/schema/tables/person-audit.table.ts @@ -7,7 +7,7 @@ export class PersonAuditTable { id!: Generated; @Column({ type: 'uuid', index: true }) - personId!: string; + personGroupId!: string; @Column({ type: 'uuid', index: true }) ownerId!: string; diff --git a/server/src/schema/tables/person-group-audit.table.ts b/server/src/schema/tables/person-group-audit.table.ts new file mode 100644 index 0000000000000..a8a8117d50a73 --- /dev/null +++ b/server/src/schema/tables/person-group-audit.table.ts @@ -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; + + @Column({ type: 'uuid', index: true }) + personGroupId!: string; + + @Column({ type: 'uuid', index: true }) + clusterGroupId!: string; + + @CreateDateColumn({ default: () => 'clock_timestamp()', index: true }) + deletedAt!: Generated; +} diff --git a/server/src/schema/tables/person-group.table.ts b/server/src/schema/tables/person-group.table.ts new file mode 100644 index 0000000000000..36e4c4a949d8c --- /dev/null +++ b/server/src/schema/tables/person-group.table.ts @@ -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; + + @ForeignKeyColumn(() => ClusterGroupTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) + clusterGroupId!: string; + + @CreateDateColumn() + createdAt!: Generated; + + @CreateIdColumn({ index: true }) + createId!: Generated; + + @UpdateDateColumn() + updatedAt!: Generated; + + @UpdateIdColumn({ index: true }) + updateId!: Generated; +} diff --git a/server/src/schema/tables/person.table.ts b/server/src/schema/tables/person.table.ts index 35447acfd08a0..0c9324c928830 100644 --- a/server/src/schema/tables/person.table.ts +++ b/server/src/schema/tables/person.table.ts @@ -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; + @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; @@ -40,9 +53,6 @@ export class PersonTable { @UpdateDateColumn() updatedAt!: Generated; - @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) - ownerId!: string; - @Column({ default: '' }) name!: Generated; diff --git a/server/src/schema/tables/user.table.ts b/server/src/schema/tables/user.table.ts index 50d56d9067fa3..be8b05d129fc8 100644 --- a/server/src/schema/tables/user.table.ts +++ b/server/src/schema/tables/user.table.ts @@ -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; + + @ForeignKeyColumn(() => ClusterGroupTable, { onUpdate: 'CASCADE', nullable: false }) + clusterGroupId!: string; } diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 0264484bfcfc0..f5c4304285dac 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -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 }); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 15385df0e0c2a..83cc7a653e977 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -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; } async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise { diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 6ed8ce925a23c..542361af26bc9 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -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 & { email: string }): Promise { + async createUser(dto: Omit, 'clusterGroupId'> & { email: string }): Promise { 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 = { ...dto }; + const payload: Omit, '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); diff --git a/server/src/services/cluster-group.service.ts b/server/src/services/cluster-group.service.ts new file mode 100644 index 0000000000000..5ed429fe74c5c --- /dev/null +++ b/server/src/services/cluster-group.service.ts @@ -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 { + const requests = await this.clusterGroupRepository.getRequests(auth.user.id); + return requests.map((request) => mapClusterGroupRequest(request)); + } + + async getRequestsForGroup(auth: AuthDto, clusterGroupId: string): Promise { + 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 { + 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> { + 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 { + 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 { + 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 { + 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 }); + } +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 766b5979bc707..d8bb618b01832 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -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, diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index a464c9e174753..7a7ebb8e023c9 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -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); } diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 6d4f04df370ea..ac602e20c1521 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -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; diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index d64e0088ebd8f..a0b7779c59412 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -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, { diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 19b1688e881a3..ca7f4d431d3fe 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -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): Promise { + async handleGeneratePersonThumbnail({ + ownerId, + personGroupId, + }: JobOf): Promise { 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); diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 57c029961e4aa..53b42db533d13 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -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' }, }, ]); }, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index a95d1f1497e9d..c6abad7cbc3b2 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -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 & { 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 & { ownerId: string })[] = []; - const missingWithFaceAsset: { id: string; ownerId: string; faceAssetId: string }[] = []; + const existingNameMap = new Map( + existingNames.map(({ personGroupId, name }) => [name.toLowerCase(), personGroupId]), + ); + const missing: (Insertable & { 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); } diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index a650b466baab1..25979464b1801 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -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 diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index e6a11786af02d..32168b65ce515 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -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,20 @@ describe(PersonService.name, () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getByGroupId.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.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); + await expect(sut.getStatistics(auth, person.personGroupId)).resolves.toEqual({ assets: 3 }); + 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 +1446,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(); - }); }); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 7c08c36e176c8..1486d9a9ce67b 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -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 { @@ -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 { - await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personId] }); - const person = await this.findOrFail(personId); + async reassignFaces(auth: AuthDto, personGroupId: string, dto: AssetFaceUpdateDto): Promise { + 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(); 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 { - await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personId] }); + async reassignFacesById(auth: AuthDto, personGroupId: string, dto: FaceDto): Promise { + 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 { 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 { - await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] }); - return mapPerson(await this.findOrFail(id)); + async getById(auth: AuthDto, personGroupId: string): Promise { + await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [personGroupId] }); + return mapPerson(await this.findOrFail(auth, personGroupId)); } - async getStatistics(auth: AuthDto, id: string): Promise { - await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] }); - return this.personRepository.getStatistics(id); + async getStatistics(auth: AuthDto, personGroupId: string): Promise { + await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [personGroupId] }); + return this.personRepository.getStatistics(personGroupId); } - async getThumbnail(auth: AuthDto, id: string): Promise { - await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] }); - const person = await this.personRepository.getById(id); + async getThumbnail(auth: AuthDto, personGroupId: string): Promise { + 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 { + 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 { - await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [id] }); + async update(auth: AuthDto, personGroupId: string, dto: PersonUpdateDto): Promise { + 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 { 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 { + // 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 (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 (!personGroupId && isCore) { + const group = await this.personRepository.createGroup(ownerId); + personGroupId = group.id; + this.logger.log(`Created person group ${personGroupId} for face ${id}`); } - if (personId) { - this.logger.debug(`Assigning face ${id} to person ${personId}`); - await this.personRepository.reassignFaces({ faceIds: [id], newPersonId: personId }); + 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 }, + }); + } + + 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): Promise { - const person = await this.personRepository.getById(id); + async handlePersonMigration({ ownerId, personGroupId }: JobOf): Promise { + 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 { + async mergePerson(auth: AuthDto, personGroupId: string, dto: MergePersonDto): Promise { 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 & { id: string } = { id: primaryPerson.id }; + const changes: Updateable = {}; 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]); } } diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index c0f5a3614d288..bc59a4ad727b7 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -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', + }, ); }); diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index c99b340daeaa5..1612b0e29c75b 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -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'), }, diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index e3842b1503c81..3e83d6c50861d 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -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); diff --git a/server/src/services/user-admin.service.spec.ts b/server/src/services/user-admin.service.spec.ts index 49aefaa870132..c52ae54221175 100644 --- a/server/src/services/user-admin.service.spec.ts +++ b/server/src/services/user-admin.service.spec.ts @@ -53,6 +53,7 @@ describe(UserAdminService.name, () => { name: userStub.user1.name, storageLabel: 'label', password: expect.anything(), + clusterGroupId: expect.any(String), }); }); }); diff --git a/server/src/types.ts b/server/src/types.ts index 27995e841fd8d..0ac56af2191ee 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -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 } diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index ce8e61456572d..3e690508dc3f2 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -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); } diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 0f4d8775b6c8d..8a82322c2955e 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -237,38 +237,47 @@ export function withFilePath(eb: ExpressionBuilder, type: AssetFile .where('asset_file.isEdited', '=', sql.lit(isEdited)); } -export function withFacesAndPeople( - eb: ExpressionBuilder, - 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>().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) => + 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>().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(qb: SelectQueryBuilder, personIds: string[]) { +export function hasPeople(qb: SelectQueryBuilder, 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'), ); @@ -511,7 +520,9 @@ export function searchAssetBuilderLegacy(kysely: Kysely, 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 +580,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 +588,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 +786,9 @@ export function searchAssetBuilder(kysely: Kysely, 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); diff --git a/server/src/utils/editor.spec.ts b/server/src/utils/editor.spec.ts index 17db0d9da3f43..ed013f9353c0c 100644 --- a/server/src/utils/editor.spec.ts +++ b/server/src/utils/editor.spec.ts @@ -64,7 +64,7 @@ const createFace = (params: Partial = {}): AssetFace => ({ boundingBoxY2: 200, imageWidth: 1000, imageHeight: 1000, - personId: null, + personGroupId: null, sourceType: SourceType.MachineLearning, person: null, updatedAt: new Date(), diff --git a/server/test/factories/asset-face.factory.ts b/server/test/factories/asset-face.factory.ts index b2286cad546c2..c24e543afe49d 100644 --- a/server/test/factories/asset-face.factory.ts +++ b/server/test/factories/asset-face.factory.ts @@ -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) { this.#person = build(PersonFactory.from(dto), builder); - this.value.personId = this.#person.build().id; + this.value.personGroupId = this.#person.build().personGroupId; return this; } diff --git a/server/test/factories/cluster-group.factory.ts b/server/test/factories/cluster-group.factory.ts new file mode 100644 index 0000000000000..21a39bfd7317d --- /dev/null +++ b/server/test/factories/cluster-group.factory.ts @@ -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) {} + + 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 }; + } +} diff --git a/server/test/factories/person-group.factory.ts b/server/test/factories/person-group.factory.ts new file mode 100644 index 0000000000000..436f777f67e5c --- /dev/null +++ b/server/test/factories/person-group.factory.ts @@ -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) {} + + 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 }; + } +} diff --git a/server/test/factories/person.factory.ts b/server/test/factories/person.factory.ts index 8e016e539859f..84b0cf5d9723d 100644 --- a/server/test/factories/person.factory.ts +++ b/server/test/factories/person.factory.ts @@ -16,7 +16,7 @@ export class PersonFactory { color: null, createdAt: newDate(), faceAssetId: null, - id: newUuid(), + personGroupId: newUuid(), isFavorite: false, isHidden: false, name: 'person', diff --git a/server/test/factories/types.ts b/server/test/factories/types.ts index 5c8c8ee2c2754..a227deef8f9cb 100644 --- a/server/test/factories/types.ts +++ b/server/test/factories/types.ts @@ -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>; export type UserLike = Partial>; export type AssetFaceLike = Partial>; export type PersonLike = Partial>; +export type PersonGroupLike = Partial>; +export type ClusterGroupLike = Partial>; export type StackLike = Partial>; export type MemoryLike = Partial>; export type PartnerLike = Partial>; diff --git a/server/test/factories/user.factory.ts b/server/test/factories/user.factory.ts index 125ce91e86eb1..df5366c9bfb09 100644 --- a/server/test/factories/user.factory.ts +++ b/server/test/factories/user.factory.ts @@ -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, diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts index 21b49ab8991f1..f6d3a42bde569 100644 --- a/server/test/fixtures/user.stub.ts +++ b/server/test/fixtures/user.stub.ts @@ -6,6 +6,7 @@ export const userStub = { admin: { ...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: { ...authStub.user1.user, status: UserStatus.Active, + clusterGroupId: 'cluster-group-id', profileChangedAt: new Date('2021-01-01'), name: 'immich_name', storageLabel: null, diff --git a/server/test/mappers.ts b/server/test/mappers.ts index 40ae78fe26fe6..f2694f184f63f 100644 --- a/server/test/mappers.ts +++ b/server/test/mappers.ts @@ -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) => { return { @@ -54,15 +56,27 @@ export const getAsDetectedFace = (face: ReturnType) = export const getForFacialRecognitionJob = ( face: ReturnType, - asset: Pick, 'ownerId' | 'visibility' | 'fileCreatedAt'> | null, + asset: + (Pick, '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, distance: number): FaceSearchResult => ({ + id: face.id, + personGroupId: face.personGroupId, + distance, +}); + export const getDehydrated = >(entity: T) => { const copiedEntity = structuredClone(entity); for (const [key, value] of Object.entries(copiedEntity)) { @@ -122,8 +136,12 @@ export const getForMemory = (memory: ReturnType) => ({ assets: memory.assets.map((asset) => getDehydrated(asset)), }); -export const getForMetadataExtraction = (asset: ReturnType) => ({ +export const getForMetadataExtraction = ( + asset: ReturnType, + { clusterGroupId }: { clusterGroupId?: string } = {}, +) => ({ id: asset.id, + clusterGroupId: clusterGroupId ?? newUuid(), checksum: asset.checksum, checksumAlgorithm: asset.checksumAlgorithm, fileCreatedAt: asset.fileCreatedAt, diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index f0f14757b2043..0d8b97107b494 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -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 = } async newUser(dto: Partial> = {}) { - 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 = } async newPerson(dto: Partial> & { 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 = (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 & { 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> & { ownerId: string }) => { +const personInsert = (person: Partial> & { 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> = {}) => { +const userInsert = (user: Partial> & { 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, diff --git a/server/test/medium/specs/repositories/person.repository.spec.ts b/server/test/medium/specs/repositories/person.repository.spec.ts index 1ff9ade1d115f..4d1604b6018fb 100644 --- a/server/test/medium/specs/repositories/person.repository.spec.ts +++ b/server/test/medium/specs/repositories/person.repository.spec.ts @@ -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({ diff --git a/server/test/medium/specs/services/auth.service.spec.ts b/server/test/medium/specs/services/auth.service.spec.ts index 5e5c880955b68..af969bae9ab13 100644 --- a/server/test/medium/specs/services/auth.service.spec.ts +++ b/server/test/medium/specs/services/auth.service.spec.ts @@ -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) => { database: db || defaultDatabase, real: [ AccessRepository, + ClusterGroupRepository, ConfigRepository, CryptoRepository, DatabaseRepository, diff --git a/server/test/medium/specs/services/cluster-group.service.spec.ts b/server/test/medium/specs/services/cluster-group.service.spec.ts new file mode 100644 index 0000000000000..43f952dfe6eb8 --- /dev/null +++ b/server/test/medium/specs/services/cluster-group.service.spec.ts @@ -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; + +const setup = (db?: Kysely) => { + 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['ctx'], userId: string) => { + const { clusterGroupId } = await ctx.database + .selectFrom('user') + .select('user.clusterGroupId') + .where('user.id', '=', userId) + .executeTakeFirstOrThrow(); + + return clusterGroupId; +}; + +const getPeople = (ctx: ReturnType['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]); + }); + }); +}); diff --git a/server/test/medium/specs/services/person.service.spec.ts b/server/test/medium/specs/services/person.service.spec.ts index 39805580f6ca2..cc43196655143 100644 --- a/server/test/medium/specs/services/person.service.spec.ts +++ b/server/test/medium/specs/services/person.service.spec.ts @@ -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, diff --git a/server/test/medium/specs/services/search.service.spec.ts b/server/test/medium/specs/services/search.service.spec.ts index 044cd8d4f5c7e..c612f45e47440 100644 --- a/server/test/medium/specs/services/search.service.spec.ts +++ b/server/test/medium/specs/services/search.service.spec.ts @@ -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 }); }); diff --git a/server/test/medium/specs/services/user.service.spec.ts b/server/test/medium/specs/services/user.service.spec.ts index 85ab9a8c0daa4..311a5cb846d33 100644 --- a/server/test/medium/specs/services/user.service.spec.ts +++ b/server/test/medium/specs/services/user.service.spec.ts @@ -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) => { 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(); }); diff --git a/server/test/medium/specs/sync/sync-asset-face.spec.ts b/server/test/medium/specs/sync/sync-asset-face.spec.ts index 74d4c536f114e..6e8e4d7039f1d 100644 --- a/server/test/medium/specs/sync/sync-asset-face.spec.ts +++ b/server/test/medium/specs/sync/sync-asset-face.spec.ts @@ -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, diff --git a/server/test/medium/specs/sync/sync-person.spec.ts b/server/test/medium/specs/sync/sync-person.spec.ts index 6fdb5a58f2d26..0c554db10978d 100644 --- a/server/test/medium/specs/sync/sync-person.spec.ts +++ b/server/test/medium/specs/sync/sync-person.spec.ts @@ -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 }), diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index f723113bd1785..908b7254522a1 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -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()), diff --git a/server/test/utils.ts b/server/test/utils.ts index 2f32f3b41211f..cd20e0165a30e 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -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 = ( overrides.asset || (mocks.asset as As), overrides.assetEdit || (mocks.assetEdit as As), overrides.assetJob || (mocks.assetJob as As), + overrides.clusterGroup || (mocks.clusterGroup as As), overrides.config || (mocks.config as As as ConfigRepository), overrides.cron || (mocks.cron as As), overrides.crypto || (mocks.crypto as As), diff --git a/web/src/lib/components/asset-viewer/DetailPanelPeople.svelte b/web/src/lib/components/asset-viewer/DetailPanelPeople.svelte index 6290209e28936..949d0e4b771c4 100644 --- a/web/src/lib/components/asset-viewer/DetailPanelPeople.svelte +++ b/web/src/lib/components/asset-viewer/DetailPanelPeople.svelte @@ -57,42 +57,44 @@ ); -{#if !authManager.isSharedLink && isOwner} +{#if !authManager.isSharedLink}
{$t('people')}
- {#if people.some((person) => person.isHidden)} + {#if isOwner} + {#if people.some((person) => person.isHidden)} + assetViewerManager.toggleHiddenPeople()} + /> + {/if} assetViewerManager.toggleHiddenPeople()} + onclick={() => assetViewerManager.toggleFaceEditMode()} /> - {/if} - assetViewerManager.toggleFaceEditMode()} - /> - {#if faceManager.data.length > 0} - assetViewerManager.openEditFacesPanel()} - /> + {#if faceManager.data.length > 0} + assetViewerManager.openEditFacesPanel()} + /> + {/if} {/if}
diff --git a/web/src/lib/components/shared-components/navigation-bar/NotificationPanel.svelte b/web/src/lib/components/shared-components/navigation-bar/NotificationPanel.svelte index 79cfff858a5bf..4e6929a87ef52 100644 --- a/web/src/lib/components/shared-components/navigation-bar/NotificationPanel.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/NotificationPanel.svelte @@ -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; } diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 802f3bc63be24..530db1e8d5867 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -67,6 +67,7 @@ export enum OpenQueryParam { STORAGE_TEMPLATE = 'storage-template', NOTIFICATIONS = 'notifications', PURCHASE_SETTINGS = 'user-purchase-settings', + SHARING = 'sharing', } export const maximumLengthSearchPeople = 100; diff --git a/web/src/lib/modals/ClusterGroupUserSelectionModal.svelte b/web/src/lib/modals/ClusterGroupUserSelectionModal.svelte new file mode 100644 index 0000000000000..2152694775e34 --- /dev/null +++ b/web/src/lib/modals/ClusterGroupUserSelectionModal.svelte @@ -0,0 +1,60 @@ + + + + + {#await loadUsers()} +
+ +
+ {:then _} + {#if availableUsers.length > 0} +
+ {#each availableUsers as user (user.id)} + selectUser(user)} selected={selectedUsers.some(({ id }) => id === user.id)}> + +
+ {user.name} + {user.email} +
+
+ {/each} +
+ + + + + {:else} + {$t('partner_page_no_more_users')} + {/if} + {/await} +
+
diff --git a/web/src/lib/modals/ClusterGroupUsersModal.svelte b/web/src/lib/modals/ClusterGroupUsersModal.svelte new file mode 100644 index 0000000000000..2bfb61257c17d --- /dev/null +++ b/web/src/lib/modals/ClusterGroupUsersModal.svelte @@ -0,0 +1,54 @@ + + + + + {#await loadUsers()} +
+ +
+ {:then _} + {$t('cluster_group_invite_description')} + +
+ {$t('users')} +
+ +
+ {#each users as user (user.id)} +
+ +
+ {user.name} + {user.email} +
+
+ {/each} +
+ {/await} +
+ + + + + + + +
diff --git a/web/src/routes/(user)/user-settings/PartnerSettings.svelte b/web/src/routes/(user)/user-settings/PartnerSettings.svelte deleted file mode 100644 index af1eff38bfb64..0000000000000 --- a/web/src/routes/(user)/user-settings/PartnerSettings.svelte +++ /dev/null @@ -1,194 +0,0 @@ - - -
- {#if partners.length > 0} - {#each partners as partner (partner.user.id)} -
-
-
- -
-

- {partner.user.name} -

-

- {partner.user.email} -

-
-
- - {#if partner.sharedByMe} - handleRemovePartner(partner.user)} - icon={mdiClose} - size="small" - aria-label={$t('stop_sharing_photos_with_user')} - /> - {/if} -
- -
- - {#if partner.sharedByMe} -
- - {$t('shared_with_partner', { values: { partner: partner.user.name } })} - - {$t('partner_can_access', { values: { partner: partner.user.name } })} -
    -
  • - - {$t('partner_can_access_assets')} -
  • -
  • - - {$t('partner_can_access_location')} -
  • -
- {/if} - - - {#if partner.sharedWithMe} -
- - {$t('shared_from_partner', { values: { partner: partner.user.name } })} - - - handleShowOnTimelineChanged(partner, isChecked)} - /> - {/if} -
-
- {/each} - {/if} - -
- -
-
diff --git a/web/src/routes/(user)/user-settings/SharingSettings.svelte b/web/src/routes/(user)/user-settings/SharingSettings.svelte new file mode 100644 index 0000000000000..349b910abd4d1 --- /dev/null +++ b/web/src/routes/(user)/user-settings/SharingSettings.svelte @@ -0,0 +1,379 @@ + + +
+ {$t('cluster_group')} + {$t('cluster_group_description')} + + + + {#each users as user, index (user.id)} +
0}> +
+ +
+

+ {user.name} + {#if user.id === authManager.user.id} + ({$t('you')}) + {/if} +

+

{user.email}

+
+
+ + {#if user.id === authManager.user.id && canLeave} + + {/if} +
+ {/each} +
+
+ + {#if sentRequests.length > 0 || receivedRequests.length > 0} +
+ {$t('pending')} +
+ + + + {#each receivedRequests as request, index (request.id)} +
0}> + {$t('request_received_description')} +
+ +
+
+ {/each} + + {#each sentRequests as request, index (request.id)} + {@const user = candidates[request.userId]} +
0 || receivedRequests.length > 0}> +
+ {#if user} + + {/if} +
+

{user?.name ?? request.userId}

+

{user?.email ?? ''}

+
+
+ + +
+ {/each} +
+
+ {/if} + +
+ +
+
+ +
+ {$t('partners')} + + {#if partners.length > 0} + {#each partners as partner (partner.user.id)} +
+
+
+ +
+

+ {partner.user.name} +

+

+ {partner.user.email} +

+
+
+ + {#if partner.sharedByMe} + handleRemovePartner(partner.user)} + icon={mdiClose} + size="small" + aria-label={$t('stop_sharing_photos_with_user')} + /> + {/if} +
+ +
+ + {#if partner.sharedByMe} +
+ + {$t('shared_with_partner', { values: { partner: partner.user.name } })} + + {$t('partner_can_access', { values: { partner: partner.user.name } })} +
    +
  • + + {$t('partner_can_access_assets')} +
  • +
  • + + {$t('partner_can_access_location')} +
  • +
+ {/if} + + + {#if partner.sharedWithMe} +
+ + {$t('shared_from_partner', { values: { partner: partner.user.name } })} + + + handleShowOnTimelineChanged(partner, isChecked)} + /> + {/if} +
+
+ {/each} + {/if} + +
+ +
+
diff --git a/web/src/routes/(user)/user-settings/UserSettingsList.svelte b/web/src/routes/(user)/user-settings/UserSettingsList.svelte index 91c32560d8479..b6595ed60f8a8 100644 --- a/web/src/routes/(user)/user-settings/UserSettingsList.svelte +++ b/web/src/routes/(user)/user-settings/UserSettingsList.svelte @@ -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 @@ - - - - + + + + diff --git a/web/src/test-data/factories/user-factory.ts b/web/src/test-data/factories/user-factory.ts index 92d1510d40fa6..d8ad4947a431a 100644 --- a/web/src/test-data/factories/user-factory.ts +++ b/web/src/test-data/factories/user-factory.ts @@ -13,6 +13,7 @@ export const userFactory = Sync.makeFactory({ export const userAdminFactory = Sync.makeFactory({ 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: '',