feat: cluster groups

This commit is contained in:
Jason Rasmussen 2026-08-12 13:19:08 -04:00 committed by Daniel Dietzler
parent 943c11c019
commit 66860b8786
No known key found for this signature in database
GPG key ID: A1C0B97CD8E18DFF
94 changed files with 4064 additions and 805 deletions

View file

@ -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) =>

View file

@ -36,6 +36,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 +734,8 @@
"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 this group",
"collapse": "Collapse",
"collapse_all": "Collapse all",
"color": "Color",
@ -830,6 +833,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,11 +1027,13 @@
"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",
"unable_to_delete_asset": "Unable to delete asset",
"unable_to_delete_assets": "Error deleting assets",
"unable_to_delete_cluster_group_request": "Unable to delete request",
"unable_to_delete_exclusion_pattern": "Unable to delete exclusion pattern",
"unable_to_delete_shared_link": "Unable to delete shared link",
"unable_to_delete_user": "Unable to delete user",
@ -1040,8 +1046,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 +1267,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 +1375,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 +1777,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 +2257,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 +2301,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",

View file

@ -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",

View file

@ -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",

View file

@ -150,6 +150,8 @@ export const endpointTags: Record<ApiTag, string> = {
[ApiTag.Assets]: 'An asset is an image or video that has been uploaded to Immich.',
[ApiTag.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.',

View file

@ -0,0 +1,107 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { ClusterGroupRequestCreateDto, ClusterGroupRequestResponseDto } from 'src/dtos/cluster-group.dto';
import { UserResponseDto } from 'src/dtos/user.dto';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { ClusterGroupService } from 'src/services/cluster-group.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.ClusterGroups)
@Controller('cluster-groups')
export class ClusterGroupController {
constructor(private service: ClusterGroupService) {}
@Get('requests')
@Authenticated({ permission: Permission.ClusterGroupRequestRead })
@Endpoint({
summary: 'Retrieve cluster group requests',
description: 'Retrieve the pending requests for the current user to join a cluster group.',
history: new HistoryBuilder().added('v3.2.0'),
})
getClusterGroupRequests(@Auth() auth: AuthDto): Promise<ClusterGroupRequestResponseDto[]> {
return this.service.getRequests(auth);
}
@Post('requests/:id/accept')
@Authenticated({ permission: Permission.ClusterGroupRequestCreate })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Accept a cluster group request',
description: 'Join the cluster group the request was created for.',
history: new HistoryBuilder().added('v3.2.0'),
})
acceptClusterGroupRequest(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.acceptRequest(auth, id);
}
@Delete('requests/:id')
@Authenticated({ permission: Permission.ClusterGroupRequestDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Decline a cluster group request',
description: 'Delete a pending request for the current user to join a cluster group.',
history: new HistoryBuilder().added('v3.2.0'),
})
deleteClusterGroupRequest(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.deleteRequest(auth, id);
}
@Get(':id/requests')
@Authenticated({ permission: Permission.ClusterGroupRequestRead })
@Endpoint({
summary: 'Retrieve the requests sent by a cluster group',
description: 'Retrieve the pending requests for other users to join the cluster group.',
history: new HistoryBuilder().added('v3.2.0'),
})
getClusterGroupRequestsForGroup(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
): Promise<ClusterGroupRequestResponseDto[]> {
return this.service.getRequestsForGroup(auth, id);
}
@Get(':id/users')
@Authenticated({ permission: Permission.ClusterGroupRead })
@Endpoint({
summary: 'Retrieve the users of a cluster group',
description: 'Retrieve the users that are a member of the cluster group.',
history: new HistoryBuilder().added('v3.2.0'),
})
getClusterGroupUsers(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserResponseDto[]> {
return this.service.getUsers(auth, id);
}
@Put(':id/requests')
@Authenticated({ permission: Permission.ClusterGroupRequestCreate })
@Endpoint({
summary: 'Create a cluster group request',
description: 'Ask another user to join the cluster group of the current user.',
history: new HistoryBuilder().added('v3.2.0'),
})
async createClusterGroupRequest(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: ClusterGroupRequestCreateDto,
@Res({ passthrough: true }) res: Response,
): Promise<ClusterGroupRequestResponseDto> {
const { duplicate, value } = await this.service.createRequest(auth, id, dto);
res.status(duplicate ? HttpStatus.OK : HttpStatus.CREATED);
return value;
}
@Post(':id/leave')
@Authenticated({ permission: Permission.ClusterGroupLeave })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Leave a cluster group',
description: 'Move the current user into a new cluster group of their own.',
history: new HistoryBuilder().added('v3.2.0'),
})
leaveClusterGroup(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.leave(auth, id);
}
}

View file

@ -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,

View file

@ -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: {

View file

@ -133,6 +133,7 @@ export type User = {
};
export type UserAdmin = User & {
clusterGroupId: string;
storageLabel: string | null;
shouldChangePassword: boolean;
isAdmin: boolean;
@ -241,7 +242,7 @@ export type Exif = Omit<Selectable<AssetExifTable>, 'updatedAt' | 'updateId' | '
export type Person = {
createdAt: Date;
id: string;
personGroupId: string;
ownerId: string;
updatedAt: Date;
updateId: string;
@ -264,7 +265,7 @@ export type AssetFace = {
boundingBoxY2: number;
imageHeight: number;
imageWidth: number;
personId: string | null;
personGroupId: string | null;
sourceType: SourceType;
person?: ShallowDehydrateObject<Person> | null;
updatedAt: Date;
@ -376,6 +377,7 @@ export const columns = {
userWithPrefix: userWithPrefixColumns,
userAdmin: [
...userColumns,
'clusterGroupId',
'createdAt',
'updatedAt',
'deletedAt',

View file

@ -170,8 +170,8 @@ const peopleFromFaces = (faces?: MaybeDehydrated<AssetFace>[]): PersonResponseDt
const peopleMap: Map<string, PersonResponseDto> = new Map();
for (const face of faces) {
if (face.person && !peopleMap.has(face.person.id)) {
peopleMap.set(face.person.id, mapPerson(face.person));
if (face.person && !peopleMap.has(face.person.personGroupId)) {
peopleMap.set(face.person.personGroupId, mapPerson(face.person));
}
}

View file

@ -0,0 +1,32 @@
import { Selectable } from 'kysely';
import { createZodDto } from 'nestjs-zod';
import { ClusterGroupRequestTable } from 'src/schema/tables/cluster-group-request.table';
import { asDateTimeString } from 'src/utils/date';
import z from 'zod';
const ClusterGroupRequestCreateSchema = z
.object({
userId: z.uuidv4().describe('User to invite into the cluster group'),
})
.meta({ id: 'ClusterGroupRequestCreateDto' });
const ClusterGroupRequestResponseSchema = z
.object({
id: z.uuidv4().describe('Request ID'),
clusterGroupId: z.uuidv4().describe('Cluster group the user is invited to join'),
userId: z.uuidv4().describe('User the request was created for'),
createdAt: z.string().meta({ format: 'date-time' }).describe('Creation date'),
})
.meta({ id: 'ClusterGroupRequestResponseDto' });
export class ClusterGroupRequestCreateDto extends createZodDto(ClusterGroupRequestCreateSchema) {}
export class ClusterGroupRequestResponseDto extends createZodDto(ClusterGroupRequestResponseSchema) {}
export function mapClusterGroupRequest(request: Selectable<ClusterGroupRequestTable>): ClusterGroupRequestResponseDto {
return {
id: request.id,
clusterGroupId: request.clusterGroupId,
userId: request.userId,
createdAt: asDateTimeString(request.createdAt),
};
}

View file

@ -173,7 +173,7 @@ export class PeopleResponseDto extends createZodDto(PeopleResponseSchema) {}
export function mapPerson(person: MaybeDehydrated<Person>): PersonResponseDto {
return {
id: person.id,
id: person.personGroupId,
name: person.name,
birthDate: asDateString(person.birthDate),
thumbnailPath: person.thumbnailPath,

View file

@ -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,

View file

@ -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',

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -0,0 +1,94 @@
-- NOTE: This file is auto generated by ./sql-generator
-- ClusterGroupRepository.create
insert into
"cluster_group"
default values
returning
*
-- ClusterGroupRepository.getForUser
select
"user"."clusterGroupId"
from
"user"
where
"user"."id" = $1
-- ClusterGroupRepository.hasOtherMembers
select
"user"."id"
from
"user"
where
"user"."clusterGroupId" = $1
and "user"."id" != $2
and "user"."deletedAt" is null
-- ClusterGroupRepository.createRequest
insert into
"cluster_group_request" ("clusterGroupId", "userId")
values
($1, $2)
on conflict ("clusterGroupId", "userId") do nothing
returning
*
-- ClusterGroupRepository.getRequest
select
"cluster_group_request".*
from
"cluster_group_request"
where
"cluster_group_request"."id" = $1
-- ClusterGroupRepository.getRequestFor
select
"cluster_group_request".*
from
"cluster_group_request"
where
"cluster_group_request"."clusterGroupId" = $1
and "cluster_group_request"."userId" = $2
-- ClusterGroupRepository.getRequests
select
"cluster_group_request".*
from
"cluster_group_request"
where
"cluster_group_request"."userId" = $1
order by
"cluster_group_request"."createdAt" asc
-- ClusterGroupRepository.getRequestsForGroup
select
"cluster_group_request".*
from
"cluster_group_request"
where
"cluster_group_request"."clusterGroupId" = $1
order by
"cluster_group_request"."createdAt" asc
-- ClusterGroupRepository.getUsers
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."clusterGroupId" = $1
and "user"."deletedAt" is null
order by
"user"."id" = $2 desc,
"user"."name" asc
-- ClusterGroupRepository.deleteRequest
delete from "cluster_group_request"
where
"cluster_group_request"."id" = $1

View file

@ -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

View file

@ -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,11 +130,13 @@ select
from
"person"
where
"person"."id" = "asset_face"."personId"
"person"."personGroupId" = "asset_face"."personGroupId"
and "person"."ownerId" = "asset"."ownerId"
) as obj
) as "person"
from
"asset_face"
inner join "asset" on "asset"."id" = "asset_face"."assetId"
where
"asset_face"."assetId" = $1
and "asset_face"."deletedAt" is null
@ -119,11 +157,13 @@ select
from
"person"
where
"person"."id" = "asset_face"."personId"
"person"."personGroupId" = "asset_face"."personGroupId"
and "person"."ownerId" = "asset"."ownerId"
) as obj
) as "person"
from
"asset_face"
inner join "asset" on "asset"."id" = "asset_face"."assetId"
where
"asset_face"."id" = $1
and "asset_face"."deletedAt" is null
@ -131,7 +171,7 @@ where
-- PersonRepository.getFaceForFacialRecognitionJob
select
"asset_face"."id",
"asset_face"."personId",
"asset_face"."personGroupId",
"asset_face"."sourceType",
(
select
@ -141,9 +181,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 +237,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 +278,7 @@ limit
-- PersonRepository.getDistinctNames
select distinct
on (lower("person"."name")) "person"."id",
on (lower("person"."name")) "person"."personGroupId",
"person"."name"
from
"person"
@ -247,7 +299,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 +319,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 +334,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 +520,16 @@ select
from
"person"
where
"person"."id" = "asset_face"."personId"
"person"."personGroupId" = "asset_face"."personGroupId"
and "person"."ownerId" = "asset"."ownerId"
) as obj
) as "person"
from
"asset_face"
inner join "asset" on "asset"."id" = "asset_face"."assetId"
where
"asset_face"."assetId" in ($1)
and "asset_face"."personId" in ($2)
and "asset_face"."personGroupId" in ($2)
and "asset_face"."deletedAt" is null
-- PersonRepository.getRandomFace
@ -326,7 +538,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 +562,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 +579,4 @@ from
and "asset"."isOffline" = $1
where
"asset_face"."assetId" = $2
and "asset_face"."personId" = $3
and "asset_face"."personGroupId" = $3

View file

@ -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

View file

@ -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",

View file

@ -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",

View file

@ -420,23 +420,60 @@ class MemoryAccess {
}
}
class ClusterGroupAccess {
constructor(private db: Kysely<DB>) {}
/** a pending request is enough to look at the group being joined */
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkInviteAccess(userId: string, clusterGroupIds: Set<string>) {
if (clusterGroupIds.size === 0) {
return new Set<string>();
}
return this.db
.selectFrom('cluster_group_request')
.select('cluster_group_request.clusterGroupId')
.where('cluster_group_request.clusterGroupId', 'in', [...clusterGroupIds])
.where('cluster_group_request.userId', '=', userId)
.execute()
.then((requests) => new Set(requests.map((request) => request.clusterGroupId)));
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, clusterGroupIds: Set<string>) {
if (clusterGroupIds.size === 0) {
return new Set<string>();
}
return this.db
.selectFrom('user')
.select('user.clusterGroupId')
.where('user.clusterGroupId', 'in', [...clusterGroupIds])
.where('user.id', '=', userId)
.execute()
.then((users) => new Set(users.map((user) => user.clusterGroupId)));
}
}
class PersonAccess {
constructor(private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, personIds: Set<string>) {
if (personIds.size === 0) {
async checkOwnerAccess(userId: string, groupIds: Set<string>) {
if (groupIds.size === 0) {
return new Set<string>();
}
return this.db
.selectFrom('person')
.select('person.id')
.where('person.id', 'in', [...personIds])
.select('person.personGroupId')
.where('person.personGroupId', 'in', [...groupIds])
.where('person.ownerId', '=', userId)
.execute()
.then((persons) => new Set(persons.map((person) => person.id)));
.then((persons) => new Set(persons.map((person) => person.personGroupId)));
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ -526,6 +563,7 @@ export class AccessRepository {
duplicate: DuplicateAccess;
memory: MemoryAccess;
notification: NotificationAccess;
clusterGroup: ClusterGroupAccess;
person: PersonAccess;
partner: PartnerAccess;
session: SessionAccess;
@ -542,6 +580,7 @@ export class AccessRepository {
this.duplicate = new DuplicateAccess(db);
this.memory = new MemoryAccess(db);
this.notification = new NotificationAccess(db);
this.clusterGroup = new ClusterGroupAccess(db);
this.person = new PersonAccess(db);
this.partner = new PartnerAccess(db);
this.session = new SessionAccess(db);

View file

@ -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();
}

View file

@ -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<void> {

View file

@ -0,0 +1,111 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
import { DB } from 'src/schema';
import { ClusterGroupRequestTable } from 'src/schema/tables/cluster-group-request.table';
@Injectable()
export class ClusterGroupRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql()
create() {
return this.db.insertInto('cluster_group').defaultValues().returningAll().executeTakeFirstOrThrow();
}
@GenerateSql({ params: [DummyValue.UUID] })
async getForUser(userId: string): Promise<string> {
const { clusterGroupId } = await this.db
.selectFrom('user')
.select('user.clusterGroupId')
.where('user.id', '=', userId)
.executeTakeFirstOrThrow();
return clusterGroupId;
}
@GenerateSql({ params: [{ clusterGroupId: DummyValue.UUID, userId: DummyValue.UUID }] })
async hasOtherMembers({ clusterGroupId, userId }: { clusterGroupId: string; userId: string }): Promise<boolean> {
const member = await this.db
.selectFrom('user')
.select('user.id')
.where('user.clusterGroupId', '=', clusterGroupId)
.where('user.id', '!=', userId)
.where('user.deletedAt', 'is', null)
.executeTakeFirst();
return !!member;
}
/** returns nothing when the request already existed */
@GenerateSql({ params: [{ clusterGroupId: DummyValue.UUID, userId: DummyValue.UUID }] })
createRequest(request: Insertable<ClusterGroupRequestTable>) {
return this.db
.insertInto('cluster_group_request')
.values(request)
.onConflict((oc) => oc.columns(['clusterGroupId', 'userId']).doNothing())
.returningAll()
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getRequest(id: string) {
return this.db
.selectFrom('cluster_group_request')
.selectAll('cluster_group_request')
.where('cluster_group_request.id', '=', id)
.executeTakeFirst();
}
@GenerateSql({ params: [{ clusterGroupId: DummyValue.UUID, userId: DummyValue.UUID }] })
getRequestFor({ clusterGroupId, userId }: { clusterGroupId: string; userId: string }) {
return this.db
.selectFrom('cluster_group_request')
.selectAll('cluster_group_request')
.where('cluster_group_request.clusterGroupId', '=', clusterGroupId)
.where('cluster_group_request.userId', '=', userId)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getRequests(userId: string) {
return this.db
.selectFrom('cluster_group_request')
.selectAll('cluster_group_request')
.where('cluster_group_request.userId', '=', userId)
.orderBy('cluster_group_request.createdAt', 'asc')
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getRequestsForGroup(clusterGroupId: string) {
return this.db
.selectFrom('cluster_group_request')
.selectAll('cluster_group_request')
.where('cluster_group_request.clusterGroupId', '=', clusterGroupId)
.orderBy('cluster_group_request.createdAt', 'asc')
.execute();
}
@GenerateSql({ params: [{ clusterGroupId: DummyValue.UUID, userId: DummyValue.UUID }] })
getUsers({ clusterGroupId, userId }: { clusterGroupId: string; userId: string }) {
return (
this.db
.selectFrom('user')
.select(columns.user)
.where('user.clusterGroupId', '=', clusterGroupId)
.where('user.deletedAt', 'is', null)
// the current user comes first, everyone else in alphabetical order
.orderBy((eb) => eb('user.id', '=', userId), 'desc')
.orderBy('user.name', 'asc')
.execute()
);
}
@GenerateSql({ params: [DummyValue.UUID] })
async deleteRequest(id: string): Promise<void> {
await this.db.deleteFrom('cluster_group_request').where('cluster_group_request.id', '=', id).execute();
}
}

View file

@ -41,6 +41,9 @@ type EventMap = {
AlbumUpdate: [{ id: string; userIds: string[]; recipientIds: string[] }];
AlbumInvite: [{ id: string; userId: string; senderName: string }];
// cluster group events
ClusterGroupRequest: [{ clusterGroupId: string; userId: string; senderName: string }];
// asset events
AssetCreate: [{ asset: Pick<Asset, 'id' | 'ownerId'>; file?: UploadFile }];
AssetTag: [{ assetId: string }];

View file

@ -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,

View file

@ -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,

View file

@ -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),

View file

@ -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,9 +63,18 @@ export type UnassignFacesOptions = DeleteFacesOptions;
export type SelectFaceOptions = (keyof Selectable<AssetFaceTable>)[];
const withPerson = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
/** 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 };
const withPerson = (eb: ExpressionBuilder<DB, 'asset_face' | 'asset'>) => {
return jsonObjectFrom(
eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_face.personId'),
eb
.selectFrom('person')
.selectAll('person')
.whereRef('person.personGroupId', '=', 'asset_face.personGroupId')
.whereRef('person.ownerId', '=', 'asset.ownerId'),
).as('person');
};
@ -78,12 +88,12 @@ const withFaceSearch = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
export class PersonRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] })
async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise<number> {
@GenerateSql({ params: [{ oldPersonGroupId: DummyValue.UUID, newPersonGroupId: DummyValue.UUID }] })
async reassignFaces({ oldPersonGroupId, faceIds, newPersonGroupId }: UpdateFacesData): Promise<number> {
const result = await this.db
.updateTable('asset_face')
.set({ personId: newPersonId })
.$if(!!oldPersonId, (qb) => qb.where('asset_face.personId', '=', oldPersonId!))
.set({ personGroupId: newPersonGroupId })
.$if(!!oldPersonGroupId, (qb) => qb.where('asset_face.personGroupId', '=', oldPersonGroupId!))
.$if(!!faceIds, (qb) => qb.where('asset_face.id', 'in', faceIds!))
.executeTakeFirst();
@ -93,19 +103,68 @@ export class PersonRepository {
async unassignFaces({ sourceType }: UnassignFacesOptions): Promise<void> {
await this.db
.updateTable('asset_face')
.set({ personId: null })
.set({ personGroupId: null })
.where('asset_face.sourceType', '=', sourceType)
.execute();
}
@GenerateSql({ params: [[{ ownerId: DummyValue.UUID, personGroupId: DummyValue.UUID }]] })
@Chunked()
async delete(people: PersonId[]): Promise<void> {
if (people.length === 0) {
return;
}
await this.db
.deleteFrom('person')
.where((eb) =>
eb.or(
people.map(({ ownerId, personGroupId }) =>
eb.and([eb('person.ownerId', '=', ownerId), eb('person.personGroupId', '=', personGroupId)]),
),
),
)
.execute();
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@Chunked()
async delete(ids: string[]): Promise<void> {
async deleteGroups(ids: string[]): Promise<void> {
if (ids.length === 0) {
return;
}
await this.db.deleteFrom('person').where('person.id', 'in', ids).execute();
await this.db.deleteFrom('person_group').where('person_group.id', 'in', ids).execute();
}
@GenerateSql()
async deleteEmptyGroups(): Promise<number> {
const result = await this.db
.deleteFrom('person_group')
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('person')
.whereRef('person.personGroupId', '=', 'person_group.id')
.select('person.personGroupId'),
),
),
)
.executeTakeFirst();
return Number(result.numDeletedRows);
}
@GenerateSql()
async deleteOrphanedClusterGroups(): Promise<number> {
const result = await this.db
.deleteFrom('cluster_group')
.where(({ not, exists, selectFrom }) =>
not(exists(selectFrom('user').whereRef('user.clusterGroupId', '=', 'cluster_group.id').select('user.id'))),
)
.executeTakeFirst();
return Number(result.numDeletedRows);
}
async deleteFaces({ sourceType }: DeleteFacesOptions): Promise<void> {
@ -116,8 +175,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 +200,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 +211,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 +240,7 @@ export class PersonRepository {
),
]),
)
.groupBy('person.id')
.groupBy(['person.ownerId', 'person.personGroupId'])
.$if(!!options?.closestFaceAssetId, (qb) =>
qb.orderBy((eb) =>
eb(
@ -218,11 +278,11 @@ 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();
}
@ -232,6 +292,7 @@ export class PersonRepository {
return this.db
.selectFrom('asset_face')
.innerJoin('asset', 'asset.id', 'asset_face.assetId')
.selectAll('asset_face')
.select(withPerson)
.where('asset_face.assetId', '=', assetId)
@ -246,6 +307,7 @@ export class PersonRepository {
// TODO return null instead of find or fail
return this.db
.selectFrom('asset_face')
.innerJoin('asset', 'asset.id', 'asset_face.assetId')
.selectAll('asset_face')
.select(withPerson)
.where('asset_face.id', '=', id)
@ -257,12 +319,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 +335,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 +355,30 @@ export class PersonRepository {
'asset_exif.orientation as exifOrientation',
])
.select((eb) => withFilePath(eb, AssetFileType.Preview).as('previewPath'))
.where('person.id', '=', id)
.where('person.ownerId', '=', ownerId)
.where('person.personGroupId', '=', personGroupId)
.where('asset_face.deletedAt', 'is', null)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async reassignFace(assetFaceId: string, newPersonId: string): Promise<number> {
async reassignFace(assetFaceId: string, newPersonGroupId: string): Promise<number> {
const result = await this.db
.updateTable('asset_face')
.set({ personId: newPersonId })
.set({ personGroupId: newPersonGroupId })
.where('asset_face.id', '=', assetFaceId)
.executeTakeFirst();
return Number(result.numChangedRows ?? 0);
}
getById(personId: string) {
@GenerateSql({ params: [{ ownerId: DummyValue.UUID, personGroupId: DummyValue.UUID }] })
getByGroupId({ ownerId, personGroupId }: PersonId) {
return this.db //
.selectFrom('person')
.selectAll('person')
.where('person.id', '=', personId)
.where('person.personGroupId', '=', personGroupId)
.where('person.ownerId', '=', ownerId)
.executeTakeFirst();
}
@ -336,7 +402,7 @@ export class PersonRepository {
getDistinctNames(userId: string, { withHidden }: PersonNameSearchOptions): Promise<PersonNameResponse[]> {
return this.db
.selectFrom('person')
.select(['person.id', 'person.name'])
.select(['person.personGroupId', 'person.name'])
.distinctOn((eb) => eb.fn('lower', ['person.name']))
.where((eb) => eb.and([eb('person.ownerId', '=', userId), eb('person.name', '!=', '')]))
.$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false))
@ -344,7 +410,7 @@ export class PersonRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
async getStatistics(personId: string): Promise<PersonStatistics> {
async getStatistics(personGroupId: string): Promise<PersonStatistics> {
const result = await this.db
.selectFrom('asset_face')
.leftJoin('asset', (join) =>
@ -356,7 +422,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 +439,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 +463,124 @@ export class PersonRepository {
return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow();
}
async createAll(people: Insertable<PersonTable>[]): Promise<string[]> {
async createAll(people: Insertable<PersonTable>[]) {
if (people.length === 0) {
return [];
}
const results = await this.db.insertInto('person').values(people).returningAll().execute();
return results.map(({ id }) => id);
return this.db.insertInto('person').values(people).returningAll().execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
createGroup(ownerId: string) {
return this.db
.insertInto('person_group')
.columns(['clusterGroupId'])
.expression((eb) => eb.selectFrom('user').select('user.clusterGroupId').where('user.id', '=', ownerId))
.returningAll()
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [{ userId: DummyValue.UUID, newClusterId: DummyValue.UUID }] })
async reassignCluster({ userId, newClusterId }: ReassignCluster): Promise<void> {
await this.db.transaction().execute(async (trx) => {
// a group nobody else has people in moves across as it is
await trx
.updateTable('person_group')
.set({ clusterGroupId: newClusterId })
.where('person_group.id', 'in', (eb) =>
eb.selectFrom('person').select('person.personGroupId').where('person.ownerId', '=', userId),
)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('person')
.select('person.personGroupId')
.whereRef('person.personGroupId', '=', 'person_group.id')
.where('person.ownerId', '!=', userId),
),
),
)
.execute();
// the rest is shared with someone else, so this user gets a group of their own for each
const mapping = await trx
.with('shared', (db) =>
db
.selectFrom('person')
.select('person.personGroupId as oldId')
.distinct()
.where('person.ownerId', '=', userId)
.where(({ exists, selectFrom }) =>
exists(
selectFrom('person as other')
.select('other.personGroupId')
.whereRef('other.personGroupId', '=', 'person.personGroupId')
.where('other.ownerId', '!=', userId),
),
),
)
.with(
(cte) => cte('mapping').materialized(),
(db) => db.selectFrom('shared').select(['shared.oldId', sql<string>`uuid_generate_v4()`.as('newId')]),
)
.with('created', (db) =>
db
.insertInto('person_group')
.columns(['id', 'clusterGroupId'])
.expression((eb) =>
eb.selectFrom('mapping').select(['mapping.newId', sql.val(newClusterId).as('clusterGroupId')]),
),
)
.selectFrom('mapping')
.select(['mapping.oldId', 'mapping.newId'])
.execute();
if (mapping.length === 0) {
return;
}
const oldIds = mapping.map(({ oldId }) => oldId);
const newIds = mapping.map(({ newId }) => newId);
const remapped = sql<{
oldId: string;
newId: string;
}>`(select unnest(${`{${oldIds}}`}::uuid[]) as "oldId", unnest(${`{${newIds}}`}::uuid[]) as "newId")`.as(
'mapping',
);
await trx
.updateTable('person')
.from(remapped)
.set((eb) => ({ personGroupId: eb.ref('mapping.newId') }))
.whereRef('person.personGroupId', '=', 'mapping.oldId')
.where('person.ownerId', '=', userId)
.execute();
await trx
.updateTable('asset_face')
.from(remapped)
.set((eb) => ({ personGroupId: eb.ref('mapping.newId') }))
.whereRef('asset_face.personGroupId', '=', 'mapping.oldId')
.where(({ exists, selectFrom }) =>
exists(
selectFrom('asset')
.select('asset.id')
.whereRef('asset.id', '=', 'asset_face.assetId')
.where('asset.ownerId', '=', userId),
),
)
.execute();
});
}
@GenerateSql({ params: [DummyValue.UUID, 2] })
async createGroups(personGroups: Insertable<PersonGroupTable>[]) {
if (personGroups.length === 0) {
return [];
}
return this.db.insertInto('person_group').values(personGroups).returningAll().execute();
}
@GenerateSql({ params: [[], [], [{ faceId: DummyValue.UUID, embedding: DummyValue.VECTOR }]] })
@ -430,11 +607,12 @@ export class PersonRepository {
await query.selectFrom(dummy).execute();
}
async update(person: Updateable<PersonTable> & { id: string }) {
async update(person: Updateable<PersonTable> & PersonId) {
return this.db
.updateTable('person')
.set(person)
.where('person.id', '=', person.id)
.where('person.ownerId', '=', person.ownerId)
.where('person.personGroupId', '=', person.personGroupId)
.returningAll()
.executeTakeFirstOrThrow();
}
@ -448,7 +626,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,7 +644,7 @@ export class PersonRepository {
.execute();
}
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] })
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, personGroupId: DummyValue.UUID }]] })
@ChunkedArray()
getFacesByIds(ids: AssetFaceId[]) {
if (ids.length === 0) {
@ -474,28 +652,29 @@ export class PersonRepository {
}
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')
.innerJoin('asset', 'asset.id', 'asset_face.assetId')
.selectAll('asset_face')
.select(withPerson)
.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 +715,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 +759,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();
}

View file

@ -54,6 +54,8 @@ export interface SearchOneToOneRelationOptions {
export interface SearchRelationOptions extends SearchOneToOneRelationOptions {
withFaces?: boolean;
withPeople?: boolean;
/** whose version of the people to select, required when selecting faces or people */
viewingUserId?: string;
}
export interface SearchDateOptions {
@ -137,6 +139,8 @@ export interface AssetSearchBuilderV3Options {
filter?: SearchFilter;
/** Server-derived ownership scope. Never client-controlled. */
userIds?: string[];
/** whose version of the people to select, required when selecting faces or people */
viewingUserId?: string;
withExif?: boolean;
withFaces?: boolean;
withPeople?: boolean;
@ -156,13 +160,14 @@ export type SmartSearchOptions = SearchDateOptions &
SearchUserIdOptions &
SearchPeopleOptions &
SearchTagOptions &
SearchOcrOptions & { visibility?: AssetVisibility | 'not-locked' };
SearchOcrOptions & { visibility?: AssetVisibility | 'not-locked'; viewingUserId?: string };
export type OcrSearchOptions = SearchDateOptions & SearchOcrOptions;
export type LargeAssetSearchOptions = AssetSearchOptions & { minFileSize?: number };
export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
export interface FaceEmbeddingSearch extends Omit<SearchEmbeddingOptions, 'userIds'> {
clusterGroupId: string;
hasPerson?: boolean;
numResults: number;
maxDistance: number;
@ -172,7 +177,7 @@ export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
export interface FaceSearchResult {
distance: number;
id: string;
personId: string | null;
personGroupId: string | null;
}
export interface AssetDuplicateResult {
@ -341,7 +346,7 @@ export class SearchRepository {
},
],
})
searchFaces({ userIds, embedding, numResults, maxDistance, hasPerson, minBirthDate }: FaceEmbeddingSearch) {
searchFaces({ clusterGroupId, embedding, numResults, maxDistance, hasPerson, minBirthDate }: FaceEmbeddingSearch) {
if (!z.int().min(1).max(1000).safeParse(numResults).success) {
throw new Error(`Invalid value for 'numResults': ${numResults}`);
}
@ -352,20 +357,29 @@ export class SearchRepository {
.with('cte', (qb) =>
qb
.selectFrom('asset_face')
.select([
'asset_face.id',
'asset_face.personId',
sql<number>`face_search.embedding <=> ${embedding}`.as('distance'),
])
.innerJoin('asset', 'asset.id', 'asset_face.assetId')
.innerJoin('face_search', 'face_search.faceId', 'asset_face.id')
.leftJoin('person', 'person.id', 'asset_face.personId')
.where('asset.ownerId', '=', anyUuid(userIds))
.select([
'asset_face.id',
'asset_face.personGroupId',
sql<number>`face_search.embedding <=> ${embedding}`.as('distance'),
])
.where('asset.ownerId', 'in', (eb) =>
eb.selectFrom('user').select('user.id').where('user.clusterGroupId', '=', clusterGroupId),
)
.where('asset.deletedAt', 'is', null)
.$if(!!hasPerson, (qb) => qb.where('asset_face.personId', 'is not', null))
.$if(!!hasPerson, (qb) => qb.where('asset_face.personGroupId', 'is not', null))
.$if(!!minBirthDate, (qb) =>
qb.where((eb) =>
eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)]),
eb.not(
eb.exists(
eb
.selectFrom('person')
.select('person.personGroupId')
.whereRef('person.personGroupId', '=', 'asset_face.personGroupId')
.where('person.birthDate', '>', minBirthDate!),
),
),
),
)
.orderBy('distance')

View file

@ -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',

View file

@ -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`,

View file

@ -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;

View file

@ -0,0 +1,232 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION person_delete_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO person_audit ("personGroupId", "ownerId")
SELECT "personGroupId", "ownerId"
FROM OLD;
RETURN NULL;
END
$$;`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "person_delete_audit"
AFTER DELETE ON "person"
REFERENCING OLD TABLE AS "old"
FOR EACH STATEMENT
WHEN (pg_trigger_depth() <= 1)
EXECUTE FUNCTION person_delete_audit();`.execute(db);
await sql`CREATE OR REPLACE FUNCTION person_group_delete_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO person_group_audit ("personGroupId", "clusterGroupId")
SELECT "id", "clusterGroupId"
FROM OLD;
RETURN NULL;
END
$$;`.execute(db);
await sql`CREATE TABLE "cluster_group" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"name" character varying,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
"updateId" uuid NOT NULL DEFAULT immich_uuid_v7(),
CONSTRAINT "cluster_group_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "cluster_group_updateId_idx" ON "cluster_group" ("updateId");`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "cluster_group_updatedAt"
BEFORE UPDATE ON "cluster_group"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
await sql`ALTER TABLE "user" ADD "clusterGroupId" uuid;`.execute(db);
await sql`UPDATE "user" SET "clusterGroupId" = uuid_generate_v4();`.execute(db);
await sql`INSERT INTO "cluster_group" ("id") SELECT "clusterGroupId" FROM "user";`.execute(db);
await sql`ALTER TABLE "user" ALTER COLUMN "clusterGroupId" SET NOT NULL;`.execute(db);
await sql`CREATE INDEX "user_clusterGroupId_idx" ON "user" ("clusterGroupId");`.execute(db);
await sql`ALTER TABLE "user" ADD CONSTRAINT "user_clusterGroupId_fkey" FOREIGN KEY ("clusterGroupId") REFERENCES "cluster_group" ("id") ON UPDATE CASCADE ON DELETE NO ACTION;`.execute(db);
await sql`CREATE TABLE "cluster_group_request" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"clusterGroupId" uuid NOT NULL,
"userId" uuid NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT "cluster_group_request_clusterGroupId_fkey" FOREIGN KEY ("clusterGroupId") REFERENCES "cluster_group" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "cluster_group_request_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "cluster_group_request_clusterGroupId_userId_uq" UNIQUE ("clusterGroupId", "userId"),
CONSTRAINT "cluster_group_request_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "cluster_group_request_clusterGroupId_idx" ON "cluster_group_request" ("clusterGroupId");`.execute(
db,
);
await sql`CREATE INDEX "cluster_group_request_userId_idx" ON "cluster_group_request" ("userId");`.execute(db);
await sql`CREATE TABLE "person_group" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"clusterGroupId" uuid NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"createId" uuid NOT NULL DEFAULT immich_uuid_v7(),
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
"updateId" uuid NOT NULL DEFAULT immich_uuid_v7(),
CONSTRAINT "person_group_clusterGroupId_fkey" FOREIGN KEY ("clusterGroupId") REFERENCES "cluster_group" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "person_group_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "person_group_clusterGroupId_idx" ON "person_group" ("clusterGroupId");`.execute(db);
await sql`CREATE INDEX "person_group_createId_idx" ON "person_group" ("createId");`.execute(db);
await sql`CREATE INDEX "person_group_updateId_idx" ON "person_group" ("updateId");`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "person_group_delete_audit"
AFTER DELETE ON "person_group"
REFERENCING OLD TABLE AS "old"
FOR EACH STATEMENT
WHEN (pg_trigger_depth() = 0)
EXECUTE FUNCTION person_group_delete_audit();`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "person_group_updatedAt"
BEFORE UPDATE ON "person_group"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
await sql`CREATE TABLE "person_group_audit" (
"id" uuid NOT NULL DEFAULT immich_uuid_v7(),
"personGroupId" uuid NOT NULL,
"clusterGroupId" uuid NOT NULL,
"deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT "person_group_audit_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "person_group_audit_personGroupId_idx" ON "person_group_audit" ("personGroupId");`.execute(db);
await sql`CREATE INDEX "person_group_audit_clusterGroupId_idx" ON "person_group_audit" ("clusterGroupId");`.execute(db);
await sql`CREATE INDEX "person_group_audit_deletedAt_idx" ON "person_group_audit" ("deletedAt");`.execute(db);
await sql`ALTER TABLE "person" ADD "personGroupId" uuid;`.execute(db);
await sql`INSERT INTO "person_group" ("id", "clusterGroupId", "createdAt")
SELECT "person"."id", "user"."clusterGroupId", "person"."createdAt"
FROM "person"
INNER JOIN "user" ON "user"."id" = "person"."ownerId";`.execute(db);
await sql`UPDATE "person" SET "personGroupId" = "id";`.execute(db);
await sql`ALTER TABLE "person" ALTER COLUMN "personGroupId" SET NOT NULL;`.execute(db);
await sql`CREATE INDEX "person_personGroupId_idx" ON "person" ("personGroupId");`.execute(db);
await sql`ALTER TABLE "person" ADD CONSTRAINT "person_personGroupId_fkey" FOREIGN KEY ("personGroupId") REFERENCES "person_group" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(
db,
);
await sql`ALTER TABLE "person_audit" ADD "personGroupId" uuid;`.execute(db);
await sql`UPDATE "person_audit" SET "personGroupId" = "personId";`.execute(db);
await sql`ALTER TABLE "person_audit" ALTER COLUMN "personGroupId" SET NOT NULL;`.execute(db);
await sql`ALTER TABLE "person_audit" DROP COLUMN "personId";`.execute(db);
await sql`CREATE INDEX "person_audit_personGroupId_idx" ON "person_audit" ("personGroupId");`.execute(db);
await sql`ALTER TABLE "asset_face" DROP CONSTRAINT "asset_face_personId_fkey";`.execute(db);
await sql`ALTER TABLE "asset_face" RENAME COLUMN "personId" TO "personGroupId";`.execute(db);
await sql`UPDATE "asset_face" SET "personGroupId" = "person"."personGroupId"
FROM "person"
WHERE "person"."id" = "asset_face"."personGroupId";`.execute(db);
await sql`ALTER TABLE "asset_face" ADD CONSTRAINT "asset_face_personGroupId_fkey" FOREIGN KEY ("personGroupId") REFERENCES "person_group" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute(
db,
);
await sql`CREATE INDEX "asset_face_personGroupId_assetId_idx" ON "asset_face" ("personGroupId", "assetId");`.execute(
db,
);
await sql`CREATE INDEX "asset_face_personGroupId_assetId_notDeleted_isVisible_idx" ON "asset_face" ("personGroupId", "assetId") WHERE ("deletedAt" IS NULL AND "isVisible" IS TRUE);`.execute(
db,
);
await sql`CREATE INDEX "asset_face_assetId_personGroupId_idx" ON "asset_face" ("assetId", "personGroupId");`.execute(
db,
);
await sql`DROP INDEX "asset_face_assetId_personId_idx";`.execute(db);
await sql`DROP INDEX "asset_face_personId_assetId_idx";`.execute(db);
await sql`DROP INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx";`.execute(db);
// a person is identified by its owner and the group it belongs to
await sql`ALTER TABLE "person" DROP CONSTRAINT "person_pkey";`.execute(db);
await sql`ALTER TABLE "person" DROP COLUMN "id";`.execute(db);
await sql`ALTER TABLE "person" ADD CONSTRAINT "person_pkey" PRIMARY KEY ("ownerId", "personGroupId");`.execute(db);
await sql`DROP INDEX "person_ownerId_idx";`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"person_delete_audit","sql":"CREATE OR REPLACE FUNCTION person_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person_audit (\\"personGroupId\\", \\"ownerId\\")\\n SELECT \\"personGroupId\\", \\"ownerId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_person_delete_audit';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"trigger","name":"person_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"person_delete_audit\\"\\n AFTER DELETE ON \\"person\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() <= 1)\\n EXECUTE FUNCTION person_delete_audit();"}'::jsonb WHERE "name" = 'trigger_person_delete_audit';`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_person_group_delete_audit', '{"type":"function","name":"person_group_delete_audit","sql":"CREATE OR REPLACE FUNCTION person_group_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person_group_audit (\\"personGroupId\\", \\"clusterGroupId\\")\\n SELECT \\"id\\", \\"clusterGroupId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_cluster_group_updatedAt', '{"type":"trigger","name":"cluster_group_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"cluster_group_updatedAt\\"\\n BEFORE UPDATE ON \\"cluster_group\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_person_group_delete_audit', '{"type":"trigger","name":"person_group_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"person_group_delete_audit\\"\\n AFTER DELETE ON \\"person_group\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION person_group_delete_audit();"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_person_group_updatedAt', '{"type":"trigger","name":"person_group_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"person_group_updatedAt\\"\\n BEFORE UPDATE ON \\"person_group\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_face_personGroupId_assetId_notDeleted_isVisible_idx', '{"type":"index","name":"asset_face_personGroupId_assetId_notDeleted_isVisible_idx","sql":"CREATE INDEX \\"asset_face_personGroupId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"personGroupId\\", \\"assetId\\") WHERE (\\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE);"}'::jsonb);`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_face_personId_assetId_notDeleted_isVisible_idx';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION person_delete_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO person_audit ("personId", "ownerId")
SELECT "id", "ownerId"
FROM OLD;
RETURN NULL;
END
$$;`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "person_delete_audit"
AFTER DELETE ON "person"
REFERENCING OLD TABLE AS "old"
FOR EACH STATEMENT
WHEN (pg_trigger_depth() = 0)
EXECUTE FUNCTION person_delete_audit();`.execute(db);
await sql`ALTER TABLE "person" DROP CONSTRAINT "person_pkey";`.execute(db);
await sql`ALTER TABLE "person" ADD "id" uuid NOT NULL DEFAULT uuid_generate_v4();`.execute(db);
await sql`UPDATE "person" SET "id" = "personGroupId";`.execute(db);
await sql`ALTER TABLE "person" ADD CONSTRAINT "person_pkey" PRIMARY KEY ("id");`.execute(db);
await sql`CREATE INDEX "person_ownerId_idx" ON "person" ("ownerId");`.execute(db);
await sql`DROP INDEX "asset_face_assetId_personGroupId_idx";`.execute(db);
await sql`DROP INDEX "asset_face_personGroupId_assetId_notDeleted_isVisible_idx";`.execute(db);
await sql`DROP INDEX "asset_face_personGroupId_assetId_idx";`.execute(db);
await sql`ALTER TABLE "asset_face" DROP CONSTRAINT "asset_face_personGroupId_fkey";`.execute(db);
await sql`ALTER TABLE "asset_face" RENAME COLUMN "personGroupId" TO "personId";`.execute(db);
await sql`UPDATE "asset_face" SET "personId" = "person"."id"
FROM "person"
WHERE "person"."personGroupId" = "asset_face"."personId";`.execute(db);
await sql`ALTER TABLE "asset_face" ADD CONSTRAINT "asset_face_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute(
db,
);
await sql`CREATE INDEX "asset_face_assetId_personId_idx" ON "asset_face" ("assetId", "personId");`.execute(db);
await sql`CREATE INDEX "asset_face_personId_assetId_idx" ON "asset_face" ("personId", "assetId");`.execute(db);
await sql`CREATE INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx" ON "asset_face" ("personId", "assetId") WHERE ("deletedAt" IS NULL AND "isVisible" IS TRUE);`.execute(
db,
);
await sql`ALTER TABLE "person_audit" ADD "personId" uuid;`.execute(db);
await sql`UPDATE "person_audit" SET "personId" = "personGroupId";`.execute(db);
await sql`ALTER TABLE "person_audit" ALTER COLUMN "personId" SET NOT NULL;`.execute(db);
await sql`CREATE INDEX "person_audit_personId_idx" ON "person_audit" ("personId");`.execute(db);
await sql`DROP INDEX "person_audit_personGroupId_idx";`.execute(db);
await sql`ALTER TABLE "person_audit" DROP COLUMN "personGroupId";`.execute(db);
await sql`ALTER TABLE "person" DROP CONSTRAINT "person_personGroupId_fkey";`.execute(db);
await sql`DROP INDEX "person_personGroupId_idx";`.execute(db);
await sql`ALTER TABLE "person" DROP COLUMN "personGroupId";`.execute(db);
await sql`ALTER TABLE "user" DROP CONSTRAINT "user_clusterGroupId_fkey";`.execute(db);
await sql`DROP INDEX "user_clusterGroupId_idx";`.execute(db);
await sql`ALTER TABLE "user" DROP COLUMN "clusterGroupId";`.execute(db);
await sql`DROP TABLE "cluster_group_request";`.execute(db);
await sql`DROP TABLE "person_group_audit";`.execute(db);
await sql`DROP TABLE "person_group";`.execute(db);
await sql`DROP TABLE "cluster_group";`.execute(db);
await sql`DROP FUNCTION person_group_delete_audit;`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"person_delete_audit","sql":"CREATE OR REPLACE FUNCTION person_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person_audit (\\"personId\\", \\"ownerId\\")\\n SELECT \\"id\\", \\"ownerId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_person_delete_audit';`.execute(
db,
);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"trigger","name":"person_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"person_delete_audit\\"\\n AFTER DELETE ON \\"person\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION person_delete_audit();"}'::jsonb WHERE "name" = 'trigger_person_delete_audit';`.execute(
db,
);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_person_group_delete_audit';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_cluster_group_updatedAt';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_person_group_delete_audit';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_person_group_updatedAt';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_face_personGroupId_assetId_notDeleted_isVisible_idx';`.execute(
db,
);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_face_personId_assetId_notDeleted_isVisible_idx', '{"type":"index","name":"asset_face_personId_assetId_notDeleted_isVisible_idx","sql":"CREATE INDEX \\"asset_face_personId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"personId\\", \\"assetId\\") WHERE (\\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE);"}'::jsonb);`.execute(
db,
);
}

View file

@ -15,7 +15,7 @@ import { SourceType } from 'src/enum';
import { asset_face_source_type } from 'src/schema/enums';
import { asset_face_audit } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { PersonGroupTable } from 'src/schema/tables/person-group.table';
@Table({ name: 'asset_face' })
@UpdatedAtTrigger('asset_face_updatedAt')
@ -26,13 +26,13 @@ import { PersonTable } from 'src/schema/tables/person.table';
when: 'pg_trigger_depth() = 0',
})
// schemaFromDatabase does not preserve column order
@Index({ name: 'asset_face_assetId_personId_idx', columns: ['assetId', 'personId'] })
@Index({ name: 'asset_face_assetId_personGroupId_idx', columns: ['assetId', 'personGroupId'] })
@Index({
name: 'asset_face_personId_assetId_notDeleted_isVisible_idx',
columns: ['personId', 'assetId'],
name: 'asset_face_personGroupId_assetId_notDeleted_isVisible_idx',
columns: ['personGroupId', 'assetId'],
where: '"deletedAt" IS NULL AND "isVisible" IS TRUE',
})
@Index({ columns: ['personId', 'assetId'] })
@Index({ columns: ['personGroupId', 'assetId'] })
export class AssetFaceTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@ -40,19 +40,19 @@ export class AssetFaceTable {
@ForeignKeyColumn(() => AssetTable, {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
// [assetId, personId] is the PK constraint
// [assetId, personGroupId] is the PK constraint
index: false,
})
assetId!: string;
@ForeignKeyColumn(() => PersonTable, {
@ForeignKeyColumn(() => PersonGroupTable, {
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
nullable: true,
// [personId, assetId] makes this redundant
// [personGroupId, assetId] makes this redundant
index: false,
})
personId!: string | null;
personGroupId!: string | null;
@Column({ default: 0, type: 'integer' })
imageWidth!: Generated<number>;

View file

@ -0,0 +1,27 @@
import {
CreateDateColumn,
ForeignKeyColumn,
Generated,
PrimaryGeneratedColumn,
Table,
Timestamp,
Unique,
} from '@immich/sql-tools';
import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table';
import { UserTable } from 'src/schema/tables/user.table';
@Table('cluster_group_request')
@Unique({ columns: ['clusterGroupId', 'userId'] })
export class ClusterGroupRequestTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@ForeignKeyColumn(() => ClusterGroupTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
clusterGroupId!: string;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
userId!: string;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
}

View file

@ -0,0 +1,29 @@
import {
Column,
CreateDateColumn,
Generated,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
@Table('cluster_group')
@UpdatedAtTrigger('cluster_group_updatedAt')
export class ClusterGroupTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@Column({ type: 'character varying', nullable: true, default: null })
name!: string | null;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}

View file

@ -7,7 +7,7 @@ export class PersonAuditTable {
id!: Generated<string>;
@Column({ type: 'uuid', index: true })
personId!: string;
personGroupId!: string;
@Column({ type: 'uuid', index: true })
ownerId!: string;

View file

@ -0,0 +1,17 @@
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
@Table('person_group_audit')
export class PersonGroupAuditTable {
@PrimaryGeneratedUuidV7Column()
id!: Generated<string>;
@Column({ type: 'uuid', index: true })
personGroupId!: string;
@Column({ type: 'uuid', index: true })
clusterGroupId!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
deletedAt!: Generated<Timestamp>;
}

View file

@ -0,0 +1,41 @@
import {
AfterDeleteTrigger,
CreateDateColumn,
ForeignKeyColumn,
Generated,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
} from '@immich/sql-tools';
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { person_group_delete_audit } from 'src/schema/functions';
import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table';
@Table('person_group')
@UpdatedAtTrigger('person_group_updatedAt')
@AfterDeleteTrigger({
scope: 'statement',
function: person_group_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() = 0',
})
export class PersonGroupTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@ForeignKeyColumn(() => ClusterGroupTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
clusterGroupId!: string;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@CreateIdColumn({ index: true })
createId!: Generated<string>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}

View file

@ -6,7 +6,6 @@ import {
ForeignKeyColumn,
Generated,
Index,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
@ -14,6 +13,7 @@ import {
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { person_delete_audit } from 'src/schema/functions';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { PersonGroupTable } from 'src/schema/tables/person-group.table';
import { UserTable } from 'src/schema/tables/user.table';
@Table('person')
@ -27,12 +27,25 @@ import { UserTable } from 'src/schema/tables/user.table';
scope: 'statement',
function: person_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() = 0',
when: 'pg_trigger_depth() <= 1',
})
@Check({ name: 'person_birthDate_chk', expression: `"birthDate" <= CURRENT_DATE` })
export class PersonTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@ForeignKeyColumn(() => UserTable, {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
primary: true,
// [ownerId, personGroupId] is the PK constraint
index: false,
})
ownerId!: string;
@ForeignKeyColumn(() => PersonGroupTable, {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
primary: true,
})
personGroupId!: string;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@ -40,9 +53,6 @@ export class PersonTable {
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string;
@Column({ default: '' })
name!: Generated<string>;

View file

@ -3,6 +3,7 @@ import {
Column,
CreateDateColumn,
DeleteDateColumn,
ForeignKeyColumn,
Generated,
Index,
PrimaryGeneratedColumn,
@ -14,6 +15,7 @@ import { ColumnType } from 'kysely';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { UserAvatarColor, UserStatus } from 'src/enum';
import { user_delete_audit } from 'src/schema/functions';
import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table';
@Table('user')
@UpdatedAtTrigger('user_updatedAt')
@ -82,4 +84,7 @@ export class UserTable {
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@ForeignKeyColumn(() => ClusterGroupTable, { onUpdate: 'CASCADE', nullable: false })
clusterGroupId!: string;
}

View file

@ -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 });

View file

@ -65,7 +65,7 @@ export class AssetService extends BaseService {
const asset = await this.assetRepository.getById(id, {
exifInfo: true,
owner: true,
faces: { person: true },
faces: { person: true, viewingUserId: auth.user.id },
stack: { assets: true },
edits: true,
tags: true,
@ -85,7 +85,7 @@ export class AssetService extends BaseService {
delete data.owner;
}
if (data.ownerId !== auth.user.id || auth.sharedLink) {
if (auth.sharedLink) {
data.people = [];
}
@ -124,7 +124,7 @@ export class AssetService extends BaseService {
throw new BadRequestException('Asset not found');
}
return mapAsset(asset, { auth });
return this.get(auth, id) as Promise<AssetResponseDto>;
}
async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise<void> {

View file

@ -14,6 +14,7 @@ import { AppRepository } from 'src/repositories/app.repository';
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { CronRepository } from 'src/repositories/cron.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
@ -74,6 +75,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
AssetRepository,
AssetEditRepository,
AssetJobRepository,
ClusterGroupRepository,
ConfigRepository,
CronRepository,
CryptoRepository,
@ -134,6 +136,7 @@ export class BaseService {
protected assetRepository: AssetRepository,
protected assetEditRepository: AssetEditRepository,
protected assetJobRepository: AssetJobRepository,
protected clusterGroupRepository: ClusterGroupRepository,
protected configRepository: ConfigRepository,
protected cronRepository: CronRepository,
protected cryptoRepository: CryptoRepository,
@ -203,6 +206,7 @@ export class BaseService {
ctx.assetRepository,
ctx.assetEditRepository,
ctx.assetJobRepository,
ctx.clusterGroupRepository,
ctx.configRepository,
ctx.cronRepository,
ctx.cryptoRepository,
@ -292,7 +296,7 @@ export class BaseService {
}
}
async createUser(dto: Insertable<UserTable> & { email: string }): Promise<UserAdmin> {
async createUser(dto: Omit<Insertable<UserTable>, 'clusterGroupId'> & { email: string }): Promise<UserAdmin> {
const exists = await this.userRepository.getByEmail(dto.email);
if (exists) {
this.logger.debug('User creation rejected: user already exists');
@ -306,7 +310,7 @@ export class BaseService {
}
}
const payload: Insertable<UserTable> = { ...dto };
const payload: Omit<Insertable<UserTable>, 'clusterGroupId'> = { ...dto };
if (payload.password) {
payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS);
}
@ -314,7 +318,8 @@ export class BaseService {
payload.storageLabel = sanitize(payload.storageLabel.replaceAll('.', ''));
}
const user = await this.userRepository.create(payload);
const clusterGroup = await this.clusterGroupRepository.create();
const user = await this.userRepository.create({ ...payload, clusterGroupId: clusterGroup.id });
await this.eventRepository.emit('UserCreate', user);

View file

@ -0,0 +1,116 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { MaybeDuplicate } from 'src/dtos/activity.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
ClusterGroupRequestCreateDto,
ClusterGroupRequestResponseDto,
mapClusterGroupRequest,
} from 'src/dtos/cluster-group.dto';
import { mapUser, UserResponseDto } from 'src/dtos/user.dto';
import { Permission } from 'src/enum';
import { BaseService } from 'src/services/base.service';
@Injectable()
export class ClusterGroupService extends BaseService {
async getRequests(auth: AuthDto): Promise<ClusterGroupRequestResponseDto[]> {
const requests = await this.clusterGroupRepository.getRequests(auth.user.id);
return requests.map((request) => mapClusterGroupRequest(request));
}
async getRequestsForGroup(auth: AuthDto, clusterGroupId: string): Promise<ClusterGroupRequestResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.ClusterGroupRequestRead, ids: [clusterGroupId] });
const requests = await this.clusterGroupRepository.getRequestsForGroup(clusterGroupId);
return requests.map((request) => mapClusterGroupRequest(request));
}
async getUsers(auth: AuthDto, clusterGroupId: string): Promise<UserResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.ClusterGroupRead, ids: [clusterGroupId] });
const users = await this.clusterGroupRepository.getUsers({ clusterGroupId, userId: auth.user.id });
return users.map((user) => mapUser(user));
}
async createRequest(
auth: AuthDto,
clusterGroupId: string,
{ userId }: ClusterGroupRequestCreateDto,
): Promise<MaybeDuplicate<ClusterGroupRequestResponseDto>> {
await this.requireAccess({ auth, permission: Permission.ClusterGroupRequestCreate, ids: [clusterGroupId] });
if (userId === auth.user.id) {
throw new BadRequestException('Cannot request to join your own cluster group');
}
const user = await this.userRepository.get(userId, {});
if (!user) {
throw new BadRequestException('Invalid user');
}
const created = await this.clusterGroupRepository.createRequest({ clusterGroupId, userId });
const request = created ?? (await this.clusterGroupRepository.getRequestFor({ clusterGroupId, userId }));
if (!request) {
throw new BadRequestException('Request not found');
}
if (created) {
await this.eventRepository.emit('ClusterGroupRequest', { clusterGroupId, userId, senderName: auth.user.name });
}
return { duplicate: !created, value: mapClusterGroupRequest(request) };
}
async acceptRequest(auth: AuthDto, id: string): Promise<void> {
const request = await this.clusterGroupRepository.getRequest(id);
if (!request || request.userId !== auth.user.id) {
throw new BadRequestException('Request not found');
}
const clusterGroupId = await this.clusterGroupRepository.getForUser(auth.user.id);
const hasOtherMembers = await this.clusterGroupRepository.hasOtherMembers({
clusterGroupId,
userId: auth.user.id,
});
if (hasOtherMembers) {
throw new BadRequestException('Leave the current cluster group before joining another one');
}
await this.clusterGroupRepository.deleteRequest(request.id);
await this.personRepository.reassignCluster({ userId: auth.user.id, newClusterId: request.clusterGroupId });
await this.userRepository.update(auth.user.id, { clusterGroupId: request.clusterGroupId });
}
async deleteRequest(auth: AuthDto, id: string): Promise<void> {
const request = await this.clusterGroupRepository.getRequest(id);
if (!request) {
throw new BadRequestException('Request not found');
}
// the user it was created for declines it, anyone in the cluster group revokes it
if (request.userId !== auth.user.id) {
await this.requireAccess({
auth,
permission: Permission.ClusterGroupRequestDelete,
ids: [request.clusterGroupId],
});
}
await this.clusterGroupRepository.deleteRequest(request.id);
}
async leave(auth: AuthDto, clusterGroupId: string): Promise<void> {
await this.requireAccess({ auth, permission: Permission.ClusterGroupLeave, ids: [clusterGroupId] });
const hasOtherMembers = await this.clusterGroupRepository.hasOtherMembers({
clusterGroupId,
userId: auth.user.id,
});
if (!hasOtherMembers) {
throw new BadRequestException('Cannot leave a cluster group without any other members');
}
const clusterGroup = await this.clusterGroupRepository.create();
await this.personRepository.reassignCluster({ userId: auth.user.id, newClusterId: clusterGroup.id });
await this.userRepository.update(auth.user.id, { clusterGroupId: clusterGroup.id });
}
}

View file

@ -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,

View file

@ -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);
}

View file

@ -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;

View file

@ -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, {

View file

@ -91,16 +91,17 @@ export class MediaService extends BaseService {
for await (const people of batched(this.personRepository.getAll(force ? undefined : { thumbnailPath: '' }))) {
const jobs: JobItem[] = [];
for (const person of people) {
const { ownerId, personGroupId } = person;
if (!person.faceAssetId) {
const face = await this.personRepository.getRandomFace(person.id);
const face = await this.personRepository.getRandomFace(personGroupId);
if (!face) {
continue;
}
await this.personRepository.update({ id: person.id, faceAssetId: face.id });
await this.personRepository.update({ ownerId, personGroupId, faceAssetId: face.id });
}
jobs.push({ name: JobName.PersonGenerateThumbnail, data: { id: person.id } });
jobs.push({ name: JobName.PersonGenerateThumbnail, data: { ownerId, personGroupId } });
}
await this.jobRepository.queueAll(jobs);
@ -125,7 +126,10 @@ export class MediaService extends BaseService {
for await (const people of batched(this.personRepository.getAll())) {
await this.jobRepository.queueAll(
people.map((person) => ({ name: JobName.PersonFileMigration, data: { id: person.id } })),
people.map(({ ownerId, personGroupId }) => ({
name: JobName.PersonFileMigration,
data: { ownerId, personGroupId },
})),
);
}
@ -387,19 +391,22 @@ export class MediaService extends BaseService {
}
@OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration })
async handleGeneratePersonThumbnail({ id }: JobOf<JobName.PersonGenerateThumbnail>): Promise<JobStatus> {
async handleGeneratePersonThumbnail({
ownerId,
personGroupId,
}: JobOf<JobName.PersonGenerateThumbnail>): Promise<JobStatus> {
const { image } = await this.getConfig({ withCache: true });
const data = await this.personRepository.getDataForThumbnailGenerationJob(id);
const data = await this.personRepository.getDataForThumbnailGenerationJob({ ownerId, personGroupId });
if (!data) {
this.logger.error(`Could not generate person thumbnail for ${id}: missing data`);
this.logger.error(`Could not generate person thumbnail for ${personGroupId}: missing data`);
return JobStatus.Failed;
}
const { ownerId, x1, y1, x2, y2, oldWidth, oldHeight, exifOrientation, previewPath, originalPath } = data;
const { x1, y1, x2, y2, oldWidth, oldHeight, exifOrientation, previewPath, originalPath } = data;
let inputImage: string | Buffer;
if (data.type === AssetType.Video) {
if (!previewPath) {
this.logger.error(`Could not generate person thumbnail for video ${id}: missing preview path`);
this.logger.error(`Could not generate person thumbnail for video ${personGroupId}: missing preview path`);
return JobStatus.Failed;
}
inputImage = previewPath;
@ -417,7 +424,7 @@ export class MediaService extends BaseService {
orientation: Buffer.isBuffer(inputImage) && exifOrientation ? Number(exifOrientation) : undefined,
});
const thumbnailPath = StorageCore.getPersonThumbnailPath({ id, ownerId });
const thumbnailPath = StorageCore.getPersonThumbnailPath({ ownerId, personGroupId });
this.storageCore.ensureFolders(thumbnailPath);
const thumbnailOptions: GenerateThumbnailOptions = {
@ -440,7 +447,7 @@ export class MediaService extends BaseService {
};
await this.mediaRepository.generateThumbnail(decodedImage, thumbnailOptions, thumbnailPath);
await this.personRepository.update({ id, thumbnailPath });
await this.personRepository.update({ ownerId, personGroupId, thumbnailPath });
return JobStatus.Success;
}

View file

@ -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' },
},
]);
},

View file

@ -893,7 +893,13 @@ export class MetadataService extends BaseService {
}
private async applyTaggedFaces(
asset: { id: string; ownerId: string; faces: { id: string; sourceType: SourceType }[]; originalPath: string },
asset: {
id: string;
ownerId: string;
clusterGroupId: string;
faces: { id: string; sourceType: SourceType }[];
originalPath: string;
},
tags: ImmichTags,
) {
if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) {
@ -902,9 +908,11 @@ export class MetadataService extends BaseService {
const facesToAdd: (Insertable<AssetFaceTable> & { assetId: string })[] = [];
const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true });
const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id]));
const missing: (Insertable<PersonTable> & { ownerId: string })[] = [];
const missingWithFaceAsset: { id: string; ownerId: string; faceAssetId: string }[] = [];
const existingNameMap = new Map(
existingNames.map(({ personGroupId, name }) => [name.toLowerCase(), personGroupId]),
);
const missing: (Insertable<PersonTable> & { name: string; personGroupId: string; clusterGroupId: string })[] = [];
const missingWithFaceAsset: { personGroupId: string; ownerId: string; faceAssetId: string }[] = [];
const adjustedRegionInfo = this.orientRegionInfo(tags.RegionInfo, tags.Orientation);
const imageWidth = adjustedRegionInfo.AppliedToDimensions.W;
@ -916,7 +924,7 @@ export class MetadataService extends BaseService {
}
const loweredName = region.Name.toLowerCase();
const personId = existingNameMap.get(loweredName) || this.cryptoRepository.randomUUID();
const personGroupId = existingNameMap.get(loweredName) || this.cryptoRepository.randomUUID();
const X = Number(region.Area.X);
const Y = Number(region.Area.Y);
@ -925,7 +933,7 @@ export class MetadataService extends BaseService {
const face = {
id: this.cryptoRepository.randomUUID(),
personId,
personGroupId,
assetId: asset.id,
imageWidth,
imageHeight,
@ -938,15 +946,27 @@ export class MetadataService extends BaseService {
facesToAdd.push(face);
if (!existingNameMap.has(loweredName)) {
missing.push({ id: personId, ownerId: asset.ownerId, name: region.Name });
missingWithFaceAsset.push({ id: personId, ownerId: asset.ownerId, faceAssetId: face.id });
missing.push({
personGroupId,
ownerId: asset.ownerId,
clusterGroupId: asset.clusterGroupId,
name: region.Name,
});
missingWithFaceAsset.push({ personGroupId, ownerId: asset.ownerId, faceAssetId: face.id });
}
}
if (missing.length > 0) {
this.logger.debugFn(() => `Creating missing persons: ${missing.map((p) => `${p.name}/${p.id}`)}`);
const newPersonIds = await this.personRepository.createAll(missing);
const jobs = newPersonIds.map((id) => ({ name: JobName.PersonGenerateThumbnail, data: { id } }) as const);
this.logger.debugFn(() => `Creating missing persons: ${missing.map((p) => `${p.name}/${p.personGroupId}`)}`);
await this.personRepository.createGroups(
missing.map((item) => ({ id: item.personGroupId, clusterGroupId: asset.clusterGroupId })),
);
await this.personRepository.createAll(missing);
const jobs = missing.map(
({ personGroupId, ownerId }) =>
({ name: JobName.PersonGenerateThumbnail, data: { personGroupId, ownerId } }) as const,
);
await this.jobRepository.queueAll(jobs);
}

View file

@ -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

View file

@ -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]));
});
});

View file

@ -34,7 +34,7 @@ import {
VectorIndex,
} from 'src/enum';
import { BoundingBox } from 'src/repositories/machine-learning.repository';
import { UpdateFacesData } from 'src/repositories/person.repository';
import { PersonId, UpdateFacesData } from 'src/repositories/person.repository';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { BaseService } from 'src/services/base.service';
@ -45,6 +45,8 @@ import { mimeTypes } from 'src/utils/mime-types';
import { batched, findOrFail, isFacialRecognitionEnabled } from 'src/utils/misc';
import { Point, transformPoints } from 'src/utils/transform';
const personKey = ({ ownerId, personGroupId }: PersonId) => `${ownerId}/${personGroupId}`;
@Injectable()
export class PersonService extends BaseService {
async getAll(auth: AuthDto, dto: PersonSearchDto): Promise<PeopleResponseDto> {
@ -56,7 +58,10 @@ export class PersonService extends BaseService {
};
if (closestPersonId) {
const person = await this.personRepository.getById(closestPersonId);
const person = await this.personRepository.getByGroupId({
ownerId: auth.user.id,
personGroupId: closestPersonId,
});
if (!person?.faceAssetId) {
throw new NotFoundException('Person not found');
}
@ -76,50 +81,51 @@ export class PersonService extends BaseService {
};
}
async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personId] });
const person = await this.findOrFail(personId);
async reassignFaces(auth: AuthDto, personGroupId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personGroupId] });
const person = await this.findOrFail(auth, personGroupId);
const result: PersonResponseDto[] = [];
const changeFeaturePhoto: string[] = [];
const changeFeaturePhoto = new Map<string, PersonId>();
for (const data of dto.data) {
const faces = await this.personRepository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]);
const faces = await this.personRepository.getFacesByIds([
{ personGroupId: data.personId, assetId: data.assetId },
]);
for (const face of faces) {
await this.requireAccess({ auth, permission: Permission.PersonCreate, ids: [face.id] });
if (person.faceAssetId === null) {
changeFeaturePhoto.push(person.id);
changeFeaturePhoto.set(personKey(person), person);
}
if (face.person && face.person.faceAssetId === face.id) {
changeFeaturePhoto.push(face.person.id);
changeFeaturePhoto.set(personKey(face.person), face.person);
}
await this.personRepository.reassignFace(face.id, personId);
await this.personRepository.reassignFace(face.id, person.personGroupId);
}
result.push(mapPerson(person));
}
if (changeFeaturePhoto.length > 0) {
// Remove duplicates
await this.createNewFeaturePhoto([...new Set(changeFeaturePhoto)]);
if (changeFeaturePhoto.size > 0) {
await this.createNewFeaturePhoto(changeFeaturePhoto.values().toArray());
}
return result;
}
async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise<PersonResponseDto> {
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personId] });
async reassignFacesById(auth: AuthDto, personGroupId: string, dto: FaceDto): Promise<PersonResponseDto> {
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personGroupId] });
await this.requireAccess({ auth, permission: Permission.PersonCreate, ids: [dto.id] });
const face = await this.personRepository.getFaceById(dto.id);
const person = await this.findOrFail(personId);
const person = await this.findOrFail(auth, personGroupId);
await this.personRepository.reassignFace(face.id, personId);
await this.personRepository.reassignFace(face.id, person.personGroupId);
if (person.faceAssetId === null) {
await this.createNewFeaturePhoto([person.id]);
await this.createNewFeaturePhoto([person]);
}
if (face.person && face.person.faceAssetId === face.id) {
await this.createNewFeaturePhoto([face.person.id]);
await this.createNewFeaturePhoto([face.person]);
}
return mapPerson(await this.findOrFail(personId));
return mapPerson(await this.findOrFail(auth, personGroupId));
}
async getFacesById(auth: AuthDto, dto: FaceDto): Promise<AssetFaceResponseDto[]> {
@ -131,37 +137,37 @@ export class PersonService extends BaseService {
return faces.map((face) => mapFaces(face, auth, asset.edits, assetDimensions));
}
async createNewFeaturePhoto(changeFeaturePhoto: string[]) {
async createNewFeaturePhoto(changeFeaturePhoto: PersonId[]) {
this.logger.debug(
`Changing feature photos for ${changeFeaturePhoto.length} ${changeFeaturePhoto.length > 1 ? 'people' : 'person'}`,
);
const jobs: JobItem[] = [];
for (const personId of changeFeaturePhoto) {
const assetFace = await this.personRepository.getRandomFace(personId);
for (const { ownerId, personGroupId } of changeFeaturePhoto) {
const assetFace = await this.personRepository.getRandomFace(personGroupId);
if (assetFace) {
await this.personRepository.update({ id: personId, faceAssetId: assetFace.id });
jobs.push({ name: JobName.PersonGenerateThumbnail, data: { id: personId } });
await this.personRepository.update({ ownerId, personGroupId, faceAssetId: assetFace.id });
jobs.push({ name: JobName.PersonGenerateThumbnail, data: { ownerId, personGroupId } });
}
}
await this.jobRepository.queueAll(jobs);
}
async getById(auth: AuthDto, id: string): Promise<PersonResponseDto> {
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] });
return mapPerson(await this.findOrFail(id));
async getById(auth: AuthDto, personGroupId: string): Promise<PersonResponseDto> {
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [personGroupId] });
return mapPerson(await this.findOrFail(auth, personGroupId));
}
async getStatistics(auth: AuthDto, id: string): Promise<PersonStatisticsResponseDto> {
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] });
return this.personRepository.getStatistics(id);
async getStatistics(auth: AuthDto, personGroupId: string): Promise<PersonStatisticsResponseDto> {
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [personGroupId] });
return this.personRepository.getStatistics(personGroupId);
}
async getThumbnail(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] });
const person = await this.personRepository.getById(id);
async getThumbnail(auth: AuthDto, personGroupId: string): Promise<ImmichFileResponse> {
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [personGroupId] });
const person = await this.personRepository.getByGroupId({ ownerId: auth.user.id, personGroupId });
if (!person || !person.thumbnailPath) {
throw new NotFoundException();
}
@ -174,8 +180,10 @@ export class PersonService extends BaseService {
}
async create(auth: AuthDto, dto: PersonCreateDto): Promise<PersonResponseDto> {
const group = await this.personRepository.createGroup(auth.user.id);
const person = await this.personRepository.create({
ownerId: auth.user.id,
personGroupId: group.id,
name: dto.name,
birthDate: dto.birthDate,
isHidden: dto.isHidden,
@ -186,15 +194,16 @@ export class PersonService extends BaseService {
return mapPerson(person);
}
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [id] });
async update(auth: AuthDto, personGroupId: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personGroupId] });
const { ownerId } = await this.findOrFail(auth, personGroupId);
const { name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite, color } = dto;
// TODO: set by faceId directly
let faceId: string | undefined;
if (assetId) {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [assetId] });
const face = await this.personRepository.getForFeatureFaceUpdate({ personId: id, assetId });
const face = await this.personRepository.getForFeatureFaceUpdate({ personGroupId, assetId });
if (!face) {
throw new BadRequestException('Invalid assetId for feature face or asset is offline');
}
@ -203,7 +212,8 @@ export class PersonService extends BaseService {
}
const person = await this.personRepository.update({
id,
ownerId,
personGroupId,
faceAssetId: faceId,
name,
birthDate,
@ -213,7 +223,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 +255,39 @@ export class PersonService extends BaseService {
async deleteAll(auth: AuthDto, { ids }: BulkIdsDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.PersonDelete, ids });
const people = await this.personRepository.getForPeopleDelete(ids);
await this.removeAllPeople(people);
await this.removeAllPersonGroups(ids);
}
@Chunked()
private async removeAllPeople(people: { id: string; thumbnailPath: string }[]) {
private async removeAllPeople(people: (PersonId & { thumbnailPath: string })[]) {
await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath)));
await this.personRepository.delete(people.map((person) => person.id));
await this.personRepository.delete(people);
this.logger.debug(`Deleted ${people.length} people`);
}
@Chunked()
private async removeAllPersonGroups(groupIds: string[]) {
if (groupIds.length === 0) {
return;
}
const people = await this.personRepository.getForPeopleDelete(groupIds);
await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath)));
await this.personRepository.deleteGroups(groupIds);
this.logger.debug(`Deleted ${groupIds.length} people`);
}
@OnJob({ name: JobName.PersonCleanup, queue: QueueName.BackgroundTask })
async handlePersonCleanup(): Promise<JobStatus> {
// each step can leave the next one something to clean up, so the order matters
const people = await this.personRepository.getAllWithoutFaces();
await this.removeAllPeople(people);
await this.removeAllPersonGroups(people.map((person) => person.personGroupId));
const personGroups = await this.personRepository.deleteEmptyGroups();
const clusterGroups = await this.personRepository.deleteOrphanedClusterGroups();
this.logger.debug(`Deleted ${personGroups} empty person groups and ${clusterGroups} orphaned cluster groups`);
return JobStatus.Success;
}
@ -429,7 +457,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 +493,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 +524,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 +535,38 @@ export class PersonService extends BaseService {
minBirthDate: new Date(face.asset.fileCreatedAt),
});
if (matchWithPerson.length > 0) {
personId = matchWithPerson[0].personId;
personGroupId = matchWithPerson?.personGroupId ?? undefined;
}
if (!personGroupId && isCore) {
const group = await this.personRepository.createGroup(ownerId);
personGroupId = group.id;
this.logger.log(`Created person group ${personGroupId} for face ${id}`);
}
if (personGroupId) {
const person = await this.personRepository.getByGroupId({ ownerId, personGroupId });
if (person) {
this.logger.debug(`Face ${id} matched person ${person.personGroupId}`);
} else {
await this.personRepository.create({ ownerId, faceAssetId: face.id, personGroupId });
this.logger.log(`Created person for face ${id} in group ${personGroupId}`);
await this.jobRepository.queue({
name: JobName.PersonGenerateThumbnail,
data: { ownerId, personGroupId },
});
}
}
if (isCore && !personId) {
this.logger.log(`Creating new person for face ${id}`);
const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id });
await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id: newPerson.id } });
personId = newPerson.id;
}
if (personId) {
this.logger.debug(`Assigning face ${id} to person ${personId}`);
await this.personRepository.reassignFaces({ faceIds: [id], newPersonId: personId });
this.logger.debug(`Assigning face ${id} to person group ${personGroupId}`);
await this.personRepository.reassignFaces({ faceIds: [id], newPersonGroupId: personGroupId });
}
return JobStatus.Success;
}
@OnJob({ name: JobName.PersonFileMigration, queue: QueueName.Migration })
async handlePersonMigration({ id }: JobOf<JobName.PersonFileMigration>): Promise<JobStatus> {
const person = await this.personRepository.getById(id);
async handlePersonMigration({ ownerId, personGroupId }: JobOf<JobName.PersonFileMigration>): Promise<JobStatus> {
const person = await this.personRepository.getByGroupId({ ownerId, personGroupId });
if (!person) {
return JobStatus.Failed;
}
@ -538,15 +576,15 @@ export class PersonService extends BaseService {
return JobStatus.Success;
}
async mergePerson(auth: AuthDto, id: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> {
async mergePerson(auth: AuthDto, personGroupId: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> {
const mergeIds = dto.ids;
if (mergeIds.includes(id)) {
if (mergeIds.includes(personGroupId)) {
throw new BadRequestException('Cannot merge a person into themselves');
}
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [id] });
let primaryPerson = await this.findOrFail(id);
const primaryName = primaryPerson.name || primaryPerson.id;
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personGroupId] });
let primaryPerson = await this.findOrFail(auth, personGroupId);
const primaryName = primaryPerson.name || primaryPerson.personGroupId;
const results: BulkIdResponseDto[] = [];
@ -564,44 +602,48 @@ export class PersonService extends BaseService {
}
try {
const mergePerson = await this.personRepository.getById(mergeId);
const mergePerson = await this.personRepository.getByGroupId({ ownerId: auth.user.id, personGroupId: mergeId });
if (!mergePerson) {
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NOT_FOUND });
continue;
}
const update: Updateable<Person> & { id: string } = { id: primaryPerson.id };
const changes: Updateable<Person> = {};
if (!primaryPerson.name && mergePerson.name) {
update.name = mergePerson.name;
changes.name = mergePerson.name;
}
if (!primaryPerson.birthDate && mergePerson.birthDate) {
update.birthDate = mergePerson.birthDate;
changes.birthDate = mergePerson.birthDate;
}
if (Object.keys(update).length > 1) {
primaryPerson = await this.personRepository.update(update);
if (Object.keys(changes).length > 0) {
primaryPerson = await this.personRepository.update({
ownerId: primaryPerson.ownerId,
personGroupId: primaryPerson.personGroupId,
...changes,
});
}
const mergeName = mergePerson.name || mergePerson.id;
const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id };
const mergeName = mergePerson.name || mergePerson.personGroupId;
const mergeData: UpdateFacesData = { oldPersonGroupId: mergeId, newPersonGroupId: primaryPerson.personGroupId };
this.logger.log(`Merging ${mergeName} into ${primaryName}`);
await this.personRepository.reassignFaces(mergeData);
await this.removeAllPeople([mergePerson]);
await this.removeAllPersonGroups([mergeId]);
this.logger.log(`Merged ${mergeName} into ${primaryName}`);
results.push({ id: mergeId, success: true });
} catch (error: Error | any) {
this.logger.error(`Unable to merge ${mergeId} into ${id}: ${error}`, error?.stack);
this.logger.error(`Unable to merge ${mergeId} into ${personGroupId}: ${error}`, error?.stack);
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.UNKNOWN });
}
}
return results;
}
private findOrFail(id: string) {
return findOrFail(() => this.personRepository.getById(id), 'Person');
private findOrFail(auth: AuthDto, personGroupId: string) {
return findOrFail(() => this.personRepository.getByGroupId({ ownerId: auth.user.id, personGroupId }), 'Person');
}
// TODO return a asset face response
@ -613,7 +655,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 +703,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 +715,7 @@ export class PersonService extends BaseService {
});
if (!person.faceAssetId) {
await this.createNewFeaturePhoto([person.id]);
await this.createNewFeaturePhoto([person]);
}
}

View file

@ -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',
},
);
});

View file

@ -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'),
},

View file

@ -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);

View file

@ -53,6 +53,7 @@ describe(UserAdminService.name, () => {
name: userStub.user1.name,
storageLabel: 'label',
password: expect.anything(),
clusterGroupId: expect.any(String),
});
});
});

View file

@ -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 }

View file

@ -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);
}

View file

@ -237,38 +237,47 @@ export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFile
.where('asset_file.isEdited', '=', sql.lit(isEdited));
}
export function withFacesAndPeople(
eb: ExpressionBuilder<DB, 'asset'>,
withHidden?: boolean,
withDeletedFace?: boolean,
) {
return jsonArrayFrom(
eb
.selectFrom('asset_face')
.leftJoinLateral(
(eb) =>
eb.selectFrom('person').selectAll('person').whereRef('asset_face.personId', '=', 'person.id').as('person'),
(join) => join.onTrue(),
)
.selectAll('asset_face')
.select((eb) => eb.table('person').$castTo<ShallowDehydrateObject<Person>>().as('person'))
.whereRef('asset_face.assetId', '=', 'asset.id')
.$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null))
.$if(!withHidden, (qb) => qb.where('asset_face.isVisible', 'is', true)),
).as('faces');
export type WithFacesAndPeopleOptions = {
/** whose version of the person to select */
viewingUserId: string;
withHidden?: boolean;
withDeletedFace?: boolean;
};
export function withFacesAndPeople({ viewingUserId, withHidden, withDeletedFace }: WithFacesAndPeopleOptions) {
return (eb: ExpressionBuilder<DB, 'asset'>) =>
jsonArrayFrom(
eb
.selectFrom('asset_face')
.leftJoinLateral(
(eb) =>
eb
.selectFrom('person')
.selectAll('person')
.whereRef('person.personGroupId', '=', 'asset_face.personGroupId')
.where('person.ownerId', '=', viewingUserId)
.as('person'),
(join) => join.onTrue(),
)
.selectAll('asset_face')
.select((eb) => eb.table('person').$castTo<ShallowDehydrateObject<Person>>().as('person'))
.whereRef('asset_face.assetId', '=', 'asset.id')
.$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null))
.$if(!withHidden, (qb) => qb.where('asset_face.isVisible', 'is', true)),
).as('faces');
}
export function hasPeople<O>(qb: SelectQueryBuilder<DB, 'asset', O>, personIds: string[]) {
export function hasPeople<O>(qb: SelectQueryBuilder<DB, 'asset', O>, personGroupIds: string[]) {
return qb.innerJoin(
(eb) =>
eb
.selectFrom('asset_face')
.select('assetId')
.where('personId', '=', anyUuid(personIds!))
.where('personGroupId', '=', anyUuid(personGroupIds!))
.where('deletedAt', 'is', null)
.where('isVisible', 'is', true)
.groupBy('assetId')
.having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length)
.having((eb) => eb.fn.count('personGroupId').distinct(), '=', personGroupIds.length)
.as('has_people'),
(join) => join.onRef('has_people.assetId', '=', 'asset.id'),
);
@ -511,7 +520,9 @@ export function searchAssetBuilderLegacy(kysely: Kysely<DB>, options: AssetSearc
)
.$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null))
.$if(!!options.withExif, withExifInner)
.$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople))
.$if(!!(options.withFaces || options.withPeople), (qb) =>
qb.select(withFacesAndPeople({ viewingUserId: options.viewingUserId! })),
)
.$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null));
}
@ -569,7 +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<DB>, options: AssetSearchBuild
.$if(!!options.userIds && options.userIds.length > 0, (qb) =>
qb.where('asset.ownerId', '=', anyUuid(options.userIds!)),
)
.$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople))
.$if(!!(options.withFaces || options.withPeople), (qb) =>
qb.select(withFacesAndPeople({ viewingUserId: options.viewingUserId! })),
)
.$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null))
.where((eb) => {
const predicates = branchPredicates(eb, filter);

View file

@ -64,7 +64,7 @@ const createFace = (params: Partial<AssetFace> = {}): AssetFace => ({
boundingBoxY2: 200,
imageWidth: 1000,
imageHeight: 1000,
personId: null,
personGroupId: null,
sourceType: SourceType.MachineLearning,
person: null,
updatedAt: new Date(),

View file

@ -27,7 +27,7 @@ export class AssetFaceFactory {
imageHeight: 500,
imageWidth: 400,
isVisible: true,
personId: null,
personGroupId: null,
sourceType: SourceType.MachineLearning,
updatedAt: newDate(),
updateId: newUuidV7(),
@ -37,7 +37,7 @@ export class AssetFaceFactory {
person(dto: PersonLike = {}, builder?: FactoryBuilder<PersonFactory>) {
this.#person = build(PersonFactory.from(dto), builder);
this.value.personId = this.#person.build().id;
this.value.personGroupId = this.#person.build().personGroupId;
return this;
}

View file

@ -0,0 +1,27 @@
import { Selectable } from 'kysely';
import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table';
import { ClusterGroupLike } from 'test/factories/types';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class ClusterGroupFactory {
private constructor(private readonly value: Selectable<ClusterGroupTable>) {}
static create(dto: ClusterGroupLike = {}) {
return ClusterGroupFactory.from(dto).build();
}
static from(dto: ClusterGroupLike = {}) {
return new ClusterGroupFactory({
id: newUuid(),
name: null,
createdAt: newDate(),
updatedAt: newDate(),
updateId: newUuidV7(),
...dto,
});
}
build() {
return { ...this.value };
}
}

View file

@ -0,0 +1,28 @@
import { Selectable } from 'kysely';
import { PersonGroupTable } from 'src/schema/tables/person-group.table';
import { PersonGroupLike } from 'test/factories/types';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class PersonGroupFactory {
private constructor(private readonly value: Selectable<PersonGroupTable>) {}
static create(dto: PersonGroupLike = {}) {
return PersonGroupFactory.from(dto).build();
}
static from(dto: PersonGroupLike = {}) {
return new PersonGroupFactory({
id: newUuid(),
clusterGroupId: newUuid(),
createdAt: newDate(),
createId: newUuidV7(),
updatedAt: newDate(),
updateId: newUuidV7(),
...dto,
});
}
build() {
return { ...this.value };
}
}

View file

@ -16,7 +16,7 @@ export class PersonFactory {
color: null,
createdAt: newDate(),
faceAssetId: null,
id: newUuid(),
personGroupId: newUuid(),
isFavorite: false,
isHidden: false,
name: 'person',

View file

@ -9,8 +9,10 @@ import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table';
import { MemoryTable } from 'src/schema/tables/memory.table';
import { PartnerTable } from 'src/schema/tables/partner.table';
import { PersonGroupTable } from 'src/schema/tables/person-group.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { SessionTable } from 'src/schema/tables/session.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
@ -29,6 +31,8 @@ export type SharedLinkLike = Partial<Selectable<SharedLinkTable>>;
export type UserLike = Partial<Selectable<UserTable>>;
export type AssetFaceLike = Partial<Selectable<AssetFaceTable>>;
export type PersonLike = Partial<Selectable<PersonTable>>;
export type PersonGroupLike = Partial<Selectable<PersonGroupTable>>;
export type ClusterGroupLike = Partial<Selectable<ClusterGroupTable>>;
export type StackLike = Partial<Selectable<StackTable>>;
export type MemoryLike = Partial<Selectable<MemoryTable>>;
export type PartnerLike = Partial<Selectable<PartnerTable>>;

View file

@ -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,

View file

@ -6,6 +6,7 @@ export const userStub = {
admin: <UserAdmin>{
...authStub.admin.user,
status: UserStatus.Active,
clusterGroupId: 'cluster-group-id',
profileChangedAt: new Date('2021-01-01'),
name: 'admin_name',
id: 'admin_id',
@ -24,6 +25,7 @@ export const userStub = {
user1: <UserAdmin>{
...authStub.user1.user,
status: UserStatus.Active,
clusterGroupId: 'cluster-group-id',
profileChangedAt: new Date('2021-01-01'),
name: 'immich_name',
storageLabel: null,

View file

@ -1,6 +1,7 @@
import { Selectable, ShallowDehydrateObject } from 'kysely';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { FaceSearchResult } from 'src/repositories/search.repository';
import { ActivityTable } from 'src/schema/tables/activity.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { PartnerTable } from 'src/schema/tables/partner.table';
@ -12,6 +13,7 @@ import { MemoryFactory } from 'test/factories/memory.factory';
import { SharedLinkFactory } from 'test/factories/shared-link.factory';
import { StackFactory } from 'test/factories/stack.factory';
import { UserFactory } from 'test/factories/user.factory';
import { newUuid } from 'test/small.factory';
export const getForStorageTemplate = (asset: ReturnType<AssetFactory['build']>) => {
return {
@ -54,15 +56,27 @@ export const getAsDetectedFace = (face: ReturnType<AssetFaceFactory['build']>) =
export const getForFacialRecognitionJob = (
face: ReturnType<AssetFaceFactory['build']>,
asset: Pick<Selectable<AssetTable>, 'ownerId' | 'visibility' | 'fileCreatedAt'> | null,
asset:
(Pick<Selectable<AssetTable>, 'ownerId' | 'visibility' | 'fileCreatedAt'> & { clusterGroupId?: string }) | null,
) => ({
...face,
asset: asset
? { ownerId: asset.ownerId, visibility: asset.visibility, fileCreatedAt: asset.fileCreatedAt.toISOString() }
? {
ownerId: asset.ownerId,
clusterGroupId: asset.clusterGroupId ?? newUuid(),
visibility: asset.visibility,
fileCreatedAt: asset.fileCreatedAt.toISOString(),
}
: null,
faceSearch: { faceId: face.id, embedding: '[1, 2, 3, 4]' },
});
export const getForFaceSearch = (face: ReturnType<AssetFaceFactory['build']>, distance: number): FaceSearchResult => ({
id: face.id,
personGroupId: face.personGroupId,
distance,
});
export const getDehydrated = <T extends Record<string, unknown>>(entity: T) => {
const copiedEntity = structuredClone(entity);
for (const [key, value] of Object.entries(copiedEntity)) {
@ -122,8 +136,12 @@ export const getForMemory = (memory: ReturnType<MemoryFactory['build']>) => ({
assets: memory.assets.map((asset) => getDehydrated(asset)),
});
export const getForMetadataExtraction = (asset: ReturnType<AssetFactory['build']>) => ({
export const getForMetadataExtraction = (
asset: ReturnType<AssetFactory['build']>,
{ clusterGroupId }: { clusterGroupId?: string } = {},
) => ({
id: asset.id,
clusterGroupId: clusterGroupId ?? newUuid(),
checksum: asset.checksum,
checksumAlgorithm: asset.checksumAlgorithm,
fileCreatedAt: asset.fileCreatedAt,

View file

@ -25,6 +25,7 @@ import { AlbumRepository } from 'src/repositories/album.repository';
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { CronRepository } from 'src/repositories/cron.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
@ -163,7 +164,8 @@ export class MediumTestContext<S extends ClassConstructor<typeof BaseService> =
}
async newUser(dto: Partial<Insertable<UserTable>> = {}) {
const user = mediumFactory.userInsert(dto);
const clusterGroup = dto.clusterGroupId ? undefined : await this.get(ClusterGroupRepository).create();
const user = mediumFactory.userInsert({ ...dto, clusterGroupId: dto.clusterGroupId ?? clusterGroup!.id });
const result = await this.get(UserRepository).create(user);
return { user, result };
}
@ -263,8 +265,14 @@ export class MediumTestContext<S extends ClassConstructor<typeof BaseService> =
}
async newPerson(dto: Partial<Insertable<PersonTable>> & { ownerId: string }) {
const person = mediumFactory.personInsert(dto);
const result = await this.get(PersonRepository).create(person);
const repository = this.get(PersonRepository);
let personGroupId = dto.personGroupId;
if (!personGroupId) {
const group = await repository.createGroup(dto.ownerId);
personGroupId = group.id;
}
const person = mediumFactory.personInsert({ ...dto, personGroupId });
const result = await repository.create(person);
return { person, result };
}
@ -445,6 +453,7 @@ const newRealRepository = <T extends BaseServiceDeps[number]>(key: T, db: Kysely
case AssetRepository:
case AssetEditRepository:
case AssetJobRepository:
case ClusterGroupRepository:
case IntegrityRepository:
case MemoryRepository:
case NotificationRepository:
@ -648,7 +657,7 @@ const assetFaceInsert = (assetFace: Partial<AssetFace> & { assetId: string }) =>
id: assetFace.id ?? newUuid(),
imageHeight: assetFace.imageHeight ?? 10,
imageWidth: assetFace.imageWidth ?? 10,
personId: assetFace.personId ?? null,
personGroupId: assetFace.personGroupId ?? null,
sourceType: assetFace.sourceType ?? SourceType.MachineLearning,
isVisible: assetFace.isVisible ?? true,
};
@ -675,13 +684,12 @@ const assetJobStatusInsert = (
};
};
const personInsert = (person: Partial<Insertable<PersonTable>> & { ownerId: string }) => {
const personInsert = (person: Partial<Insertable<PersonTable>> & { ownerId: string; personGroupId: string }) => {
const defaults = {
birthDate: person.birthDate || null,
color: person.color || null,
createdAt: person.createdAt || newDate(),
faceAssetId: person.faceAssetId || null,
id: person.id || newUuid(),
isFavorite: person.isFavorite || false,
isHidden: person.isHidden || false,
name: person.name || 'Test Name',
@ -715,7 +723,7 @@ const sessionInsert = ({
};
};
const userInsert = (user: Partial<Insertable<UserTable>> = {}) => {
const userInsert = (user: Partial<Insertable<UserTable>> & { clusterGroupId: string }) => {
const id = user.id || newUuid();
const defaults = {
@ -804,7 +812,7 @@ const loginDetails = () => {
};
const loginResponse = (): LoginResponseDto => {
const user = userInsert({});
const user = userInsert({ clusterGroupId: newUuid() });
return {
accessToken: 'access-token',
userId: user.id,

View file

@ -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({

View file

@ -3,6 +3,7 @@ import { hash } from 'bcrypt';
import { Kysely } from 'kysely';
import { AuthType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
@ -26,6 +27,7 @@ const setup = (db?: Kysely<DB>) => {
database: db || defaultDatabase,
real: [
AccessRepository,
ClusterGroupRepository,
ConfigRepository,
CryptoRepository,
DatabaseRepository,

View file

@ -0,0 +1,496 @@
import { Kysely } from 'kysely';
import { AccessRepository } from 'src/repositories/access.repository';
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { DB } from 'src/schema';
import { ClusterGroupService } from 'src/services/cluster-group.service';
import { newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
const ctx = newMediumService(ClusterGroupService, {
database: db || defaultDatabase,
real: [AccessRepository, ClusterGroupRepository, PersonRepository, UserRepository],
mock: [LoggingRepository, EventRepository],
});
ctx.ctx.getMock(EventRepository).emit.mockResolvedValue();
return ctx;
};
const getClusterGroupId = async (ctx: ReturnType<typeof setup>['ctx'], userId: string) => {
const { clusterGroupId } = await ctx.database
.selectFrom('user')
.select('user.clusterGroupId')
.where('user.id', '=', userId)
.executeTakeFirstOrThrow();
return clusterGroupId;
};
const getPeople = (ctx: ReturnType<typeof setup>['ctx'], ownerId: string) =>
ctx.database.selectFrom('person').selectAll('person').where('person.ownerId', '=', ownerId).execute();
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(ClusterGroupService.name, () => {
describe('createRequest', () => {
it('should create a request for another user', async () => {
const { sut, ctx } = setup();
const { user: owner } = await ctx.newUser();
const { user: invitee } = await ctx.newUser();
const auth = factory.auth({ user: owner });
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
const { value: request } = await sut.createRequest(auth, clusterGroupId, { userId: invitee.id });
expect(request).toEqual(
expect.objectContaining({ clusterGroupId, userId: invitee.id, createdAt: expect.any(String) }),
);
expect(ctx.getMock(EventRepository).emit).toHaveBeenCalledWith('ClusterGroupRequest', {
clusterGroupId,
userId: invitee.id,
senderName: owner.name,
});
});
it('should reject a cluster group the user is not a member of', async () => {
const { sut, ctx } = setup();
const { user: owner } = await ctx.newUser();
const { user: other } = await ctx.newUser();
const auth = factory.auth({ user: owner });
const otherClusterGroupId = await getClusterGroupId(ctx, other.id);
await expect(sut.createRequest(auth, otherClusterGroupId, { userId: other.id })).rejects.toThrow(
'Not found or no clusterGroupRequest.create access',
);
});
it('should return the existing request when it was already created', async () => {
const { sut, ctx } = setup();
const { user: owner } = await ctx.newUser();
const { user: invitee } = await ctx.newUser();
const auth = factory.auth({ user: owner });
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
const created = await sut.createRequest(auth, clusterGroupId, { userId: invitee.id });
expect(created.duplicate).toBe(false);
const again = await sut.createRequest(auth, clusterGroupId, { userId: invitee.id });
expect(again.duplicate).toBe(true);
expect(again.value).toEqual(created.value);
await expect(sut.getRequests(factory.auth({ user: invitee }))).resolves.toEqual([created.value]);
});
it('should reject an unknown user', async () => {
const { sut, ctx } = setup();
const { user: owner } = await ctx.newUser();
const auth = factory.auth({ user: owner });
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
await expect(sut.createRequest(auth, clusterGroupId, { userId: factory.uuid() })).rejects.toThrow('Invalid user');
});
});
describe('getRequests', () => {
it('should only return the requests for the current user', async () => {
const { sut, ctx } = setup();
const { user: owner } = await ctx.newUser();
const { user: invitee } = await ctx.newUser();
const { user: other } = await ctx.newUser();
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
const { value: request } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, {
userId: invitee.id,
});
await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, { userId: other.id });
await expect(sut.getRequests(factory.auth({ user: invitee }))).resolves.toEqual([request]);
});
});
describe('getRequestsForGroup', () => {
it('should return the requests sent by the cluster group', async () => {
const { sut, ctx } = setup();
const { user: owner } = await ctx.newUser();
const { user: invitee } = await ctx.newUser();
const { user: other } = await ctx.newUser();
const auth = factory.auth({ user: owner });
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
const { value: request } = await sut.createRequest(auth, clusterGroupId, { userId: invitee.id });
await expect(sut.getRequestsForGroup(auth, clusterGroupId)).resolves.toEqual([request]);
await expect(sut.getRequestsForGroup(factory.auth({ user: other }), clusterGroupId)).rejects.toThrow(
'Not found or no clusterGroupRequest.read access',
);
});
});
describe('getUsers', () => {
it('should return the members of the cluster group', async () => {
const { sut, ctx } = setup();
const { user: owner } = await ctx.newUser();
const { user: member } = await ctx.newUser();
const { user: other } = await ctx.newUser();
const auth = factory.auth({ user: owner });
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
const { value: request } = await sut.createRequest(auth, clusterGroupId, { userId: member.id });
await sut.acceptRequest(factory.auth({ user: member }), request.id);
const users = await sut.getUsers(auth, clusterGroupId);
expect(users.map(({ id }) => id)).toEqual(expect.arrayContaining([owner.id, member.id]));
expect(users.map(({ id }) => id)).not.toContain(other.id);
await expect(sut.getUsers(factory.auth({ user: other }), clusterGroupId)).rejects.toThrow(
'Not found or no clusterGroup.read access',
);
});
it('should let a user with a pending request see the members', async () => {
const { sut, ctx } = setup();
const { user: owner } = await ctx.newUser();
const { user: invitee } = await ctx.newUser();
const auth = factory.auth({ user: owner });
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
await expect(sut.getUsers(factory.auth({ user: invitee }), clusterGroupId)).rejects.toThrow(
'Not found or no clusterGroup.read access',
);
await sut.createRequest(auth, clusterGroupId, { userId: invitee.id });
const users = await sut.getUsers(factory.auth({ user: invitee }), clusterGroupId);
expect(users.map(({ id }) => id)).toContain(owner.id);
});
});
describe('acceptRequest', () => {
it('should move the user into the cluster group and delete the request', async () => {
const { sut, ctx } = setup();
const { user: owner } = await ctx.newUser();
const { user: invitee } = await ctx.newUser();
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
const { value: request } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, {
userId: invitee.id,
});
await sut.acceptRequest(factory.auth({ user: invitee }), request.id);
await expect(getClusterGroupId(ctx, invitee.id)).resolves.toBe(clusterGroupId);
await expect(sut.getRequests(factory.auth({ user: invitee }))).resolves.toEqual([]);
});
it('should not accept a request belonging to someone else', async () => {
const { sut, ctx } = setup();
const { user: owner } = await ctx.newUser();
const { user: invitee } = await ctx.newUser();
const { user: other } = await ctx.newUser();
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
const otherClusterGroupId = await getClusterGroupId(ctx, other.id);
const { value: request } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, {
userId: invitee.id,
});
await expect(sut.acceptRequest(factory.auth({ user: other }), request.id)).rejects.toThrow('Request not found');
await expect(getClusterGroupId(ctx, other.id)).resolves.toBe(otherClusterGroupId);
});
});
describe('leave', () => {
it('should move the user into a new cluster group', async () => {
const { sut, ctx } = setup();
const { user: owner } = await ctx.newUser();
const { user } = await ctx.newUser();
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
const auth = factory.auth({ user });
const { value: request } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, {
userId: user.id,
});
await sut.acceptRequest(auth, request.id);
await sut.leave(auth, clusterGroupId);
const newClusterGroupId = await getClusterGroupId(ctx, user.id);
expect(newClusterGroupId).not.toBe(clusterGroupId);
await expect(
ctx.database
.selectFrom('cluster_group')
.select('cluster_group.id')
.where('cluster_group.id', '=', newClusterGroupId)
.executeTakeFirst(),
).resolves.toBeDefined();
});
it('should reject a cluster group the user is not a member of', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: other } = await ctx.newUser();
const auth = factory.auth({ user });
const otherClusterGroupId = await getClusterGroupId(ctx, other.id);
await expect(sut.leave(auth, otherClusterGroupId)).rejects.toThrow('Not found or no clusterGroup.leave access');
});
it('should not let the last member leave', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const clusterGroupId = await getClusterGroupId(ctx, user.id);
await expect(sut.leave(auth, clusterGroupId)).rejects.toThrow(
'Cannot leave a cluster group without any other members',
);
await expect(getClusterGroupId(ctx, user.id)).resolves.toBe(clusterGroupId);
});
});
describe('joining a cluster group', () => {
it('should take the groups of the user along', async () => {
const { sut, ctx } = setup(await getKyselyDB());
const personRepo = ctx.get(PersonRepository);
const { user: owner } = await ctx.newUser();
const { user } = await ctx.newUser();
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ ownerId: user.id });
const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId });
const { value: request } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, {
userId: user.id,
});
await sut.acceptRequest(factory.auth({ user }), request.id);
// the group keeps its id, it only changes cluster group
await expect(personRepo.getByGroupId(person)).resolves.toEqual(
expect.objectContaining({ personGroupId: person.personGroupId }),
);
await expect(
ctx.database
.selectFrom('person_group')
.select('person_group.clusterGroupId')
.where('person_group.id', '=', person.personGroupId)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ clusterGroupId });
await expect(
ctx.database
.selectFrom('asset_face')
.select('asset_face.personGroupId')
.where('asset_face.id', '=', assetFace.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ personGroupId: person.personGroupId });
await expect(getClusterGroupId(ctx, user.id)).resolves.toBe(clusterGroupId);
});
it('should require the user to leave their current cluster group first', async () => {
const { sut, ctx } = setup(await getKyselyDB());
const { user: owner } = await ctx.newUser();
const { user } = await ctx.newUser();
const { user: third } = await ctx.newUser();
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
const thirdClusterGroupId = await getClusterGroupId(ctx, third.id);
// the user joins the first group
const { value: first } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, {
userId: user.id,
});
await sut.acceptRequest(factory.auth({ user }), first.id);
// and is then asked to join another one without leaving
const { value: second } = await sut.createRequest(factory.auth({ user: third }), thirdClusterGroupId, {
userId: user.id,
});
await expect(sut.acceptRequest(factory.auth({ user }), second.id)).rejects.toThrow(
'Leave the current cluster group before joining another one',
);
await expect(getClusterGroupId(ctx, user.id)).resolves.toBe(clusterGroupId);
});
});
describe('leaving a shared cluster group', () => {
it('should recreate the shared groups and take the rest of its own along', async () => {
const { sut, ctx } = setup(await getKyselyDB());
const personRepo = ctx.get(PersonRepository);
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const clusterGroupId = await getClusterGroupId(ctx, user1.id);
const { value: request } = await sut.createRequest(factory.auth({ user: user1 }), clusterGroupId, {
userId: user2.id,
});
await sut.acceptRequest(factory.auth({ user: user2 }), request.id);
// a group both of them have a person in
const { person: shared1 } = await ctx.newPerson({ ownerId: user1.id });
await ctx.newPerson({ ownerId: user2.id, personGroupId: shared1.personGroupId });
// a group only the leaving user has a person in
const { person: only2 } = await ctx.newPerson({ ownerId: user2.id });
// a group only the remaining user has a person in
const { person: only1 } = await ctx.newPerson({ ownerId: user1.id });
const { asset: asset2 } = await ctx.newAsset({ ownerId: user2.id });
const { assetFace: sharedFace2 } = await ctx.newAssetFace({
assetId: asset2.id,
personGroupId: shared1.personGroupId,
});
const { asset: asset1 } = await ctx.newAsset({ ownerId: user1.id });
const { assetFace: sharedFace1 } = await ctx.newAssetFace({
assetId: asset1.id,
personGroupId: shared1.personGroupId,
});
await sut.leave(factory.auth({ user: user2 }), clusterGroupId);
const newClusterGroupId = await getClusterGroupId(ctx, user2.id);
expect(newClusterGroupId).not.toBe(clusterGroupId);
// the shared group is recreated for the leaving user, the one only they had comes along as it is
const leaverPeople = await getPeople(ctx, user2.id);
const movedShared = leaverPeople.find(({ personGroupId }) => personGroupId !== only2.personGroupId);
expect(movedShared).toBeDefined();
expect(movedShared!.personGroupId).not.toBe(shared1.personGroupId);
await expect(personRepo.getByGroupId(only2)).resolves.toBeDefined();
// the remaining user is untouched
await expect(personRepo.getByGroupId(shared1)).resolves.toBeDefined();
await expect(personRepo.getByGroupId(only1)).resolves.toBeDefined();
const groups = await ctx.database
.selectFrom('person_group')
.select(['person_group.id', 'person_group.clusterGroupId'])
.execute();
expect(groups).toEqual(
expect.arrayContaining([
{ id: shared1.personGroupId, clusterGroupId },
{ id: only1.personGroupId, clusterGroupId },
{ id: only2.personGroupId, clusterGroupId: newClusterGroupId },
{ id: movedShared!.personGroupId, clusterGroupId: newClusterGroupId },
]),
);
// only the faces on the assets of the leaving user follow the recreated group
const faces = await ctx.database
.selectFrom('asset_face')
.select(['asset_face.id', 'asset_face.personGroupId'])
.where('asset_face.id', 'in', [sharedFace1.id, sharedFace2.id])
.execute();
expect(faces).toEqual(
expect.arrayContaining([
{ id: sharedFace1.id, personGroupId: shared1.personGroupId },
{ id: sharedFace2.id, personGroupId: movedShared!.personGroupId },
]),
);
});
it('should give each shared group its own new group when a user leaves the group', async () => {
const { sut, ctx } = setup(await getKyselyDB());
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const clusterGroupId = await getClusterGroupId(ctx, user1.id);
const { value: request } = await sut.createRequest(factory.auth({ user: user1 }), clusterGroupId, {
userId: user2.id,
});
await sut.acceptRequest(factory.auth({ user: user2 }), request.id);
// two groups both users have a person in
const { person: sharedA } = await ctx.newPerson({ ownerId: user1.id });
const { person: sharedB } = await ctx.newPerson({ ownerId: user1.id });
await ctx.newPerson({ ownerId: user2.id, personGroupId: sharedA.personGroupId, name: 'Alice' });
await ctx.newPerson({ ownerId: user2.id, personGroupId: sharedB.personGroupId, name: 'Bob' });
const { asset } = await ctx.newAsset({ ownerId: user2.id });
const { assetFace: faceA } = await ctx.newAssetFace({ assetId: asset.id, personGroupId: sharedA.personGroupId });
const { assetFace: faceB } = await ctx.newAssetFace({ assetId: asset.id, personGroupId: sharedB.personGroupId });
await sut.leave(factory.auth({ user: user2 }), clusterGroupId);
const moved = await getPeople(ctx, user2.id);
const movedGroupIds = moved.map(({ personGroupId }) => personGroupId);
expect(movedGroupIds).toHaveLength(2);
expect(movedGroupIds).not.toContain(sharedA.personGroupId);
expect(movedGroupIds).not.toContain(sharedB.personGroupId);
expect(new Set(movedGroupIds).size).toBe(2);
// the group id changes on the way out, so the name is what identifies each person
const movedA = moved.find(({ name }) => name === 'Alice');
const movedB = moved.find(({ name }) => name === 'Bob');
const faces = await ctx.database
.selectFrom('asset_face')
.select(['asset_face.id', 'asset_face.personGroupId'])
.where('asset_face.id', 'in', [faceA.id, faceB.id])
.execute();
expect(faces).toEqual(
expect.arrayContaining([
{ id: faceA.id, personGroupId: movedA!.personGroupId },
{ id: faceB.id, personGroupId: movedB!.personGroupId },
]),
);
});
});
describe('deleteRequest', () => {
it('should let the user it was created for decline it', async () => {
const { sut, ctx } = setup();
const { user: owner } = await ctx.newUser();
const { user: invitee } = await ctx.newUser();
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
const inviteeClusterGroupId = await getClusterGroupId(ctx, invitee.id);
const { value: request } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, {
userId: invitee.id,
});
await sut.deleteRequest(factory.auth({ user: invitee }), request.id);
await expect(sut.getRequests(factory.auth({ user: invitee }))).resolves.toEqual([]);
await expect(getClusterGroupId(ctx, invitee.id)).resolves.toBe(inviteeClusterGroupId);
});
it('should let the cluster group it was created by revoke it', async () => {
const { sut, ctx } = setup();
const { user: owner } = await ctx.newUser();
const { user: invitee } = await ctx.newUser();
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
const { value: request } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, {
userId: invitee.id,
});
await sut.deleteRequest(factory.auth({ user: owner }), request.id);
await expect(sut.getRequests(factory.auth({ user: invitee }))).resolves.toEqual([]);
});
it('should not let an unrelated user delete it', async () => {
const { sut, ctx } = setup();
const { user: owner } = await ctx.newUser();
const { user: invitee } = await ctx.newUser();
const { user: other } = await ctx.newUser();
const clusterGroupId = await getClusterGroupId(ctx, owner.id);
const { value: request } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, {
userId: invitee.id,
});
await expect(sut.deleteRequest(factory.auth({ user: other }), request.id)).rejects.toThrow(
'Not found or no clusterGroupRequest.delete access',
);
await expect(sut.getRequests(factory.auth({ user: invitee }))).resolves.toEqual([request]);
});
});
});

View file

@ -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,

View file

@ -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 });
});

View file

@ -1,6 +1,7 @@
import { Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { ImmichEnvironment, JobName, JobStatus, UserAvatarColor } from 'src/enum';
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { EventRepository } from 'src/repositories/event.repository';
@ -12,7 +13,7 @@ import { DB } from 'src/schema';
import { UserService } from 'src/services/user.service';
import { HumanReadableSize } from 'src/utils/bytes';
import { mediumFactory, newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { factory, newUuid } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
const userLicense = {
@ -28,7 +29,7 @@ const setup = (db?: Kysely<DB>) => {
return newMediumService(UserService, {
database: db || defaultDatabase,
real: [CryptoRepository, ConfigRepository, SystemMetadataRepository, UserRepository],
real: [ClusterGroupRepository, CryptoRepository, ConfigRepository, SystemMetadataRepository, UserRepository],
mock: [LoggingRepository, JobRepository, EventRepository],
});
};
@ -44,7 +45,7 @@ describe(UserService.name, () => {
it('should create a user', async () => {
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
const user = mediumFactory.userInsert();
const user = mediumFactory.userInsert({ clusterGroupId: newUuid() });
const created = await sut.createUser({ name: user.name, email: user.email });
expect(created).toEqual(expect.objectContaining({ name: user.name, email: user.email }));
@ -54,7 +55,7 @@ describe(UserService.name, () => {
it('should reject user with duplicate email', async () => {
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
const user = mediumFactory.userInsert();
const user = mediumFactory.userInsert({ clusterGroupId: newUuid() });
await expect(sut.createUser({ name: 'Test', email: user.email })).resolves.toMatchObject({ email: user.email });
await expect(sut.createUser({ name: 'Test', email: user.email })).rejects.toThrow('Email is not available');
});
@ -62,7 +63,7 @@ describe(UserService.name, () => {
it('should not return password', async () => {
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
const dto = mediumFactory.userInsert({ password: 'password' });
const dto = mediumFactory.userInsert({ clusterGroupId: newUuid(), password: 'password' });
const user = await sut.createUser({ name: 'Test', email: dto.email, password: 'password' });
expect((user as any).password).toBeUndefined();
});

View file

@ -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,

View file

@ -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 }),

View file

@ -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()),

View file

@ -26,6 +26,7 @@ import { AppRepository } from 'src/repositories/app.repository';
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { CronRepository } from 'src/repositories/cron.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
@ -75,6 +76,7 @@ import { AuthService } from 'src/services/auth.service';
import { BaseService } from 'src/services/base.service';
import { RepositoryInterface } from 'src/types';
import { getKyselyConfig } from 'src/utils/database';
import { ClusterGroupFactory } from 'test/factories/cluster-group.factory';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
@ -236,6 +238,7 @@ export type ServiceOverrides = {
asset: AssetRepository;
assetEdit: AssetEditRepository;
assetJob: AssetJobRepository;
clusterGroup: ClusterGroupRepository;
config: ConfigRepository;
cron: CronRepository;
crypto: CryptoRepository;
@ -319,6 +322,7 @@ export const getMocks = () => {
asset: newAssetRepositoryMock(),
assetEdit: automock(AssetEditRepository),
assetJob: automock(AssetJobRepository),
clusterGroup: automock(ClusterGroupRepository),
app: automock(AppRepository, { strict: false }),
config: newConfigRepositoryMock(),
database: databaseMock,
@ -369,6 +373,9 @@ export const getMocks = () => {
workflow: automock(WorkflowRepository, { strict: true }),
};
// every new user gets a cluster group, which is incidental to most tests
mocks.clusterGroup.create.mockResolvedValue(ClusterGroupFactory.create());
return mocks;
};
@ -389,6 +396,7 @@ export const newTestService = <T extends BaseService>(
overrides.asset || (mocks.asset as As<AssetRepository>),
overrides.assetEdit || (mocks.assetEdit as As<AssetEditRepository>),
overrides.assetJob || (mocks.assetJob as As<AssetJobRepository>),
overrides.clusterGroup || (mocks.clusterGroup as As<ClusterGroupRepository>),
overrides.config || (mocks.config as As<ConfigRepository> as ConfigRepository),
overrides.cron || (mocks.cron as As<CronRepository>),
overrides.crypto || (mocks.crypto as As<CryptoRepository>),

View file

@ -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;
}

View file

@ -67,6 +67,7 @@ export enum OpenQueryParam {
STORAGE_TEMPLATE = 'storage-template',
NOTIFICATIONS = 'notifications',
PURCHASE_SETTINGS = 'user-purchase-settings',
SHARING = 'sharing',
}
export const maximumLengthSearchPeople = 100;

View file

@ -0,0 +1,60 @@
<script lang="ts">
import UserAvatar from '$lib/components/shared-components/UserAvatar.svelte';
import { searchUsers, type UserResponseDto } from '@immich/sdk';
import { Button, ListButton, LoadingSpinner, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
excludedUserIds: string[];
onClose: (users?: UserResponseDto[]) => void;
}
let { excludedUserIds, onClose }: Props = $props();
let availableUsers: UserResponseDto[] = $state([]);
let selectedUsers: UserResponseDto[] = $state([]);
const loadUsers = async () => {
const users = await searchUsers();
const excluded = new Set(excludedUserIds);
availableUsers = users.filter(({ id }) => !excluded.has(id));
};
const selectUser = (user: UserResponseDto) => {
selectedUsers = selectedUsers.some(({ id }) => id === user.id)
? selectedUsers.filter((selectedUser) => selectedUser.id !== user.id)
: [...selectedUsers, user];
};
</script>
<Modal title={$t('add_user')} {onClose} size="small">
<ModalBody>
{#await loadUsers()}
<div class="flex w-full place-content-center place-items-center">
<LoadingSpinner />
</div>
{:then _}
{#if availableUsers.length > 0}
<div class="flex max-h-75 immich-scrollbar flex-col gap-2 overflow-y-auto">
{#each availableUsers as user (user.id)}
<ListButton onclick={() => selectUser(user)} selected={selectedUsers.some(({ id }) => id === user.id)}>
<UserAvatar {user} size="md" />
<div class="grow text-start">
<Text fontWeight="medium">{user.name}</Text>
<Text size="tiny" color="muted">{user.email}</Text>
</div>
</ListButton>
{/each}
</div>
<ModalFooter>
<Button shape="round" fullWidth onclick={() => onClose(selectedUsers)} disabled={selectedUsers.length === 0}>
{$t('add')}
</Button>
</ModalFooter>
{:else}
<Text color="muted">{$t('partner_page_no_more_users')}</Text>
{/if}
{/await}
</ModalBody>
</Modal>

View file

@ -0,0 +1,41 @@
<script lang="ts">
import UserAvatar from '$lib/components/shared-components/UserAvatar.svelte';
import { getClusterGroupUsers, type UserResponseDto } from '@immich/sdk';
import { LoadingSpinner, Modal, ModalBody, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
clusterGroupId: string;
onClose: () => void;
}
let { clusterGroupId, onClose }: Props = $props();
let users: UserResponseDto[] = $state([]);
const loadUsers = async () => {
users = await getClusterGroupUsers({ id: clusterGroupId });
};
</script>
<Modal title={$t('cluster_group')} {onClose} size="small">
<ModalBody>
{#await loadUsers()}
<div class="flex w-full place-content-center place-items-center">
<LoadingSpinner />
</div>
{:then _}
<div class="flex max-h-75 immich-scrollbar flex-col gap-4 overflow-y-auto">
{#each users as user (user.id)}
<div class="flex items-center gap-4">
<UserAvatar {user} size="md" />
<div class="text-start">
<Text fontWeight="medium">{user.name}</Text>
<Text size="tiny" color="muted">{user.email}</Text>
</div>
</div>
{/each}
</div>
{/await}
</ModalBody>
</Modal>

View file

@ -1,194 +0,0 @@
<script lang="ts">
import SettingSwitch from '$lib/components/shared-components/settings/SettingSwitch.svelte';
import UserAvatar from '$lib/components/shared-components/UserAvatar.svelte';
import PartnerSelectionModal from '$lib/modals/PartnerSelectionModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import {
createPartner,
getPartners,
PartnerDirection,
removePartner,
updatePartner,
type PartnerResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { Button, Icon, IconButton, modalManager, Text } from '@immich/ui';
import { mdiCheck, mdiClose } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface PartnerSharing {
user: UserResponseDto;
sharedByMe: boolean;
sharedWithMe: boolean;
inTimeline: boolean;
}
let partners: Array<PartnerSharing> = $state([]);
onMount(async () => {
await refreshPartners();
});
const refreshPartners = async () => {
partners = [];
const [sharedBy, sharedWith] = await Promise.all([
getPartners({ direction: PartnerDirection.SharedBy }),
getPartners({ direction: PartnerDirection.SharedWith }),
]);
for (const candidate of sharedBy) {
partners = [
...partners,
{
user: candidate,
sharedByMe: true,
sharedWithMe: false,
inTimeline: candidate.inTimeline ?? false,
},
];
}
for (const candidate of sharedWith) {
const existIndex = partners.findIndex((p) => candidate.id === p.user.id);
if (existIndex === -1) {
partners = [
...partners,
{
user: candidate,
sharedByMe: false,
sharedWithMe: true,
inTimeline: candidate.inTimeline ?? false,
},
];
} else {
partners[existIndex].sharedWithMe = true;
partners[existIndex].inTimeline = candidate.inTimeline ?? false;
}
}
};
const handleRemovePartner = async (partner: PartnerResponseDto) => {
const isConfirmed = await modalManager.showDialog({
title: $t('stop_photo_sharing'),
prompt: $t('stop_photo_sharing_description', { values: { partner: partner.name } }),
});
if (!isConfirmed) {
return;
}
try {
await removePartner({ id: partner.id });
await refreshPartners();
} catch (error) {
handleError(error, $t('errors.unable_to_remove_partner'));
}
};
const handleCreatePartners = async () => {
const users = await modalManager.show(PartnerSelectionModal, {});
if (!users) {
return;
}
try {
for (const user of users) {
await createPartner({ partnerCreateDto: { sharedWithId: user.id } });
}
await refreshPartners();
} catch (error) {
handleError(error, $t('errors.unable_to_add_partners'));
}
};
const handleShowOnTimelineChanged = async (partner: PartnerSharing, inTimeline: boolean) => {
try {
await updatePartner({ id: partner.user.id, partnerUpdateDto: { inTimeline } });
partner.inTimeline = inTimeline;
} catch (error) {
handleError(error, $t('errors.unable_to_update_timeline_display_status'));
}
};
</script>
<section class="my-4">
{#if partners.length > 0}
{#each partners as partner (partner.user.id)}
<div class="mt-6 rounded-2xl border border-gray-200 bg-slate-50 p-5 dark:border-gray-800 dark:bg-gray-900">
<div class="flex justify-between gap-4 rounded-lg pb-4 transition-all">
<div class="flex gap-4">
<UserAvatar user={partner.user} size="md" />
<div class="text-start">
<p class="text-immich-fg dark:text-immich-dark-fg">
{partner.user.name}
</p>
<p class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">
{partner.user.email}
</p>
</div>
</div>
{#if partner.sharedByMe}
<IconButton
shape="round"
color="secondary"
variant="ghost"
onclick={() => handleRemovePartner(partner.user)}
icon={mdiClose}
size="small"
aria-label={$t('stop_sharing_photos_with_user')}
/>
{/if}
</div>
<div class="text-immich-dark-gray dark:text-gray-200">
<!-- I am sharing my assets with this user -->
{#if partner.sharedByMe}
<hr class="my-4 border border-gray-200 dark:border-gray-700" />
<Text class="my-4" size="small" fontWeight="medium">
{$t('shared_with_partner', { values: { partner: partner.user.name } })}
</Text>
<Text size="tiny" fontWeight="medium"
>{$t('partner_can_access', { values: { partner: partner.user.name } })}</Text
>
<ul class="text-sm">
<li class="mt-2 flex place-items-center gap-2 py-1">
<Icon icon={mdiCheck} />
{$t('partner_can_access_assets')}
</li>
<li class="flex place-items-center gap-2 py-1">
<Icon icon={mdiCheck} />
{$t('partner_can_access_location')}
</li>
</ul>
{/if}
<!-- this user is sharing assets with me -->
{#if partner.sharedWithMe}
<hr class="my-4 border border-gray-200 dark:border-gray-700" />
<Text class="my-4" size="small" fontWeight="medium">
{$t('shared_from_partner', { values: { partner: partner.user.name } })}
</Text>
<SettingSwitch
title={$t('show_in_timeline')}
subtitle={$t('show_in_timeline_setting_description')}
bind:checked={partner.inTimeline}
onToggle={(isChecked) => handleShowOnTimelineChanged(partner, isChecked)}
/>
{/if}
</div>
</div>
{/each}
{/if}
<div class="mt-5 flex justify-end">
<Button shape="round" size="small" onclick={() => handleCreatePartners()}>{$t('add_partner')}</Button>
</div>
</section>

View file

@ -0,0 +1,370 @@
<script lang="ts">
import SettingSwitch from '$lib/components/shared-components/settings/SettingSwitch.svelte';
import UserAvatar from '$lib/components/shared-components/UserAvatar.svelte';
import ClusterGroupUserSelectionModal from '$lib/modals/ClusterGroupUserSelectionModal.svelte';
import ClusterGroupUsersModal from '$lib/modals/ClusterGroupUsersModal.svelte';
import PartnerSelectionModal from '$lib/modals/PartnerSelectionModal.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import {
createClusterGroupRequest,
createPartner,
deleteClusterGroupRequest,
getClusterGroupRequests,
getClusterGroupRequestsForGroup,
getClusterGroupUsers,
getMyUser,
getPartners,
leaveClusterGroup,
PartnerDirection,
removePartner,
searchUsers,
updatePartner,
type ClusterGroupRequestResponseDto,
type PartnerResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { Button, Card, CardBody, Icon, IconButton, modalManager, Text } from '@immich/ui';
import { mdiCheck, mdiClose } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface PartnerSharing {
user: UserResponseDto;
sharedByMe: boolean;
sharedWithMe: boolean;
inTimeline: boolean;
}
let clusterGroupId: string = $state('');
let users: UserResponseDto[] = $state([]);
let sentRequests: ClusterGroupRequestResponseDto[] = $state([]);
let receivedRequests: ClusterGroupRequestResponseDto[] = $state([]);
// a request is sent to someone outside of the group, so they come from elsewhere
let candidates: Record<string, UserResponseDto> = $state({});
let partners: Array<PartnerSharing> = $state([]);
const canLeave = $derived(users.length > 1);
onMount(async () => {
await Promise.all([refresh(), refreshPartners()]);
});
const refresh = async () => {
try {
const { clusterGroupId: id } = await getMyUser();
clusterGroupId = id;
const [groupUsers, sent, received, allUsers] = await Promise.all([
getClusterGroupUsers({ id }),
getClusterGroupRequestsForGroup({ id }),
getClusterGroupRequests(),
searchUsers(),
]);
users = groupUsers;
sentRequests = sent;
receivedRequests = received;
candidates = Object.fromEntries(allUsers.map((user) => [user.id, user]));
} catch (error) {
handleError(error, $t('errors.unable_to_load_cluster_group'));
}
};
const handleAddUsers = async () => {
const excludedUserIds = [...users.map(({ id }) => id), ...sentRequests.map(({ userId }) => userId)];
const selected = await modalManager.show(ClusterGroupUserSelectionModal, { excludedUserIds });
if (!selected) {
return;
}
try {
for (const user of selected) {
await createClusterGroupRequest({
id: clusterGroupId,
clusterGroupRequestCreateDto: { userId: user.id },
});
}
await refresh();
} catch (error) {
handleError(error, $t('errors.unable_to_create_cluster_group_request'));
}
};
const handleViewGroup = async (request: ClusterGroupRequestResponseDto) => {
await modalManager.show(ClusterGroupUsersModal, { clusterGroupId: request.clusterGroupId });
};
const handleDeleteRequest = async (request: ClusterGroupRequestResponseDto) => {
try {
await deleteClusterGroupRequest({ id: request.id });
await refresh();
} catch (error) {
handleError(error, $t('errors.unable_to_delete_cluster_group_request'));
}
};
const handleLeave = async () => {
const isConfirmed = await modalManager.showDialog({
title: $t('leave_group'),
prompt: $t('leave_group_description'),
});
if (!isConfirmed) {
return;
}
try {
await leaveClusterGroup({ id: clusterGroupId });
await refresh();
} catch (error) {
handleError(error, $t('errors.unable_to_leave_cluster_group'));
}
};
const refreshPartners = async () => {
partners = [];
const [sharedBy, sharedWith] = await Promise.all([
getPartners({ direction: PartnerDirection.SharedBy }),
getPartners({ direction: PartnerDirection.SharedWith }),
]);
for (const candidate of sharedBy) {
partners = [
...partners,
{
user: candidate,
sharedByMe: true,
sharedWithMe: false,
inTimeline: candidate.inTimeline ?? false,
},
];
}
for (const candidate of sharedWith) {
const existIndex = partners.findIndex((p) => candidate.id === p.user.id);
if (existIndex === -1) {
partners = [
...partners,
{
user: candidate,
sharedByMe: false,
sharedWithMe: true,
inTimeline: candidate.inTimeline ?? false,
},
];
} else {
partners[existIndex].sharedWithMe = true;
partners[existIndex].inTimeline = candidate.inTimeline ?? false;
}
}
};
const handleRemovePartner = async (partner: PartnerResponseDto) => {
const isConfirmed = await modalManager.showDialog({
title: $t('stop_photo_sharing'),
prompt: $t('stop_photo_sharing_description', { values: { partner: partner.name } }),
});
if (!isConfirmed) {
return;
}
try {
await removePartner({ id: partner.id });
await refreshPartners();
} catch (error) {
handleError(error, $t('errors.unable_to_remove_partner'));
}
};
const handleCreatePartners = async () => {
const users = await modalManager.show(PartnerSelectionModal, {});
if (!users) {
return;
}
try {
for (const user of users) {
await createPartner({ partnerCreateDto: { sharedWithId: user.id } });
}
await refreshPartners();
} catch (error) {
handleError(error, $t('errors.unable_to_add_partners'));
}
};
const handleShowOnTimelineChanged = async (partner: PartnerSharing, inTimeline: boolean) => {
try {
await updatePartner({ id: partner.user.id, partnerUpdateDto: { inTimeline } });
partner.inTimeline = inTimeline;
} catch (error) {
handleError(error, $t('errors.unable_to_update_timeline_display_status'));
}
};
</script>
<section class="my-4">
<Text size="large" fontWeight="medium">{$t('cluster_group')}</Text>
<Text size="small" color="muted">{$t('cluster_group_description')}</Text>
<Card class="mt-4">
<CardBody>
{#each users as user, index (user.id)}
<div class="flex items-center justify-between gap-4" class:mt-4={index > 0}>
<div class="flex items-center gap-4">
<UserAvatar {user} size="md" />
<div class="text-start">
<p class="text-immich-fg dark:text-immich-dark-fg">
{user.name}
{#if user.id === authManager.user.id}
<span class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">({$t('you')})</span>
{/if}
</p>
<p class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">{user.email}</p>
</div>
</div>
{#if user.id === authManager.user.id && canLeave}
<Button shape="round" size="small" color="secondary" onclick={() => handleLeave()}>
{$t('leave')}
</Button>
{/if}
</div>
{/each}
</CardBody>
</Card>
{#if sentRequests.length > 0 || receivedRequests.length > 0}
<div class="mt-4">
<Text size="small" fontWeight="medium">{$t('pending')}</Text>
</div>
<Card color="secondary" class="mt-2">
<CardBody>
{#each receivedRequests as request, index (request.id)}
<div class="flex items-center justify-between gap-4" class:mt-4={index > 0}>
<Text size="small">{$t('request_received_description')}</Text>
<div class="flex gap-2">
<Button shape="round" size="small" color="secondary" onclick={() => handleViewGroup(request)}>
{$t('view_group')}
</Button>
<Button shape="round" size="small" color="danger" onclick={() => handleDeleteRequest(request)}>
{$t('decline')}
</Button>
</div>
</div>
{/each}
{#each sentRequests as request, index (request.id)}
{@const user = candidates[request.userId]}
<div class="flex items-center justify-between gap-4" class:mt-4={index > 0 || receivedRequests.length > 0}>
<div class="flex items-center gap-4">
{#if user}
<UserAvatar {user} size="md" />
{/if}
<div class="text-start">
<p class="text-immich-fg dark:text-immich-dark-fg">{user?.name ?? request.userId}</p>
<p class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">{user?.email ?? ''}</p>
</div>
</div>
<Button shape="round" size="small" color="secondary" onclick={() => handleDeleteRequest(request)}>
{$t('cancel')}
</Button>
</div>
{/each}
</CardBody>
</Card>
{/if}
<div class="mt-5 flex justify-end">
<Button shape="round" size="small" onclick={() => handleAddUsers()}>{$t('add_user')}</Button>
</div>
</section>
<section class="my-4">
<Text size="large" fontWeight="medium">{$t('partners')}</Text>
{#if partners.length > 0}
{#each partners as partner (partner.user.id)}
<div class="mt-6 rounded-2xl border border-gray-200 bg-slate-50 p-5 dark:border-gray-800 dark:bg-gray-900">
<div class="flex justify-between gap-4 rounded-lg pb-4 transition-all">
<div class="flex gap-4">
<UserAvatar user={partner.user} size="md" />
<div class="text-start">
<p class="text-immich-fg dark:text-immich-dark-fg">
{partner.user.name}
</p>
<p class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">
{partner.user.email}
</p>
</div>
</div>
{#if partner.sharedByMe}
<IconButton
shape="round"
color="secondary"
variant="ghost"
onclick={() => handleRemovePartner(partner.user)}
icon={mdiClose}
size="small"
aria-label={$t('stop_sharing_photos_with_user')}
/>
{/if}
</div>
<div class="text-immich-dark-gray dark:text-gray-200">
<!-- I am sharing my assets with this user -->
{#if partner.sharedByMe}
<hr class="my-4 border border-gray-200 dark:border-gray-700" />
<Text class="my-4" size="small" fontWeight="medium">
{$t('shared_with_partner', { values: { partner: partner.user.name } })}
</Text>
<Text size="tiny" fontWeight="medium"
>{$t('partner_can_access', { values: { partner: partner.user.name } })}</Text
>
<ul class="text-sm">
<li class="mt-2 flex place-items-center gap-2 py-1">
<Icon icon={mdiCheck} />
{$t('partner_can_access_assets')}
</li>
<li class="flex place-items-center gap-2 py-1">
<Icon icon={mdiCheck} />
{$t('partner_can_access_location')}
</li>
</ul>
{/if}
<!-- this user is sharing assets with me -->
{#if partner.sharedWithMe}
<hr class="my-4 border border-gray-200 dark:border-gray-700" />
<Text class="my-4" size="small" fontWeight="medium">
{$t('shared_from_partner', { values: { partner: partner.user.name } })}
</Text>
<SettingSwitch
title={$t('show_in_timeline')}
subtitle={$t('show_in_timeline_setting_description')}
bind:checked={partner.inTimeline}
onToggle={(isChecked) => handleShowOnTimelineChanged(partner, isChecked)}
/>
{/if}
</div>
</div>
{/each}
{/if}
<div class="mt-5 flex justify-end">
<Button shape="round" size="small" onclick={() => handleCreatePartners()}>{$t('add_partner')}</Button>
</div>
</section>

View file

@ -31,7 +31,7 @@
import ChangePasswordSettings from './ChangePasswordSettings.svelte';
import DeviceList from './DeviceList.svelte';
import OauthSettings from './OauthSettings.svelte';
import PartnerSettings from './PartnerSettings.svelte';
import SharingSettings from './SharingSettings.svelte';
import UserApiKeyList from './UserApiKeyList.svelte';
import UserProfileSettings from './UserProfileSettings.svelte';
@ -129,15 +129,6 @@
<ChangePasswordSettings />
</SettingAccordion>
<SettingAccordion
icon={mdiAccountGroupOutline}
key="partner-sharing"
title={$t('partner_sharing')}
subtitle={$t('manage_sharing_with_partners')}
>
<PartnerSettings />
</SettingAccordion>
<SettingAccordion
icon={mdiLockSmart}
key="user-pin-code-settings"
@ -157,3 +148,12 @@
>
<UserPurchaseSettings />
</SettingAccordion>
<SettingAccordion
icon={mdiAccountGroupOutline}
key={OpenQueryParam.SHARING}
title={$t('sharing')}
subtitle={$t('manage_sharing_with_other_users')}
>
<SharingSettings />
</SettingAccordion>

View file

@ -13,6 +13,7 @@ export const userFactory = Sync.makeFactory<UserResponseDto>({
export const userAdminFactory = Sync.makeFactory<UserAdminResponseDto>({
id: Sync.each(() => faker.string.uuid()),
clusterGroupId: Sync.each(() => faker.string.uuid()),
email: Sync.each(() => faker.internet.email()),
name: Sync.each(() => faker.person.fullName()),
profileImagePath: '',