From c12d5e6d35f1c0829348a6cc76e1beb15f6e9c93 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:29:15 +0200 Subject: [PATCH] feat: people management (#31620) Co-authored-by: Jason Rasmussen Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- i18n/en.json | 14 +- .../lib/domain/services/timeline.service.dart | 4 +- .../repositories/timeline.repository.dart | 53 +- .../lib/presentation/pages/person.page.dart | 3 +- mobile/lib/utils/openapi_patching.dart | 1 + .../timeline_repository_test.dart | 4 +- open-api/immich-openapi-specs.json | 533 +++++++++++- packages/sdk/src/fetch-client.ts | 154 +++- pnpm-lock.yaml | 3 + .../src/controllers/person.controller.spec.ts | 4 +- server/src/controllers/person.controller.ts | 47 +- server/src/database.ts | 18 +- server/src/dtos/partner.dto.ts | 4 +- server/src/dtos/person.dto.ts | 133 ++- server/src/enum.ts | 16 + server/src/queries/access.repository.sql | 36 +- server/src/queries/person.repository.sql | 757 ++++++++++++++++-- server/src/queries/person.user.repository.sql | 85 ++ server/src/repositories/access.repository.ts | 54 +- .../src/repositories/activity.repository.ts | 2 +- server/src/repositories/index.ts | 2 + server/src/repositories/partner.repository.ts | 5 - .../repositories/person-user.repository.ts | 111 +++ server/src/repositories/person.repository.ts | 363 +++++++-- server/src/schema/enums.ts | 6 + server/src/schema/functions.ts | 37 + server/src/schema/index.ts | 12 +- .../migrations/1790616293884-PersonSharing.ts | 102 +++ server/src/schema/migrations/ORDER | 1 + server/src/schema/tables/person-user.table.ts | 60 ++ server/src/schema/tables/person.table.ts | 8 +- server/src/services/base.service.ts | 21 +- server/src/services/partner.service.spec.ts | 6 +- server/src/services/partner.service.ts | 14 +- server/src/services/person.service.spec.ts | 292 +++++-- server/src/services/person.service.ts | 287 +++++-- server/src/utils/access.ts | 58 +- server/test/factories/person.factory.ts | 9 +- server/test/factories/types.ts | 9 +- server/test/medium.factory.ts | 27 + .../person-user.repository.spec.ts | 400 +++++++++ .../repositories/person.repository.spec.ts | 204 ++++- .../specs/services/partner.service.spec.ts | 9 +- .../specs/services/person.service.spec.ts | 460 ++++++++++- .../repositories/access.repository.mock.ts | 2 +- server/test/utils.ts | 4 + web/eslint.config.js | 25 +- web/package.json | 1 + web/src/lib/constants.ts | 4 + web/src/lib/managers/event-manager.svelte.ts | 4 + ...UsersModal.svelte => AddUsersModal.svelte} | 23 +- web/src/lib/modals/AlbumOptionsModal.svelte | 2 +- web/src/lib/modals/PeopleFilterModal.svelte | 137 ++++ .../lib/modals/PeopleFilterUserPicker.svelte | 42 + .../lib/modals/PersonEditAccessModal.svelte | 90 +++ .../modals/PersonEditBirthDateModal.svelte | 39 - web/src/lib/modals/PersonEditModal.svelte | 196 +++++ web/src/lib/services/album.service.ts | 11 +- web/src/lib/services/person-user.service.ts | 100 +++ web/src/lib/services/person.service.ts | 67 +- web/src/lib/types.ts | 7 + web/src/lib/utils/person.spec.ts | 3 + web/src/routes/(user)/people/+page.svelte | 64 +- web/src/routes/(user)/people/+page.ts | 16 +- .../routes/(user)/people/PeopleCard.svelte | 5 +- .../[[assetId=id]]/+page.svelte | 121 ++- web/src/test-data/factories/person-factory.ts | 3 + 67 files changed, 4812 insertions(+), 582 deletions(-) create mode 100644 server/src/queries/person.user.repository.sql create mode 100644 server/src/repositories/person-user.repository.ts create mode 100644 server/src/schema/migrations/1790616293884-PersonSharing.ts create mode 100644 server/src/schema/tables/person-user.table.ts create mode 100644 server/test/medium/specs/repositories/person-user.repository.spec.ts rename web/src/lib/modals/{AlbumAddUsersModal.svelte => AddUsersModal.svelte} (80%) create mode 100644 web/src/lib/modals/PeopleFilterModal.svelte create mode 100644 web/src/lib/modals/PeopleFilterUserPicker.svelte create mode 100644 web/src/lib/modals/PersonEditAccessModal.svelte delete mode 100644 web/src/lib/modals/PersonEditBirthDateModal.svelte create mode 100644 web/src/lib/modals/PersonEditModal.svelte create mode 100644 web/src/lib/services/person-user.service.ts diff --git a/i18n/en.json b/i18n/en.json index 77ea86f7c..d95352197 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -791,6 +791,7 @@ "copied_image_to_clipboard": "Copied image to clipboard.", "copied_to_clipboard": "Copied to clipboard!", "copy_error": "Copy error", + "copy_from_my_person": "Copy from my person", "copy_image": "Copy Image", "copy_json": "Copy JSON", "copy_link": "Copy link", @@ -943,6 +944,7 @@ "edit_location_dialog_title": "Location", "edit_name": "Edit name", "edit_people": "Edit people", + "edit_person": "Edit person", "edit_tag": "Edit tag", "edit_title": "Edit Title", "edit_user": "Edit user", @@ -1210,6 +1212,7 @@ "headers_settings_tile_title": "Custom proxy headers", "height": "Height", "hi_user": "Hi {name} ({email})", + "hidden": "Hidden", "hide_all_people": "Hide all people", "hide_asset_owners": "Hide asset owners", "hide_gallery": "Hide gallery", @@ -1398,6 +1401,8 @@ "manage_media_access_subtitle": "Allow the Immich app to manage and move media files.", "manage_media_access_title": "Media Management Access", "manage_oauth_account": "Manage OAuth Account", + "manage_person_access": "Manage access", + "manage_person_access_description": "Give other users access to this person, allowing them to view or manage it on your behalf.", "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", @@ -1550,6 +1555,7 @@ "no_exif_info_available": "No exif info available", "no_explore_results_message": "Upload more photos to explore your collection.", "no_favorites_message": "Add favorites to quickly find your best pictures and videos", + "no_filter": "No filter", "no_libraries_message": "Create an external library to view your photos and videos", "no_local_assets_found": "No local assets found with this checksum", "no_locked_photos_message": "Photos and videos in the locked folder are hidden and won't show up as you browse or search your library.", @@ -1668,8 +1674,11 @@ "person_age_year_months": "1 year, {months, plural, one {# month} other {# months}} old", "person_age_years": "{years, plural, one {# year} other {# years}} old", "person_birthdate": "Born on {date}", + "person_edit_change_for_all_users": "Also change for {people}?", "person_hidden": "{name}{hidden, select, true { (hidden)} other {}}", + "person_read_access_message": "You only have read access to this person.", "person_recognized": "Person recognized", + "person_share_no_users": "Every user in your cluster group already has access to this person.", "photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.", "photos": "Photos", "photos_and_videos": "Photos & Videos", @@ -1759,6 +1768,7 @@ "rating_description": "Display the EXIF rating in the info panel", "reaction_options": "Reaction options", "read_changelog": "Read Changelog", + "readonly_access": "Read-only access", "readonly_mode_disabled": "Read-only mode disabled", "readonly_mode_enabled": "Read-only mode enabled", "ready_for_upload": "Ready for upload", @@ -1965,7 +1975,6 @@ "set_as_album_cover": "Set as album cover", "set_as_featured_photo": "Set as featured photo", "set_as_profile_picture": "Set as profile picture", - "set_date_of_birth": "Set date of birth", "set_profile_picture": "Set profile picture", "set_slideshow_to_fullscreen": "Set Slideshow to fullscreen", "set_stack_primary_asset": "Set as primary asset", @@ -2043,6 +2052,7 @@ "shared_links": "Shared links", "shared_links_description": "Share photos and videos with a link", "shared_photos_and_videos_count": "{assetCount, plural, other {# shared photos & videos.}}", + "shared_with": "Shared with", "shared_with_me": "Shared with me", "shared_with_partner": "Shared with {partner}", "sharing": "Sharing", @@ -2313,6 +2323,8 @@ "view_album": "View Album", "view_all": "View All", "view_all_users": "View all users", + "view_and_edit_person_fields": "View and edit person fields for this user.", + "view_as": "View as {name}", "view_asset_owners": "View asset owners", "view_details": "View Details", "view_group": "View group", diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 5dc8ec3a8..4c77e8047 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -74,8 +74,8 @@ class TimelineFactory { TimelineService place(String place) => TimelineService(_timelineRepository.place(place, groupBy)); - TimelineService person(String userId, String personId) => - TimelineService(_timelineRepository.person(userId, personId, groupBy)); + TimelineService person(List userIds, String personId) => + TimelineService(_timelineRepository.person(userIds, personId, groupBy)); TimelineService fromAssets(List assets, TimelineOrigin type) => TimelineService(_timelineRepository.fromAssets(assets, type)); diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index 729de23cd..821b2ed2a 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -366,10 +366,10 @@ class TimelineRepository extends DatabaseAccessor with $TimelineRepositor origin: TimelineOrigin.place, ); - TimelineQuery person(String userId, String personId, GroupAssetsBy groupBy) => ( - bucketSource: () => _watchPersonBucket(userId, personId, groupBy: groupBy), + TimelineQuery person(List userIds, String personId, GroupAssetsBy groupBy) => ( + bucketSource: () => _watchPersonBucket(userIds, personId, groupBy: groupBy), assetSource: (offset, count) => - _getPersonBucketAssets(userId, personId, groupBy: groupBy, offset: offset, count: count), + _getPersonBucketAssets(userIds, personId, groupBy: groupBy, offset: offset, count: count), origin: TimelineOrigin.person, ); @@ -430,24 +430,30 @@ class TimelineRepository extends DatabaseAccessor with $TimelineRepositor return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get(); } - Stream> _watchPersonBucket(String userId, String personId, {GroupAssetsBy groupBy = GroupAssetsBy.day}) { - final idQuery = _db.assetFaceEntity.selectOnly() + Expression _personAssetFilter(List userIds, String personId) { + final faceAssetIds = _db.assetFaceEntity.selectOnly() ..addColumns([_db.assetFaceEntity.assetId]) ..where( _db.assetFaceEntity.personId.equals(personId) & _db.assetFaceEntity.isVisible.equals(true) & _db.assetFaceEntity.deletedAt.isNull(), ); + final albumAssetIds = _db.remoteAlbumAssetEntity.selectOnly()..addColumns([_db.remoteAlbumAssetEntity.assetId]); + return _db.remoteAssetEntity.id.isInQuery(faceAssetIds) & + (_db.remoteAssetEntity.ownerId.isIn(userIds) | _db.remoteAssetEntity.id.isInQuery(albumAssetIds)) & + _db.remoteAssetEntity.deletedAt.isNull() & + _db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline); + } + Stream> _watchPersonBucket( + List userIds, + String personId, { + GroupAssetsBy groupBy = GroupAssetsBy.day, + }) { if (groupBy == GroupAssetsBy.none) { final query = _db.remoteAssetEntity.selectOnly() ..addColumns([_db.remoteAssetEntity.id.count()]) - ..where( - _db.remoteAssetEntity.id.isInQuery(idQuery) & - _db.remoteAssetEntity.deletedAt.isNull() & - _db.remoteAssetEntity.ownerId.equals(userId) & - _db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline), - ); + ..where(_personAssetFilter(userIds, personId)); return query.map((row) { final count = row.read(_db.remoteAssetEntity.id.count())!; @@ -460,12 +466,7 @@ class TimelineRepository extends DatabaseAccessor with $TimelineRepositor final query = _db.remoteAssetEntity.selectOnly() ..addColumns([assetCountExp, dateExp]) - ..where( - _db.remoteAssetEntity.id.isInQuery(idQuery) & - _db.remoteAssetEntity.ownerId.equals(userId) & - _db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) & - _db.remoteAssetEntity.deletedAt.isNull(), - ) + ..where(_personAssetFilter(userIds, personId)) ..groupBy([dateExp]) ..orderBy([OrderingTerm.desc(dateExp)]); @@ -477,28 +478,14 @@ class TimelineRepository extends DatabaseAccessor with $TimelineRepositor } Future> _getPersonBucketAssets( - String userId, + List userIds, String personId, { required int offset, required int count, GroupAssetsBy groupBy = GroupAssetsBy.day, }) { - final idQuery = _db.assetFaceEntity.selectOnly() - ..addColumns([_db.assetFaceEntity.assetId]) - ..where( - _db.assetFaceEntity.personId.equals(personId) & - _db.assetFaceEntity.isVisible.equals(true) & - _db.assetFaceEntity.deletedAt.isNull(), - ); - final query = _db.remoteAssetEntity.select() - ..where( - (row) => - row.id.isInQuery(idQuery) & - row.deletedAt.isNull() & - row.ownerId.equals(userId) & - row.visibility.equalsValue(AssetVisibility.timeline), - ) + ..where((_) => _personAssetFilter(userIds, personId)) ..orderBy(_assetDateOrder(groupBy)) ..limit(count, offset: offset); diff --git a/mobile/lib/presentation/pages/person.page.dart b/mobile/lib/presentation/pages/person.page.dart index 15bbd6ad2..bb6e3f2da 100644 --- a/mobile/lib/presentation/pages/person.page.dart +++ b/mobile/lib/presentation/pages/person.page.dart @@ -82,7 +82,8 @@ class _PersonPageState extends ConsumerState { throw Exception('User must be logged in to view person timeline'); } - final timelineService = ref.watch(timelineFactoryProvider).person(user.id, _person.id); + final users = ref.watch(timelineUsersProvider).valueOrNull ?? [user.id]; + final timelineService = ref.watch(timelineFactoryProvider).person(users, _person.id); ref.onDispose(timelineService.dispose); return timelineService; }), diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index 90ec10d76..1ac6aa174 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -42,6 +42,7 @@ final Map> openApiPatches = { 'ServerFeaturesDto': {'ocr': false, 'realtimeTranscoding': false}, 'SearchAssetResponseDto': {'nextCursor': null}, 'MemoriesResponse': {'duration': 5, 'sidebarWeb': false}, + 'PersonResponseDto': {'otherPeople': const [], 'sharedBy': const [], 'sharedWith': const []}, 'WorkflowResponseDto': {'logging': false}, }; diff --git a/mobile/test/medium/repositories/timeline_repository_test.dart b/mobile/test/medium/repositories/timeline_repository_test.dart index 3de31e966..15df15dd1 100644 --- a/mobile/test/medium/repositories/timeline_repository_test.dart +++ b/mobile/test/medium/repositories/timeline_repository_test.dart @@ -113,7 +113,7 @@ void main() { await ctx.newFace(assetId: asset.id, personId: person.id); await ctx.newFace(assetId: asset.id, personId: person.id); - final query = sut.person(user.id, person.id, .day); + final query = sut.person([user.id], person.id, .day); final buckets = await query.bucketSource().first; expect(buckets, hasLength(1)); @@ -140,7 +140,7 @@ void main() { await ctx.newFace(assetId: shiftedLater.id, personId: person.id); await ctx.newFace(assetId: shiftedEarlier.id, personId: person.id); - final query = sut.person(user.id, person.id, .day); + final query = sut.person([user.id], person.id, .day); final buckets = await query.bucketSource().first; expect(buckets, hasLength(2)); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 0e20c69f7..370731eda 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9717,7 +9717,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BulkIdsDto" + "$ref": "#/components/schemas/PeopleDeleteDto" } } }, @@ -9786,6 +9786,24 @@ "type": "string" } }, + { + "name": "isFavorite", + "required": false, + "in": "query", + "description": "Filter by favorite status", + "schema": { + "type": "boolean" + } + }, + { + "name": "isHidden", + "required": false, + "in": "query", + "description": "Filter by hidden status", + "schema": { + "type": "boolean" + } + }, { "name": "page", "required": false, @@ -9798,6 +9816,28 @@ "type": "integer" } }, + { + "name": "sharedById", + "required": false, + "in": "query", + "description": "Only include people to which the user gave access", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + } + }, + { + "name": "sharedWithId", + "required": false, + "in": "query", + "description": "Only include people to which the user was given access", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + } + }, { "name": "size", "required": false, @@ -10043,6 +10083,197 @@ "x-immich-state": "Stable" } }, + "/people/users": { + "delete": { + "description": "Remove user access to people", + "operationId": "removeUsersFromPeople", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonUsersDeleteDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Remove users from people", + "tags": [ + "People" + ], + "x-immich-history": [ + { + "version": "v3.3", + "state": "Added" + }, + { + "version": "v3.3", + "state": "Stable" + } + ], + "x-immich-permission": "person.delete", + "x-immich-state": "Stable" + }, + "get": { + "description": "Retrieve a list of users and the people to which they have been given access", + "operationId": "getUsersForPeople", + "parameters": [ + { + "name": "direction", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/SharingDirection" + } + }, + { + "name": "personId", + "required": false, + "in": "query", + "description": "Person ID", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + } + }, + { + "name": "role", + "required": false, + "in": "query", + "description": "Role of user", + "schema": { + "$ref": "#/components/schemas/PersonUserRole" + } + }, + { + "name": "sharedById", + "required": false, + "in": "query", + "description": "User ID of the user that gave access", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + } + }, + { + "name": "sharedWithId", + "required": false, + "in": "query", + "description": "User ID of the user that was given access", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonUsersResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Get people access", + "tags": [ + "People" + ], + "x-immich-history": [ + { + "version": "v3.3", + "state": "Added" + }, + { + "version": "v3.3", + "state": "Stable" + } + ], + "x-immich-permission": "person.read", + "x-immich-state": "Stable" + }, + "put": { + "description": "Give users access to people", + "operationId": "addUsersToPeople", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonUsersCreateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Give users access to people", + "tags": [ + "People" + ], + "x-immich-history": [ + { + "version": "v3.3", + "state": "Added" + }, + { + "version": "v3.3", + "state": "Stable" + } + ], + "x-immich-permission": "person.update", + "x-immich-state": "Stable" + } + }, "/people/{id}": { "delete": { "description": "Delete an individual person.", @@ -10059,6 +10290,16 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonDeleteDto" + } + } + }, + "required": true + }, "responses": { "204": { "description": "" @@ -20263,6 +20504,12 @@ "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 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" + }, "width": { "description": "Face bounding box width", "maximum": 9007199254740991, @@ -20403,6 +20650,12 @@ "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 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" } }, "required": [ @@ -24650,6 +24903,32 @@ ], "type": "object" }, + "PeopleDeleteDto": { + "properties": { + "ids": { + "description": "IDs to process", + "items": { + "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" + }, + "type": "array" + }, + "userId": { + "type": "string" + } + }, + "required": [ + "ids" + ], + "type": "object", + "x-immich-history": [ + { + "version": "v3.3", + "state": "Added" + } + ] + }, "PeopleResponse": { "properties": { "enabled": { @@ -24788,6 +25067,12 @@ "name": { "description": "Person name", "type": "string" + }, + "userId": { + "description": "User ID", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" } }, "required": [ @@ -24795,6 +25080,52 @@ ], "type": "object" }, + "PeopleUserResponseDto": { + "properties": { + "avatarColor": { + "$ref": "#/components/schemas/UserAvatarColor" + }, + "email": { + "description": "User email", + "format": "email", + "pattern": "^[\\p{L}\\p{M}\\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?(?:\\.[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?)*$", + "type": "string" + }, + "id": { + "description": "User 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" + }, + "name": { + "description": "User name", + "type": "string" + }, + "profileChangedAt": { + "description": "Profile change date", + "format": "date-time", + "type": "string" + }, + "profileImagePath": { + "description": "Profile image path", + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/PersonUserRole", + "description": "Access role" + } + }, + "required": [ + "avatarColor", + "email", + "id", + "name", + "profileChangedAt", + "profileImagePath", + "role" + ], + "type": "object" + }, "Permission": { "description": "List of permissions", "enum": [ @@ -24998,6 +25329,52 @@ }, "type": "object" }, + "PersonDeleteDto": { + "default": {}, + "properties": { + "userId": { + "type": "string" + } + }, + "type": "object", + "x-immich-history": [ + { + "version": "v3.3", + "state": "Added" + }, + { + "version": "v3.3", + "state": "Stable" + } + ], + "x-immich-state": "Stable" + }, + "PersonOtherResponseDto": { + "properties": { + "birthDate": { + "nullable": true, + "type": "string" + }, + "name": { + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/PersonUserRole" + }, + "sharedById": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + } + }, + "required": [ + "birthDate", + "name", + "role", + "sharedById" + ], + "type": "object" + }, "PersonResponseDto": { "properties": { "birthDate": { @@ -25050,6 +25427,26 @@ "description": "Person name", "type": "string" }, + "otherPeople": { + "items": { + "$ref": "#/components/schemas/PersonOtherResponseDto" + }, + "type": "array" + }, + "sharedBy": { + "description": "Users that gave the current user access to this person", + "items": { + "$ref": "#/components/schemas/PeopleUserResponseDto" + }, + "type": "array" + }, + "sharedWith": { + "description": "Users the current user gave access to this person", + "items": { + "$ref": "#/components/schemas/PeopleUserResponseDto" + }, + "type": "array" + }, "thumbnailPath": { "description": "Thumbnail path", "type": "string" @@ -25076,6 +25473,9 @@ "id", "isHidden", "name", + "otherPeople", + "sharedBy", + "sharedWith", "thumbnailPath" ], "type": "object" @@ -25125,10 +25525,133 @@ "name": { "description": "Person name", "type": "string" + }, + "userId": { + "description": "User ID", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" } }, "type": "object" }, + "PersonUserRole": { + "description": "Levels of access for managing people resources on behalf of another user.", + "enum": [ + "read", + "write", + "admin" + ], + "type": "string" + }, + "PersonUsersCreateDto": { + "properties": { + "personIds": { + "description": "Person IDs", + "items": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + }, + "type": "array" + }, + "role": { + "$ref": "#/components/schemas/PersonUserRole", + "description": "Role that should be applied" + }, + "sharedWithIds": { + "description": "User IDs that should be given access to the person", + "items": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "personIds", + "role", + "sharedWithIds" + ], + "type": "object" + }, + "PersonUsersDeleteDto": { + "items": { + "properties": { + "personId": { + "description": "Person ID", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + }, + "sharedById": { + "description": "User ID of the user that gave access to the person", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + }, + "sharedWithId": { + "description": "User ID of the user that was given access to the person", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + } + }, + "required": [ + "personId", + "sharedWithId" + ], + "type": "object" + }, + "type": "array" + }, + "PersonUsersResponseDto": { + "items": { + "properties": { + "personId": { + "description": "Person ID", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/PersonUserRole", + "description": "Access role" + }, + "sharedBy": { + "$ref": "#/components/schemas/UserResponseDto", + "description": "The user that gave access to this person" + }, + "sharedById": { + "description": "User ID of the user that gave access to this person", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + }, + "sharedWith": { + "$ref": "#/components/schemas/UserResponseDto", + "description": "The user that was given access to this person" + }, + "sharedWithId": { + "description": "User ID of the user that was given access to this person", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + } + }, + "required": [ + "personId", + "sharedById", + "sharedWithId", + "sharedBy", + "sharedWith", + "role" + ], + "type": "object" + }, + "type": "array" + }, "PinCodeChangeDto": { "properties": { "newPinCode": { @@ -27972,6 +28495,14 @@ }, "type": "object" }, + "SharingDirection": { + "description": "Sharing direction", + "enum": [ + "shared-by", + "shared-with" + ], + "type": "string" + }, "SignUpDto": { "properties": { "email": { diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 0b5a8b38c..596c5a8fa 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -1216,6 +1216,27 @@ export type ExifResponseDto = { /** Time zone */ timeZone?: string | null; }; +export type PersonOtherResponseDto = { + birthDate: string | null; + name: string; + role: PersonUserRole; + sharedById: string; +}; +export type PeopleUserResponseDto = { + avatarColor: UserAvatarColor; + /** User email */ + email: string; + /** User ID */ + id: string; + /** User name */ + name: string; + /** Profile change date */ + profileChangedAt: string; + /** Profile image path */ + profileImagePath: string; + /** Access role */ + role: PersonUserRole; +}; export type PersonResponseDto = { /** Person date of birth */ birthDate: string | null; @@ -1229,6 +1250,11 @@ export type PersonResponseDto = { isHidden: boolean; /** Person name */ name: string; + otherPeople: PersonOtherResponseDto[]; + /** Users that gave the current user access to this person */ + sharedBy: PeopleUserResponseDto[]; + /** Users the current user gave access to this person */ + sharedWith: PeopleUserResponseDto[]; /** Thumbnail path */ thumbnailPath: string; /** Last update date */ @@ -1700,6 +1726,8 @@ export type AssetFaceCreateDto = { imageWidth: number; /** Person ID */ personId: string; + /** User ID */ + userId?: string; /** Face bounding box width */ width: number; /** Face bounding box X coordinate */ @@ -1964,6 +1992,11 @@ export type PartnerUpdateDto = { /** Show partner assets in timeline */ inTimeline: boolean; }; +export type PeopleDeleteDto = { + /** IDs to process */ + ids: string[]; + userId?: string; +}; export type PeopleResponseDto = { /** Whether there are more pages */ hasNextPage?: boolean; @@ -2000,6 +2033,8 @@ export type PeopleUpdateItem = { isHidden?: boolean; /** Person name */ name?: string; + /** User ID */ + userId?: string; }; export type PeopleUpdateDto = { /** People to update */ @@ -2009,6 +2044,39 @@ export type MergePersonDto = { /** Person IDs to merge */ ids: string[]; }; +export type PersonUsersDeleteDto = { + /** Person ID */ + personId: string; + /** User ID of the user that gave access to the person */ + sharedById?: string; + /** User ID of the user that was given access to the person */ + sharedWithId: string; +}[]; +export type PersonUsersResponseDto = { + /** Person ID */ + personId: string; + /** Access role */ + role: PersonUserRole; + /** The user that gave access to this person */ + sharedBy: UserResponseDto; + /** User ID of the user that gave access to this person */ + sharedById: string; + /** The user that was given access to this person */ + sharedWith: UserResponseDto; + /** User ID of the user that was given access to this person */ + sharedWithId: string; +}[]; +export type PersonUsersCreateDto = { + /** Person IDs */ + personIds: string[]; + /** Role that should be applied */ + role: PersonUserRole; + /** User IDs that should be given access to the person */ + sharedWithIds: string[]; +}; +export type PersonDeleteDto = { + userId?: string; +}; export type PersonUpdateDto = { /** Person date of birth */ birthDate?: string | null; @@ -2022,12 +2090,16 @@ export type PersonUpdateDto = { isHidden?: boolean; /** Person name */ name?: string; + /** User ID */ + userId?: string; }; export type AssetFaceUpdateItem = { /** Asset ID */ assetId: string; /** Person ID */ personId: string; + /** User ID */ + userId?: string; }; export type AssetFaceUpdateDto = { /** Face update items */ @@ -6010,22 +6082,26 @@ export function updatePartner({ id, partnerUpdateDto }: { /** * Delete people */ -export function deletePeople({ bulkIdsDto }: { - bulkIdsDto: BulkIdsDto; +export function deletePeople({ peopleDeleteDto }: { + peopleDeleteDto: PeopleDeleteDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText("/people", oazapfts.json({ ...opts, method: "DELETE", - body: bulkIdsDto + body: peopleDeleteDto }))); } /** * Get all people */ -export function getAllPeople({ closestAssetId, closestPersonId, page, size, withHidden }: { +export function getAllPeople({ closestAssetId, closestPersonId, isFavorite, isHidden, page, sharedById, sharedWithId, size, withHidden }: { closestAssetId?: string; closestPersonId?: string; + isFavorite?: boolean; + isHidden?: boolean; page?: number; + sharedById?: string; + sharedWithId?: string; size?: number; withHidden?: boolean; }, opts?: Oazapfts.RequestOpts) { @@ -6035,7 +6111,11 @@ export function getAllPeople({ closestAssetId, closestPersonId, page, size, with }>(`/people${QS.query(QS.explode({ closestAssetId, closestPersonId, + isFavorite, + isHidden, page, + sharedById, + sharedWithId, size, withHidden }))}`, { @@ -6087,16 +6167,65 @@ export function mergePeople({ mergePersonDto }: { body: mergePersonDto }))); } +/** + * Remove users from people + */ +export function removeUsersFromPeople({ personUsersDeleteDto }: { + personUsersDeleteDto: PersonUsersDeleteDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/people/users", oazapfts.json({ + ...opts, + method: "DELETE", + body: personUsersDeleteDto + }))); +} +/** + * Get people access + */ +export function getUsersForPeople({ direction, personId, role, sharedById, sharedWithId }: { + direction?: SharingDirection; + personId?: string; + role?: PersonUserRole; + sharedById?: string; + sharedWithId?: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: PersonUsersResponseDto; + }>(`/people/users${QS.query(QS.explode({ + direction, + personId, + role, + sharedById, + sharedWithId + }))}`, { + ...opts + })); +} +/** + * Give users access to people + */ +export function addUsersToPeople({ personUsersCreateDto }: { + personUsersCreateDto: PersonUsersCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/people/users", oazapfts.json({ + ...opts, + method: "PUT", + body: personUsersCreateDto + }))); +} /** * Delete person */ -export function deletePerson({ id }: { +export function deletePerson({ id, personDeleteDto }: { id: string; + personDeleteDto: PersonDeleteDto; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/people/${encodeURIComponent(id)}`, { + return oazapfts.ok(oazapfts.fetchText(`/people/${encodeURIComponent(id)}`, oazapfts.json({ ...opts, - method: "DELETE" - })); + method: "DELETE", + body: personDeleteDto + }))); } /** * Get a person @@ -8145,6 +8274,11 @@ export enum AssetJobName { RegenerateThumbnail = "regenerate-thumbnail", TranscodeVideo = "transcode-video" } +export enum PersonUserRole { + Read = "read", + Write = "write", + Admin = "admin" +} export enum AssetTypeEnum { Image = "IMAGE", Video = "VIDEO", @@ -8229,6 +8363,10 @@ export enum PartnerDirection { SharedBy = "shared-by", SharedWith = "shared-with" } +export enum SharingDirection { + SharedBy = "shared-by", + SharedWith = "shared-with" +} export enum WorkflowType { AssetV1 = "AssetV1" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55673bf3b..d662aa5fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1005,6 +1005,9 @@ importers: eslint-plugin-compat: specifier: ^7.0.0 version: 7.0.2(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1)) + eslint-plugin-prettier: + specifier: ^5.5.6 + version: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1)))(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))(prettier@3.9.8) eslint-plugin-svelte: specifier: ^3.12.4 version: 3.23.0(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))(svelte@5.57.0(@typescript-eslint/types@8.70.0)) diff --git a/server/src/controllers/person.controller.spec.ts b/server/src/controllers/person.controller.spec.ts index 34cc811fb..f8058ddd4 100644 --- a/server/src/controllers/person.controller.spec.ts +++ b/server/src/controllers/person.controller.spec.ts @@ -149,13 +149,13 @@ describe(PersonController.name, () => { describe('DELETE /people/:id', () => { it('should require a valid uuid', async () => { - const { status, body } = await request(ctx.getHttpServer()).delete(`/people/invalid`); + const { status, body } = await request(ctx.getHttpServer()).delete(`/people/invalid`).send({}); expect(status).toBe(400); expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); it('should respond with 204', async () => { - const { status } = await request(ctx.getHttpServer()).delete(`/people/${factory.uuid()}`); + const { status } = await request(ctx.getHttpServer()).delete(`/people/${factory.uuid()}`).send({}); expect(status).toBe(204); expect(service.delete).toHaveBeenCalled(); }); diff --git a/server/src/controllers/person.controller.ts b/server/src/controllers/person.controller.ts index a2d8e9801..bd3d7ae1f 100644 --- a/server/src/controllers/person.controller.ts +++ b/server/src/controllers/person.controller.ts @@ -17,17 +17,23 @@ import { ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger'; import type { NextFunction, Response } from 'express'; import type { AuthDto } from 'src/dtos/auth.dto.js'; import { Endpoint, HistoryBuilder } from 'src/decorators.js'; -import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto.js'; +import { BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto.js'; import { AssetFaceUpdateDto, MergePersonDto, + PeopleDeleteDto, PeopleResponseDto, PeopleUpdateDto, PersonCreateDto, + PersonDeleteDto, PersonResponseDto, PersonSearchDto, PersonStatisticsResponseDto, PersonUpdateDto, + PersonUsersCreateDto, + PersonUsersDeleteDto, + PersonUsersResponseDto, + PersonUsersSearchDto, } from 'src/dtos/person.dto.js'; import { ApiTag, Permission } from 'src/enum.js'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard.js'; @@ -87,7 +93,7 @@ export class PersonController { description: 'Bulk delete a list of people at once.', history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), }) - deletePeople(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise { + deletePeople(@Auth() auth: AuthDto, @Body() dto: PeopleDeleteDto): Promise { return this.service.deleteAll(auth, dto); } @@ -140,8 +146,8 @@ export class PersonController { description: 'Delete an individual person.', history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), }) - deletePerson(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { - return this.service.delete(auth, id); + deletePerson(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: PersonDeleteDto): Promise { + return this.service.delete(auth, id, dto); } @Get(':id/statistics') @@ -219,4 +225,37 @@ export class PersonController { ): Promise { return this.service.mergePeople(auth, { ids: [id, ...dto.ids] }); } + + @Get('users') + @Authenticated({ permission: Permission.PersonRead }) + @Endpoint({ + summary: 'Get people access', + description: 'Retrieve a list of users and the people to which they have been given access', + history: new HistoryBuilder().added('v3.3').stable('v3.3'), + }) + getUsersForPeople(@Auth() auth: AuthDto, @Query() dto: PersonUsersSearchDto): Promise { + return this.service.getUsersForPeople(auth, dto); + } + + @Put('users') + @Authenticated({ permission: Permission.PersonUpdate }) + @Endpoint({ + summary: 'Give users access to people', + description: 'Give users access to people', + history: new HistoryBuilder().added('v3.3').stable('v3.3'), + }) + addUsersToPeople(@Auth() auth: AuthDto, @Body() dto: PersonUsersCreateDto): Promise { + return this.service.addUsersToPeople(auth, dto); + } + + @Delete('users') + @Authenticated({ permission: Permission.PersonDelete }) + @Endpoint({ + summary: 'Remove users from people', + description: 'Remove user access to people', + history: new HistoryBuilder().added('v3.3').stable('v3.3'), + }) + removeUsersFromPeople(@Auth() auth: AuthDto, @Body() dto: PersonUsersDeleteDto): Promise { + return this.service.removeUsersFromPeople(auth, dto); + } } diff --git a/server/src/database.ts b/server/src/database.ts index 783db2d68..31ac40e59 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -1,6 +1,7 @@ import { Selectable, ShallowDehydrateObject } from 'kysely'; import type { UserMetadataItem } from 'src/types.js'; import { MapAsset } from 'src/dtos/asset-response.dto.js'; +import { PersonUserRole } from 'src/dtos/person.dto.js'; import { AlbumUserRole, AssetFileType, @@ -253,8 +254,13 @@ export type Person = { faceAssetId: string | null; isHidden: boolean; thumbnailPath: string; + otherPeople: { sharedById: string; name: string; birthDate: string | null; role: PersonUserRole }[]; + sharedBy: PersonUser[]; + sharedWith: PersonUser[]; }; +export type PersonUser = User & { role: PersonUserRole }; + export type AssetFace = { id: string; deletedAt: Date | null; @@ -276,7 +282,7 @@ export type AssetFace = { export type Plugin = Selectable; const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const; -const userWithPrefixColumns = [ +const user2Columns = [ 'user2.id', 'user2.name', 'user2.email', @@ -374,7 +380,15 @@ export const columns = { authApiKey: ['api_key.id', 'api_key.permissions'], authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'], user: userColumns, - userWithPrefix: userWithPrefixColumns, + user2: user2Columns, + userPrefix: [ + 'user.id', + 'user.name', + 'user.email', + 'user.avatarColor', + 'user.profileImagePath', + 'user.profileChangedAt', + ], userAdmin: [ ...userColumns, 'clusterGroupId', diff --git a/server/src/dtos/partner.dto.ts b/server/src/dtos/partner.dto.ts index 1647d140c..f77c9f1a3 100644 --- a/server/src/dtos/partner.dto.ts +++ b/server/src/dtos/partner.dto.ts @@ -1,9 +1,7 @@ import { createZodDto } from 'nestjs-zod'; import z from 'zod'; import { UserResponseSchema } from 'src/dtos/user.dto.js'; -import { PartnerDirection } from 'src/repositories/partner.repository.js'; - -const PartnerDirectionSchema = z.enum(PartnerDirection).describe('Partner direction').meta({ id: 'PartnerDirection' }); +import { PartnerDirectionSchema } from 'src/enum.js'; const PartnerCreateSchema = z .object({ diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index 5a175f22d..5bc4d406f 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -2,11 +2,13 @@ import { Selectable } from 'kysely'; import { createZodDto } from 'nestjs-zod'; import z from 'zod'; import type { ImageDimensions, MaybeDehydrated } from 'src/types.js'; -import { AssetFace, Person } from 'src/database.js'; +import { AssetFace, Person, PersonUser, User } from 'src/database.js'; import { HistoryBuilder } from 'src/decorators.js'; +import { BulkIdsSchema } from 'src/dtos/asset-ids.response.dto.js'; import { AuthDto } from 'src/dtos/auth.dto.js'; import { AssetEditActionItem } from 'src/dtos/editing.dto.js'; -import { SourceTypeSchema } from 'src/enum.js'; +import { UserResponseSchema, mapUser } from 'src/dtos/user.dto.js'; +import { SharingDirectionSchema, SourceTypeSchema } from 'src/enum.js'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table.js'; import { asDateString, asDateTimeString } from 'src/utils/date.js'; import { transformFaceBoundingBox } from 'src/utils/transform.js'; @@ -30,6 +32,7 @@ const PersonCreateSchema = z const PersonUpdateSchema = PersonCreateSchema.extend({ featureFaceAssetId: z.uuidv4().optional().describe('Asset ID used for feature face thumbnail'), + userId: z.uuid().optional().describe('User ID'), }).meta({ id: 'PersonUpdateDto' }); const PeopleUpdateItemSchema = PersonUpdateSchema.extend({ @@ -55,9 +58,37 @@ const PersonSearchSchema = z closestAssetId: z.uuidv4().optional().describe('Closest asset ID for similarity search'), page: z.coerce.number().int().min(1).default(1).describe('Page number for pagination'), size: z.coerce.number().int().min(1).max(1000).default(500).describe('Number of items per page'), + sharedById: z.uuid().optional().describe('Only include people to which the user gave access'), + sharedWithId: z.uuid().optional().describe('Only include people to which the user was given access'), + isFavorite: stringToBool.optional().describe('Filter by favorite status'), + isHidden: stringToBool.optional().describe('Filter by hidden status'), }) .meta({ id: 'PersonSearchDto' }); +export enum PersonUserRole { + Read = 'read', + Write = 'write', + Admin = 'admin', +} + +const PersonUserRoleSchema = z + .enum(PersonUserRole) + .describe('Levels of access for managing people resources on behalf of another user.') + .meta({ id: 'PersonUserRole' }); + +const PersonOtherResponseSchema = z + .object({ + sharedById: z.uuid(), + name: z.string(), + birthDate: z.string().nullable(), + role: PersonUserRoleSchema, + }) + .meta({ id: 'PersonOtherResponseDto' }); + +const PeopleUserResponseSchema = UserResponseSchema.extend({ + role: PersonUserRoleSchema.describe('Access role'), +}).meta({ id: 'PeopleUserResponseDto' }); + export const PersonResponseSchema = z .object({ id: z.uuidv4().describe('Person ID'), @@ -83,17 +114,33 @@ export const PersonResponseSchema = z .optional() .describe('Person color (hex)') .meta(new HistoryBuilder().added('v1.126.0').stable('v2').getExtensions()), + otherPeople: z.array(PersonOtherResponseSchema), + sharedBy: z.array(PeopleUserResponseSchema).describe('Users that gave the current user access to this person'), + sharedWith: z.array(PeopleUserResponseSchema).describe('Users the current user gave access to this person'), }) .meta({ id: 'PersonResponseDto' }); +const PersonDeleteSchema = z + .object({ userId: z.string().optional() }) + .default({}) + .meta({ id: 'PersonDeleteDto', ...new HistoryBuilder().added('v3.3').stable('v3.3').getExtensions() }); +// TODO(v4) change to {userId: string, personId: string}[] +const PeopleDeleteSchema = BulkIdsSchema.extend({ userId: z.string().optional() }).meta({ + id: 'PeopleDeleteDto', + ...new HistoryBuilder().added('v3.3').getExtensions(), +}); + export class PersonCreateDto extends createZodDto(PersonCreateSchema) {} export class PersonUpdateDto extends createZodDto(PersonUpdateSchema) {} +export class PersonDeleteDto extends createZodDto(PersonDeleteSchema) {} +export class PeopleDeleteDto extends createZodDto(PeopleDeleteSchema) {} export class PeopleUpdateDto extends createZodDto(PeopleUpdateSchema) {} export class MergePersonDto extends createZodDto(MergePersonSchema) {} export class PersonSearchDto extends createZodDto(PersonSearchSchema) {} export class PersonResponseDto extends createZodDto(PersonResponseSchema) {} +export class PeopleUserResponseDto extends createZodDto(PeopleUserResponseSchema) {} -export const AssetFaceResponseSchema = z +const AssetFaceResponseSchema = z .object({ id: z.uuidv4().describe('Face ID'), imageHeight: z.int().min(0).describe('Image height in pixels'), @@ -114,6 +161,7 @@ const AssetFaceUpdateItemSchema = z .object({ personId: z.uuidv4().describe('Person ID'), assetId: z.uuidv4().describe('Asset ID'), + userId: z.uuidv4().optional().describe('User ID'), }) .meta({ id: 'AssetFaceUpdateItem' }); @@ -150,11 +198,56 @@ const PersonStatisticsResponseSchema = z }) .meta({ id: 'PersonStatisticsResponseDto' }); +const PersonUsersResponseSchema = z + .array( + z.object({ + personId: z.uuid().describe('Person ID'), + sharedById: z.uuid().describe('User ID of the user that gave access to this person'), + sharedWithId: z.uuid().describe('User ID of the user that was given access to this person'), + sharedBy: UserResponseSchema.describe('The user that gave access to this person'), + sharedWith: UserResponseSchema.describe('The user that was given access to this person'), + role: PersonUserRoleSchema.describe('Access role'), + }), + ) + .meta({ id: 'PersonUsersResponseDto' }); + +const PersonUsersSearchSchema = z + .object({ + personId: z.uuid().optional().describe('Person ID'), + direction: SharingDirectionSchema.optional(), + sharedById: z.uuid().optional().describe('User ID of the user that gave access'), + sharedWithId: z.uuid().optional().describe('User ID of the user that was given access'), + role: PersonUserRoleSchema.optional().describe('Role of user'), + }) + .meta({ id: 'PersonUsersSearchDto' }); + +const PersonUsersCreateSchema = z + .object({ + personIds: z.array(z.uuid()).describe('Person IDs'), + sharedWithIds: z.array(z.uuid()).describe('User IDs that should be given access to the person'), + role: PersonUserRoleSchema.describe('Role that should be applied'), + }) + .meta({ id: 'PersonUsersCreateDto' }); + +const PersonUsersDeleteSchema = z + .array( + z.object({ + personId: z.uuid().describe('Person ID'), + sharedWithId: z.uuid().describe('User ID of the user that was given access to the person'), + sharedById: z.uuid().optional().describe('User ID of the user that gave access to the person'), + }), + ) + .meta({ id: 'PersonUsersDeleteDto' }); + export class AssetFaceUpdateDto extends createZodDto(AssetFaceUpdateSchema) {} export class FaceDto extends createZodDto(FaceSchema) {} export class AssetFaceCreateDto extends createZodDto(AssetFaceCreateSchema) {} export class AssetFaceDeleteDto extends createZodDto(AssetFaceDeleteSchema) {} export class PersonStatisticsResponseDto extends createZodDto(PersonStatisticsResponseSchema) {} +export class PersonUsersResponseDto extends createZodDto(PersonUsersResponseSchema) {} +export class PersonUsersSearchDto extends createZodDto(PersonUsersSearchSchema) {} +export class PersonUsersCreateDto extends createZodDto(PersonUsersCreateSchema) {} +export class PersonUsersDeleteDto extends createZodDto(PersonUsersDeleteSchema) {} const PeopleResponseSchema = z .object({ @@ -171,7 +264,11 @@ const PeopleResponseSchema = z .describe('People response'); export class PeopleResponseDto extends createZodDto(PeopleResponseSchema) {} -export function mapPerson(person: MaybeDehydrated): PersonResponseDto { +type OptionalKeys = 'otherPeople' | 'sharedBy' | 'sharedWith'; + +export function mapPerson( + person: MaybeDehydrated & Partial>>, +): PersonResponseDto { return { id: person.personGroupId, name: person.name, @@ -181,9 +278,37 @@ export function mapPerson(person: MaybeDehydrated): PersonResponseDto { isFavorite: person.isFavorite, color: person.color ?? undefined, updatedAt: asDateTimeString(person.updatedAt), + // TODO: use different response dtos for asset faces, which do not load the sharing properties + otherPeople: person.otherPeople ?? [], + sharedBy: (person.sharedBy ?? []).map((user) => mapPeopleUser(user)), + sharedWith: (person.sharedWith ?? []).map((user) => mapPeopleUser(user)), }; } +const mapPeopleUser = (user: MaybeDehydrated): PeopleUserResponseDto => ({ + ...mapUser(user), + role: user.role, +}); + +type PersonUserShare = { + personId: string; + sharedById: string; + sharedWithId: string; + role: PersonUserRole; + sharedBy: MaybeDehydrated; + sharedWith: MaybeDehydrated; +}; + +export const mapPersonUsers = (shares: PersonUserShare[]): PersonUsersResponseDto => + shares.map((share) => ({ + personId: share.personId, + sharedById: share.sharedById, + sharedWithId: share.sharedWithId, + role: share.role, + sharedBy: mapUser(share.sharedBy), + sharedWith: mapUser(share.sharedWith), + })); + function mapFacesWithoutPerson( face: MaybeDehydrated>, edits?: AssetEditActionItem[], diff --git a/server/src/enum.ts b/server/src/enum.ts index 622014cd1..6dd5ee36c 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -96,6 +96,22 @@ export enum MemoryType { export const MemoryTypeSchema = z.enum(MemoryType).describe('Memory type').meta({ id: 'MemoryType' }); +export enum SharingDirection { + SharedBy = 'shared-by', + SharedWith = 'shared-with', +} + +export const SharingDirectionSchema = z + .enum(SharingDirection) + .describe('Sharing direction') + .meta({ id: 'SharingDirection' }); + +// TODO(v4) replace with SharingDirection +export const PartnerDirectionSchema = z + .enum(SharingDirection) + .describe('Partner direction') + .meta({ id: 'PartnerDirection' }); + export enum AssetOrderWithRandom { // Include existing values Asc = AssetOrder.Asc, diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index d21941f12..f66abe3b9 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -241,14 +241,40 @@ where "user"."id" = $2 ) --- AccessRepository.person.checkOwnerAccess +-- AccessRepository.person.checkAccess select - "person"."personGroupId" + "personGroupId", + "ownerId" from - "person" + ( + select + unnest($1::uuid[]) as "personGroupId", + unnest($2::uuid[]) as "ownerId" + ) as "people" where - "person"."personGroupId" in ($1) - and "person"."ownerId" = $2 + ( + exists ( + select + * + from + "person" + where + "people"."personGroupId" = "person"."personGroupId" + and "people"."ownerId" = "person"."ownerId" + and "person"."ownerId" = $3 + ) + or exists ( + select + * + from + "person_user" + where + "person_user"."sharedWithId" = $4 + and "person_user"."role" in ($5) + and "people"."personGroupId" = "person_user"."personGroupId" + and "people"."ownerId" = "person_user"."sharedById" + ) + ) -- AccessRepository.person.checkFaceOwnerAccess select diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 8a85c742e..08e76d68c 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -10,6 +10,19 @@ where "asset_face"."assetId" = "asset"."id" and "asset_face"."personGroupId" = $2 +-- PersonRepository.updateGroupId +begin +update "asset_face" +set + "personGroupId" = $1 +from + "asset" +where + "asset_face"."assetId" = "asset"."id" + and "asset_face"."personGroupId" = $2 + and "asset"."ownerId" = $3 +rollback + -- PersonRepository.unassignFaces update "asset_face" set @@ -135,49 +148,148 @@ limit -- PersonRepository.getAllForUser select - "person".* + ( + select + to_json(obj) + from + ( + select + "person".* + from + "person" + where + "person"."personGroupId" = "person_group"."id" + and "person"."ownerId" = $1 + ) as obj + ) as "ownedPerson", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "person_user"."sharedById", + "person_user"."role", + "other"."name", + "other"."birthDate" + from + "person" as "other" + inner join "person_user" on "person_user"."personGroupId" = "other"."personGroupId" + and "person_user"."sharedById" = "other"."ownerId" + and "person_user"."sharedWithId" = $2 + where + "other"."personGroupId" = "person_group"."id" + and ( + "other"."birthDate" is not null + or "other"."name" != $3 + ) + ) as agg + ) as "otherPeople", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "id", + "name", + "email", + "avatarColor", + "profileImagePath", + "profileChangedAt", + "person_user"."role" + from + "person_user" + inner join "user" on "user"."id" = "person_user"."sharedById" + and "user"."deletedAt" is null + where + "person_user"."personGroupId" = "person_group"."id" + and "person_user"."sharedWithId" = $4 + order by + "user"."name" + ) as agg + ) as "sharedBy", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "id", + "name", + "email", + "avatarColor", + "profileImagePath", + "profileChangedAt", + "person_user"."role" + from + "person_user" + inner join "user" on "user"."id" = "person_user"."sharedWithId" + and "user"."deletedAt" is null + where + "person_user"."personGroupId" = "person_group"."id" + and "person_user"."sharedById" = $5 + order by + "user"."name" + ) as agg + ) as "sharedWith" from - "person" - inner join "asset_face" on "asset_face"."personGroupId" = "person"."personGroupId" - inner join "asset" on "asset_face"."assetId" = "asset"."id" - and "asset"."ownerId" = "person"."ownerId" + "person_group" + inner join "person" as "owned" on "owned"."personGroupId" = "person_group"."id" + and "owned"."ownerId" = $6 + left join "asset_face" on "asset_face"."personGroupId" = "person_group"."id" + and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" is true + left join "asset" on "asset"."id" = "asset_face"."assetId" + and "asset"."ownerId" = $7 and "asset"."visibility" = 'timeline' and "asset"."deletedAt" is null where - "person"."ownerId" = $1 - and "asset_face"."deletedAt" is null - and "asset_face"."isVisible" is true - and "person"."isHidden" = $2 + "owned"."isHidden" = $8 + and 1 = 1 group by - "person"."ownerId", - "person"."personGroupId" + "person_group"."id", + "owned"."ownerId", + "owned"."personGroupId" having ( - "person"."name" != $3 - or count("asset_face"."assetId") >= COALESCE( + exists ( + select + "person_user"."sharedWithId" + from + "person_user" + where + "person_user"."personGroupId" = "person_group"."id" + and "person_user"."sharedWithId" = $9 + ) + or ( + count("asset"."id") > $10 + and "owned"."name" != $11 + ) + or count("asset"."id") >= COALESCE( ( SELECT value -> 'people' ->> 'minimumFaces' FROM user_metadata WHERE - "userId" = $4 + "userId" = $12 AND key = 'preferences' ), '3' )::int ) order by - "person"."isHidden" asc, - "person"."isFavorite" desc, - NULLIF(person.name, '') is null asc, - count("asset_face"."assetId") desc, - NULLIF(person.name, '') asc nulls last, - "person"."createdAt" + "owned"."isHidden" asc, + "owned"."isFavorite" desc, + NULLIF("owned"."name", '') is null asc, + count("asset"."id") desc, + NULLIF("owned"."name", '') asc nulls last, + "owned"."createdAt" limit - $5 + $13 offset - $6 + $14 -- PersonRepository.getAllWithoutFaces select @@ -206,20 +318,91 @@ select from ( select - "person".* + "person".*, + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "person_user"."sharedById", + "person_user"."role", + "other"."name", + "other"."birthDate" + from + "person" as "other" + inner join "person_user" on "person_user"."personGroupId" = "other"."personGroupId" + and "person_user"."sharedById" = "other"."ownerId" + and "person_user"."sharedWithId" = $1 + where + "other"."personGroupId" = "person"."personGroupId" + and ( + "other"."birthDate" is not null + or "other"."name" != $2 + ) + ) as agg + ) as "otherPeople", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "id", + "name", + "email", + "avatarColor", + "profileImagePath", + "profileChangedAt", + "person_user"."role" + from + "person_user" + inner join "user" on "user"."id" = "person_user"."sharedById" + and "user"."deletedAt" is null + where + "person_user"."personGroupId" = "person"."personGroupId" + and "person_user"."sharedWithId" = $3 + order by + "user"."name" + ) as agg + ) as "sharedBy", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "id", + "name", + "email", + "avatarColor", + "profileImagePath", + "profileChangedAt", + "person_user"."role" + from + "person_user" + inner join "user" on "user"."id" = "person_user"."sharedWithId" + and "user"."deletedAt" is null + where + "person_user"."personGroupId" = "person"."personGroupId" + and "person_user"."sharedById" = $4 + order by + "user"."name" + ) as agg + ) as "sharedWith" from "person" where "person"."personGroupId" = "asset_face"."personGroupId" - and "person"."ownerId" = $1 + and "person"."ownerId" = $5 ) as obj ) as "person" from "asset_face" where - "asset_face"."assetId" = $2 + "asset_face"."assetId" = $6 and "asset_face"."deletedAt" is null - and "asset_face"."isVisible" = $3 + and "asset_face"."isVisible" = $7 order by "asset_face"."boundingBoxX1" asc @@ -232,18 +415,89 @@ select from ( select - "person".* + "person".*, + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "person_user"."sharedById", + "person_user"."role", + "other"."name", + "other"."birthDate" + from + "person" as "other" + inner join "person_user" on "person_user"."personGroupId" = "other"."personGroupId" + and "person_user"."sharedById" = "other"."ownerId" + and "person_user"."sharedWithId" = $1 + where + "other"."personGroupId" = "person"."personGroupId" + and ( + "other"."birthDate" is not null + or "other"."name" != $2 + ) + ) as agg + ) as "otherPeople", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "id", + "name", + "email", + "avatarColor", + "profileImagePath", + "profileChangedAt", + "person_user"."role" + from + "person_user" + inner join "user" on "user"."id" = "person_user"."sharedById" + and "user"."deletedAt" is null + where + "person_user"."personGroupId" = "person"."personGroupId" + and "person_user"."sharedWithId" = $3 + order by + "user"."name" + ) as agg + ) as "sharedBy", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "id", + "name", + "email", + "avatarColor", + "profileImagePath", + "profileChangedAt", + "person_user"."role" + from + "person_user" + inner join "user" on "user"."id" = "person_user"."sharedWithId" + and "user"."deletedAt" is null + where + "person_user"."personGroupId" = "person"."personGroupId" + and "person_user"."sharedById" = $4 + order by + "user"."name" + ) as agg + ) as "sharedWith" from "person" where "person"."personGroupId" = "asset_face"."personGroupId" - and "person"."ownerId" = $1 + and "person"."ownerId" = $5 ) as obj ) as "person" from "asset_face" where - "asset_face"."id" = $2 + "asset_face"."id" = $6 and "asset_face"."deletedAt" is null -- PersonRepository.getFaceForFacialRecognitionJob @@ -326,14 +580,216 @@ set where "asset_face"."id" = $2 +-- PersonRepository.getForUser +select + ( + select + to_json(obj) + from + ( + select + "person".* + from + "person" + where + "person"."personGroupId" = "person_group"."id" + and "person"."ownerId" = $1 + ) as obj + ) as "ownedPerson", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "person_user"."sharedById", + "person_user"."role", + "other"."name", + "other"."birthDate" + from + "person" as "other" + inner join "person_user" on "person_user"."personGroupId" = "other"."personGroupId" + and "person_user"."sharedById" = "other"."ownerId" + and "person_user"."sharedWithId" = $2 + where + "other"."personGroupId" = "person_group"."id" + and ( + "other"."birthDate" is not null + or "other"."name" != $3 + ) + ) as agg + ) as "otherPeople", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "id", + "name", + "email", + "avatarColor", + "profileImagePath", + "profileChangedAt", + "person_user"."role" + from + "person_user" + inner join "user" on "user"."id" = "person_user"."sharedById" + and "user"."deletedAt" is null + where + "person_user"."personGroupId" = "person_group"."id" + and "person_user"."sharedWithId" = $4 + order by + "user"."name" + ) as agg + ) as "sharedBy", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "id", + "name", + "email", + "avatarColor", + "profileImagePath", + "profileChangedAt", + "person_user"."role" + from + "person_user" + inner join "user" on "user"."id" = "person_user"."sharedWithId" + and "user"."deletedAt" is null + where + "person_user"."personGroupId" = "person_group"."id" + and "person_user"."sharedById" = $5 + order by + "user"."name" + ) as agg + ) as "sharedWith" +from + "person_group" +where + "person_group"."id" = $6 + and exists ( + select + "person"."ownerId" + from + "person" + where + "person"."personGroupId" = "person_group"."id" + and "person"."ownerId" = $7 + ) + -- PersonRepository.getByGroupId select - "person".* + "person".*, + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "person_user"."sharedById", + "person_user"."role", + "other"."name", + "other"."birthDate" + from + "person" as "other" + inner join "person_user" on "person_user"."personGroupId" = "other"."personGroupId" + and "person_user"."sharedById" = "other"."ownerId" + and "person_user"."sharedWithId" = $1 + where + "other"."personGroupId" = "person"."personGroupId" + and ( + "other"."birthDate" is not null + or "other"."name" != $2 + ) + ) as agg + ) as "otherPeople", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "id", + "name", + "email", + "avatarColor", + "profileImagePath", + "profileChangedAt", + "person_user"."role" + from + "person_user" + inner join "user" on "user"."id" = "person_user"."sharedById" + and "user"."deletedAt" is null + where + "person_user"."personGroupId" = "person"."personGroupId" + and "person_user"."sharedWithId" = $3 + order by + "user"."name" + ) as agg + ) as "sharedBy", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "id", + "name", + "email", + "avatarColor", + "profileImagePath", + "profileChangedAt", + "person_user"."role" + from + "person_user" + inner join "user" on "user"."id" = "person_user"."sharedWithId" + and "user"."deletedAt" is null + where + "person_user"."personGroupId" = "person"."personGroupId" + and "person_user"."sharedById" = $4 + order by + "user"."name" + ) as agg + ) as "sharedWith" from "person" where - "person"."personGroupId" = $1 - and "person"."ownerId" = $2 + "person"."personGroupId" = $5 + and "person"."ownerId" = $6 + +-- PersonRepository.getForThumbnail +select + ( + select + "person"."thumbnailPath" + from + "person" + where + "person"."personGroupId" = "person_group"."id" + and "person"."ownerId" = $1 + ) as "thumbnailPath", + ( + select + "person"."thumbnailPath" + from + "person" + inner join "person_user" on "person_user"."personGroupId" = "person"."personGroupId" + and "person_user"."sharedById" = "person"."ownerId" + and "person_user"."sharedWithId" = $2 + where + "person"."personGroupId" = "person_group"."id" + and "person"."thumbnailPath" != $3 + limit + $4 + ) as "sharedThumbnailPath" +from + "person_group" +where + "person_group"."id" = $5 -- PersonRepository.getByName with @@ -342,17 +798,88 @@ with set_config('pg_trgm.word_similarity_threshold', '0.5', true) as "thresh" ) select - "person".* + "person".*, + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "person_user"."sharedById", + "person_user"."role", + "other"."name", + "other"."birthDate" + from + "person" as "other" + inner join "person_user" on "person_user"."personGroupId" = "other"."personGroupId" + and "person_user"."sharedById" = "other"."ownerId" + and "person_user"."sharedWithId" = $1 + where + "other"."personGroupId" = "person"."personGroupId" + and ( + "other"."birthDate" is not null + or "other"."name" != $2 + ) + ) as agg + ) as "otherPeople", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "id", + "name", + "email", + "avatarColor", + "profileImagePath", + "profileChangedAt", + "person_user"."role" + from + "person_user" + inner join "user" on "user"."id" = "person_user"."sharedById" + and "user"."deletedAt" is null + where + "person_user"."personGroupId" = "person"."personGroupId" + and "person_user"."sharedWithId" = $3 + order by + "user"."name" + ) as agg + ) as "sharedBy", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "id", + "name", + "email", + "avatarColor", + "profileImagePath", + "profileChangedAt", + "person_user"."role" + from + "person_user" + inner join "user" on "user"."id" = "person_user"."sharedWithId" + and "user"."deletedAt" is null + where + "person_user"."personGroupId" = "person"."personGroupId" + and "person_user"."sharedById" = $4 + order by + "user"."name" + ) as agg + ) as "sharedWith" from "similarity_threshold", "person" where - "person"."ownerId" = $1 - and f_unaccent ("person"."name") %> f_unaccent ($2) + "person"."ownerId" = $5 + and f_unaccent ("person"."name") %> f_unaccent ($6) order by - f_unaccent ("person"."name") <->>> f_unaccent ($3) + f_unaccent ("person"."name") <->>> f_unaccent ($7) limit - $4 + $8 -- PersonRepository.getDistinctNames select distinct @@ -366,66 +893,55 @@ where and "person"."name" != $2 ) --- PersonRepository.getStatistics -select - count(distinct ("asset"."id")) as "count" -from - "asset_face" - left join "asset" on "asset"."id" = "asset_face"."assetId" - and "asset"."visibility" = 'timeline' - and "asset"."deletedAt" is null - and ( - "asset"."ownerId" = $1::uuid - or exists ( - select - 1 as "exists" - from - "album_asset" - inner join "album" on "album"."id" = "album_asset"."albumId" - and "album"."deletedAt" is null - inner join "album_user" on "album_user"."albumId" = "album"."id" - and "album_user"."userId" = $2::uuid - where - "album_asset"."assetId" = "asset"."id" - ) - ) -where - "asset_face"."deletedAt" is null - and "asset_face"."isVisible" is true - and "asset_face"."personGroupId" = $3 - -- PersonRepository.getNumberOfPeople select coalesce(count(*), 0) as "total", coalesce( count(*) filter ( where - "isHidden" = $1 + "owned"."isHidden" = $1 ), 0 ) as "hidden" from - "person" + "person_group" + left join "person" as "owned" on "owned"."personGroupId" = "person_group"."id" + and "owned"."ownerId" = $2 where - exists ( - select - from - "asset_face" - where - "asset_face"."personGroupId" = "person"."personGroupId" - and "asset_face"."deletedAt" is null - and "asset_face"."isVisible" = $2 + ( + ( + "owned"."ownerId" is not null and exists ( select from - "asset" + "asset_face" where - "asset"."id" = "asset_face"."assetId" - and "asset"."visibility" = 'timeline' - and "asset"."deletedAt" is null + "asset_face"."personGroupId" = "person_group"."id" + and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" = $3 + and exists ( + select + from + "asset" + where + "asset"."id" = "asset_face"."assetId" + and "asset"."ownerId" = $4 + and "asset"."visibility" = 'timeline' + and "asset"."deletedAt" is null + ) ) + ) + or exists ( + select + "person_user"."sharedById" + from + "person_user" + where + "person_user"."personGroupId" = "person_group"."id" + and "person_user"."sharedWithId" = $5 + ) ) - and "person"."ownerId" = $3 + and 1 = 1 -- PersonRepository.createGroup insert into @@ -609,19 +1125,90 @@ select from ( select - "person".* + "person".*, + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "person_user"."sharedById", + "person_user"."role", + "other"."name", + "other"."birthDate" + from + "person" as "other" + inner join "person_user" on "person_user"."personGroupId" = "other"."personGroupId" + and "person_user"."sharedById" = "other"."ownerId" + and "person_user"."sharedWithId" = $1 + where + "other"."personGroupId" = "person"."personGroupId" + and ( + "other"."birthDate" is not null + or "other"."name" != $2 + ) + ) as agg + ) as "otherPeople", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "id", + "name", + "email", + "avatarColor", + "profileImagePath", + "profileChangedAt", + "person_user"."role" + from + "person_user" + inner join "user" on "user"."id" = "person_user"."sharedById" + and "user"."deletedAt" is null + where + "person_user"."personGroupId" = "person"."personGroupId" + and "person_user"."sharedWithId" = $3 + order by + "user"."name" + ) as agg + ) as "sharedBy", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "id", + "name", + "email", + "avatarColor", + "profileImagePath", + "profileChangedAt", + "person_user"."role" + from + "person_user" + inner join "user" on "user"."id" = "person_user"."sharedWithId" + and "user"."deletedAt" is null + where + "person_user"."personGroupId" = "person"."personGroupId" + and "person_user"."sharedById" = $4 + order by + "user"."name" + ) as agg + ) as "sharedWith" from "person" where "person"."personGroupId" = "asset_face"."personGroupId" - and "person"."ownerId" = $1 + and "person"."ownerId" = $5 ) as obj ) as "person" from "asset_face" where - "asset_face"."assetId" in ($2) - and "asset_face"."personGroupId" in ($3) + "asset_face"."assetId" in ($6) + and "asset_face"."personGroupId" in ($7) and "asset_face"."deletedAt" is null -- PersonRepository.getRandomFace @@ -671,5 +1258,3 @@ from "person" where "person"."personGroupId" in ($1) -order by - "person"."ownerId" diff --git a/server/src/queries/person.user.repository.sql b/server/src/queries/person.user.repository.sql new file mode 100644 index 000000000..3c0162889 --- /dev/null +++ b/server/src/queries/person.user.repository.sql @@ -0,0 +1,85 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- PersonUserRepository.searchPeopleUsers +select + "person_user"."personGroupId" as "personId", + "person_user"."sharedById", + "person_user"."sharedWithId", + "person_user"."role", + ( + select + to_json(obj) + from + ( + select + "user"."id", + "user"."name", + "user"."email", + "user"."avatarColor", + "user"."profileImagePath", + "user"."profileChangedAt" + from + "user" + where + "user"."id" = "person_user"."sharedById" + ) as obj + ) as "sharedBy", + ( + select + to_json(obj) + from + ( + select + "user"."id", + "user"."name", + "user"."email", + "user"."avatarColor", + "user"."profileImagePath", + "user"."profileChangedAt" + from + "user" + where + "user"."id" = "person_user"."sharedWithId" + ) as obj + ) as "sharedWith" +from + "person_user" +where + ( + "person_user"."sharedById" = $1 + or "person_user"."sharedWithId" = $2 + ) + +-- PersonUserRepository.createAll +insert into + "person_user" ( + "personGroupId", + "sharedById", + "sharedWithId", + "role" + ) +values + ($1, $2, $3, $4) +on conflict ("personGroupId", "sharedById", "sharedWithId") do update +set + "role" = "excluded"."role" +returning + * + +-- PersonUserRepository.deleteAll +delete from "person_user" +where + exists ( + select + * + from + ( + select + unnest($1::uuid[]) as "personGroupId", + unnest($2::uuid[]) as "sharedWithId" + ) as "people" + where + "people"."personGroupId" = "person_user"."personGroupId" + and "people"."sharedWithId" = "person_user"."sharedWithId" + ) + and "person_user"."sharedById" = $3 diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 3c1e5c694..6e9a3c2b3 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -2,7 +2,9 @@ import { Injectable } from '@nestjs/common'; import { type Kysely, type NotNull, sql } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators.js'; +import { PersonUserRole } from 'src/dtos/person.dto.js'; import { AlbumUserRole, AssetVisibility } from 'src/enum.js'; +import { PersonId } from 'src/repositories/person.repository.js'; import { DB } from 'src/schema/index.js'; import { asUuid } from 'src/utils/database.js'; @@ -519,20 +521,54 @@ class ClusterGroupRequestAccess { class PersonAccess { constructor(private db: Kysely) {} - @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET, [PersonUserRole.Admin]] }) @ChunkedSet({ paramIndex: 1 }) - async checkOwnerAccess(userId: string, personGroupIds: Set) { - if (personGroupIds.size === 0) { - return new Set(); + async checkAccess(userId: string, personIds: Set, roles: PersonUserRole[]) { + if (personIds.size === 0 || roles.length === 0) { + return new Set(); } + const personGroupIds = personIds + .values() + .map(({ personGroupId }) => personGroupId) + .toArray(); + const ownerIds = personIds + .values() + .map(({ ownerId }) => ownerId) + .toArray(); + return this.db - .selectFrom('person') - .select('person.personGroupId') - .where('person.personGroupId', 'in', [...personGroupIds]) - .where('person.ownerId', '=', userId) + .selectFrom( + sql<{ personGroupId: string; ownerId: string }>`( + select + unnest(${personGroupIds}::uuid[]) as "personGroupId", + unnest(${ownerIds}::uuid[]) as "ownerId" + )`.as('people'), + ) + .select(['personGroupId', 'ownerId']) + .where((eb) => + eb.or([ + eb.exists( + eb + .selectFrom('person') + .whereRef('people.personGroupId', '=', 'person.personGroupId') + .whereRef('people.ownerId', '=', 'person.ownerId') + .where('person.ownerId', '=', userId) + .selectAll(), + ), + eb.exists( + eb + .selectFrom('person_user') + .where('person_user.sharedWithId', '=', userId) + .where('person_user.role', 'in', roles) + .whereRef('people.personGroupId', '=', 'person_user.personGroupId') + .whereRef('people.ownerId', '=', 'person_user.sharedById') + .selectAll(), + ), + ]), + ) .execute() - .then((persons) => new Set(persons.map((person) => person.personGroupId))); + .then((items) => new Set(items)); } @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) diff --git a/server/src/repositories/activity.repository.ts b/server/src/repositories/activity.repository.ts index 45382d6d1..8e4fc75fc 100644 --- a/server/src/repositories/activity.repository.ts +++ b/server/src/repositories/activity.repository.ts @@ -31,7 +31,7 @@ export class ActivityRepository { join.onRef('user2.id', '=', 'activity.userId').on('user2.deletedAt', 'is', null), ) .innerJoinLateral( - (eb) => eb.selectFrom(dummy).select(columns.userWithPrefix).as('user'), + (eb) => eb.selectFrom(dummy).select(columns.user2).as('user'), (join) => join.onTrue(), ) .select((eb) => eb.fn.toJson('user').as('user')) diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index ddd80b77b..f2c6d1adf 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -31,6 +31,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository import { OAuthRepository } from 'src/repositories/oauth.repository.js'; import { OcrRepository } from 'src/repositories/ocr.repository.js'; import { PartnerRepository } from 'src/repositories/partner.repository.js'; +import { PersonUserRepository } from 'src/repositories/person-user.repository.js'; import { PersonRepository } from 'src/repositories/person.repository.js'; import { PluginRepository } from 'src/repositories/plugin.repository.js'; import { ProcessRepository } from 'src/repositories/process.repository.js'; @@ -89,6 +90,7 @@ export const repositories = [ ClusterGroupRepository, PartnerRepository, PersonRepository, + PersonUserRepository, PluginRepository, ProcessRepository, SearchRepository, diff --git a/server/src/repositories/partner.repository.ts b/server/src/repositories/partner.repository.ts index c6c88c01f..c30c6fb48 100644 --- a/server/src/repositories/partner.repository.ts +++ b/server/src/repositories/partner.repository.ts @@ -12,11 +12,6 @@ export interface PartnerIds { sharedWithId: string; } -export enum PartnerDirection { - SharedBy = 'shared-by', - SharedWith = 'shared-with', -} - const withSharedBy = (eb: ExpressionBuilder) => { return jsonObjectFrom( eb.selectFrom('user as sharedBy').select(columns.user).whereRef('sharedBy.id', '=', 'partner.sharedById'), diff --git a/server/src/repositories/person-user.repository.ts b/server/src/repositories/person-user.repository.ts new file mode 100644 index 000000000..730f721c4 --- /dev/null +++ b/server/src/repositories/person-user.repository.ts @@ -0,0 +1,111 @@ +import { Injectable } from '@nestjs/common'; +import { Insertable, Kysely, sql } from 'kysely'; +import { jsonObjectFrom } from 'kysely/helpers/postgres'; +import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database.js'; +import { DummyValue, GenerateSql } from 'src/decorators.js'; +import { PersonUserRole, PersonUsersDeleteDto, PersonUsersSearchDto } from 'src/dtos/person.dto.js'; +import { SharingDirection } from 'src/enum.js'; +import { DB } from 'src/schema/index.js'; +import { PersonUserTable } from 'src/schema/tables/person-user.table.js'; + +@Injectable() +export class PersonUserRepository { + constructor(@InjectKysely() private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID, {}] }) + searchPeopleUsers(ownerId: string, dto: PersonUsersSearchDto) { + return this.db + .selectFrom('person_user') + .select([ + 'person_user.personGroupId as personId', + 'person_user.sharedById', + 'person_user.sharedWithId', + 'person_user.role', + ]) + .select((eb) => [ + jsonObjectFrom( + eb.selectFrom('user').select(columns.userPrefix).whereRef('user.id', '=', 'person_user.sharedById'), + ) + .$notNull() + .as('sharedBy'), + jsonObjectFrom( + eb.selectFrom('user').select(columns.userPrefix).whereRef('user.id', '=', 'person_user.sharedWithId'), + ) + .$notNull() + .as('sharedWith'), + ]) + .$if(!!dto.personId, (qb) => qb.where('person_user.personGroupId', '=', dto.personId!)) + .$if(!!dto.sharedWithId, (qb) => qb.where('person_user.sharedWithId', '=', dto.sharedWithId!)) + .$if(!!dto.sharedById, (qb) => qb.where('person_user.sharedById', '=', dto.sharedById!)) + .$if(!!dto.role, (qb) => qb.where('person_user.role', '=', dto.role!)) + .$if(dto.direction === undefined, (qb) => + qb.where((eb) => + eb.or([eb('person_user.sharedById', '=', ownerId), eb('person_user.sharedWithId', '=', ownerId)]), + ), + ) + .$if(dto.direction !== undefined, (qb) => + qb.where( + dto.direction === SharingDirection.SharedBy ? 'person_user.sharedById' : 'person_user.sharedWithId', + '=', + ownerId, + ), + ) + .execute(); + } + + @GenerateSql({ + params: [ + { + personGroupId: DummyValue.UUID, + sharedById: DummyValue.UUID, + sharedWithId: DummyValue.UUID, + role: PersonUserRole.Admin, + }, + ], + }) + createAll(dto: Insertable[]) { + if (dto.length === 0) { + return []; + } + + return this.db + .insertInto('person_user') + .values(dto) + .onConflict((oc) => + oc + .columns(['personGroupId', 'sharedById', 'sharedWithId']) + .doUpdateSet((eb) => ({ role: eb.ref('excluded.role') })), + ) + .returningAll() + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID, [{ personId: DummyValue.UUID, sharedWithId: DummyValue.UUID }]] }) + async deleteAll(sharedById: string, dto: PersonUsersDeleteDto) { + if (dto.length === 0) { + return; + } + + const personGroupIds = dto.map(({ personId }) => personId); + const sharedWithIds = dto.map(({ sharedWithId }) => sharedWithId); + const people = sql<{ personGroupId: string; sharedWithId: string }>`( + select + unnest(${personGroupIds}::uuid[]) as "personGroupId", + unnest(${sharedWithIds}::uuid[]) as "sharedWithId" + )`.as('people'); + + await this.db + .deleteFrom('person_user') + .where(({ exists, selectFrom }) => + exists( + selectFrom(people) + .whereRef('people.personGroupId', '=', 'person_user.personGroupId') + .whereRef('people.sharedWithId', '=', 'person_user.sharedWithId') + .selectAll(), + ), + ) + .where('person_user.sharedById', '=', sharedById) + .execute(); + } +} diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index ad4aa1534..6f0c6bffc 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -1,21 +1,47 @@ import { Injectable } from '@nestjs/common'; -import { type ExpressionBuilder, type Insertable, type Kysely, type Updateable, sql } from 'kysely'; -import { jsonObjectFrom } from 'kysely/helpers/postgres'; +import { + type Expression, + type ExpressionBuilder, + type Insertable, + type Kysely, + type Selectable, + type ShallowDehydrateObject, + type SqlBool, + type Updateable, + expressionBuilder, + sql, +} from 'kysely'; +import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; -import { AssetFace } from 'src/database.js'; +import { AssetFace, PersonUser, columns } from 'src/database.js'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators.js'; -import { AssetFileType, AssetVisibility, SourceType, UserMetadataKey } from 'src/enum.js'; +import { PersonUserRole } from 'src/dtos/person.dto.js'; +import { AssetFileType, AssetVisibility, SharingDirection, SourceType, UserMetadataKey } from 'src/enum.js'; import { type YearMonthDay } from 'src/repositories/asset.repository.js'; import { DB } from 'src/schema/index.js'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table.js'; import { FaceSearchTable } from 'src/schema/tables/face-search.table.js'; import { PersonGroupTable } from 'src/schema/tables/person-group.table.js'; import { PersonTable } from 'src/schema/tables/person.table.js'; -import { asUuid, dummy, inSharedAlbum, removeUndefinedKeys, withFilePath } from 'src/utils/database.js'; +import { anyUuid, dummy, inSharedAlbum, removeUndefinedKeys, withFilePath } from 'src/utils/database.js'; import { isLeapDayObserved } from 'src/utils/date.js'; import { type PaginationOptions, paginationHelper } from 'src/utils/pagination.js'; -export interface PersonSearchOptions { +type PersonGroupRow = { + ownedPerson: ShallowDehydrateObject>; + otherPeople: { sharedById: string; role: PersonUserRole; name: string; birthDate: string | null }[]; + sharedBy: PersonUser[]; + sharedWith: PersonUser[]; +}; + +export interface PersonFilterOptions { + sharedById?: string; + sharedWithId?: string; + isFavorite?: boolean; + isHidden?: boolean; +} + +export interface PersonSearchOptions extends PersonFilterOptions { withHidden: boolean; closestFaceAssetId?: string; } @@ -34,6 +60,12 @@ export interface AssetFaceId { personGroupId: string; } +export interface UpdateGroupIdData { + oldPersonGroupId: string; + ownerId: string; + newPersonGroupId: string; +} + export interface UpdateFacesData { oldPersonGroupId?: string; faceIds?: string[]; @@ -77,12 +109,117 @@ export type WithPersonOptions = { viewingUserId: string; }; +const withOwnedPerson = (userId: string) => { + return (eb: ExpressionBuilder) => + jsonObjectFrom( + eb + .selectFrom('person') + .selectAll('person') + .whereRef('person.personGroupId', '=', 'person_group.id') + .where('person.ownerId', '=', userId), + ) + .$notNull() + .as('ownedPerson'); +}; + +const withOtherPeopleFor = (userId: string, personGroupId: Expression) => + jsonArrayFrom( + expressionBuilder() + .selectFrom('person as other') + .innerJoin('person_user', (join) => + join + .onRef('person_user.personGroupId', '=', 'other.personGroupId') + .onRef('person_user.sharedById', '=', 'other.ownerId') + .on('person_user.sharedWithId', '=', userId), + ) + .select(['person_user.sharedById', 'person_user.role', 'other.name', 'other.birthDate']) + .where('other.personGroupId', '=', personGroupId) + .where((eb) => eb.or([eb('other.birthDate', 'is not', null), eb('other.name', '!=', '')])), + ).as('otherPeople'); + +const withPersonUsersFor = (userId: string, personGroupId: Expression, direction: SharingDirection) => { + const [userColumn, viewerColumn] = + direction === SharingDirection.SharedBy + ? (['person_user.sharedById', 'person_user.sharedWithId'] as const) + : (['person_user.sharedWithId', 'person_user.sharedById'] as const); + + return jsonArrayFrom( + expressionBuilder() + .selectFrom('person_user') + .innerJoin('user', (join) => join.onRef('user.id', '=', userColumn).on('user.deletedAt', 'is', null)) + .select(columns.user) + .select('person_user.role') + .where('person_user.personGroupId', '=', personGroupId) + .where(viewerColumn, '=', userId) + .orderBy('user.name'), + ) + .$castTo() + .as(direction === SharingDirection.SharedBy ? 'sharedBy' : 'sharedWith'); +}; + +const withSharing = (userId: string, personGroupId: Expression) => [ + withOtherPeopleFor(userId, personGroupId), + withPersonUsersFor(userId, personGroupId, SharingDirection.SharedBy), + withPersonUsersFor(userId, personGroupId, SharingDirection.SharedWith), +]; + +const withOtherPeople = (userId: string) => { + return (eb: ExpressionBuilder) => withSharing(userId, eb.ref('person_group.id')); +}; + +const withOtherPeopleForPerson = (userId: string) => { + return (eb: ExpressionBuilder) => withSharing(userId, eb.ref('person.personGroupId')); +}; + +const withFilters = (userId: string, options: PersonFilterOptions = {}) => { + const { sharedById, sharedWithId, isFavorite, isHidden } = options; + return (eb: ExpressionBuilder) => { + const filters: Expression[] = []; + + if (sharedById || sharedWithId) { + filters.push( + eb.exists( + eb + .selectFrom('person_user') + .whereRef('person_user.personGroupId', '=', 'person_group.id') + // only consider shares that involve the current user + .where((eb) => + eb.or([eb('person_user.sharedById', '=', userId), eb('person_user.sharedWithId', '=', userId)]), + ) + .$if(!!sharedById, (qb) => qb.where('person_user.sharedById', '=', sharedById!)) + .$if(!!sharedWithId, (qb) => qb.where('person_user.sharedWithId', '=', sharedWithId!)), + ), + ); + } + + if (isFavorite !== undefined) { + filters.push(eb('owned.isFavorite', '=', isFavorite)); + } + + if (isHidden !== undefined) { + filters.push(eb('owned.isHidden', '=', isHidden)); + } + + return eb.and(filters); + }; +}; + +const asPerson = ({ ownedPerson, otherPeople, sharedBy, sharedWith }: PersonGroupRow) => ({ + ...ownedPerson, + otherPeople, + sharedBy, + sharedWith, +}); + +const faceCount = (eb: ExpressionBuilder) => eb.fn.count('asset.id'); + const withPerson = ({ viewingUserId }: WithPersonOptions) => { return (eb: ExpressionBuilder) => jsonObjectFrom( eb .selectFrom('person') .selectAll('person') + .select(withOtherPeopleForPerson(viewingUserId)) .whereRef('person.personGroupId', '=', 'asset_face.personGroupId') .where('person.ownerId', '=', viewingUserId), ).as('person'); @@ -113,6 +250,28 @@ export class PersonRepository { return Number(result.numUpdatedRows ?? 0); } + @GenerateSql({ params: [{ oldPersonGroupId: DummyValue.UUID, newPersonGroupId: DummyValue.UUID }] }) + updateGroupId({ oldPersonGroupId, ownerId, newPersonGroupId }: UpdateGroupIdData) { + return this.db.transaction().execute(async (trx) => { + await trx + .updateTable('asset_face') + .from('asset') + .whereRef('asset_face.assetId', '=', 'asset.id') + .set({ personGroupId: newPersonGroupId }) + .where('asset_face.personGroupId', '=', oldPersonGroupId) + .where('asset.ownerId', '=', ownerId) + .executeTakeFirst(); + + return trx + .updateTable('person') + .set({ personGroupId: newPersonGroupId }) + .where('person.personGroupId', '=', oldPersonGroupId) + .where('person.ownerId', '=', ownerId) + .returningAll() + .executeTakeFirstOrThrow(); + }); + } + @GenerateSql({ params: [{ sourceType: SourceType.MachineLearning, clusterGroupId: DummyValue.UUID }] }) async unassignFaces({ sourceType, clusterGroupId }: UnassignFacesOptions): Promise { await this.db @@ -270,26 +429,39 @@ export class PersonRepository { @GenerateSql({ params: [{ take: 1, skip: 0 }, DummyValue.UUID] }) async getAllForUser(pagination: PaginationOptions, userId: string, options?: PersonSearchOptions) { const items = await this.db - .selectFrom('person') - .selectAll('person') - .innerJoin('asset_face', 'asset_face.personGroupId', 'person.personGroupId') - .innerJoin('asset', (join) => + .selectFrom('person_group') + .innerJoin('person as owned', (join) => + join.onRef('owned.personGroupId', '=', 'person_group.id').on('owned.ownerId', '=', userId), + ) + .leftJoin('asset_face', (join) => join - .onRef('asset_face.assetId', '=', 'asset.id') - .onRef('asset.ownerId', '=', 'person.ownerId') + .onRef('asset_face.personGroupId', '=', 'person_group.id') + .on('asset_face.deletedAt', 'is', null) + .on('asset_face.isVisible', 'is', true), + ) + .leftJoin('asset', (join) => + join + .onRef('asset.id', '=', 'asset_face.assetId') + .on('asset.ownerId', '=', userId) .on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline)) .on('asset.deletedAt', 'is', null), ) - .where('person.ownerId', '=', userId) - .where('asset_face.deletedAt', 'is', null) - .where('asset_face.isVisible', 'is', true) - .orderBy('person.isHidden', 'asc') - .orderBy('person.isFavorite', 'desc') + .select(withOwnedPerson(userId)) + .select(withOtherPeople(userId)) + .groupBy(['person_group.id', 'owned.ownerId', 'owned.personGroupId']) .having((eb) => eb.or([ - eb('person.name', '!=', ''), + // shared people are always included + eb.exists( + eb + .selectFrom('person_user') + .select('person_user.sharedWithId') + .whereRef('person_user.personGroupId', '=', 'person_group.id') + .where('person_user.sharedWithId', '=', userId), + ), + eb.and([eb(faceCount, '>', 0), eb('owned.name', '!=', '')]), eb( - (innerEb) => innerEb.fn.count('asset_face.assetId'), + faceCount, '>=', sql`COALESCE( (SELECT value -> 'people' ->> 'minimumFaces' @@ -301,7 +473,8 @@ export class PersonRepository { ), ]), ) - .groupBy(['person.ownerId', 'person.personGroupId']) + .orderBy('owned.isHidden', 'asc') + .orderBy('owned.isFavorite', 'desc') .$if(!!options?.closestFaceAssetId, (qb) => qb.orderBy((eb) => eb( @@ -309,7 +482,7 @@ export class PersonRepository { eb .selectFrom('face_search') .select('face_search.embedding') - .whereRef('face_search.faceId', '=', 'person.faceAssetId'), + .whereRef('face_search.faceId', '=', 'owned.faceAssetId'), '<=>', (eb) => eb @@ -321,17 +494,22 @@ export class PersonRepository { ) .$if(!options?.closestFaceAssetId, (qb) => qb - .orderBy(sql`NULLIF(person.name, '') is null`, 'asc') - .orderBy((eb) => eb.fn.count('asset_face.assetId'), 'desc') - .orderBy(sql`NULLIF(person.name, '')`, (om) => om.asc().nullsLast()) - .orderBy('person.createdAt'), + .orderBy(sql`NULLIF("owned"."name", '') is null`, 'asc') + .orderBy(faceCount, 'desc') + .orderBy(sql`NULLIF("owned"."name", '')`, (om) => om.asc().nullsLast()) + .orderBy('owned.createdAt'), ) - .$if(!options?.withHidden, (qb) => qb.where('person.isHidden', '=', false)) + // an explicit isHidden filter takes precedence over withHidden + .$if(!options?.withHidden && options?.isHidden === undefined, (qb) => qb.where('owned.isHidden', '=', false)) + .where(withFilters(userId, options)) .offset(pagination.skip ?? 0) .limit(pagination.take + 1) .execute(); - return paginationHelper(items, pagination.take); + return paginationHelper( + items.map((item) => asPerson(item)), + pagination.take, + ); } @GenerateSql() @@ -431,16 +609,67 @@ export class PersonRepository { return Number(result.numChangedRows ?? 0); } + @GenerateSql({ params: [{ userId: DummyValue.UUID, personGroupId: DummyValue.UUID }] }) + async getForUser({ userId, personGroupId }: { userId: string; personGroupId: string }) { + const group = await this.db + .selectFrom('person_group') + .select(withOwnedPerson(userId)) + .select(withOtherPeople(userId)) + .where('person_group.id', '=', personGroupId) + .where((eb) => + eb.exists( + eb + .selectFrom('person') + .select('person.ownerId') + .whereRef('person.personGroupId', '=', 'person_group.id') + .where('person.ownerId', '=', userId), + ), + ) + .executeTakeFirst(); + + return group && asPerson(group); + } + @GenerateSql({ params: [{ ownerId: DummyValue.UUID, personGroupId: DummyValue.UUID }] }) getByGroupId({ ownerId, personGroupId }: PersonId) { - return this.db // + return this.db .selectFrom('person') .selectAll('person') + .select(withOtherPeopleForPerson(ownerId)) .where('person.personGroupId', '=', personGroupId) .where('person.ownerId', '=', ownerId) .executeTakeFirst(); } + @GenerateSql({ params: [{ ownerId: DummyValue.UUID, personGroupId: DummyValue.UUID }] }) + getForThumbnail({ ownerId, personGroupId }: PersonId) { + return this.db + .selectFrom('person_group') + .select(({ selectFrom }) => [ + // TODO-DANIEL I would've done a left join of person_user and sorted by person.ownerId = ownerId desc. + // should probably discuss with Mert which approach is more efficient. + selectFrom('person') + .select('person.thumbnailPath') + .whereRef('person.personGroupId', '=', 'person_group.id') + .where('person.ownerId', '=', ownerId) + .as('thumbnailPath'), + selectFrom('person') + .innerJoin('person_user', (join) => + join + .onRef('person_user.personGroupId', '=', 'person.personGroupId') + .onRef('person_user.sharedById', '=', 'person.ownerId') + .on('person_user.sharedWithId', '=', ownerId), + ) + .select('person.thumbnailPath') + .whereRef('person.personGroupId', '=', 'person_group.id') + .where('person.thumbnailPath', '!=', '') + .limit(1) + .as('sharedThumbnailPath'), + ]) + .where('person_group.id', '=', personGroupId) + .executeTakeFirst(); + } + @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] }) getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions) { return this.db @@ -449,6 +678,7 @@ export class PersonRepository { ) .selectFrom(['similarity_threshold', 'person']) .selectAll('person') + .select(withOtherPeopleForPerson(userId)) .where('person.ownerId', '=', userId) .where(() => sql`f_unaccent("person"."name") %> f_unaccent(${personName})`) .orderBy(sql`f_unaccent("person"."name") <->>> f_unaccent(${personName})`) @@ -469,7 +699,10 @@ export class PersonRepository { } @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) - async getStatistics(personGroupId: string, userId: string): Promise { + async getStatistics( + personGroupId: string, + { ownerId, partnerIds }: { ownerId: string; partnerIds: string[] }, + ): Promise { const result = await this.db .selectFrom('asset_face') .leftJoin('asset', (join) => @@ -477,7 +710,7 @@ export class PersonRepository { .onRef('asset.id', '=', 'asset_face.assetId') .on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline)) .on('asset.deletedAt', 'is', null) - .on((eb) => eb.or([eb('asset.ownerId', '=', asUuid(userId)), inSharedAlbum(eb, userId)])), + .on((eb) => eb.or([eb('asset.ownerId', '=', anyUuid([ownerId, ...partnerIds])), inSharedAlbum(eb, ownerId)])), ) .select((eb) => eb.fn.count(eb.fn('distinct', ['asset.id'])).as('count')) .where('asset_face.deletedAt', 'is', null) @@ -489,38 +722,60 @@ export class PersonRepository { assets: result ? Number(result.count) : 0, }; } - @GenerateSql({ params: [DummyValue.UUID] }) - getNumberOfPeople(userId: string) { + getNumberOfPeople(userId: string, options?: PersonFilterOptions) { const zero = sql.lit(0); return this.db - .selectFrom('person') - .where((eb) => - eb.exists((eb) => - eb - .selectFrom('asset_face') - .whereRef('asset_face.personGroupId', '=', 'person.personGroupId') - .where('asset_face.deletedAt', 'is', null) - .where('asset_face.isVisible', '=', true) - .where((eb) => - eb.exists((eb) => - eb - .selectFrom('asset') - .whereRef('asset.id', '=', 'asset_face.assetId') - .where('asset.visibility', '=', sql.lit(AssetVisibility.Timeline)) - .where('asset.deletedAt', 'is', null), - ), - ), - ), + .selectFrom('person_group') + .leftJoin('person as owned', (join) => + join.onRef('owned.personGroupId', '=', 'person_group.id').on('owned.ownerId', '=', userId), ) - .where('person.ownerId', '=', userId) + .where((eb) => + eb.or([ + eb.and([ + eb('owned.ownerId', 'is not', null), + eb.exists((eb) => + eb + .selectFrom('asset_face') + .whereRef('asset_face.personGroupId', '=', 'person_group.id') + .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', '=', true) + .where((eb) => + eb.exists((eb) => + eb + .selectFrom('asset') + .whereRef('asset.id', '=', 'asset_face.assetId') + .where('asset.ownerId', '=', userId) + .where('asset.visibility', '=', sql.lit(AssetVisibility.Timeline)) + .where('asset.deletedAt', 'is', null), + ), + ), + ), + ]), + eb.exists((eb) => + eb + .selectFrom('person_user') + .select('person_user.sharedById') + .whereRef('person_user.personGroupId', '=', 'person_group.id') + .where('person_user.sharedWithId', '=', userId), + ), + ]), + ) + .where(withFilters(userId, options)) .select((eb) => eb.fn.coalesce(eb.fn.countAll(), zero).as('total')) - .select((eb) => eb.fn.coalesce(eb.fn.countAll().filterWhere('isHidden', '=', true), zero).as('hidden')) + .select((eb) => + eb.fn.coalesce(eb.fn.countAll().filterWhere('owned.isHidden', '=', true), zero).as('hidden'), + ) .executeTakeFirstOrThrow(); } create(person: Insertable) { - return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow(); + return this.db + .insertInto('person') + .values(person) + .returningAll() + .returning(withOtherPeopleForPerson(person.ownerId)) + .executeTakeFirstOrThrow(); } async createAll(people: Insertable[]) { @@ -674,6 +929,7 @@ export class PersonRepository { .where('person.ownerId', '=', person.ownerId) .where('person.personGroupId', '=', person.personGroupId) .returningAll() + .returning(withOtherPeopleForPerson(person.ownerId)) .executeTakeFirstOrThrow(); } @@ -816,7 +1072,6 @@ export class PersonRepository { .selectFrom('person') .selectAll('person') .where('person.personGroupId', 'in', personGroupIds) - .orderBy('person.ownerId') .execute(); } } diff --git a/server/src/schema/enums.ts b/server/src/schema/enums.ts index 512b16754..dbc4e74a3 100644 --- a/server/src/schema/enums.ts +++ b/server/src/schema/enums.ts @@ -1,4 +1,5 @@ import { registerEnum } from '@immich/sql-tools'; +import { PersonUserRole } from 'src/dtos/person.dto.js'; import { AlbumUserRole, AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType, VideoCodec } from 'src/enum.js'; export const album_user_role_enum = registerEnum({ @@ -30,3 +31,8 @@ export const video_stream_variant_codec_enum = registerEnum({ name: 'video_stream_variant_codec_enum', values: [VideoCodec.Av1, VideoCodec.Hevc, VideoCodec.H264], }); + +export const person_user_role_enum = registerEnum({ + name: 'person_user_role_enum', + values: Object.values(PersonUserRole), +}); diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index 531c9b81b..83db76910 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -219,6 +219,43 @@ export const person_delete_audit = registerFunction({ END`, }); +export const person_user_after_insert = registerFunction({ + name: 'person_user_after_insert', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO person ("ownerId", "personGroupId", "name", "birthDate") + SELECT DISTINCT ON (i."sharedWithId", i."personGroupId") + i."sharedWithId", i."personGroupId", shared."name", shared."birthDate" + FROM inserted_rows i + INNER JOIN person shared + ON shared."ownerId" = i."sharedById" AND shared."personGroupId" = i."personGroupId" + ORDER BY i."sharedWithId", i."personGroupId", shared."name" = '', shared."birthDate" IS NULL + ON CONFLICT ("ownerId", "personGroupId") DO UPDATE + SET + "name" = CASE WHEN person."name" = '' THEN EXCLUDED."name" ELSE person."name" END, + "birthDate" = COALESCE(person."birthDate", EXCLUDED."birthDate") + WHERE (person."name" = '' AND EXCLUDED."name" <> '') + OR (person."birthDate" IS NULL AND EXCLUDED."birthDate" IS NOT NULL); + RETURN NULL; + END`, +}); + +export const person_delete_shares = registerFunction({ + name: 'person_delete_shares', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + DELETE FROM person_user + USING deleted_rows + WHERE person_user."personGroupId" = deleted_rows."personGroupId" + AND person_user."sharedWithId" = deleted_rows."ownerId"; + RETURN NULL; + END`, +}); + export const person_group_delete_audit = registerFunction({ name: 'person_group_delete_audit', returnType: 'TRIGGER', diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 8f85c552e..9ee8c00b1 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -4,6 +4,7 @@ import { asset_face_source_type, asset_visibility_enum, assets_status_enum, + person_user_role_enum, } from 'src/schema/enums.js'; import { album_user_after_insert, @@ -68,6 +69,7 @@ import { PartnerTable } from 'src/schema/tables/partner.table.js'; import { PersonAuditTable } from 'src/schema/tables/person-audit.table.js'; import { PersonGroupAuditTable } from 'src/schema/tables/person-group-audit.table.js'; import { PersonGroupTable } from 'src/schema/tables/person-group.table.js'; +import { PersonUserTable } from 'src/schema/tables/person-user.table.js'; import { PersonTable } from 'src/schema/tables/person.table.js'; import { PluginMethodTable } from 'src/schema/tables/plugin-method.table.js'; import { PluginTable } from 'src/schema/tables/plugin.table.js'; @@ -139,6 +141,7 @@ export class ImmichDatabase { PartnerTable, PersonTable, PersonAuditTable, + PersonUserTable, PersonGroupTable, PersonGroupAuditTable, SessionTable, @@ -189,7 +192,13 @@ export class ImmichDatabase { album_user_delete, ]; - enum = [album_user_role_enum, assets_status_enum, asset_face_source_type, asset_visibility_enum]; + enum = [ + album_user_role_enum, + assets_status_enum, + asset_face_source_type, + asset_visibility_enum, + person_user_role_enum, + ]; } export interface Migrations { @@ -256,6 +265,7 @@ export interface DB { person: PersonTable; person_audit: PersonAuditTable; + person_user: PersonUserTable; person_group: PersonGroupTable; person_group_audit: PersonGroupAuditTable; diff --git a/server/src/schema/migrations/1790616293884-PersonSharing.ts b/server/src/schema/migrations/1790616293884-PersonSharing.ts new file mode 100644 index 000000000..1e3f820d0 --- /dev/null +++ b/server/src/schema/migrations/1790616293884-PersonSharing.ts @@ -0,0 +1,102 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION person_user_after_insert() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO person ("ownerId", "personGroupId", "name", "birthDate") + SELECT i."sharedWithId", i."personGroupId", shared."name", shared."birthDate" + FROM inserted_rows i + INNER JOIN person shared + ON shared."ownerId" = i."sharedById" AND shared."personGroupId" = i."personGroupId" + ON CONFLICT ("ownerId", "personGroupId") DO NOTHING; + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE OR REPLACE FUNCTION person_delete_shares() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + DELETE FROM person_user + USING deleted_rows + WHERE person_user."personGroupId" = deleted_rows."personGroupId" + AND person_user."sharedWithId" = deleted_rows."ownerId"; + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE TYPE "person_user_role_enum" AS ENUM ('read','write','admin');`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "person_delete_shares" + AFTER DELETE ON "person" + REFERENCING OLD TABLE AS "deleted_rows" + FOR EACH STATEMENT + EXECUTE FUNCTION person_delete_shares();`.execute(db); + await sql`CREATE TABLE "person_user" ( + "personGroupId" uuid NOT NULL, + "sharedById" uuid NOT NULL, + "sharedWithId" uuid NOT NULL, + "role" person_user_role_enum NOT NULL, + "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 "person_user_sharedWithId_fkey" FOREIGN KEY ("sharedWithId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "person_user_sharedById_personGroupId_fkey" FOREIGN KEY ("sharedById", "personGroupId") REFERENCES "person" ("ownerId", "personGroupId") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "person_user_pkey" PRIMARY KEY ("personGroupId", "sharedById", "sharedWithId") +);`.execute(db); + await sql`CREATE INDEX "person_user_sharedById_personGroupId_idx" ON "person_user" ("sharedById", "personGroupId");`.execute(db); + await sql`CREATE INDEX "person_user_sharedWithId_idx" ON "person_user" ("sharedWithId");`.execute(db); + await sql`CREATE INDEX "person_user_updateId_idx" ON "person_user" ("updateId");`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "person_user_after_insert" + AFTER INSERT ON "person_user" + REFERENCING NEW TABLE AS "inserted_rows" + FOR EACH STATEMENT + EXECUTE FUNCTION person_user_after_insert();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "person_user_updatedAt" + BEFORE UPDATE ON "person_user" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); + await sql`CREATE OR REPLACE FUNCTION person_user_after_insert() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO person ("ownerId", "personGroupId", "name", "birthDate") + SELECT DISTINCT ON (i."sharedWithId", i."personGroupId") + i."sharedWithId", i."personGroupId", shared."name", shared."birthDate" + FROM inserted_rows i + INNER JOIN person shared + ON shared."ownerId" = i."sharedById" AND shared."personGroupId" = i."personGroupId" + ORDER BY i."sharedWithId", i."personGroupId", shared."name" = '', shared."birthDate" IS NULL + ON CONFLICT ("ownerId", "personGroupId") DO UPDATE + SET + "name" = CASE WHEN person."name" = '' THEN EXCLUDED."name" ELSE person."name" END, + "birthDate" = COALESCE(person."birthDate", EXCLUDED."birthDate") + WHERE (person."name" = '' AND EXCLUDED."name" <> '') + OR (person."birthDate" IS NULL AND EXCLUDED."birthDate" IS NOT NULL); + RETURN NULL; + END + $$;`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_person_user_after_insert', '{"type":"function","name":"person_user_after_insert","sql":"CREATE OR REPLACE FUNCTION person_user_after_insert()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person (\\"ownerId\\", \\"personGroupId\\", \\"name\\", \\"birthDate\\")\\n SELECT i.\\"sharedWithId\\", i.\\"personGroupId\\", shared.\\"name\\", shared.\\"birthDate\\"\\n FROM inserted_rows i\\n INNER JOIN person shared\\n ON shared.\\"ownerId\\" = i.\\"sharedById\\" AND shared.\\"personGroupId\\" = i.\\"personGroupId\\"\\n ON CONFLICT (\\"ownerId\\", \\"personGroupId\\") DO NOTHING;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_person_delete_shares', '{"type":"function","name":"person_delete_shares","sql":"CREATE OR REPLACE FUNCTION person_delete_shares()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n DELETE FROM person_user\\n USING deleted_rows\\n WHERE person_user.\\"personGroupId\\" = deleted_rows.\\"personGroupId\\"\\n AND person_user.\\"sharedWithId\\" = deleted_rows.\\"ownerId\\";\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_person_delete_shares', '{"type":"trigger","name":"person_delete_shares","sql":"CREATE OR REPLACE TRIGGER \\"person_delete_shares\\"\\n AFTER DELETE ON \\"person\\"\\n REFERENCING OLD TABLE AS \\"deleted_rows\\"\\n FOR EACH STATEMENT\\n EXECUTE FUNCTION person_delete_shares();"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_person_user_after_insert', '{"type":"trigger","name":"person_user_after_insert","sql":"CREATE OR REPLACE TRIGGER \\"person_user_after_insert\\"\\n AFTER INSERT ON \\"person_user\\"\\n REFERENCING NEW TABLE AS \\"inserted_rows\\"\\n FOR EACH STATEMENT\\n EXECUTE FUNCTION person_user_after_insert();"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_person_user_updatedAt', '{"type":"trigger","name":"person_user_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"person_user_updatedAt\\"\\n BEFORE UPDATE ON \\"person_user\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"person_user_after_insert","sql":"CREATE OR REPLACE FUNCTION person_user_after_insert()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person (\\"ownerId\\", \\"personGroupId\\", \\"name\\", \\"birthDate\\")\\n SELECT DISTINCT ON (i.\\"sharedWithId\\", i.\\"personGroupId\\")\\n i.\\"sharedWithId\\", i.\\"personGroupId\\", shared.\\"name\\", shared.\\"birthDate\\"\\n FROM inserted_rows i\\n INNER JOIN person shared\\n ON shared.\\"ownerId\\" = i.\\"sharedById\\" AND shared.\\"personGroupId\\" = i.\\"personGroupId\\"\\n ORDER BY i.\\"sharedWithId\\", i.\\"personGroupId\\", shared.\\"name\\" = '''', shared.\\"birthDate\\" IS NULL\\n ON CONFLICT (\\"ownerId\\", \\"personGroupId\\") DO UPDATE\\n SET\\n \\"name\\" = CASE WHEN person.\\"name\\" = '''' THEN EXCLUDED.\\"name\\" ELSE person.\\"name\\" END,\\n \\"birthDate\\" = COALESCE(person.\\"birthDate\\", EXCLUDED.\\"birthDate\\")\\n WHERE (person.\\"name\\" = '''' AND EXCLUDED.\\"name\\" <> '''')\\n OR (person.\\"birthDate\\" IS NULL AND EXCLUDED.\\"birthDate\\" IS NOT NULL);\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_person_user_after_insert';`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "person_user_after_insert" ON "person_user";`.execute(db); + await sql`DROP FUNCTION person_user_after_insert;`.execute(db); + await sql`DROP TRIGGER "person_delete_shares" ON "person";`.execute(db); + await sql`DROP FUNCTION person_delete_shares;`.execute(db); + await sql`DROP TABLE "person_user";`.execute(db); + await sql`DROP TYPE "person_user_role_enum";`.execute(db); + await sql`DROP TRIGGER "person_user_updatedAt" ON "person_user";`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_person_user_after_insert';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_person_delete_shares';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_person_delete_shares';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_person_user_after_insert';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_person_user_updatedAt';`.execute(db); +} diff --git a/server/src/schema/migrations/ORDER b/server/src/schema/migrations/ORDER index 2db4ae0ca..64e7f323e 100644 --- a/server/src/schema/migrations/ORDER +++ b/server/src/schema/migrations/ORDER @@ -96,3 +96,4 @@ 1787148183730-DeleteMismatchedMemoryAssets 1789419229196-ConvertUserOAuthIdEmptyStringToNull 1790587508209-RenameGeoNamesCountries +1790616293884-PersonSharing diff --git a/server/src/schema/tables/person-user.table.ts b/server/src/schema/tables/person-user.table.ts new file mode 100644 index 000000000..b8fb2ec67 --- /dev/null +++ b/server/src/schema/tables/person-user.table.ts @@ -0,0 +1,60 @@ +import { + AfterInsertTrigger, + Column, + CreateDateColumn, + ForeignKeyColumn, + ForeignKeyConstraint, + type Generated, + PrimaryColumn, + Table, + Timestamp, + UpdateDateColumn, +} from '@immich/sql-tools'; +import { UpdateIdColumn, UpdatedAtTrigger } from 'src/decorators.js'; +import { PersonUserRole } from 'src/dtos/person.dto.js'; +import { person_user_role_enum } from 'src/schema/enums.js'; +import { person_user_after_insert } from 'src/schema/functions.js'; +import { PersonTable } from 'src/schema/tables/person.table.js'; +import { UserTable } from 'src/schema/tables/user.table.js'; + +@Table('person_user') +@UpdatedAtTrigger('person_user_updatedAt') +@AfterInsertTrigger({ + name: 'person_user_after_insert', + scope: 'statement', + referencingNewTableAs: 'inserted_rows', + function: person_user_after_insert, +}) +@ForeignKeyConstraint({ + columns: ['sharedById', 'personGroupId'], + referenceColumns: ['ownerId', 'personGroupId'], + referenceTable: () => PersonTable, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', +}) +export class PersonUserTable { + @PrimaryColumn({ type: 'uuid' }) + personGroupId!: string; + + @PrimaryColumn({ type: 'uuid' }) + sharedById!: string; + + @ForeignKeyColumn(() => UserTable, { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + primary: true, + }) + sharedWithId!: string; + + @Column({ enum: person_user_role_enum }) + role!: PersonUserRole; + + @CreateDateColumn() + createdAt!: Generated; + + @UpdateDateColumn() + updatedAt!: Generated; + + @UpdateIdColumn({ index: true }) + updateId!: Generated; +} diff --git a/server/src/schema/tables/person.table.ts b/server/src/schema/tables/person.table.ts index d51e20bc3..15f836fc4 100644 --- a/server/src/schema/tables/person.table.ts +++ b/server/src/schema/tables/person.table.ts @@ -11,7 +11,7 @@ import { UpdateDateColumn, } from '@immich/sql-tools'; import { UpdateIdColumn, UpdatedAtTrigger } from 'src/decorators.js'; -import { person_delete_audit } from 'src/schema/functions.js'; +import { person_delete_audit, person_delete_shares } from 'src/schema/functions.js'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table.js'; import { PersonGroupTable } from 'src/schema/tables/person-group.table.js'; import { UserTable } from 'src/schema/tables/user.table.js'; @@ -29,6 +29,12 @@ import { UserTable } from 'src/schema/tables/user.table.js'; referencingOldTableAs: 'old', when: 'pg_trigger_depth() <= 1', }) +@AfterDeleteTrigger({ + name: 'person_delete_shares', + scope: 'statement', + function: person_delete_shares, + referencingOldTableAs: 'deleted_rows', +}) @Check({ name: 'person_birthDate_chk', expression: `"birthDate" <= CURRENT_DATE` }) export class PersonTable { @ForeignKeyColumn(() => UserTable, { diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 1dc1f6a6a..c15567d43 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -39,6 +39,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository import { OAuthRepository } from 'src/repositories/oauth.repository.js'; import { OcrRepository } from 'src/repositories/ocr.repository.js'; import { PartnerRepository } from 'src/repositories/partner.repository.js'; +import { PersonUserRepository } from 'src/repositories/person-user.repository.js'; import { PersonRepository } from 'src/repositories/person.repository.js'; import { PluginRepository } from 'src/repositories/plugin.repository.js'; import { ProcessRepository } from 'src/repositories/process.repository.js'; @@ -62,7 +63,14 @@ import { ViewRepository } from 'src/repositories/view-repository.js'; import { WebsocketRepository } from 'src/repositories/websocket.repository.js'; import { WorkflowRepository } from 'src/repositories/workflow.repository.js'; import { UserTable } from 'src/schema/tables/user.table.js'; -import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access.js'; +import { + AccessPersonRequest, + AccessRequest, + checkAccess, + checkPersonAccess, + requireAccess, + requirePersonAccess, +} from 'src/utils/access.js'; import { getConfig, updateConfig } from 'src/utils/config.js'; export const BASE_SERVICE_DEPENDENCIES = [ @@ -100,6 +108,7 @@ export const BASE_SERVICE_DEPENDENCIES = [ OcrRepository, PartnerRepository, PersonRepository, + PersonUserRepository, PluginRepository, ProcessRepository, SearchRepository, @@ -162,6 +171,7 @@ export class BaseService { protected ocrRepository: OcrRepository, protected partnerRepository: PartnerRepository, protected personRepository: PersonRepository, + protected personUserRepository: PersonUserRepository, protected pluginRepository: PluginRepository, protected processRepository: ProcessRepository, protected searchRepository: SearchRepository, @@ -233,6 +243,7 @@ export class BaseService { ctx.ocrRepository, ctx.partnerRepository, ctx.personRepository, + ctx.personUserRepository, ctx.pluginRepository, ctx.processRepository, ctx.searchRepository, @@ -289,6 +300,14 @@ export class BaseService { return checkAccess(this.accessRepository, request); } + requirePersonAccess(request: AccessPersonRequest) { + return requirePersonAccess(this.accessRepository, request); + } + + checkPersonAccess(request: AccessPersonRequest) { + return checkPersonAccess(this.accessRepository, request); + } + async isSetupAvailable(): Promise { const { setup } = this.configRepository.getEnv(); return setup.allow && !(await this.userRepository.hasAdmin()); diff --git a/server/src/services/partner.service.spec.ts b/server/src/services/partner.service.spec.ts index 5cb25fc5b..b8135a63c 100644 --- a/server/src/services/partner.service.spec.ts +++ b/server/src/services/partner.service.spec.ts @@ -1,5 +1,5 @@ import { BadRequestException } from '@nestjs/common'; -import { PartnerDirection } from 'src/repositories/partner.repository.js'; +import { SharingDirection } from 'src/enum.js'; import { PartnerService } from 'src/services/partner.service.js'; import { AuthFactory } from 'test/factories/auth.factory.js'; import { PartnerFactory } from 'test/factories/partner.factory.js'; @@ -29,7 +29,7 @@ describe(PartnerService.name, () => { mocks.partner.getAll.mockResolvedValue([getForPartner(sharedWithUser1), getForPartner(sharedWithUser2)]); - await expect(sut.search(auth, { direction: PartnerDirection.SharedBy })).resolves.toBeDefined(); + await expect(sut.search(auth, { direction: SharingDirection.SharedBy })).resolves.toBeDefined(); expect(mocks.partner.getAll).toHaveBeenCalledWith(user1.id); }); @@ -41,7 +41,7 @@ describe(PartnerService.name, () => { const auth = AuthFactory.create({ id: user1.id }); mocks.partner.getAll.mockResolvedValue([getForPartner(sharedWithUser1), getForPartner(sharedWithUser2)]); - await expect(sut.search(auth, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined(); + await expect(sut.search(auth, { direction: SharingDirection.SharedWith })).resolves.toBeDefined(); expect(mocks.partner.getAll).toHaveBeenCalledWith(user1.id); }); }); diff --git a/server/src/services/partner.service.ts b/server/src/services/partner.service.ts index 4d5d74e0e..9938d7460 100644 --- a/server/src/services/partner.service.ts +++ b/server/src/services/partner.service.ts @@ -3,8 +3,8 @@ import { Partner } from 'src/database.js'; import { AuthDto } from 'src/dtos/auth.dto.js'; import { PartnerCreateDto, PartnerResponseDto, PartnerSearchDto, PartnerUpdateDto } from 'src/dtos/partner.dto.js'; import { mapUser } from 'src/dtos/user.dto.js'; -import { Permission } from 'src/enum.js'; -import { PartnerDirection, PartnerIds } from 'src/repositories/partner.repository.js'; +import { Permission, SharingDirection } from 'src/enum.js'; +import { PartnerIds } from 'src/repositories/partner.repository.js'; import { BaseService } from 'src/services/base.service.js'; @Injectable() @@ -23,7 +23,7 @@ export class PartnerService extends BaseService { } const partner = await this.partnerRepository.create(partnerId); - return this.mapPartner(partner, PartnerDirection.SharedBy); + return this.mapPartner(partner, SharingDirection.SharedBy); } async remove(auth: AuthDto, sharedWithId: string): Promise { @@ -38,7 +38,7 @@ export class PartnerService extends BaseService { async search(auth: AuthDto, { direction }: PartnerSearchDto): Promise { const partners = await this.partnerRepository.getAll(auth.user.id); - const key = direction === PartnerDirection.SharedBy ? 'sharedById' : 'sharedWithId'; + const key = direction === SharingDirection.SharedBy ? 'sharedById' : 'sharedWithId'; return partners .filter((partner): partner is Partner => !!(partner.sharedBy && partner.sharedWith)) // Filter out soft deleted users .filter((partner) => partner[key] === auth.user.id) @@ -50,12 +50,12 @@ export class PartnerService extends BaseService { const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id }; const entity = await this.partnerRepository.update(partnerId, { inTimeline: dto.inTimeline }); - return this.mapPartner(entity, PartnerDirection.SharedWith); + return this.mapPartner(entity, SharingDirection.SharedWith); } - private mapPartner(partner: Partner, direction: PartnerDirection): PartnerResponseDto { + private mapPartner(partner: Partner, direction: SharingDirection): PartnerResponseDto { // this is opposite to return the non-me user of the "partner" - const sharedUser = direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy; + const sharedUser = direction === SharingDirection.SharedBy ? partner.sharedWith : partner.sharedBy; const user = mapUser(sharedUser); return { ...user, inTimeline: partner.inTimeline }; diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 09021ff57..ee0c8d436 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -1,12 +1,13 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto.js'; -import { mapFaces, mapPerson } from 'src/dtos/person.dto.js'; +import { PersonUserRole, mapFaces, mapPerson } from 'src/dtos/person.dto.js'; import { AssetFileType, CacheControl, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum.js'; import { PersonService } from 'src/services/person.service.js'; import { ImmichFileResponse } from 'src/utils/file.js'; import { AssetFaceFactory } from 'test/factories/asset-face.factory.js'; import { AssetFactory } from 'test/factories/asset.factory.js'; import { AuthFactory } from 'test/factories/auth.factory.js'; +import { PartnerFactory } from 'test/factories/partner.factory.js'; import { PersonGroupFactory } from 'test/factories/person-group.factory.js'; import { PersonFactory } from 'test/factories/person.factory.js'; import { UserFactory } from 'test/factories/user.factory.js'; @@ -14,15 +15,20 @@ import { authStub } from 'test/fixtures/auth.stub.js'; import { systemConfigStub } from 'test/fixtures/system-config.stub.js'; import { getAsDetectedFace, + getDehydrated, getForAsset, getForAssetFace, getForDetectedFaces, getForFaceSearch, getForFacialRecognitionJob, + getForPartner, } from 'test/mappers.js'; import { newDate, newUuid } from 'test/small.factory.js'; import { ServiceMocks, makeStream, newTestService } from 'test/utils.js'; +const PERSON_READ_ROLES = [PersonUserRole.Read, PersonUserRole.Write, PersonUserRole.Admin]; +const PERSON_WRITE_ROLES = [PersonUserRole.Write, PersonUserRole.Admin]; + describe(PersonService.name, () => { let sut: PersonService; let mocks: ServiceMocks; @@ -41,7 +47,7 @@ describe(PersonService.name, () => { const [person, hiddenPerson] = [PersonFactory.create(), PersonFactory.create({ isHidden: true })]; mocks.person.getAllForUser.mockResolvedValue({ - items: [person, hiddenPerson], + items: [getDehydrated(person), getDehydrated(hiddenPerson)], hasNextPage: false, }); mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); @@ -67,7 +73,7 @@ describe(PersonService.name, () => { const [isFavorite, person] = [PersonFactory.create({ isFavorite: true }), PersonFactory.create()]; mocks.person.getAllForUser.mockResolvedValue({ - items: [isFavorite, person], + items: [getDehydrated(isFavorite), getDehydrated(person)], hasNextPage: false, }); mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); @@ -93,32 +99,39 @@ describe(PersonService.name, () => { it('should require person.read permission', async () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); - mocks.person.getByGroupId.mockResolvedValue(person); + mocks.person.getForUser.mockResolvedValue(getDehydrated(person)); await expect(sut.getById(auth, person.personGroupId)).rejects.toBeInstanceOf(BadRequestException); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId])); + expect(mocks.access.person.checkAccess).toHaveBeenCalledWith( + auth.user.id, + new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]), + PERSON_READ_ROLES, + ); }); it('should throw a bad request when person is not found', async () => { const auth = AuthFactory.create(); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['unknown'])); + const ids = [{ personGroupId: 'unknown', ownerId: auth.user.id }]; + + mocks.access.person.checkAccess.mockResolvedValue(new Set(ids)); await expect(sut.getById(auth, 'unknown')).rejects.toBeInstanceOf(BadRequestException); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['unknown'])); + expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_READ_ROLES); }); it('should get a person by id', async () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); + const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }]; - mocks.person.getByGroupId.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); + mocks.person.getForUser.mockResolvedValue(getDehydrated(person)); + mocks.access.person.checkAccess.mockResolvedValue(new Set(ids)); await expect(sut.getById(auth, person.personGroupId)).resolves.toEqual( expect.objectContaining({ id: person.personGroupId }), ); - expect(mocks.person.getByGroupId).toHaveBeenCalledWith({ - ownerId: auth.user.id, + expect(mocks.person.getForUser).toHaveBeenCalledWith({ + userId: auth.user.id, personGroupId: person.personGroupId, }); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId])); + expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_READ_ROLES); }); }); @@ -127,38 +140,72 @@ describe(PersonService.name, () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); - mocks.person.getByGroupId.mockResolvedValue(person); + mocks.person.getForThumbnail.mockResolvedValue({ + thumbnailPath: person.thumbnailPath, + sharedThumbnailPath: null, + }); 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.personGroupId])); + expect(mocks.access.person.checkAccess).toHaveBeenCalledWith( + auth.user.id, + new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]), + PERSON_READ_ROLES, + ); }); it('should throw an error when personId is invalid', async () => { const auth = AuthFactory.create(); + const ids = [{ personGroupId: 'unknown', ownerId: auth.user.id }]; - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['unknown'])); + mocks.access.person.checkAccess.mockResolvedValue(new Set(ids)); + mocks.person.getForThumbnail.mockResolvedValue(undefined); await expect(sut.getThumbnail(auth, 'unknown')).rejects.toBeInstanceOf(NotFoundException); expect(mocks.storage.createReadStream).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['unknown'])); + expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_READ_ROLES); }); it('should throw an error when person has no thumbnail', async () => { const auth = AuthFactory.create(); const person = PersonFactory.create({ thumbnailPath: '' }); + const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }]; - mocks.person.getByGroupId.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); + mocks.person.getForThumbnail.mockResolvedValue({ thumbnailPath: '', sharedThumbnailPath: null }); + mocks.access.person.checkAccess.mockResolvedValue(new Set(ids)); 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.personGroupId])); + expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_READ_ROLES); }); it('should serve the thumbnail', async () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); + const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }]; - mocks.person.getByGroupId.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); + mocks.person.getForThumbnail.mockResolvedValue({ + thumbnailPath: person.thumbnailPath, + sharedThumbnailPath: null, + }); + mocks.access.person.checkAccess.mockResolvedValue(new Set(ids)); + await expect(sut.getThumbnail(auth, person.personGroupId)).resolves.toEqual( + new ImmichFileResponse({ + path: person.thumbnailPath, + contentType: 'image/jpeg', + cacheControl: CacheControl.PrivateWithoutCache, + }), + ); + expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_READ_ROLES); + }); + + it('should fall back to a shared thumbnail', async () => { + const auth = AuthFactory.create(); + const person = PersonFactory.create(); + const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }]; + + mocks.person.getForThumbnail.mockResolvedValue({ + thumbnailPath: '', + sharedThumbnailPath: person.thumbnailPath, + }); + mocks.access.person.checkAccess.mockResolvedValue(new Set(ids)); await expect(sut.getThumbnail(auth, person.personGroupId)).resolves.toEqual( new ImmichFileResponse({ path: person.thumbnailPath, @@ -166,39 +213,45 @@ describe(PersonService.name, () => { cacheControl: CacheControl.PrivateWithoutCache, }), ); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId])); }); }); describe('update', () => { it('should require person.write permission', async () => { const auth = AuthFactory.create(); - const person = PersonFactory.create(); + const person = PersonFactory.create({ ownerId: auth.user.id }); - 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.personGroupId])); + expect(mocks.access.person.checkAccess).toHaveBeenCalledWith( + auth.user.id, + new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]), + PERSON_WRITE_ROLES, + ); }); it('should throw an error when personId is invalid', async () => { - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set()); - await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf( - BadRequestException, - ); + const auth = AuthFactory.create(); + + mocks.access.person.checkAccess.mockResolvedValue(new Set()); + await expect(sut.update(auth, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf(BadRequestException); expect(mocks.person.update).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkAccess).toHaveBeenCalledWith( + auth.user.id, + new Set([{ personGroupId: 'person-1', ownerId: auth.user.id }]), + PERSON_WRITE_ROLES, + ); }); it("should update a person's name", async () => { const auth = AuthFactory.create(); - const person = PersonFactory.create({ name: 'Person 1' }); + const person = PersonFactory.create({ ownerId: auth.user.id, name: 'Person 1' }); + const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }]; - mocks.person.getByGroupId.mockResolvedValue(person); mocks.person.update.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); + mocks.access.person.checkAccess.mockResolvedValue(new Set(ids)); await expect(sut.update(auth, person.personGroupId, { name: 'Person 1' })).resolves.toEqual( expect.objectContaining({ id: person.personGroupId, name: 'Person 1' }), @@ -209,16 +262,36 @@ describe(PersonService.name, () => { personGroupId: person.personGroupId, name: 'Person 1', }); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId])); + expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES); + }); + + it("should update another user's person when userId is provided", async () => { + const auth = AuthFactory.create(); + const person = PersonFactory.create({ name: 'Person 1' }); + const ids = [{ personGroupId: person.personGroupId, ownerId: person.ownerId }]; + + mocks.person.update.mockResolvedValue(person); + mocks.access.person.checkAccess.mockResolvedValue(new Set(ids)); + + await expect( + sut.update(auth, person.personGroupId, { name: 'Person 1', userId: person.ownerId }), + ).resolves.toEqual(expect.objectContaining({ id: person.personGroupId, name: 'Person 1' })); + + expect(mocks.person.update).toHaveBeenCalledWith({ + ownerId: person.ownerId, + personGroupId: person.personGroupId, + name: 'Person 1', + }); + expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES); }); it("should update a person's date of birth", async () => { const auth = AuthFactory.create(); - const person = PersonFactory.create({ birthDate: new Date('1976-06-30') }); + const person = PersonFactory.create({ ownerId: auth.user.id, birthDate: new Date('1976-06-30') }); + const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }]; - mocks.person.getByGroupId.mockResolvedValue(person); mocks.person.update.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); + mocks.access.person.checkAccess.mockResolvedValue(new Set(ids)); await expect(sut.update(auth, person.personGroupId, { birthDate: '1976-06-30' })).resolves.toEqual({ id: person.personGroupId, @@ -227,6 +300,9 @@ describe(PersonService.name, () => { thumbnailPath: person.thumbnailPath, isHidden: false, isFavorite: false, + otherPeople: [], + sharedBy: [], + sharedWith: [], updatedAt: expect.any(String), }); expect(mocks.person.update).toHaveBeenCalledWith({ @@ -236,16 +312,16 @@ describe(PersonService.name, () => { }); expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queueAll).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId])); + expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES); }); it('should update a person visibility', async () => { const auth = AuthFactory.create(); - const person = PersonFactory.create({ isHidden: true }); + const person = PersonFactory.create({ ownerId: auth.user.id, isHidden: true }); + const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }]; - mocks.person.getByGroupId.mockResolvedValue(person); mocks.person.update.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); + mocks.access.person.checkAccess.mockResolvedValue(new Set(ids)); await expect(sut.update(auth, person.personGroupId, { isHidden: true })).resolves.toEqual( expect.objectContaining({ isHidden: true }), @@ -256,16 +332,16 @@ describe(PersonService.name, () => { personGroupId: person.personGroupId, isHidden: true, }); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId])); + expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES); }); it('should update a person favorite status', async () => { const auth = AuthFactory.create(); - const person = PersonFactory.create({ isFavorite: true }); + const person = PersonFactory.create({ ownerId: auth.user.id, isFavorite: true }); + const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }]; - mocks.person.getByGroupId.mockResolvedValue(person); mocks.person.update.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); + mocks.access.person.checkAccess.mockResolvedValue(new Set(ids)); await expect(sut.update(auth, person.personGroupId, { isFavorite: true })).resolves.toEqual( expect.objectContaining({ isFavorite: true }), @@ -276,19 +352,19 @@ describe(PersonService.name, () => { personGroupId: person.personGroupId, isFavorite: true, }); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId])); + expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES); }); it("should update a person's thumbnailPath", async () => { const face = AssetFaceFactory.create(); const auth = AuthFactory.create(); - const person = PersonFactory.create(); + const person = PersonFactory.create({ ownerId: auth.user.id }); + const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }]; - 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.personGroupId])); + mocks.access.person.checkAccess.mockResolvedValue(new Set(ids)); await expect(sut.update(auth, person.personGroupId, { featureFaceAssetId: face.assetId })).resolves.toEqual( expect.objectContaining({ id: person.personGroupId }), @@ -307,39 +383,45 @@ describe(PersonService.name, () => { name: JobName.PersonGenerateThumbnail, data: { ownerId: person.ownerId, personGroupId: person.personGroupId }, }); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId])); + expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES); }); it('should throw an error when the face feature assetId is invalid', async () => { const auth = AuthFactory.create(); - const person = PersonFactory.create(); + const person = PersonFactory.create({ ownerId: auth.user.id }); + const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }]; - mocks.person.getByGroupId.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); + mocks.access.person.checkAccess.mockResolvedValue(new Set(ids)); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['-1'])); + mocks.person.getForFeatureFaceUpdate.mockResolvedValue(undefined); 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.personGroupId])); + expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES); }); }); describe('updateAll', () => { it('should throw an error when personId is invalid', async () => { - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.access.person.checkAccess.mockResolvedValue(new Set()); await expect(sut.updateAll(authStub.admin, { people: [{ id: 'person-1', name: 'Person 1' }] })).resolves.toEqual([ { error: BulkIdErrorReason.UNKNOWN, id: 'person-1', success: false }, ]); expect(mocks.person.update).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkAccess).toHaveBeenCalledWith( + authStub.admin.user.id, + new Set([{ personGroupId: 'person-1', ownerId: authStub.admin.user.id }]), + PERSON_WRITE_ROLES, + ); }); }); describe('reassignFaces', () => { it('should throw an error if user has no access to the person', async () => { - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.access.person.checkAccess.mockResolvedValue(new Set()); await expect( sut.reassignFaces(AuthFactory.create(), 'person-id', { @@ -355,7 +437,9 @@ describe(PersonService.name, () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); + mocks.access.person.checkAccess.mockResolvedValue( + new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]), + ); mocks.person.getByGroupId.mockResolvedValue(person); mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id])); mocks.person.getFacesByIds.mockResolvedValue([getForAssetFace(face)]); @@ -421,7 +505,9 @@ describe(PersonService.name, () => { }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); + mocks.access.person.checkAccess.mockResolvedValue( + new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]), + ); mocks.asset.getById.mockResolvedValue(getForAsset(asset)); mocks.person.getByGroupId.mockResolvedValue(person); mocks.person.getRandomFace.mockResolvedValue(featureFace); @@ -472,7 +558,9 @@ 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.personGroupId])); + mocks.access.person.checkAccess.mockResolvedValue( + new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]), + ); mocks.asset.getById.mockResolvedValue(getForAsset(asset)); mocks.person.getByGroupId.mockResolvedValue(person); @@ -501,7 +589,9 @@ describe(PersonService.name, () => { const person = PersonFactory.create({ faceAssetId: null }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set()); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); + mocks.access.person.checkAccess.mockResolvedValue( + new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]), + ); await expect( sut.createFace(auth, { @@ -537,40 +627,48 @@ describe(PersonService.name, () => { describe('reassignFacesById', () => { it('should create a new person', async () => { + const auth = AuthFactory.create(); const face = AssetFaceFactory.create(); - const person = PersonFactory.create(); + const person = PersonFactory.create({ ownerId: auth.user.id, faceAssetId: newUuid() }); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); + mocks.access.person.checkAccess.mockResolvedValue( + new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]), + ); mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id])); mocks.person.getFaceById.mockResolvedValue(getForAssetFace(face)); mocks.person.reassignFace.mockResolvedValue(1); 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), - }, - ); + await expect(sut.reassignFacesById(auth, person.personGroupId, { id: face.id })).resolves.toEqual({ + birthDate: person.birthDate, + isHidden: person.isHidden, + isFavorite: person.isFavorite, + id: person.personGroupId, + name: person.name, + otherPeople: [], + sharedBy: [], + sharedWith: [], + thumbnailPath: person.thumbnailPath, + updatedAt: expect.any(String), + }); expect(mocks.job.queue).not.toHaveBeenCalledWith(); expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); }); it('should fail if user has not the correct permissions on the asset', async () => { + const auth = AuthFactory.create(); const face = AssetFaceFactory.create(); - const person = PersonFactory.create(); + const person = PersonFactory.create({ ownerId: auth.user.id }); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); + mocks.access.person.checkAccess.mockResolvedValue( + new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]), + ); + mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set()); mocks.person.getFaceById.mockResolvedValue(getForAssetFace(face)); mocks.person.reassignFace.mockResolvedValue(1); mocks.person.getByGroupId.mockResolvedValue(person); await expect( - sut.reassignFacesById(AuthFactory.create(), person.personGroupId, { + sut.reassignFacesById(auth, person.personGroupId, { id: face.id, }), ).rejects.toBeInstanceOf(BadRequestException); @@ -1296,22 +1394,50 @@ describe(PersonService.name, () => { it('should get correct number of person', async () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); + const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }]; - mocks.person.getByGroupId.mockResolvedValue(person); mocks.person.getStatistics.mockResolvedValue({ assets: 3 }); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); + mocks.partner.getAll.mockResolvedValue([]); + mocks.access.person.checkAccess.mockResolvedValue(new Set(ids)); await expect(sut.getStatistics(auth, person.personGroupId)).resolves.toEqual({ assets: 3 }); - expect(mocks.person.getStatistics).toHaveBeenCalledWith(person.personGroupId, auth.user.id); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId])); + expect(mocks.person.getStatistics).toHaveBeenCalledWith(person.personGroupId, { + ownerId: auth.user.id, + partnerIds: [], + }); + expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_READ_ROLES); + }); + + it('should only include partners that are shown in the timeline', async () => { + const auth = AuthFactory.create(); + const person = PersonFactory.create(); + const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }]; + const inTimeline = UserFactory.create(); + const notInTimeline = UserFactory.create(); + + mocks.person.getStatistics.mockResolvedValue({ assets: 3 }); + mocks.partner.getAll.mockResolvedValue([ + getForPartner(PartnerFactory.from({ inTimeline: true }).sharedBy(inTimeline).sharedWith(auth.user).build()), + getForPartner(PartnerFactory.from({ inTimeline: false }).sharedBy(notInTimeline).sharedWith(auth.user).build()), + ]); + mocks.access.person.checkAccess.mockResolvedValue(new Set(ids)); + await expect(sut.getStatistics(auth, person.personGroupId)).resolves.toEqual({ assets: 3 }); + expect(mocks.person.getStatistics).toHaveBeenCalledWith(person.personGroupId, { + ownerId: auth.user.id, + partnerIds: [inTimeline.id], + }); }); it('should require person.read permission', async () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); - 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])); + expect(mocks.person.getStatistics).not.toHaveBeenCalled(); + expect(mocks.access.person.checkAccess).toHaveBeenCalledWith( + auth.user.id, + new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]), + PERSON_READ_ROLES, + ); }); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 5f47632c0..13dc96351 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -1,10 +1,9 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { Insertable, Selectable, Updateable } from 'kysely'; +import { Insertable } from 'kysely'; import { isUndefined, omitBy } from 'lodash-es'; import type { JobItem, JobOf } from 'src/types.js'; -import { Person } from 'src/database.js'; import { Chunked, OnJob } from 'src/decorators.js'; -import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto.js'; +import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto.js'; import { AuthDto } from 'src/dtos/auth.dto.js'; import { AssetFaceCreateDto, @@ -13,15 +12,22 @@ import { AssetFaceUpdateDto, FaceDto, MergePersonDto, + PeopleDeleteDto, PeopleResponseDto, PeopleUpdateDto, PersonCreateDto, + PersonDeleteDto, PersonResponseDto, PersonSearchDto, PersonStatisticsResponseDto, PersonUpdateDto, + PersonUsersCreateDto, + PersonUsersDeleteDto, + PersonUsersResponseDto, + PersonUsersSearchDto, mapFaces, mapPerson, + mapPersonUsers, } from 'src/dtos/person.dto.js'; import { AssetVisibility, @@ -36,14 +42,15 @@ import { VectorIndex, } from 'src/enum.js'; import { BoundingBox } from 'src/repositories/machine-learning.repository.js'; -import { PersonId, UpdateFacesData } from 'src/repositories/person.repository.js'; +import { PersonId } from 'src/repositories/person.repository.js'; import { DB } from 'src/schema/index.js'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table.js'; import { FaceSearchTable } from 'src/schema/tables/face-search.table.js'; -import { PersonTable } from 'src/schema/tables/person.table.js'; +import { PersonUserTable } from 'src/schema/tables/person-user.table.js'; import { BaseService } from 'src/services/base.service.js'; -import { getDimensions } from 'src/utils/asset.util.js'; +import { getDimensions, getMyPartnerIds } from 'src/utils/asset.util.js'; import { ImmichFileResponse } from 'src/utils/file.js'; +import { isHttpException } from 'src/utils/logger.js'; import { mimeTypes } from 'src/utils/mime-types.js'; import { batched, findOrFail, isFacialRecognitionEnabled } from 'src/utils/misc.js'; import { Point, transformPoints } from 'src/utils/transform.js'; @@ -53,7 +60,7 @@ const personKey = ({ ownerId, personGroupId }: PersonId) => `${ownerId}/${person @Injectable() export class PersonService extends BaseService { async getAll(auth: AuthDto, dto: PersonSearchDto): Promise { - const { withHidden = false, closestAssetId, closestPersonId, page, size } = dto; + const { withHidden = false, closestAssetId, closestPersonId, page, size, ...filters } = dto; let closestFaceAssetId = closestAssetId; const pagination = { take: size, @@ -73,8 +80,9 @@ export class PersonService extends BaseService { const { items, hasNextPage } = await this.personRepository.getAllForUser(pagination, auth.user.id, { withHidden, closestFaceAssetId, + ...filters, }); - const { total, hidden } = await this.personRepository.getNumberOfPeople(auth.user.id); + const { total, hidden } = await this.personRepository.getNumberOfPeople(auth.user.id, filters); return { people: items.map((person) => mapPerson(person)), @@ -85,8 +93,16 @@ export class PersonService extends BaseService { } async reassignFaces(auth: AuthDto, personGroupId: string, dto: AssetFaceUpdateDto): Promise { - await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personGroupId] }); + const ids = dto.data.map((item) => ({ personGroupId: item.personId, ownerId: item.userId ?? auth.user.id })); + + await this.requirePersonAccess({ + auth, + permission: Permission.PersonUpdate, + ids: [{ personGroupId, ownerId: auth.user.id }, ...ids], + }); + const person = await this.findOrFail(auth, personGroupId); + const result: PersonResponseDto[] = []; const changeFeaturePhoto = new Map(); for (const data of dto.data) { @@ -121,7 +137,11 @@ export class PersonService extends BaseService { } async reassignFacesById(auth: AuthDto, personGroupId: string, dto: FaceDto): Promise { - await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personGroupId] }); + await this.requirePersonAccess({ + auth, + permission: Permission.PersonUpdate, + ids: [{ personGroupId, ownerId: auth.user.id }], + }); await this.requireAccess({ auth, permission: Permission.PersonCreate, ids: [dto.id] }); const face = await this.personRepository.getFaceById(dto.id, { viewingUserId: auth.user.id }); const person = await this.findOrFail(auth, personGroupId); @@ -167,25 +187,46 @@ export class PersonService extends BaseService { } async getById(auth: AuthDto, personGroupId: string): Promise { - await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [personGroupId] }); - return mapPerson(await this.findOrFail(auth, personGroupId)); + await this.requirePersonAccess({ + auth, + permission: Permission.PersonRead, + ids: [{ personGroupId, ownerId: auth.user.id }], + }); + return mapPerson( + await findOrFail(() => this.personRepository.getForUser({ userId: auth.user.id, personGroupId }), 'Person'), + ); } async getStatistics(auth: AuthDto, personGroupId: string): Promise { - await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [personGroupId] }); - return this.personRepository.getStatistics(personGroupId, auth.user.id); + await this.requirePersonAccess({ + auth, + permission: Permission.PersonRead, + ids: [{ personGroupId, ownerId: auth.user.id }], + }); + + const partnerIds = await getMyPartnerIds({ + userId: auth.user.id, + repository: this.partnerRepository, + timelineEnabled: true, + }); + return this.personRepository.getStatistics(personGroupId, { ownerId: auth.user.id, partnerIds }); } async getThumbnail(auth: AuthDto, personGroupId: string): Promise { - await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [personGroupId] }); - const person = await this.personRepository.getByGroupId({ ownerId: auth.user.id, personGroupId }); - if (!person || !person.thumbnailPath) { + await this.requirePersonAccess({ + auth, + permission: Permission.PersonRead, + ids: [{ personGroupId, ownerId: auth.user.id }], + }); + const personGroup = await this.personRepository.getForThumbnail({ ownerId: auth.user.id, personGroupId }); + const thumbnailPath = personGroup?.thumbnailPath || personGroup?.sharedThumbnailPath; + if (!thumbnailPath) { throw new NotFoundException(); } return new ImmichFileResponse({ - path: person.thumbnailPath, - contentType: mimeTypes.lookup(person.thumbnailPath), + path: thumbnailPath, + contentType: mimeTypes.lookup(thumbnailPath), cacheControl: CacheControl.PrivateWithoutCache, }); } @@ -206,9 +247,14 @@ export class PersonService extends BaseService { } async update(auth: AuthDto, personGroupId: string, dto: PersonUpdateDto): Promise { - await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personGroupId] }); + const ownerId = dto.userId ?? auth.user.id; + + await this.requirePersonAccess({ + auth, + permission: Permission.PersonUpdate, + ids: [{ personGroupId, ownerId }], + }); - 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; @@ -240,8 +286,8 @@ export class PersonService extends BaseService { return mapPerson(person); } - delete(auth: AuthDto, id: string): Promise { - return this.deleteAll(auth, { ids: [id] }); + delete(auth: AuthDto, id: string, dto: PersonDeleteDto): Promise { + return this.deleteAll(auth, { ids: [id], ...dto }); } async updateAll(auth: AuthDto, dto: PeopleUpdateDto): Promise { @@ -254,19 +300,28 @@ export class PersonService extends BaseService { birthDate: person.birthDate, featureFaceAssetId: person.featureFaceAssetId, isFavorite: person.isFavorite, + userId: person.userId, }); results.push({ id: person.id, success: true }); } catch (error: Error | any) { - this.logger.error(`Unable to update ${person.id} : ${error}`, error?.stack); + if (!isHttpException(error)) { + this.logger.error(`Unable to update ${person.id} : ${error}`, error?.stack); + } results.push({ id: person.id, success: false, error: BulkIdErrorReason.UNKNOWN }); } } return results; } - async deleteAll(auth: AuthDto, { ids }: BulkIdsDto): Promise { - await this.requireAccess({ auth, permission: Permission.PersonDelete, ids }); - await this.removeAllPersonGroups(ids, auth.user.id); + async deleteAll(auth: AuthDto, { ids, userId }: PeopleDeleteDto): Promise { + const ownerId = userId ?? auth.user.id; + await this.requirePersonAccess({ + auth, + permission: Permission.PersonDelete, + ids: ids.map((id) => ({ personGroupId: id, ownerId })), + }); + await this.removeAllPersonGroups(ids, ownerId); + await this.personRepository.deleteEmptyGroups(); } @Chunked() @@ -277,7 +332,6 @@ export class PersonService extends BaseService { const people = await this.personRepository.delete(groupIds, ownerId); await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath))); - await this.personRepository.deleteEmptyGroups(); this.logger.debug(`Deleted ${groupIds.length} people`); } @@ -595,80 +649,91 @@ export class PersonService extends BaseService { throw new BadRequestException('Cannot merge a person into themselves'); } - const results: BulkIdResponseDto[] = []; + const allowed = await this.checkPersonAccess({ + auth, + permission: Permission.PersonMerge, + ids: ids.map((id) => ({ personGroupId: id, ownerId: auth.user.id })), + }); + const allowedIds = new Set(allowed.values().map((item) => item.personGroupId)); + const results = new Map( + ids.map((id) => [ + id, + { + id, + success: false, + error: allowedIds.has(id) ? BulkIdErrorReason.NOT_FOUND : BulkIdErrorReason.NO_PERMISSION, + }, + ]), + ); - const allowedIds = await this.checkAccess({ auth, permission: Permission.PersonMerge, ids }); + const orderedIds = ids.filter((id) => allowedIds.has(id)); + const [targetId, ...sourceIds] = orderedIds; + const people = targetId ? await this.personRepository.getForMergePerson(orderedIds) : []; + const lookup = new Map(people.map((person) => [personKey(person), person])); + const ownerIds = new Set([auth.user.id, ...people.map((person) => person.ownerId)]); + const targets = new Map( + people.filter((person) => person.personGroupId === targetId).map((person) => [person.ownerId, person]), + ); - const peopleMap: Record[]> = {}; - - for (const mergePerson of await this.personRepository.getForMergePerson(ids)) { - if (!peopleMap[mergePerson.personGroupId]) { - peopleMap[mergePerson.personGroupId] = []; - } - peopleMap[mergePerson.personGroupId].push(mergePerson); + if (targets.has(auth.user.id)) { + results.set(targetId, { id: targetId, success: true }); } - const targetPeople: Record> = {}; - for (const mergeId of ids) { - const hasAccess = allowedIds.has(mergeId); - if (!hasAccess) { - results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NO_PERMISSION }); - continue; - } - - for (const mergePerson of peopleMap[mergeId]) { - if (!targetPeople[mergePerson.ownerId]) { - targetPeople[mergePerson.ownerId] = mergePerson; + for (const id of sourceIds) { + for (const ownerId of ownerIds) { + const isAuthUser = ownerId === auth.user.id; + const source = lookup.get(personKey({ ownerId, personGroupId: id })); + let target = targets.get(ownerId); + if (!source) { continue; } - const targetPerson = targetPeople[mergePerson.ownerId]; - - if ( - mergePerson.ownerId !== auth.user.id && - ((targetPerson.name && mergePerson.name) || (targetPerson.birthDate && mergePerson.birthDate)) - ) { + // skip other users when there are conflicts + if (!isAuthUser && target && ((target.name && source.name) || (target.birthDate && source.birthDate))) { continue; } - const changes: Updateable = omitBy( - { - name: mergePerson.name && !targetPerson.name ? mergePerson.name : undefined, - birthDate: mergePerson.birthDate && !targetPerson.birthDate ? mergePerson.birthDate : undefined, - }, - isUndefined, - ); - - if (Object.keys(changes).length > 0) { - targetPeople[mergePerson.ownerId] = await this.personRepository.update({ - ownerId: targetPerson.ownerId, - personGroupId: targetPerson.personGroupId, - ...changes, - }); - } - - const mergeName = mergePerson.name || mergePerson.personGroupId; - const mergeData: UpdateFacesData = { - oldPersonGroupId: mergeId, - newPersonGroupId: targetPerson.personGroupId, - ownerId: targetPerson.ownerId, - }; - this.logger.log(`Merging ${mergeName} into ${targetPerson.name || targetPerson.personGroupId}`); - try { - await this.personRepository.reassignFaces(mergeData); - await this.removeAllPersonGroups([mergeId], targetPerson.ownerId); + if (!target) { + // user has no person in the target group yet, so this one is updated to it + targets.set( + ownerId, + await this.personRepository.updateGroupId({ ownerId, oldPersonGroupId: id, newPersonGroupId: targetId }), + ); + continue; + } - this.logger.log(`Merged ${mergeName} into ${targetPerson.name || targetPerson.personGroupId}`); - results.push({ id: mergeId, success: true }); + const changes = omitBy( + { + name: source.name && !target.name ? source.name : undefined, + birthDate: source.birthDate && !target.birthDate ? source.birthDate : undefined, + }, + isUndefined, + ); + if (Object.keys(changes).length > 0) { + target = await this.personRepository.update({ ownerId, personGroupId: targetId, ...changes }); + targets.set(ownerId, target); + } + + await this.personRepository.reassignFaces({ oldPersonGroupId: id, newPersonGroupId: targetId, ownerId }); + await this.removeAllPersonGroups([id], ownerId); + this.logger.log(`Merged ${source.name || id} into ${target.name || targetId}`); + + if (isAuthUser) { + results.set(id, { id, success: true }); + } } catch (error: any) { - this.logger.error(`Unable to merge ${mergeId} into ${targetPerson.personGroupId}: ${error}`, error?.stack); - results.push({ id: mergeId, success: false, error: BulkIdErrorReason.UNKNOWN }); + this.logger.error(`Unable to merge ${id} into ${targetId}: ${error}`, error?.stack); + if (isAuthUser) { + results.set(id, { id, success: false, error: BulkIdErrorReason.UNKNOWN }); + } } } } - return results; + await this.personRepository.deleteEmptyGroups(); + + return results.values().toArray(); } private findOrFail(auth: AuthDto, personGroupId: string) { @@ -679,7 +744,11 @@ export class PersonService extends BaseService { async createFace(auth: AuthDto, dto: AssetFaceCreateDto): Promise { await Promise.all([ this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [dto.assetId] }), - this.requireAccess({ auth, permission: Permission.PersonRead, ids: [dto.personId] }), + this.requirePersonAccess({ + auth, + permission: Permission.PersonRead, + ids: [{ personGroupId: dto.personId, ownerId: auth.user.id }], + }), ]); const [asset, person] = await Promise.all([ @@ -707,7 +776,6 @@ export class PersonService extends BaseService { const scaleFactor = asset.width / dto.imageWidth; topLeft = { x: topLeft.x * scaleFactor, y: topLeft.y * scaleFactor }; bottomRight = { x: bottomRight.x * scaleFactor, y: bottomRight.y * scaleFactor }; - const [invertedTopLeft, invertedBottomRight] = transformPoints( [topLeft, bottomRight], edits, @@ -754,6 +822,51 @@ export class PersonService extends BaseService { return dto.force ? this.personRepository.deleteAssetFace(id) : this.personRepository.softDeleteAssetFaces(id); } + async getUsersForPeople(auth: AuthDto, dto: PersonUsersSearchDto): Promise { + const sharedUsers = await this.personUserRepository.searchPeopleUsers(auth.user.id, dto); + + return mapPersonUsers(sharedUsers); + } + + async addUsersToPeople(auth: AuthDto, dto: PersonUsersCreateDto) { + await this.requirePersonAccess({ + auth, + permission: Permission.PersonUpdate, + ids: dto.personIds.map((item) => ({ personGroupId: item, ownerId: auth.user.id })), + }); + + const user = await findOrFail(() => this.userRepository.get(auth.user.id, {}), 'User'); + const clusterGroupUsers = await this.clusterGroupRepository.getUsers({ + clusterGroupId: user.clusterGroupId, + userId: user.id, + }); + const clusterGroupUserIds = new Set(clusterGroupUsers.map(({ id }) => id)); + + const items: Insertable[] = []; + const sharedById = auth.user.id; + + for (const sharedWithId of dto.sharedWithIds) { + if (!clusterGroupUserIds.has(sharedWithId)) { + continue; + } + + for (const personGroupId of dto.personIds) { + items.push({ personGroupId, sharedById, sharedWithId, role: dto.role }); + } + } + + await this.personUserRepository.createAll(items); + } + + async removeUsersFromPeople(auth: AuthDto, dto: PersonUsersDeleteDto) { + await this.requirePersonAccess({ + auth, + permission: Permission.PersonUpdate, + ids: dto.map((item) => ({ personGroupId: item.personId, ownerId: item.sharedById ?? auth.user.id })), + }); + await this.personUserRepository.deleteAll(auth.user.id, dto); + } + private vacuum(...tables: (keyof DB)[]): Promise { return Promise.all( tables.map((table) => diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index efb497b48..0666f2d67 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -1,8 +1,10 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { AuthSharedLink } from 'src/database.js'; import { AuthDto } from 'src/dtos/auth.dto.js'; +import { PersonUserRole } from 'src/dtos/person.dto.js'; import { AlbumUserRole, Permission } from 'src/enum.js'; import { AccessRepository } from 'src/repositories/access.repository.js'; +import { PersonId } from 'src/repositories/person.repository.js'; import { areSetsEqual, isSetSuperset, setDifference, setUnion } from 'src/utils/set.js'; export type GrantedRequest = { @@ -24,6 +26,12 @@ export type AccessRequest = { ids: Set | string[]; }; +export type AccessPersonRequest = { + auth: AuthDto; + permission: Permission; + ids: Set | PersonId[]; +}; + type SharedLinkAccessRequest = { sharedLink: AuthSharedLink; permission: Permission; ids: Set }; type OtherAccessRequest = { auth: AuthDto; permission: Permission; ids: Set }; @@ -41,6 +49,49 @@ export const requireAccess = async (access: AccessRepository, request: AccessReq } }; +const PERSON_READ_ROLES = [PersonUserRole.Read, PersonUserRole.Write, PersonUserRole.Admin]; +const PERSON_WRITE_ROLES = [PersonUserRole.Write, PersonUserRole.Admin]; +const PERSON_ADMIN_ROLES = [PersonUserRole.Admin]; + +const asStringSet = (source: Set): Set => + new Set(source.values().map((item) => `${item.personGroupId}/$${item.ownerId}`)); + +export const requirePersonAccess = async (access: AccessRepository, request: AccessPersonRequest) => { + const allowedIds = await checkPersonAccess(access, request); + if (!areSetsEqual(asStringSet(new Set(request.ids)), asStringSet(allowedIds))) { + throw new BadRequestException(`Not found or no ${request.permission} access`); + } +}; + +export const checkPersonAccess = async ( + access: AccessRepository, + { ids, auth, permission }: AccessPersonRequest, +): Promise> => { + const idSet = Array.isArray(ids) ? new Set(ids) : ids; + if (idSet.size === 0) { + return new Set(); + } + + switch (permission) { + case Permission.PersonRead: { + return access.person.checkAccess(auth.user.id, idSet, PERSON_READ_ROLES); + } + + case Permission.PersonUpdate: { + return access.person.checkAccess(auth.user.id, idSet, PERSON_WRITE_ROLES); + } + + case Permission.PersonDelete: + case Permission.PersonMerge: { + return access.person.checkAccess(auth.user.id, idSet, PERSON_ADMIN_ROLES); + } + + default: { + return new Set(); + } + } +}; + export const checkAccess = async ( access: AccessRepository, { ids, auth, permission }: AccessRequest, @@ -297,13 +348,6 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return access.person.checkFaceOwnerAccess(auth.user.id, ids); } - case Permission.PersonRead: - case Permission.PersonUpdate: - case Permission.PersonDelete: - case Permission.PersonMerge: { - return await access.person.checkOwnerAccess(auth.user.id, ids); - } - case Permission.PersonReassign: { return access.person.checkFaceOwnerAccess(auth.user.id, ids); } diff --git a/server/test/factories/person.factory.ts b/server/test/factories/person.factory.ts index 700180ea8..0ce57db53 100644 --- a/server/test/factories/person.factory.ts +++ b/server/test/factories/person.factory.ts @@ -1,10 +1,8 @@ -import { Selectable } from 'kysely'; -import { PersonTable } from 'src/schema/tables/person.table.js'; -import { PersonLike } from 'test/factories/types.js'; +import { PersonLike, PersonRow } from 'test/factories/types.js'; import { newDate, newUuid, newUuidV7 } from 'test/small.factory.js'; export class PersonFactory { - private constructor(private readonly value: Selectable) {} + private constructor(private readonly value: PersonRow) {} static create(dto: PersonLike = {}) { return PersonFactory.from(dto).build(); @@ -20,6 +18,9 @@ export class PersonFactory { isFavorite: false, isHidden: false, name: 'person', + otherPeople: [], + sharedBy: [], + sharedWith: [], ownerId: newUuid(), thumbnailPath: '/data/thumbs/person-thumbnail.jpg', updatedAt: newDate(), diff --git a/server/test/factories/types.ts b/server/test/factories/types.ts index 2f4e2cabe..6b262209c 100644 --- a/server/test/factories/types.ts +++ b/server/test/factories/types.ts @@ -1,4 +1,6 @@ import { Selectable } from 'kysely'; +import { PersonUser } from 'src/database.js'; +import { PersonUserRole } from 'src/dtos/person.dto.js'; import { OAuthProfile } from 'src/repositories/oauth.repository.js'; import { ActivityTable } from 'src/schema/tables/activity.table.js'; import { AlbumUserTable } from 'src/schema/tables/album-user.table.js'; @@ -30,7 +32,12 @@ export type AlbumUserLike = Partial>; export type SharedLinkLike = Partial>; export type UserLike = Partial>; export type AssetFaceLike = Partial>; -export type PersonLike = Partial>; +export type PersonRow = Selectable & { + otherPeople: { sharedById: string; role: PersonUserRole; name: string; birthDate: string | null }[]; + sharedBy: PersonUser[]; + sharedWith: PersonUser[]; +}; +export type PersonLike = Partial; export type PersonGroupLike = Partial>; export type ClusterGroupLike = Partial>; export type StackLike = Partial>; diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index b30e80e9e..d78e9a0f2 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -10,6 +10,7 @@ import { AssetFace } from 'src/database.js'; import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto.js'; import { SystemConfig } from 'src/dtos/config.dto.js'; import { AssetEditActionItem, AssetEditsCreateDto } from 'src/dtos/editing.dto.js'; +import { PersonUserRole } from 'src/dtos/person.dto.js'; import { AlbumUserRole, AssetType, @@ -49,6 +50,7 @@ import { MetadataRepository } from 'src/repositories/metadata.repository.js'; import { NotificationRepository } from 'src/repositories/notification.repository.js'; import { OcrRepository } from 'src/repositories/ocr.repository.js'; import { PartnerRepository } from 'src/repositories/partner.repository.js'; +import { PersonUserRepository } from 'src/repositories/person-user.repository.js'; import { PersonRepository } from 'src/repositories/person.repository.js'; import { PluginRepository } from 'src/repositories/plugin.repository.js'; import { SearchRepository } from 'src/repositories/search.repository.js'; @@ -75,6 +77,7 @@ import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table.js'; import { AssetTable } from 'src/schema/tables/asset.table.js'; import { FaceSearchTable } from 'src/schema/tables/face-search.table.js'; import { MemoryTable } from 'src/schema/tables/memory.table.js'; +import { PersonUserTable } from 'src/schema/tables/person-user.table.js'; import { PersonTable } from 'src/schema/tables/person.table.js'; import { SessionTable } from 'src/schema/tables/session.table.js'; import { StackTable } from 'src/schema/tables/stack.table.js'; @@ -291,6 +294,14 @@ export class MediumTestContext = return { person, result }; } + async newPersonUser( + dto: Partial> & { personGroupId: string; sharedById: string; sharedWithId: string }, + ) { + const personUser = mediumFactory.personUserInsert(dto); + const [result] = await this.get(PersonUserRepository).createAll([personUser]); + return { personUser, result }; + } + async newSession(dto: Partial> & { userId: string }) { const session = mediumFactory.sessionInsert(dto); const result = await this.get(SessionRepository).create(session); @@ -485,6 +496,7 @@ const newRealRepository = (key: T, db: Kysely case OcrRepository: case PartnerRepository: case PersonRepository: + case PersonUserRepository: case SearchRepository: case SessionRepository: case SharedLinkRepository: @@ -732,6 +744,20 @@ const personInsert = (person: Partial> & { ownerId: stri }; }; +const personUserInsert = ( + personUser: Partial> & { + personGroupId: string; + sharedById: string; + sharedWithId: string; + }, +) => { + const defaults = { + role: personUser.role ?? PersonUserRole.Write, + }; + + return { ...defaults, ...personUser }; +}; + const sha256 = (value: string) => createHash('sha256').update(value).digest(); const sessionInsert = ({ @@ -872,6 +898,7 @@ export const mediumFactory = { albumInsert, faceInsert, personInsert, + personUserInsert, sessionInsert, syncStream, userInsert, diff --git a/server/test/medium/specs/repositories/person-user.repository.spec.ts b/server/test/medium/specs/repositories/person-user.repository.spec.ts new file mode 100644 index 000000000..58ce21c7e --- /dev/null +++ b/server/test/medium/specs/repositories/person-user.repository.spec.ts @@ -0,0 +1,400 @@ +import { Kysely } from 'kysely'; +import { DateTime } from 'luxon'; +import { PersonUserRole } from 'src/dtos/person.dto.js'; +import { SharingDirection } from 'src/enum.js'; +import { LoggingRepository } from 'src/repositories/logging.repository.js'; +import { PersonUserRepository } from 'src/repositories/person-user.repository.js'; +import { PersonRepository } from 'src/repositories/person.repository.js'; +import { person_delete_shares, person_user_after_insert } from 'src/schema/functions.js'; +import { DB } from 'src/schema/index.js'; +import { BaseService } from 'src/services/base.service.js'; +import { MediumTestContext, newMediumService } from 'test/medium.factory.js'; +import { getKyselyDB } from 'test/utils.js'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + const { ctx } = newMediumService(BaseService, { + database: db || defaultDatabase, + real: [], + mock: [LoggingRepository], + }); + return { ctx, sut: ctx.get(PersonUserRepository) }; +}; + +const getPeopleByGroupId = (ctx: MediumTestContext, personGroupId: string) => + ctx.database + .selectFrom('person') + .selectAll() + .where('personGroupId', '=', personGroupId) + .orderBy('createdAt') + .execute(); + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +const newShares = async () => { + const { ctx, sut } = setup(); + const [{ user: owner }, { user: reader }, { user: writer }, { user: stranger }] = [ + await ctx.newUser(), + await ctx.newUser(), + await ctx.newUser(), + await ctx.newUser(), + ]; + const { result: alice } = await ctx.newPerson({ ownerId: owner.id, name: 'Alice' }); + const { result: bob } = await ctx.newPerson({ ownerId: stranger.id, name: 'Bob' }); + + await sut.createAll([ + { + personGroupId: alice.personGroupId, + sharedById: owner.id, + sharedWithId: reader.id, + role: PersonUserRole.Read, + }, + { + personGroupId: alice.personGroupId, + sharedById: owner.id, + sharedWithId: writer.id, + role: PersonUserRole.Write, + }, + { + personGroupId: bob.personGroupId, + sharedById: stranger.id, + sharedWithId: owner.id, + role: PersonUserRole.Admin, + }, + ]); + + return { ctx, sut, owner, reader, writer, stranger, alice, bob }; +}; + +describe(PersonUserRepository.name, () => { + describe('createAll', () => { + describe(person_user_after_insert.name, () => { + it('should create automatically person for the user it was shared with', async () => { + const { ctx, sut } = setup(); + const [{ user: owner }, { user: sharedWith }] = [await ctx.newUser(), await ctx.newUser()]; + const { result: person } = await ctx.newPerson({ + ownerId: owner.id, + name: 'Alice', + birthDate: DateTime.fromISO('1990-04-01').toJSDate(), + }); + + await sut.createAll([ + { + personGroupId: person.personGroupId, + sharedById: owner.id, + sharedWithId: sharedWith.id, + role: PersonUserRole.Read, + }, + ]); + + const people = await getPeopleByGroupId(ctx, person.personGroupId); + + expect(people).toHaveLength(2); + expect(people).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ownerId: owner.id, + personGroupId: person.personGroupId, + }), + expect.objectContaining({ + ownerId: sharedWith.id, + personGroupId: person.personGroupId, + name: person.name, + birthDate: person.birthDate, + }), + ]), + ); + }); + + it('should create a person for each user in a bulk insert', async () => { + const { ctx, sut } = setup(); + const [{ user: owner }, { user: sharedWith1 }, { user: sharedWith2 }] = [ + await ctx.newUser(), + await ctx.newUser(), + await ctx.newUser(), + ]; + const { result: person } = await ctx.newPerson({ ownerId: owner.id, name: 'Alice' }); + + await sut.createAll([ + { + personGroupId: person.personGroupId, + sharedById: owner.id, + sharedWithId: sharedWith1.id, + role: PersonUserRole.Read, + }, + { + personGroupId: person.personGroupId, + sharedById: owner.id, + sharedWithId: sharedWith2.id, + role: PersonUserRole.Write, + }, + ]); + + const people = await getPeopleByGroupId(ctx, person.personGroupId); + + expect(people).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ownerId: owner.id, + personGroupId: person.personGroupId, + }), + expect.objectContaining({ + ownerId: sharedWith1.id, + personGroupId: person.personGroupId, + }), + expect.objectContaining({ + ownerId: sharedWith2.id, + personGroupId: person.personGroupId, + }), + ]), + ); + }); + + it('should create one person when two owners share the same group with a user', async () => { + const { ctx, sut } = setup(); + const [{ user: owner1 }, { user: owner2 }, { user: sharedWith }] = [ + await ctx.newUser(), + await ctx.newUser(), + await ctx.newUser(), + ]; + const { result: person } = await ctx.newPerson({ ownerId: owner1.id, name: 'Alice' }); + await ctx + .get(PersonRepository) + .create({ ownerId: owner2.id, personGroupId: person.personGroupId, name: 'Alice (theirs)' }); + + await sut.createAll([ + { + personGroupId: person.personGroupId, + sharedById: owner1.id, + sharedWithId: sharedWith.id, + role: PersonUserRole.Read, + }, + { + personGroupId: person.personGroupId, + sharedById: owner2.id, + sharedWithId: sharedWith.id, + role: PersonUserRole.Read, + }, + ]); + + const people = await getPeopleByGroupId(ctx, person.personGroupId); + + expect(people).toHaveLength(3); + expect(people.filter(({ ownerId }) => ownerId === sharedWith.id)).toHaveLength(1); + }); + + it('should skip existing records', async () => { + const { ctx, sut } = setup(); + const [{ user: owner }, { user: sharedWith }] = [await ctx.newUser(), await ctx.newUser()]; + const { result: person } = await ctx.newPerson({ ownerId: owner.id, name: 'Alice' }); + const existing = await ctx.get(PersonRepository).create({ + ownerId: sharedWith.id, + personGroupId: person.personGroupId, + name: 'Alice (mine)', + isFavorite: true, + }); + + await sut.createAll([ + { + personGroupId: person.personGroupId, + sharedById: owner.id, + sharedWithId: sharedWith.id, + role: PersonUserRole.Read, + }, + ]); + + const people = await getPeopleByGroupId(ctx, person.personGroupId); + + expect(people).toHaveLength(2); + expect(people).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ownerId: owner.id, + personGroupId: person.personGroupId, + }), + expect.objectContaining({ + ownerId: sharedWith.id, + personGroupId: person.personGroupId, + name: 'Alice (mine)', + isFavorite: true, + // the updatedAt trigger rewrites updateId on any update, so an unchanged + // updateId proves the row was skipped rather than overwritten + updateId: existing.updateId, + }), + ]), + ); + }); + + it('should not create a person when only the role changes', async () => { + const { ctx, sut } = setup(); + const [{ user: owner }, { user: sharedWith }] = [await ctx.newUser(), await ctx.newUser()]; + const { result: person } = await ctx.newPerson({ ownerId: owner.id, name: 'Alice' }); + const share = { + personGroupId: person.personGroupId, + sharedById: owner.id, + sharedWithId: sharedWith.id, + role: PersonUserRole.Read, + }; + + await sut.createAll([share]); + const [, created] = await getPeopleByGroupId(ctx, person.personGroupId); + + await sut.createAll([{ ...share, role: PersonUserRole.Write }]); + + const people = await getPeopleByGroupId(ctx, person.personGroupId); + + expect(people).toHaveLength(2); + expect(people).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ownerId: sharedWith.id, + personGroupId: person.personGroupId, + updateId: created.updateId, + }), + ]), + ); + await expect(sut.searchPeopleUsers(owner.id, {})).resolves.toEqual([ + expect.objectContaining({ sharedWithId: sharedWith.id, role: PersonUserRole.Write }), + ]); + }); + }); + }); + + describe('searchPeopleUsers', () => { + it('should return shares in both directions by default', async () => { + const { sut, owner, reader, writer } = await newShares(); + + const results = await sut.searchPeopleUsers(owner.id, {}); + + expect(results).toHaveLength(3); + expect(results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ sharedWithId: reader.id, role: PersonUserRole.Read }), + expect.objectContaining({ sharedWithId: writer.id, role: PersonUserRole.Write }), + expect.objectContaining({ sharedWithId: owner.id, role: PersonUserRole.Admin }), + ]), + ); + }); + + it('should filter by role', async () => { + const { sut, owner, writer } = await newShares(); + + await expect(sut.searchPeopleUsers(owner.id, { role: PersonUserRole.Write })).resolves.toEqual([ + expect.objectContaining({ sharedWithId: writer.id, role: PersonUserRole.Write }), + ]); + }); + + it('should filter by sharedWithId', async () => { + const { sut, owner, reader } = await newShares(); + + await expect(sut.searchPeopleUsers(owner.id, { sharedWithId: reader.id })).resolves.toEqual([ + expect.objectContaining({ sharedWithId: reader.id, role: PersonUserRole.Read }), + ]); + }); + + it('should filter by sharedById', async () => { + const { sut, owner, stranger } = await newShares(); + + await expect(sut.searchPeopleUsers(owner.id, { sharedById: stranger.id })).resolves.toEqual([ + expect.objectContaining({ sharedById: stranger.id, sharedWithId: owner.id }), + ]); + }); + + it('should filter by personId', async () => { + const { sut, owner, bob } = await newShares(); + + await expect(sut.searchPeopleUsers(owner.id, { personId: bob.personGroupId })).resolves.toEqual([ + expect.objectContaining({ personId: bob.personGroupId, sharedWithId: owner.id }), + ]); + }); + + it('should only return shares the owner created for the shared-by direction', async () => { + const { sut, owner, reader, writer } = await newShares(); + + const results = await sut.searchPeopleUsers(owner.id, { direction: SharingDirection.SharedBy }); + + expect(results).toHaveLength(2); + expect(results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ sharedWithId: reader.id }), + expect.objectContaining({ sharedWithId: writer.id }), + ]), + ); + }); + + it('should only return shares pointed at the owner for the shared-with direction', async () => { + const { sut, owner, stranger } = await newShares(); + + await expect(sut.searchPeopleUsers(owner.id, { direction: SharingDirection.SharedWith })).resolves.toEqual([ + expect.objectContaining({ sharedById: stranger.id, sharedWithId: owner.id }), + ]); + }); + + it('should combine role and direction filters', async () => { + const { sut, owner } = await newShares(); + + await expect( + sut.searchPeopleUsers(owner.id, { direction: SharingDirection.SharedBy, role: PersonUserRole.Admin }), + ).resolves.toEqual([]); + }); + }); + describe(person_delete_shares.name, () => { + it('should delete shares pointing at a user when their person is deleted', async () => { + const { ctx, sut } = setup(); + const [{ user: owner }, { user: sharedWith }] = [await ctx.newUser(), await ctx.newUser()]; + const { result: person } = await ctx.newPerson({ ownerId: owner.id, name: 'Alice' }); + + await sut.createAll([ + { + personGroupId: person.personGroupId, + sharedById: owner.id, + sharedWithId: sharedWith.id, + role: PersonUserRole.Read, + }, + ]); + await expect(sut.searchPeopleUsers(owner.id, {})).resolves.toHaveLength(1); + + // the recipient deletes their own copy, as PersonService.deleteAll does + await ctx.get(PersonRepository).delete([person.personGroupId], sharedWith.id); + + await expect(sut.searchPeopleUsers(owner.id, {})).resolves.toEqual([]); + expect(await getPeopleByGroupId(ctx, person.personGroupId)).toEqual([ + expect.objectContaining({ ownerId: owner.id }), + ]); + }); + + it('should leave shares with other users intact', async () => { + const { ctx, sut } = setup(); + const [{ user: owner }, { user: sharedWith1 }, { user: sharedWith2 }] = [ + await ctx.newUser(), + await ctx.newUser(), + await ctx.newUser(), + ]; + const { result: person } = await ctx.newPerson({ ownerId: owner.id, name: 'Alice' }); + + await sut.createAll([ + { + personGroupId: person.personGroupId, + sharedById: owner.id, + sharedWithId: sharedWith1.id, + role: PersonUserRole.Read, + }, + { + personGroupId: person.personGroupId, + sharedById: owner.id, + sharedWithId: sharedWith2.id, + role: PersonUserRole.Read, + }, + ]); + + await ctx.get(PersonRepository).delete([person.personGroupId], sharedWith1.id); + + await expect(sut.searchPeopleUsers(owner.id, {})).resolves.toEqual([ + expect.objectContaining({ sharedWithId: sharedWith2.id }), + ]); + }); + }); +}); diff --git a/server/test/medium/specs/repositories/person.repository.spec.ts b/server/test/medium/specs/repositories/person.repository.spec.ts index c17fdc9a5..1fc4e12f0 100644 --- a/server/test/medium/specs/repositories/person.repository.spec.ts +++ b/server/test/medium/specs/repositories/person.repository.spec.ts @@ -1,10 +1,12 @@ import { Kysely } from 'kysely'; +import { PersonUserRole } from 'src/dtos/person.dto.js'; import { AssetFileType } from 'src/enum.js'; import { LoggingRepository } from 'src/repositories/logging.repository.js'; +import { PersonUserRepository } from 'src/repositories/person-user.repository.js'; import { PersonRepository } from 'src/repositories/person.repository.js'; import { DB } from 'src/schema/index.js'; import { BaseService } from 'src/services/base.service.js'; -import { newMediumService } from 'test/medium.factory.js'; +import { MediumTestContext, newMediumService } from 'test/medium.factory.js'; import { getKyselyDB } from 'test/utils.js'; let defaultDatabase: Kysely; @@ -18,6 +20,31 @@ const setup = (db?: Kysely) => { return { ctx, sut: ctx.get(PersonRepository) }; }; +const listFor = async (sut: PersonRepository, userId: string, options?: { withHidden: boolean }) => { + const { items } = await sut.getAllForUser({ take: 100, skip: 0 }, userId, options); + return items; +}; + +// a named person with at least one visible face clears the minimum-faces rule +const newNamedPerson = async (ctx: MediumTestContext, ownerId: string, name: string) => { + const { result: person } = await ctx.newPerson({ ownerId, name }); + const { asset } = await ctx.newAsset({ ownerId }); + await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId }); + return person; +}; + +const otherPeopleOf = async (sut: PersonRepository, ctx: MediumTestContext, userId: string, groupId: string) => { + const listItems = await listFor(sut, userId); + const listed = listItems.find((item) => item.personGroupId === groupId); + const forUser = await sut.getForUser({ userId, personGroupId: groupId }); + const byGroupId = await sut.getByGroupId({ ownerId: userId, personGroupId: groupId }); + return { + getAllForUser: listed?.otherPeople, + getForUser: forUser?.otherPeople, + getByGroupId: byGroupId?.otherPeople, + }; +}; + beforeAll(async () => { defaultDatabase = await getKyselyDB(); }); @@ -128,6 +155,77 @@ describe(PersonRepository.name, () => { }); }); + describe('otherPeople', () => { + it('should report the person shared with the user from every lookup', async () => { + const { ctx, sut } = setup(); + const [{ user: owner }, { user: recipient }] = [await ctx.newUser(), await ctx.newUser()]; + const person = await newNamedPerson(ctx, owner.id, 'Alice'); + + await ctx.get(PersonUserRepository).createAll([ + { + personGroupId: person.personGroupId, + sharedById: owner.id, + sharedWithId: recipient.id, + role: PersonUserRole.Write, + }, + ]); + + const expected = [{ sharedById: owner.id, role: PersonUserRole.Write, name: 'Alice', birthDate: null }]; + const results = await otherPeopleOf(sut, ctx, recipient.id, person.personGroupId); + + expect(results.getAllForUser).toEqual(expected); + expect(results.getForUser).toEqual(expected); + expect(results.getByGroupId).toEqual(expected); + }); + + it('should not report a share pointed at a different user', async () => { + const { ctx, sut } = setup(); + const [{ user: owner }, { user: first }, { user: second }] = [ + await ctx.newUser(), + await ctx.newUser(), + await ctx.newUser(), + ]; + const person = await newNamedPerson(ctx, owner.id, 'Alice'); + + await ctx.get(PersonUserRepository).createAll([ + { + personGroupId: person.personGroupId, + sharedById: owner.id, + sharedWithId: first.id, + role: PersonUserRole.Read, + }, + { + personGroupId: person.personGroupId, + sharedById: owner.id, + sharedWithId: second.id, + role: PersonUserRole.Read, + }, + ]); + + const expected = [{ sharedById: owner.id, role: PersonUserRole.Read, name: 'Alice', birthDate: null }]; + const results = await otherPeopleOf(sut, ctx, first.id, person.personGroupId); + + expect(results.getAllForUser).toEqual(expected); + expect(results.getForUser).toEqual(expected); + expect(results.getByGroupId).toEqual(expected); + }); + + it('should be empty for a group the user was never shared into', async () => { + const { ctx, sut } = setup(); + const [{ user: owner }, { user: other }] = [await ctx.newUser(), await ctx.newUser()]; + const person = await newNamedPerson(ctx, owner.id, 'Alice'); + + await ctx.database + .insertInto('person') + .values({ ownerId: other.id, name: 'Alice', personGroupId: person.personGroupId }) + .execute(); + + const results = await otherPeopleOf(sut, ctx, other.id, person.personGroupId); + + expect(results.getForUser).toEqual([]); + expect(results.getByGroupId).toEqual([]); + }); + }); describe('deleteEmptyGroups', () => { it('should delete groups that no longer have any people', async () => { const { ctx, sut } = setup(await getKyselyDB()); @@ -332,4 +430,108 @@ describe(PersonRepository.name, () => { ).resolves.toEqual(undefined); }); }); + + describe('getAllForUser', () => { + it('should return the user own people', async () => { + const { ctx, sut } = setup(await getKyselyDB()); + const { user } = await ctx.newUser(); + const person = await newNamedPerson(ctx, user.id, 'Alice'); + + await expect(listFor(sut, user.id)).resolves.toEqual([ + expect.objectContaining({ ownerId: user.id, personGroupId: person.personGroupId, name: 'Alice' }), + ]); + }); + + it('should not return people belonging to another user', async () => { + const { ctx, sut } = setup(await getKyselyDB()); + const [{ user: user1 }, { user: user2 }] = [await ctx.newUser(), await ctx.newUser()]; + await newNamedPerson(ctx, user1.id, 'Alice'); + + await expect(listFor(sut, user2.id)).resolves.toEqual([]); + }); + + it('should return a person shared with the user', async () => { + const { ctx, sut } = setup(await getKyselyDB()); + const [{ user: owner }, { user: sharedWith }] = [await ctx.newUser(), await ctx.newUser()]; + const person = await newNamedPerson(ctx, owner.id, 'Alice'); + + await ctx.get(PersonUserRepository).createAll([ + { + personGroupId: person.personGroupId, + sharedById: owner.id, + sharedWithId: sharedWith.id, + role: PersonUserRole.Read, + }, + ]); + + await expect(listFor(sut, sharedWith.id)).resolves.toEqual([ + expect.objectContaining({ + ownerId: sharedWith.id, + personGroupId: person.personGroupId, + // the trigger seeds the name from the sharer + name: 'Alice', + otherPeople: [expect.objectContaining({ sharedById: owner.id, name: 'Alice' })], + }), + ]); + }); + + it('should return a shared person even when it has no faces of its own', async () => { + const { ctx, sut } = setup(await getKyselyDB()); + const [{ user: owner }, { user: sharedWith }] = [await ctx.newUser(), await ctx.newUser()]; + // no faces at all, so only the "shared people are always included" rule can let it through + const { result: person } = await ctx.newPerson({ ownerId: owner.id, name: 'Alice' }); + + await ctx.get(PersonUserRepository).createAll([ + { + personGroupId: person.personGroupId, + sharedById: owner.id, + sharedWithId: sharedWith.id, + role: PersonUserRole.Read, + }, + ]); + + await expect(listFor(sut, sharedWith.id)).resolves.toEqual([ + expect.objectContaining({ ownerId: sharedWith.id, personGroupId: person.personGroupId }), + ]); + }); + + it('should stop returning a shared person once the user deletes their copy', async () => { + const { ctx, sut } = setup(await getKyselyDB()); + const [{ user: owner }, { user: sharedWith }] = [await ctx.newUser(), await ctx.newUser()]; + const person = await newNamedPerson(ctx, owner.id, 'Alice'); + + await ctx.get(PersonUserRepository).createAll([ + { + personGroupId: person.personGroupId, + sharedById: owner.id, + sharedWithId: sharedWith.id, + role: PersonUserRole.Read, + }, + ]); + await expect(listFor(sut, sharedWith.id)).resolves.toHaveLength(1); + + await sut.delete([person.personGroupId], sharedWith.id); + + await expect(listFor(sut, sharedWith.id)).resolves.toEqual([]); + // the owner still has theirs + await expect(listFor(sut, owner.id)).resolves.toHaveLength(1); + }); + + it('should exclude hidden people unless asked for them', async () => { + const { ctx, sut } = setup(await getKyselyDB()); + const { user } = await ctx.newUser(); + const visible = await newNamedPerson(ctx, user.id, 'Alice'); + const hidden = await newNamedPerson(ctx, user.id, 'Bob'); + await ctx.database + .updateTable('person') + .set({ isHidden: true }) + .where('personGroupId', '=', hidden.personGroupId) + .execute(); + + await expect(listFor(sut, user.id)).resolves.toEqual([ + expect.objectContaining({ personGroupId: visible.personGroupId }), + ]); + await expect(listFor(sut, user.id, { withHidden: true })).resolves.toHaveLength(2); + }); + }); }); diff --git a/server/test/medium/specs/services/partner.service.spec.ts b/server/test/medium/specs/services/partner.service.spec.ts index 581065a81..7f06e6664 100644 --- a/server/test/medium/specs/services/partner.service.spec.ts +++ b/server/test/medium/specs/services/partner.service.spec.ts @@ -1,7 +1,8 @@ import { Kysely } from 'kysely'; +import { SharingDirection } from 'src/enum.js'; import { AccessRepository } from 'src/repositories/access.repository.js'; import { LoggingRepository } from 'src/repositories/logging.repository.js'; -import { PartnerDirection, PartnerRepository } from 'src/repositories/partner.repository.js'; +import { PartnerRepository } from 'src/repositories/partner.repository.js'; import { UserRepository } from 'src/repositories/user.repository.js'; import { DB } from 'src/schema/index.js'; import { PartnerService } from 'src/services/partner.service.js'; @@ -61,7 +62,7 @@ describe(PartnerService.name, () => { const { user: partner } = await ctx.newUser(); await ctx.newPartner({ sharedById: user.id, sharedWithId: partner.id }); - await expect(sut.search(factory.auth({ user }), { direction: PartnerDirection.SharedBy })).resolves.toEqual([ + await expect(sut.search(factory.auth({ user }), { direction: SharingDirection.SharedBy })).resolves.toEqual([ expect.objectContaining({ id: partner.id }), ]); }); @@ -72,7 +73,7 @@ describe(PartnerService.name, () => { const { user: partner } = await ctx.newUser(); await ctx.newPartner({ sharedById: partner.id, sharedWithId: user.id }); - await expect(sut.search(factory.auth({ user }), { direction: PartnerDirection.SharedWith })).resolves.toEqual([ + await expect(sut.search(factory.auth({ user }), { direction: SharingDirection.SharedWith })).resolves.toEqual([ expect.objectContaining({ id: partner.id }), ]); }); @@ -111,7 +112,7 @@ describe(PartnerService.name, () => { await ctx.newPartner({ sharedById: user.id, sharedWithId: partner.id }); await expect(sut.remove(factory.auth({ user }), partner.id)).resolves.toBeUndefined(); - await expect(sut.search(factory.auth({ user }), { direction: PartnerDirection.SharedBy })).resolves.toEqual([]); + await expect(sut.search(factory.auth({ user }), { direction: SharingDirection.SharedBy })).resolves.toEqual([]); }); it('should throw when the partner does not exist', async () => { diff --git a/server/test/medium/specs/services/person.service.spec.ts b/server/test/medium/specs/services/person.service.spec.ts index f898849f9..e76582f0b 100644 --- a/server/test/medium/specs/services/person.service.spec.ts +++ b/server/test/medium/specs/services/person.service.spec.ts @@ -1,24 +1,28 @@ +import { NotFoundException } from '@nestjs/common'; import { Kysely } from 'kysely'; import { DateTime } from 'luxon'; import { AssetEditAction, MirrorAxis } from 'src/dtos/editing.dto.js'; -import { AssetFaceCreateDto } from 'src/dtos/person.dto.js'; +import { AssetFaceCreateDto, PersonSearchDto, PersonUserRole } from 'src/dtos/person.dto.js'; import { AssetFileType, JobName } from 'src/enum.js'; import { AccessRepository } from 'src/repositories/access.repository.js'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository.js'; import { AssetJobRepository } from 'src/repositories/asset-job.repository.js'; import { AssetRepository } from 'src/repositories/asset.repository.js'; +import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository.js'; import { ConfigRepository } from 'src/repositories/config.repository.js'; import { DatabaseRepository } from 'src/repositories/database.repository.js'; import { JobRepository } from 'src/repositories/job.repository.js'; import { LoggingRepository } from 'src/repositories/logging.repository.js'; import { MachineLearningRepository } from 'src/repositories/machine-learning.repository.js'; +import { PersonUserRepository } from 'src/repositories/person-user.repository.js'; import { PersonRepository } from 'src/repositories/person.repository.js'; import { StorageRepository } from 'src/repositories/storage.repository.js'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository.js'; +import { UserRepository } from 'src/repositories/user.repository.js'; import { DB } from 'src/schema/index.js'; import { PersonService } from 'src/services/person.service.js'; import { newMediumService } from 'test/medium.factory.js'; -import { factory } from 'test/small.factory.js'; +import { factory, newUuid } from 'test/small.factory.js'; import { getKyselyDB } from 'test/utils.js'; let defaultDatabase: Kysely; @@ -32,9 +36,12 @@ const setup = (db?: Kysely) => { ConfigRepository, DatabaseRepository, PersonRepository, + PersonUserRepository, AssetRepository, AssetEditRepository, SystemMetadataRepository, + UserRepository, + ClusterGroupRepository, ], mock: [JobRepository, LoggingRepository, StorageRepository, MachineLearningRepository], }); @@ -45,12 +52,263 @@ beforeAll(async () => { }); describe(PersonService.name, () => { + describe('reassignFaces', () => { + it('should require access to the person the auth user is assigning to', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user2.id }); + + await expect(sut.reassignFaces(factory.auth({ user }), person.personGroupId, { data: [] })).rejects.toThrow( + 'Not found or no person.update access', + ); + }); + + it('should allow person owner access', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id }); + + await expect(sut.reassignFaces(factory.auth({ user }), person.personGroupId, { data: [] })).resolves.toEqual([]); + }); + + it('should not allow shared read only access', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user2.id }); + await ctx.newPersonUser({ + personGroupId: person.personGroupId, + sharedById: user2.id, + sharedWithId: user.id, + role: PersonUserRole.Read, + }); + + await expect( + sut.reassignFaces(factory.auth({ user }), person.personGroupId, { + data: [{ personId: person.personGroupId, userId: user2.id, assetId: newUuid() }], + }), + ).rejects.toThrow('Not found or no person.update access'); + }); + + it('should allow shared write access', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user2.id }); + await ctx.newPersonUser({ + personGroupId: person.personGroupId, + sharedById: user2.id, + sharedWithId: user.id, + role: PersonUserRole.Write, + }); + + await expect( + sut.reassignFaces(factory.auth({ user }), person.personGroupId, { + data: [{ personId: person.personGroupId, userId: user2.id, assetId: newUuid() }], + }), + ).resolves.toEqual([expect.objectContaining({ id: person.personGroupId })]); + }); + }); + + describe('getAll', () => { + it('should filter by sharing direction and user', async () => { + const { ctx, sut } = setup(await getKyselyDB()); + const { user: user1 } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser({ clusterGroupId: user1.clusterGroupId }); + const { user: user3 } = await ctx.newUser({ clusterGroupId: user1.clusterGroupId }); + const { asset } = await ctx.newAsset({ ownerId: user1.id }); + + const { person: sharedByMe } = await ctx.newPerson({ ownerId: user1.id, name: 'Shared by me' }); + const { person: notShared } = await ctx.newPerson({ ownerId: user1.id, name: 'Not shared' }); + const { person: sharedWithMe } = await ctx.newPerson({ ownerId: user3.id, name: 'Shared with me' }); + await ctx.newAssetFace({ assetId: asset.id, personGroupId: sharedByMe.personGroupId }); + await ctx.newAssetFace({ assetId: asset.id, personGroupId: notShared.personGroupId }); + await ctx.newPersonUser({ + personGroupId: sharedByMe.personGroupId, + sharedById: user1.id, + sharedWithId: user2.id, + }); + await ctx.newPersonUser({ + personGroupId: sharedWithMe.personGroupId, + sharedById: user3.id, + sharedWithId: user1.id, + }); + + const auth = factory.auth({ user: user1 }); + const getIds = async (dto: Partial) => { + const { people, total } = await sut.getAll(auth, { page: 1, size: 10, ...dto }); + expect(total).toBe(people.length); + return people.map(({ id }) => id).toSorted(); + }; + + await expect(getIds({})).resolves.toEqual( + [sharedByMe.personGroupId, notShared.personGroupId, sharedWithMe.personGroupId].toSorted(), + ); + await expect(getIds({ sharedById: user1.id })).resolves.toEqual([sharedByMe.personGroupId]); + await expect(getIds({ sharedWithId: user2.id })).resolves.toEqual([sharedByMe.personGroupId]); + await expect(getIds({ sharedWithId: user3.id })).resolves.toEqual([]); + await expect(getIds({ sharedWithId: user1.id })).resolves.toEqual([sharedWithMe.personGroupId]); + await expect(getIds({ sharedById: user3.id })).resolves.toEqual([sharedWithMe.personGroupId]); + await expect(getIds({ sharedById: user2.id })).resolves.toEqual([]); + }); + + it('should filter by favorite and hidden', async () => { + const { ctx, sut } = setup(await getKyselyDB()); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const { person: favorite } = await ctx.newPerson({ ownerId: user.id, isFavorite: true }); + const { person: hidden } = await ctx.newPerson({ ownerId: user.id, isHidden: true }); + const { person: neither } = await ctx.newPerson({ ownerId: user.id }); + for (const person of [favorite, hidden, neither]) { + await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId }); + } + + const auth = factory.auth({ user }); + const getIds = async (dto: Partial) => { + const { people, total } = await sut.getAll(auth, { page: 1, size: 10, withHidden: true, ...dto }); + expect(total).toBe(people.length); + return people.map(({ id }) => id).toSorted(); + }; + + await expect(getIds({ isFavorite: true })).resolves.toEqual([favorite.personGroupId]); + await expect(getIds({ isFavorite: false })).resolves.toEqual( + [hidden.personGroupId, neither.personGroupId].toSorted(), + ); + await expect(getIds({ isHidden: true })).resolves.toEqual([hidden.personGroupId]); + await expect(getIds({ isHidden: false })).resolves.toEqual( + [favorite.personGroupId, neither.personGroupId].toSorted(), + ); + await expect(getIds({ isFavorite: true, isHidden: true })).resolves.toEqual([]); + }); + + it('should include hidden people when filtering by isHidden without withHidden', async () => { + const { ctx, sut } = setup(await getKyselyDB()); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const { person: hidden } = await ctx.newPerson({ ownerId: user.id, isHidden: true }); + const { person: visible } = await ctx.newPerson({ ownerId: user.id }); + await ctx.newAssetFace({ assetId: asset.id, personGroupId: hidden.personGroupId }); + await ctx.newAssetFace({ assetId: asset.id, personGroupId: visible.personGroupId }); + + const auth = factory.auth({ user }); + + await expect(sut.getAll(auth, { page: 1, size: 10, isHidden: true })).resolves.toEqual( + expect.objectContaining({ people: [expect.objectContaining({ id: hidden.personGroupId })], total: 1 }), + ); + await expect(sut.getAll(auth, { page: 1, size: 10, isHidden: false })).resolves.toEqual( + expect.objectContaining({ people: [expect.objectContaining({ id: visible.personGroupId })], total: 1 }), + ); + }); + }); + + describe('getById', () => { + it('should require person.read access', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id }); + + await expect(sut.getById(factory.auth(), person.personGroupId)).rejects.toThrow( + 'Not found or no person.read access', + ); + }); + + it('should return own version of shared person but copy over a shared name and birth date', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user2.id, name: "User2's person", birthDate: new Date() }); + await ctx.newPersonUser({ personGroupId: person.personGroupId, sharedById: user2.id, sharedWithId: user.id }); + + await expect(sut.getById(factory.auth({ user }), person.personGroupId)).resolves.toEqual( + expect.objectContaining({ + id: person.personGroupId, + name: person.name, + birthDate: expect.any(String), + otherPeople: [expect.objectContaining({ sharedById: user2.id })], + }), + ); + }); + + it('should include the users the person is shared by and shared with', async () => { + const { ctx, sut } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: user1 } = await ctx.newUser({ clusterGroupId: owner.clusterGroupId }); + const { person } = await ctx.newPerson({ ownerId: owner.id }); + await ctx.newPersonUser({ + personGroupId: person.personGroupId, + sharedById: owner.id, + sharedWithId: user1.id, + role: PersonUserRole.Write, + }); + + await expect(sut.getById(factory.auth({ user: owner }), person.personGroupId)).resolves.toEqual( + expect.objectContaining({ + sharedBy: [], + sharedWith: [expect.objectContaining({ id: user1.id, email: user1.email, role: PersonUserRole.Write })], + }), + ); + await expect(sut.getById(factory.auth({ user: user1 }), person.personGroupId)).resolves.toEqual( + expect.objectContaining({ + sharedBy: [expect.objectContaining({ id: owner.id, email: owner.email, role: PersonUserRole.Write })], + sharedWith: [], + }), + ); + }); + }); + + describe('getThumbnail', () => { + it('should require person.read access', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id }); + + await expect(sut.getThumbnail(factory.auth(), person.personGroupId)).rejects.toThrow( + 'Not found or no person.read access', + ); + }); + + it('should return own thumbnail path', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id }); + + await expect(sut.getThumbnail(factory.auth({ user }), person.personGroupId)).resolves.toEqual( + expect.objectContaining({ path: person.thumbnailPath }), + ); + }); + + it('should fall back to shared thumbnail path', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user2.id }); + await ctx.newPersonUser({ personGroupId: person.personGroupId, sharedById: user2.id, sharedWithId: user.id }); + + await expect(sut.getThumbnail(factory.auth({ user }), person.personGroupId)).resolves.toEqual( + expect.objectContaining({ path: person.thumbnailPath }), + ); + }); + + it('should fail if there is no (shared) thumbnail available', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id, thumbnailPath: undefined }); + await ctx.newPerson({ ownerId: user2.id, personGroupId: person.personGroupId }); + + await expect(sut.getThumbnail(factory.auth({ user }), person.personGroupId)).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + }); + describe('delete', () => { it('should throw an error when there is no access', async () => { const { sut } = setup(); const auth = factory.auth(); const personId = factory.uuid(); - await expect(sut.delete(auth, personId)).rejects.toThrow('Not found or no person.delete access'); + await expect(sut.delete(auth, personId, {})).rejects.toThrow('Not found or no person.delete access'); }); it('should delete the person', async () => { @@ -65,7 +323,7 @@ describe(PersonService.name, () => { await expect(personRepo.getByGroupId(person)).resolves.toEqual( expect.objectContaining({ personGroupId: person.personGroupId }), ); - await expect(sut.delete(auth, person.personGroupId)).resolves.toBeUndefined(); + await expect(sut.delete(auth, person.personGroupId, {})).resolves.toBeUndefined(); await expect(personRepo.getByGroupId(person)).resolves.toBeUndefined(); expect(storageMock.unlink).toHaveBeenCalledWith(person.thumbnailPath); @@ -299,33 +557,6 @@ describe(PersonService.name, () => { ]), ); }); - - it('should not merge into person another user does not have', async () => { - const { sut, ctx } = setup(); - const storageMock = ctx.getMock(StorageRepository); - const { user: user1 } = await ctx.newUser(); - const { user: user2 } = await ctx.newUser({ clusterGroupId: user1.clusterGroupId }); - const { person: person1 } = await ctx.newPerson({ ownerId: user1.id }); - const { person: person2 } = await ctx.newPerson({ ownerId: user1.id }); - await ctx.newPerson({ - ownerId: user2.id, - personGroupId: person2.personGroupId, - }); - const { asset } = await ctx.newAsset({ ownerId: user2.id }); - await ctx.newAssetFace({ assetId: asset.id, personGroupId: person2.personGroupId }); - storageMock.unlink.mockResolvedValue(); - - const auth = factory.auth({ user: user1 }); - - await sut.mergePeople(auth, { ids: [person1.personGroupId, person2.personGroupId] }); - const user1People = await Array.fromAsync(ctx.get(PersonRepository).getAll({ ownerId: user1.id })); - const user2People = await Array.fromAsync(ctx.get(PersonRepository).getAll({ ownerId: user2.id })); - expect(user1People).toEqual([expect.objectContaining({ personGroupId: person1.personGroupId })]); - expect(user2People).toEqual([expect.objectContaining({ personGroupId: person2.personGroupId })]); - await expect(ctx.get(PersonRepository).getFaces(asset.id, { viewingUserId: asset.ownerId })).resolves.toEqual([ - expect.objectContaining({ personGroupId: person2.personGroupId }), - ]); - }); }); describe('createFace', () => { @@ -1012,4 +1243,171 @@ describe(PersonService.name, () => { ); }); }); + + describe('addUsersToPeople', () => { + it('should skip sharedWith users that are not in the same cluster group', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: user1 } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: owner.id }); + const auth = factory.auth({ user: owner }); + + await sut.addUsersToPeople(auth, { + personIds: [person.personGroupId], + sharedWithIds: [user1.id], + role: PersonUserRole.Read, + }); + + await expect(sut.getUsersForPeople(auth, {})).resolves.toHaveLength(0); + }); + + it('should add user to person', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: user1 } = await ctx.newUser({ clusterGroupId: owner.clusterGroupId }); + const { person } = await ctx.newPerson({ ownerId: owner.id }); + const auth = factory.auth({ user: owner }); + + await sut.addUsersToPeople(auth, { + personIds: [person.personGroupId], + sharedWithIds: [user1.id], + role: PersonUserRole.Read, + }); + + await expect(sut.getUsersForPeople(auth, {})).resolves.toEqual([ + expect.objectContaining({ + sharedBy: expect.objectContaining({ id: owner.id }), + sharedWith: expect.objectContaining({ id: user1.id }), + }), + ]); + }); + + it('should update the name and birthdate when sharing to an existing copy', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: user1 } = await ctx.newUser({ clusterGroupId: owner.clusterGroupId }); + const { person } = await ctx.newPerson({ ownerId: owner.id, name: 'Owner name', birthDate: '1990-01-01' }); + await ctx.newPerson({ ownerId: user1.id, personGroupId: person.personGroupId, name: '', birthDate: null }); + + await sut.addUsersToPeople(factory.auth({ user: owner }), { + personIds: [person.personGroupId], + sharedWithIds: [user1.id], + role: PersonUserRole.Read, + }); + + await expect(sut.getById(factory.auth({ user: user1 }), person.personGroupId)).resolves.toEqual( + expect.objectContaining({ name: 'Owner name', birthDate: '1990-01-01' }), + ); + }); + + it('should skip updating when sharing to an existing copy with values', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: user1 } = await ctx.newUser({ clusterGroupId: owner.clusterGroupId }); + const { person } = await ctx.newPerson({ ownerId: owner.id, name: 'Owner name', birthDate: '1990-01-01' }); + await ctx.newPerson({ + ownerId: user1.id, + personGroupId: person.personGroupId, + name: 'User1 name', + birthDate: '2000-02-02', + }); + + await sut.addUsersToPeople(factory.auth({ user: owner }), { + personIds: [person.personGroupId], + sharedWithIds: [user1.id], + role: PersonUserRole.Read, + }); + + await expect(sut.getById(factory.auth({ user: user1 }), person.personGroupId)).resolves.toEqual( + expect.objectContaining({ name: 'User1 name', birthDate: '2000-02-02' }), + ); + }); + }); + + describe('removeUsersFromPeople', () => { + it('should work with an empty list', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: sharedWith } = await ctx.newUser({ clusterGroupId: owner.clusterGroupId }); + const { person } = await ctx.newPerson({ ownerId: owner.id }); + const auth = factory.auth({ user: owner }); + + await sut.addUsersToPeople(auth, { + personIds: [person.personGroupId], + sharedWithIds: [sharedWith.id], + role: PersonUserRole.Read, + }); + + await sut.removeUsersFromPeople(auth, []); + + await expect(sut.getUsersForPeople(auth, {})).resolves.toHaveLength(1); + }); + + it('should throw an error when there is no access', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: owner } = await ctx.newUser({ clusterGroupId: user.clusterGroupId }); + const { person } = await ctx.newPerson({ ownerId: owner.id }); + const auth = factory.auth({ user }); + + await expect( + sut.removeUsersFromPeople(auth, [{ personId: person.personGroupId, sharedWithId: user.id }]), + ).rejects.toThrow('Not found or no person.update access'); + }); + + it('should delete only the requested person and user pairs', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: user1 } = await ctx.newUser({ clusterGroupId: owner.clusterGroupId }); + const { user: user2 } = await ctx.newUser({ clusterGroupId: owner.clusterGroupId }); + const { person: person1 } = await ctx.newPerson({ ownerId: owner.id }); + const { person: person2 } = await ctx.newPerson({ ownerId: owner.id }); + const auth = factory.auth({ user: owner }); + + await sut.addUsersToPeople(auth, { + personIds: [person1.personGroupId, person2.personGroupId], + sharedWithIds: [user1.id, user2.id], + role: PersonUserRole.Read, + }); + + await expect(sut.getUsersForPeople(auth, {})).resolves.toHaveLength(4); + + await sut.removeUsersFromPeople(auth, [ + { personId: person1.personGroupId, sharedWithId: user1.id }, + { personId: person2.personGroupId, sharedWithId: user2.id }, + ]); + + const shares = await sut.getUsersForPeople(auth, {}); + expect(shares).toHaveLength(2); + expect(shares).toEqual( + expect.arrayContaining([ + expect.objectContaining({ personId: person1.personGroupId, sharedWithId: user2.id }), + expect.objectContaining({ personId: person2.personGroupId, sharedWithId: user1.id }), + ]), + ); + }); + + it('should not delete the same pair shared by another user', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: otherOwner } = await ctx.newUser({ clusterGroupId: owner.clusterGroupId }); + const { user: sharedWith } = await ctx.newUser({ clusterGroupId: owner.clusterGroupId }); + const { person } = await ctx.newPerson({ ownerId: owner.id }); + await ctx.newPerson({ ownerId: otherOwner.id, personGroupId: person.personGroupId }); + + const auth = factory.auth({ user: owner }); + const otherAuth = factory.auth({ user: otherOwner }); + const dto = { personIds: [person.personGroupId], sharedWithIds: [sharedWith.id], role: PersonUserRole.Read }; + + await sut.addUsersToPeople(auth, dto); + await sut.addUsersToPeople(otherAuth, dto); + + await sut.removeUsersFromPeople(auth, [{ personId: person.personGroupId, sharedWithId: sharedWith.id }]); + + await expect(sut.getUsersForPeople(auth, {})).resolves.toEqual([]); + await expect(sut.getUsersForPeople(otherAuth, {})).resolves.toEqual([ + expect.objectContaining({ personId: person.personGroupId, sharedWithId: sharedWith.id }), + ]); + }); + }); }); diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 1bb9f33ae..3c3e3ea59 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -61,7 +61,7 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => { person: { checkFaceOwnerAccess: vitest.fn().mockResolvedValue(new Set()), - checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + checkAccess: vitest.fn().mockResolvedValue(new Set()), }, partner: { diff --git a/server/test/utils.ts b/server/test/utils.ts index 5f0850b4b..fe9a33603 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -52,6 +52,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository import { OAuthRepository } from 'src/repositories/oauth.repository.js'; import { OcrRepository } from 'src/repositories/ocr.repository.js'; import { PartnerRepository } from 'src/repositories/partner.repository.js'; +import { PersonUserRepository } from 'src/repositories/person-user.repository.js'; import { PersonRepository } from 'src/repositories/person.repository.js'; import { PluginRepository } from 'src/repositories/plugin.repository.js'; import { ProcessRepository } from 'src/repositories/process.repository.js'; @@ -266,6 +267,7 @@ export type ServiceOverrides = { oauth: OAuthRepository; partner: PartnerRepository; person: PersonRepository; + personUser: PersonUserRepository; plugin: PluginRepository; process: ProcessRepository; search: SearchRepository; @@ -352,6 +354,7 @@ export const getMocks = () => { oauth: automock(OAuthRepository, { args: [loggerMock] }), partner: automock(PartnerRepository, { strict: false }), person: automock(PersonRepository, { strict: false }), + personUser: automock(PersonUserRepository, { strict: false }), plugin: automock(PluginRepository, { strict: true, args: [databaseMock, loggerMock] }), process: automock(ProcessRepository), search: automock(SearchRepository, { strict: false }), @@ -426,6 +429,7 @@ export const newTestService = ( overrides.ocr || (mocks.ocr as As), overrides.partner || (mocks.partner as As), overrides.person || (mocks.person as As), + overrides.personUser || (mocks.personUser as As), overrides.plugin || (mocks.plugin as As), overrides.process || (mocks.process as As), overrides.search || (mocks.search as As), diff --git a/web/eslint.config.js b/web/eslint.config.js index 048e8200d..8d54fef10 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -1,10 +1,11 @@ import js from '@eslint/js'; import tslintPluginCompat from '@koddsson/eslint-plugin-tscompat'; -import prettier from 'eslint-config-prettier'; import eslintPluginBetterTailwindcss from 'eslint-plugin-better-tailwindcss'; import eslintPluginCompat from 'eslint-plugin-compat'; +import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import eslintPluginSvelte from 'eslint-plugin-svelte'; import eslintPluginUnicorn from 'eslint-plugin-unicorn'; +import { defineConfig } from 'eslint/config'; import globals from 'globals'; import parser from 'svelte-eslint-parser'; import typescriptEslint from 'typescript-eslint'; @@ -19,14 +20,18 @@ const NAVIGATION_PARAM_READ = [ " [arguments.0.property.name='PREVIOUS_ROUTE'])", ].join(''); -export default typescriptEslint.config( +export default defineConfig( ...eslintPluginSvelte.configs.recommended, eslintPluginUnicorn.configs.recommended, js.configs.recommended, - prettier, { plugins: { tscompat: tslintPluginCompat, + compat: eslintPluginCompat, + }, + settings: { + polyfills: [], + lintAllEsApis: true, }, rules: { 'tscompat/tscompat': [ @@ -39,6 +44,7 @@ export default typescriptEslint.config( .filter((line) => line && !line.startsWith('#')), }, ], + 'compat/compat': 'error', }, languageOptions: { parser, @@ -49,18 +55,6 @@ export default typescriptEslint.config( }, // ignores: ['**/service-worker/**'], }, - { - plugins: { - compat: eslintPluginCompat, - }, - settings: { - polyfills: [], - lintAllEsApis: true, - }, - rules: { - 'compat/compat': 'error', - }, - }, { ignores: [ '**/.DS_Store', @@ -203,4 +197,5 @@ export default typescriptEslint.config( }, }, }, + eslintPluginPrettierRecommended, ); diff --git a/web/package.json b/web/package.json index 13686f3c8..6a14cd205 100644 --- a/web/package.json +++ b/web/package.json @@ -98,6 +98,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-better-tailwindcss": "^4.5.0", "eslint-plugin-compat": "^7.0.0", + "eslint-plugin-prettier": "^5.5.6", "eslint-plugin-svelte": "^3.12.4", "eslint-plugin-unicorn": "^75.0.0", "factory.ts": "^1.4.1", diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 5079e44c0..f5569e1bc 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -47,12 +47,16 @@ export enum QueryParameter { ACTION = 'action', ASSET_ID = 'assetId', ID = 'id', + IS_FAVORITE = 'isFavorite', + IS_HIDDEN = 'isHidden', IS_OPEN = 'isOpen', IS_SAVED = 'isSaved', OPEN_SETTING = 'openSetting', PREVIOUS_ROUTE = 'previousRoute', QUERY = 'query', SEARCHED_PEOPLE = 'searchedPeople', + SHARED_BY_ID = 'sharedById', + SHARED_WITH_ID = 'sharedWithId', SMART_SEARCH = 'smartSearch', PAGE = 'page', PATH = 'path', diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index da28453aa..2caf78e55 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -8,6 +8,7 @@ import type { LibraryResponseDto, LoginResponseDto, PersonResponseDto, + PersonUserRole, QueueResponseDto, ReleaseEventV1, SharedLinkResponseDto, @@ -53,6 +54,9 @@ export type Events = { AlbumUserDelete: [{ albumId: string; userId: string }]; PersonUpdate: [PersonResponseDto]; + PersonShare: [{ personId: string }]; + PersonUserUpdate: [{ personId: string; userId: string; role: PersonUserRole }]; + PersonUserDelete: [{ personId: string; userId: string }]; PersonThumbnailReady: [{ id: string }]; PersonAssetDelete: [{ id: string; assetId: string }]; diff --git a/web/src/lib/modals/AlbumAddUsersModal.svelte b/web/src/lib/modals/AddUsersModal.svelte similarity index 80% rename from web/src/lib/modals/AlbumAddUsersModal.svelte rename to web/src/lib/modals/AddUsersModal.svelte index 5b66bd736..87177b187 100644 --- a/web/src/lib/modals/AlbumAddUsersModal.svelte +++ b/web/src/lib/modals/AddUsersModal.svelte @@ -1,9 +1,8 @@ @@ -84,7 +91,7 @@ {:else} - {$t('album_share_no_users')} + {emptyMessage} {/each} {/if} diff --git a/web/src/lib/modals/AlbumOptionsModal.svelte b/web/src/lib/modals/AlbumOptionsModal.svelte index 40d605daa..1ebce5bed 100644 --- a/web/src/lib/modals/AlbumOptionsModal.svelte +++ b/web/src/lib/modals/AlbumOptionsModal.svelte @@ -102,7 +102,7 @@
- {$t('people')} + {$t('users')} {#if !readOnly} {/if} diff --git a/web/src/lib/modals/PeopleFilterModal.svelte b/web/src/lib/modals/PeopleFilterModal.svelte new file mode 100644 index 000000000..cd229bba4 --- /dev/null +++ b/web/src/lib/modals/PeopleFilterModal.svelte @@ -0,0 +1,137 @@ + + +{#snippet row(id: string, label: string, userId: string, onPick: () => Promise)} + {@const user = getUser(userId)} +
+ + +
+{/snippet} + + + + {@render row('people-filter-shared-by', $t('shared_by'), sharedById, onPickSharedBy)} + {@render row('people-filter-shared-with', $t('shared_with'), sharedWithId, onPickSharedWith)} +
+ + isFavorite === true, () => (isFavorite = cycle(isFavorite))} + indeterminate={isFavorite === undefined} + /> +
+
+ + isHidden === true, () => (isHidden = cycle(isHidden))} + indeterminate={isHidden === undefined} + /> +
+
+ +
+
+
diff --git a/web/src/lib/modals/PeopleFilterUserPicker.svelte b/web/src/lib/modals/PeopleFilterUserPicker.svelte new file mode 100644 index 000000000..668c8d48a --- /dev/null +++ b/web/src/lib/modals/PeopleFilterUserPicker.svelte @@ -0,0 +1,42 @@ + + + onClose()} size="small"> + + {#if allowEmpty} + onClose('')}> +
+ +
+ {$t('no_filter')} +
+ {/if} + {#each [authManager.user, ...users] as user (user.id)} + onClose(user.id)}> + +
+ {user.id === authManager.user.id ? $t('you') : user.name} + {user.email} +
+
+ {/each} +
+
diff --git a/web/src/lib/modals/PersonEditAccessModal.svelte b/web/src/lib/modals/PersonEditAccessModal.svelte new file mode 100644 index 000000000..5cebaa323 --- /dev/null +++ b/web/src/lib/modals/PersonEditAccessModal.svelte @@ -0,0 +1,90 @@ + + + + + + + {$t('manage_person_access_description')} + + + + {$t('users')} + + +
+ {#each personUsers as { sharedWith, role } (sharedWith.id)} + {@const { Delete } = getPersonUserActions($t, person, sharedWith)} +
+
+
+
+ +
+ {sharedWith.name} +
+ + + + + + (targetPerson.birthDate ? DateTime.fromISO(targetPerson.birthDate) : undefined), + (value) => (targetPerson.birthDate = value?.toISO() ?? null) + } + maxDate={DateTime.now()} + /> + {$t('birthdate_set_description')} + + + {#if candidates.length > 1 && isWritable} + + + + {/if} + + diff --git a/web/src/lib/services/album.service.ts b/web/src/lib/services/album.service.ts index dfda3c278..64b7d2378 100644 --- a/web/src/lib/services/album.service.ts +++ b/web/src/lib/services/album.service.ts @@ -34,7 +34,7 @@ import { goto } from '$app/navigation'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; -import AlbumAddUsersModal from '$lib/modals/AlbumAddUsersModal.svelte'; +import AddUsersModal from '$lib/modals/AddUsersModal.svelte'; import AlbumEditModal from '$lib/modals/AlbumEditModal.svelte'; import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; @@ -66,10 +66,15 @@ export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) = const isEditor = isAlbumEditor(album); const AddUsers: ActionItem = { - title: $t('invite_people'), + title: $t('add_user'), icon: mdiPlus, color: 'primary', - onAction: () => modalManager.show(AlbumAddUsersModal, { album }), + onAction: () => + modalManager.show(AddUsersModal, { + excludedUserIds: album.albumUsers.map(({ user: { id } }) => id), + // TODO that explicit UserResponseDto[] shouldn't be necessary, but svelte's types seem to be messed up right now and AlbumAddUsersModal has a bad type + onAddUsers: (users: UserResponseDto[]) => handleAddUsersToAlbum(album, users), + }), }; const CreateSharedLink: ActionItem = { diff --git a/web/src/lib/services/person-user.service.ts b/web/src/lib/services/person-user.service.ts new file mode 100644 index 000000000..39bc29a82 --- /dev/null +++ b/web/src/lib/services/person-user.service.ts @@ -0,0 +1,100 @@ +import { + addUsersToPeople, + getClusterGroupUsers, + PersonUserRole, + removeUsersFromPeople, + type PersonResponseDto, + type PersonUsersResponseDto, + type UserResponseDto, +} from '@immich/sdk'; +import { modalManager, toastManager, type ActionItem } from '@immich/ui'; +import { mdiCheck, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import { type MessageFormatter } from 'svelte-i18n'; +import { authManager } from '$lib/managers/auth-manager.svelte'; +import { eventManager } from '$lib/managers/event-manager.svelte'; +import AddUsersModal from '$lib/modals/AddUsersModal.svelte'; +import { handleError } from '$lib/utils/handle-error'; +import { getFormatter } from '$lib/utils/i18n'; + +export const getPersonUsersActions = ( + $t: MessageFormatter, + person: PersonResponseDto, + personUsers: PersonUsersResponseDto, +) => { + const AddUsers: ActionItem = { + title: $t('add_user'), + icon: mdiPlus, + color: 'primary', + onAction: () => + modalManager.show(AddUsersModal, { + emptyMessage: $t('person_share_no_users'), + excludedUserIds: [authManager.user.id, ...personUsers.map(({ sharedWithId }) => sharedWithId)], + loadUsers: () => getClusterGroupUsers({ id: authManager.user.clusterGroupId }), + onAddUsers: (users: UserResponseDto[]) => handleSharePersonWithUsers(person, users), + }), + }; + + return { AddUsers }; +}; + +export const getPersonUserActions = ($t: MessageFormatter, person: PersonResponseDto, user: UserResponseDto) => { + const Delete: ActionItem = { + title: $t('delete_user'), + icon: mdiTrashCanOutline, + onAction: () => handleDeletePersonUser(person, user), + }; + + return { Delete }; +}; + +export const handleSharePersonWithUsers = async (person: PersonResponseDto, users: UserResponseDto[]) => { + const $t = await getFormatter(); + + try { + await addUsersToPeople({ + personUsersCreateDto: { + personIds: [person.id], + sharedWithIds: users.map(({ id }) => id), + role: PersonUserRole.Read, + }, + }); + eventManager.emit('PersonShare', { personId: person.id }); + return true; + } catch (error) { + handleError(error, $t('errors.something_went_wrong')); + } +}; + +export const handleUpdatePersonUserRole = async ({ + personId, + userId, + role, +}: { + personId: string; + userId: string; + role: PersonUserRole; +}) => { + const $t = await getFormatter(); + + try { + await addUsersToPeople({ + personUsersCreateDto: { personIds: [personId], sharedWithIds: [userId], role }, + }); + eventManager.emit('PersonUserUpdate', { personId, userId, role }); + toastManager.primary({ icon: mdiCheck, title: $t('saved') }); + } catch (error) { + handleError(error, $t('errors.something_went_wrong')); + } +}; + +export const handleDeletePersonUser = async (person: PersonResponseDto, user: UserResponseDto) => { + const $t = await getFormatter(); + + try { + await removeUsersFromPeople({ personUsersDeleteDto: [{ personId: person.id, sharedWithId: user.id }] }); + eventManager.emit('PersonUserDelete', { personId: person.id, userId: user.id }); + toastManager.primary({ icon: mdiCheck, title: $t('saved') }); + } catch (error) { + handleError(error, $t('errors.something_went_wrong')); + } +}; diff --git a/web/src/lib/services/person.service.ts b/web/src/lib/services/person.service.ts index 9835c5e82..9f8f16010 100644 --- a/web/src/lib/services/person.service.ts +++ b/web/src/lib/services/person.service.ts @@ -1,24 +1,34 @@ -import { updatePerson, type AssetResponseDto, type PersonResponseDto } from '@immich/sdk'; +import { + getPerson, + updatePeople, + updatePerson, + type AssetResponseDto, + type PeopleUpdateDto, + type PersonResponseDto, + type PersonUpdateDto, +} from '@immich/sdk'; import { modalManager, toastManager, type ActionItem } from '@immich/ui'; import { - mdiCalendarEditOutline, + mdiAccountMultipleOutline, mdiEyeOffOutline, mdiEyeOutline, mdiFaceManProfile, mdiHeartMinusOutline, mdiHeartOutline, + mdiPencilOutline, } from '@mdi/js'; import type { MessageFormatter } from 'svelte-i18n'; import { eventManager } from '$lib/managers/event-manager.svelte'; -import PersonEditBirthDateModal from '$lib/modals/PersonEditBirthDateModal.svelte'; +import PersonEditAccessModal from '$lib/modals/PersonEditAccessModal.svelte'; +import PersonEditModal from '$lib/modals/PersonEditModal.svelte'; import { handleError } from '$lib/utils/handle-error'; import { getFormatter } from '$lib/utils/i18n'; export const getPersonActions = ($t: MessageFormatter, person: PersonResponseDto) => { - const SetDateOfBirth: ActionItem = { - title: $t('set_date_of_birth'), - icon: mdiCalendarEditOutline, - onAction: () => modalManager.show(PersonEditBirthDateModal, { person }), + const Edit: ActionItem = { + title: $t('edit_person'), + icon: mdiPencilOutline, + onAction: () => modalManager.show(PersonEditModal, { person }), }; const Favorite: ActionItem = { @@ -49,7 +59,13 @@ export const getPersonActions = ($t: MessageFormatter, person: PersonResponseDto onAction: () => handleShowPerson(person), }; - return { SetDateOfBirth, Favorite, Unfavorite, HidePerson, ShowPerson }; + const Access: ActionItem = { + title: 'Manage access', + icon: mdiAccountMultipleOutline, + onAction: () => modalManager.show(PersonEditAccessModal, { person }), + }; + + return { Edit, Favorite, Unfavorite, HidePerson, ShowPerson, Access }; }; export const getPersonAssetActions = ($t: MessageFormatter, person: PersonResponseDto, asset: AssetResponseDto) => { @@ -98,6 +114,41 @@ const handleHidePerson = async (person: { id: string }) => { } }; +export const handleUpdatePerson = async (id: string, personUpdateDto: PersonUpdateDto) => { + const $t = await getFormatter(); + + try { + await updatePerson({ id, personUpdateDto }); + return true; + } catch (error) { + handleError(error, $t('errors.something_went_wrong')); + } +}; + +export const handleUpdatePeople = async (peopleUpdateDto: PeopleUpdateDto) => { + const $t = await getFormatter(); + + try { + const bulkResponse = await updatePeople({ peopleUpdateDto }); + + const ids = new Set(peopleUpdateDto.people.map(({ id }) => id)); + const responses = await Promise.all([...ids].map((id) => getPerson({ id }))); + for (const response of responses) { + eventManager.emit('PersonUpdate', response); + } + + if (bulkResponse.some((response) => !response.success)) { + toastManager.danger($t('errors.something_went_wrong')); + return false; + } + + return true; + } catch (error) { + console.log('uh oh'); + handleError(error, $t('errors.something_went_wrong')); + } +}; + const handleShowPerson = async (person: { id: string }) => { const $t = await getFormatter(); diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 0d42b3cdc..43b044e14 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -76,6 +76,13 @@ export type SearchFilter = { rating?: number | null; }; +export type PeopleFilter = { + sharedById?: string; + sharedWithId?: string; + isFavorite?: boolean; + isHidden?: boolean; +}; + export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object'; export type JSONSchemaProperty = { diff --git a/web/src/lib/utils/person.spec.ts b/web/src/lib/utils/person.spec.ts index 3e4d4f36b..fe489dc59 100644 --- a/web/src/lib/utils/person.spec.ts +++ b/web/src/lib/utils/person.spec.ts @@ -7,6 +7,9 @@ const makePerson = (overrides: Partial = {}): PersonResponseD thumbnailPath: '', isHidden: false, birthDate: null, + otherPeople: [], + sharedBy: [], + sharedWith: [], ...overrides, }); diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index 63f7cf4dc..e22a76d36 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -9,6 +9,7 @@ import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte'; import OnEvents from '$lib/components/OnEvents.svelte'; import { QueryParameter, SessionStorageKey } from '$lib/constants'; + import PeopleFilterModal from '$lib/modals/PeopleFilterModal.svelte'; import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte'; import { Route } from '$lib/route'; import { locale } from '$lib/stores/preferences.store'; @@ -18,8 +19,8 @@ import { handleError } from '$lib/utils/handle-error'; import { clearQueryParam } from '$lib/utils/navigation'; import { getAllPeople, getPerson, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk'; - import { Button, Icon, modalManager, toastManager } from '@immich/ui'; - import { mdiAccountOff, mdiEyeOutline } from '@mdi/js'; + import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui'; + import { mdiAccountOff, mdiEyeOutline, mdiTune } from '@mdi/js'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -72,7 +73,7 @@ handlePromiseError( Promise.all( Array.from({ length: pagesToLoad }, (_, i) => { - return getAllPeople({ withHidden: true, page: startingPage + i }); + return getAllPeople({ withHidden: true, page: startingPage + i, ...data.filter }); }), ).then((pages) => { for (const page of pages) { @@ -96,7 +97,11 @@ } try { - const { people: newPeople, hasNextPage } = await getAllPeople({ withHidden: true, page: nextPage }); + const { people: newPeople, hasNextPage } = await getAllPeople({ + withHidden: true, + page: nextPage, + ...data.filter, + }); people = people.concat(newPeople); if (nextPage !== null) { currentPage = nextPage; @@ -165,14 +170,44 @@ await goto(Route.viewPerson(detail, { previousRoute: Route.people(), action: 'merge' })); }; + const hasFilter = $derived(Object.values(data.filter).some((value) => value !== undefined)); + + const handleFilter = async () => { + const filter = await modalManager.show(PeopleFilterModal, { filter: data.filter }); + if (!filter) { + return; + } + + const url = new URL($page.url); + for (const [key, value] of [ + [QueryParameter.SHARED_BY_ID, filter.sharedById], + [QueryParameter.SHARED_WITH_ID, filter.sharedWithId], + [QueryParameter.IS_FAVORITE, filter.isFavorite], + [QueryParameter.IS_HIDDEN, filter.isHidden], + ] as const) { + if (value === undefined) { + url.searchParams.delete(key); + } else { + url.searchParams.set(key, String(value)); + } + } + + await goto(url, { keepFocus: true }); + currentPage = 1; + nextPage = data.people.hasNextPage ? 2 : null; + }; + const onResetSearchBar = async () => { await clearQueryParam(QueryParameter.SEARCHED_PEOPLE, $page.url); }; let people = $derived(data.people.people); - let visiblePeople = $derived(people.filter((people) => !people.isHidden)); - let countVisiblePeople = $derived(searchName ? searchedPeopleLocal.length : data.people.total - data.people.hidden); + // hidden people are only shown when explicitly filtering for them + let visiblePeople = $derived(data.filter.isHidden ? people : people.filter((people) => !people.isHidden)); + let countVisiblePeople = $derived( + searchName ? searchedPeopleLocal.length : data.people.total - (data.filter.isHidden ? 0 : data.people.hidden), + ); let showPeople = $derived(searchName ? searchedPeopleLocal : visiblePeople); const onNameChangeInputFocus = (person: PersonResponseDto) => { @@ -272,8 +307,8 @@ ]} > {#snippet buttons()} - {#if people.length > 0} -
+
+ {#if people.length > 0} {/snippet} {#if countVisiblePeople > 0 && (!searchName || searchedPeopleLocal.length > 0)} diff --git a/web/src/routes/(user)/people/+page.ts b/web/src/routes/(user)/people/+page.ts index 4ae6a4503..d18fb86c8 100644 --- a/web/src/routes/(user)/people/+page.ts +++ b/web/src/routes/(user)/people/+page.ts @@ -1,4 +1,6 @@ import { getAllPeople } from '@immich/sdk'; +import { QueryParameter } from '$lib/constants'; +import type { PeopleFilter } from '$lib/types'; import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; import type { PageLoad } from './$types'; @@ -6,11 +8,23 @@ import type { PageLoad } from './$types'; export const load = (async ({ url }) => { await authenticate(url); - const people = await getAllPeople({ withHidden: true }); + const getBoolean = (key: QueryParameter) => { + const value = url.searchParams.get(key); + return value === null ? undefined : value === 'true'; + }; + + const filter: PeopleFilter = { + sharedById: url.searchParams.get(QueryParameter.SHARED_BY_ID) ?? undefined, + sharedWithId: url.searchParams.get(QueryParameter.SHARED_WITH_ID) ?? undefined, + isFavorite: getBoolean(QueryParameter.IS_FAVORITE), + isHidden: getBoolean(QueryParameter.IS_HIDDEN), + }; + const people = await getAllPeople({ withHidden: true, ...filter }); const $t = await getFormatter(); return { people, + filter, meta: { title: $t('people'), }, diff --git a/web/src/routes/(user)/people/PeopleCard.svelte b/web/src/routes/(user)/people/PeopleCard.svelte index 335c382ab..91a658084 100644 --- a/web/src/routes/(user)/people/PeopleCard.svelte +++ b/web/src/routes/(user)/people/PeopleCard.svelte @@ -15,11 +15,11 @@ let { person, onMergePeople }: Props = $props(); - const { SetDateOfBirth, HidePerson, Favorite, Unfavorite } = $derived(getPersonActions($t, person)); + const { Edit, HidePerson, Favorite, Unfavorite, Access } = $derived(getPersonActions($t, person)); const items = $derived([ + Edit, HidePerson, - SetDateOfBirth, { icon: mdiAccountMultipleCheckOutline, title: $t('merge_people'), @@ -27,6 +27,7 @@ }, Favorite, Unfavorite, + Access, ]); diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 23b2c4e9a..d70d0878f 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -27,6 +27,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; + import PersonEditModal from '$lib/modals/PersonEditModal.svelte'; import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte'; import { Route } from '$lib/route'; import { getAssetBulkActions } from '$lib/services/asset.service'; @@ -36,17 +37,24 @@ import { getPeopleThumbnailUrl } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; import { normalizeSearchString } from '$lib/utils/string-utils'; - import { AssetVisibility, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk'; + import { AssetVisibility, PersonUserRole, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk'; import { ActionButton, CommandPaletteDefaultProvider, ContextMenuButton, + IconButton, LoadingSpinner, modalManager, toastManager, type ActionItem, } from '@immich/ui'; - import { mdiAccountBoxOutline, mdiAccountMultipleCheckOutline, mdiArrowLeft, mdiDotsVertical } from '@mdi/js'; + import { + mdiAccountBoxOutline, + mdiAccountMultipleCheckOutline, + mdiArrowLeft, + mdiDotsVertical, + mdiPencilOutline, + } from '@mdi/js'; import { DateTime } from 'luxon'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; @@ -63,6 +71,7 @@ let numberOfAssets = $derived(data.statistics.assets); let person = $derived(data.person); + const altItems = $derived(person.otherPeople.filter(({ name }) => !!name && name !== person.name)); let thumbnailData = $derived(getPeopleThumbnailUrl(person)); let timelineManager = $state() as TimelineManager; @@ -305,7 +314,7 @@ await updateAssetCount(); }; - const { SetDateOfBirth, Favorite, Unfavorite, HidePerson, ShowPerson } = $derived(getPersonActions($t, person)); + const { Edit, Favorite, Unfavorite, HidePerson, ShowPerson, Access } = $derived(getPersonActions($t, person)); const SelectFeaturePhoto: ActionItem = { title: $t('select_featured_photo'), icon: mdiAccountBoxOutline, @@ -373,44 +382,75 @@ {thumbnailData} /> {:else} -
- + modalManager.show(PersonEditModal, { person })} + aria-label={$t('edit')} + />
- + {#if altItems.length > 0} + {@const parts = new Intl.ListFormat($locale).formatToParts( + altItems.map(({ sharedById }) => sharedById), + )} +

+ {#each parts as { type, value } (value)} + {#if type === 'element'} + {@const altItem = altItems.find(({ sharedById }) => sharedById === value)!} + {#if [PersonUserRole.Write, PersonUserRole.Admin].includes(altItem.role)} + + {:else} + {altItem.name} + {/if} + {:else} + {value} + {/if} + {/each} +

+ {/if} +

+ {$t('assets_count', { values: { count: numberOfAssets } })} +

+ {#if person.birthDate} +

+ {$t('person_birthdate', { + values: { + date: DateTime.fromISO(person.birthDate).toLocaleString( + { + month: 'numeric', + day: 'numeric', + year: 'numeric', + }, + { locale: $locale }, + ), + }, + })} +

+ {/if} +
{/if} @@ -501,8 +541,9 @@ {#if viewMode === PersonPageViewMode.VIEW_ASSETS} goto(previousRoute)}> {#snippet trailing()} + {/snippet} diff --git a/web/src/test-data/factories/person-factory.ts b/web/src/test-data/factories/person-factory.ts index 3a12b431b..b1a625ea9 100644 --- a/web/src/test-data/factories/person-factory.ts +++ b/web/src/test-data/factories/person-factory.ts @@ -9,4 +9,7 @@ export const personFactory = Sync.makeFactory({ name: Sync.each(() => faker.person.fullName()), thumbnailPath: Sync.each(() => faker.system.filePath()), updatedAt: Sync.each(() => faker.date.recent().toISOString()), + otherPeople: [], + sharedBy: [], + sharedWith: [], });