diff --git a/e2e/src/specs/server/api/asset.e2e-spec.ts b/e2e/src/specs/server/api/asset.e2e-spec.ts index ccebd1ca77..c10f663fb9 100644 --- a/e2e/src/specs/server/api/asset.e2e-spec.ts +++ b/e2e/src/specs/server/api/asset.e2e-spec.ts @@ -125,7 +125,7 @@ describe('/asset', () => { }), ]); - const person1 = await utils.createPerson(user1.accessToken, { + const person1 = await utils.createPerson(user1, { name: 'Test Person', }); await utils.createFace({ diff --git a/e2e/src/specs/server/api/person.e2e-spec.ts b/e2e/src/specs/server/api/person.e2e-spec.ts index 20290cd941..3d7c1d6ea4 100644 --- a/e2e/src/specs/server/api/person.e2e-spec.ts +++ b/e2e/src/specs/server/api/person.e2e-spec.ts @@ -37,40 +37,40 @@ describe('/people', () => { nameBillPersonFavourite, nameFreddyPersonFavourite, ] = await Promise.all([ - utils.createPerson(admin.accessToken, { + utils.createPerson(admin, { name: 'visible_person', }), - utils.createPerson(admin.accessToken, { + utils.createPerson(admin, { name: 'hidden_person', isHidden: true, }), - utils.createPerson(admin.accessToken, { + utils.createPerson(admin, { name: 'multiple_assets_person', }), // --- Setup for the specific sorting test --- - utils.createPerson(admin.accessToken, { + utils.createPerson(admin, { name: 'Charlie', }), - utils.createPerson(admin.accessToken, { + utils.createPerson(admin, { name: 'Bob', }), - utils.createPerson(admin.accessToken, { + utils.createPerson(admin, { name: 'Alice', }), - utils.createPerson(admin.accessToken, { + utils.createPerson(admin, { name: '', }), - utils.createPerson(admin.accessToken, { + utils.createPerson(admin, { name: '', }), - utils.createPerson(admin.accessToken, { + utils.createPerson(admin, { name: '', }), - utils.createPerson(admin.accessToken, { + utils.createPerson(admin, { name: 'Bill', isFavorite: true, }), - utils.createPerson(admin.accessToken, { + utils.createPerson(admin, { name: 'Freddy', isFavorite: true, }), @@ -315,7 +315,7 @@ describe('/people', () => { }); it('should mark a person as favorite', async () => { - const person = await utils.createPerson(admin.accessToken, { + const person = await utils.createPerson(admin, { name: 'visible_person', }); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 3124dd0609..e1efa08af6 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -7,6 +7,7 @@ import { CreateAlbumDto, CreateLibraryDto, JobCreateDto, + LoginResponseDto, MaintenanceAction, ManualJobName, MetadataSearchDto, @@ -427,9 +428,9 @@ export const utils = { deleteAssets: (accessToken: string, ids: string[]) => deleteAssets({ assetBulkDeleteDto: { ids } }, { headers: asBearerAuth(accessToken) }), - createPerson: async (accessToken: string, dto?: PersonCreateDto) => { - const person = await createPerson({ personCreateDto: dto || {} }, { headers: asBearerAuth(accessToken) }); - await utils.setPersonThumbnail(person.id); + createPerson: async (login: LoginResponseDto, dto?: PersonCreateDto) => { + const person = await createPerson({ personCreateDto: dto || {} }, { headers: asBearerAuth(login.accessToken) }); + await utils.setPersonThumbnail(person.id, login.userId); return person; }, @@ -442,12 +443,15 @@ export const utils = { await client.query('INSERT INTO asset_face ("assetId", "personId") VALUES ($1, $2)', [assetId, personId]); }, - setPersonThumbnail: async (personId: string) => { + setPersonThumbnail: async (personId: string, ownerId: string) => { if (!client) { return; } - await client.query(`UPDATE "person" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "id" = $1`, [personId]); + await client.query( + `UPDATE "person_user" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "personId" = $1 and "ownerId" = $2`, + [personId, ownerId], + ); }, createSharedLink: (accessToken: string, dto: SharedLinkCreateDto) => diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index bc3bf82094..9e8bbf071c 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -25061,7 +25061,10 @@ "StackV1", "StackDeleteV1", "PersonV1", + "PersonV2", "PersonDeleteV1", + "PersonUserV1", + "PersonUserDeleteV1", "AssetFaceV1", "AssetFaceV2", "AssetFaceDeleteV1", @@ -25286,6 +25289,82 @@ ], "type": "object" }, + "SyncPersonUserDeleteV1": { + "properties": { + "ownerId": { + "description": "Owner 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" + }, + "personId": { + "description": "Person 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": [ + "ownerId", + "personId" + ], + "type": "object" + }, + "SyncPersonUserV1": { + "properties": { + "createdAt": { + "description": "Created at", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "isFavorite": { + "description": "Is favorite", + "type": "boolean" + }, + "isHidden": { + "description": "Is hidden", + "type": "boolean" + }, + "ownerId": { + "description": "Owner 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" + }, + "personId": { + "description": "Person 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" + }, + "thumbnailFaceAssetId": { + "description": "Face asset ID", + "format": "uuid", + "nullable": true, + "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" + }, + "updatedAt": { + "description": "Updated at", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + } + }, + "required": [ + "createdAt", + "isFavorite", + "isHidden", + "ownerId", + "personId", + "thumbnailFaceAssetId", + "updatedAt" + ], + "type": "object" + }, "SyncPersonV1": { "properties": { "birthDate": { @@ -25359,6 +25438,63 @@ ], "type": "object" }, + "SyncPersonV2": { + "properties": { + "birthDate": { + "description": "Birth date", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "nullable": true, + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "color": { + "description": "Color", + "nullable": true, + "type": "string" + }, + "createdAt": { + "description": "Created at", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "id": { + "description": "Person 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": "Person name", + "type": "string" + }, + "trustedGroupId": { + "description": "Trusted group 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" + }, + "updatedAt": { + "description": "Updated at", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + } + }, + "required": [ + "birthDate", + "color", + "createdAt", + "id", + "name", + "trustedGroupId", + "updatedAt" + ], + "type": "object" + }, "SyncRequestType": { "description": "Sync request type", "enum": [ @@ -25386,6 +25522,8 @@ "StacksV1", "UsersV1", "PeopleV1", + "PeopleV2", + "PersonUsersV1", "AssetFacesV1", "AssetFacesV2", "UserMetadataV1" diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 3ac958ce2d..08483ef595 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -3273,6 +3273,28 @@ export type SyncPersonDeleteV1 = { /** Person ID */ personId: string; }; +export type SyncPersonUserDeleteV1 = { + /** Owner ID */ + ownerId: string; + /** Person ID */ + personId: string; +}; +export type SyncPersonUserV1 = { + /** Created at */ + createdAt: string; + /** Is favorite */ + isFavorite: boolean; + /** Is hidden */ + isHidden: boolean; + /** Owner ID */ + ownerId: string; + /** Person ID */ + personId: string; + /** Face asset ID */ + thumbnailFaceAssetId: string | null; + /** Updated at */ + updatedAt: string; +}; export type SyncPersonV1 = { /** Birth date */ birthDate: string | null; @@ -3295,6 +3317,22 @@ export type SyncPersonV1 = { /** Updated at */ updatedAt: string; }; +export type SyncPersonV2 = { + /** Birth date */ + birthDate: string | null; + /** Color */ + color: string | null; + /** Created at */ + createdAt: string; + /** Person ID */ + id: string; + /** Person name */ + name: string; + /** Trusted group ID */ + trustedGroupId: string; + /** Updated at */ + updatedAt: string; +}; export type SyncResetV1 = {}; export type SyncStackDeleteV1 = { /** Stack ID */ @@ -7569,7 +7607,10 @@ export enum SyncEntityType { StackV1 = "StackV1", StackDeleteV1 = "StackDeleteV1", PersonV1 = "PersonV1", + PersonV2 = "PersonV2", PersonDeleteV1 = "PersonDeleteV1", + PersonUserV1 = "PersonUserV1", + PersonUserDeleteV1 = "PersonUserDeleteV1", AssetFaceV1 = "AssetFaceV1", AssetFaceV2 = "AssetFaceV2", AssetFaceDeleteV1 = "AssetFaceDeleteV1", @@ -7604,6 +7645,8 @@ export enum SyncRequestType { StacksV1 = "StacksV1", UsersV1 = "UsersV1", PeopleV1 = "PeopleV1", + PeopleV2 = "PeopleV2", + PersonUsersV1 = "PersonUsersV1", AssetFacesV1 = "AssetFacesV1", AssetFacesV2 = "AssetFacesV2", UserMetadataV1 = "UserMetadataV1" diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 185ab95e62..2cb0ec297f 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -16,7 +16,7 @@ import { ConfigRepository } from 'src/repositories/config.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { MoveRepository } from 'src/repositories/move.repository'; -import { PersonRepository } from 'src/repositories/person.repository'; +import { PersonUserRepository } from 'src/repositories/person-user.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { VideoInterfaces } from 'src/types'; @@ -25,6 +25,7 @@ import { getConfig } from 'src/utils/config'; export interface MoveRequest { entityId: string; + ownerId: string; pathType: PathType; oldPath: string | null; newPath: string; @@ -52,7 +53,7 @@ export class StorageCore { private configRepository: ConfigRepository, private cryptoRepository: CryptoRepository, private moveRepository: MoveRepository, - private personRepository: PersonRepository, + private personUserRepository: PersonUserRepository, private storageRepository: StorageRepository, private systemMetadataRepository: SystemMetadataRepository, private logger: LoggingRepository, @@ -65,7 +66,7 @@ export class StorageCore { configRepository: ConfigRepository, cryptoRepository: CryptoRepository, moveRepository: MoveRepository, - personRepository: PersonRepository, + personUserRepository: PersonUserRepository, storageRepository: StorageRepository, systemMetadataRepository: SystemMetadataRepository, logger: LoggingRepository, @@ -76,7 +77,7 @@ export class StorageCore { configRepository, cryptoRepository, moveRepository, - personRepository, + personUserRepository, storageRepository, systemMetadataRepository, logger, @@ -161,6 +162,7 @@ export class StorageCore { const oldFile = getAssetFile(files, fileType, { isEdited: false }); return this.moveFile({ entityId, + ownerId: asset.ownerId, pathType: fileType, oldPath: oldFile?.path || null, newPath: StorageCore.getImagePath(asset, { fileType, format, isEdited: false }), @@ -171,6 +173,7 @@ export class StorageCore { const encodedVideoFile = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false }); return this.moveFile({ entityId: asset.id, + ownerId: asset.ownerId, pathType: AssetPathType.EncodedVideo, oldPath: encodedVideoFile?.path || null, newPath: StorageCore.getEncodedVideoPath(asset), @@ -183,6 +186,7 @@ export class StorageCore { case PersonPathType.Face: { await this.moveFile({ entityId, + ownerId: person.ownerId, pathType, oldPath: thumbnailPath, newPath: StorageCore.getPersonThumbnailPath(person), @@ -192,7 +196,7 @@ export class StorageCore { } async moveFile(request: MoveRequest) { - const { entityId, pathType, oldPath, newPath, assetInfo } = request; + const { entityId, ownerId, pathType, oldPath, newPath, assetInfo } = request; if (!oldPath || oldPath === newPath) { return; } @@ -265,7 +269,7 @@ export class StorageCore { } } - await this.savePath(pathType, entityId, newPath); + await this.savePath(pathType, entityId, ownerId, newPath); await this.moveRepository.delete(move.id); } @@ -318,7 +322,7 @@ export class StorageCore { return { dri, mali }; } - private savePath(pathType: PathType, id: string, newPath: string) { + private savePath(pathType: PathType, id: string, ownerId: string, newPath: string) { switch (pathType) { case AssetPathType.Original: { return this.assetRepository.update({ id, originalPath: newPath }); @@ -334,7 +338,7 @@ export class StorageCore { } case PersonPathType.Face: { - return this.personRepository.update({ id, thumbnailPath: newPath }); + return this.personUserRepository.update({ personId: id, ownerId, thumbnailPath: newPath }); } case UserPathType.Profile: { diff --git a/server/src/database.ts b/server/src/database.ts index 100ba451e7..ee7d7d7e62 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -26,6 +26,7 @@ export type AuthUser = { email: string; quotaUsageInBytes: number; quotaSizeInBytes: number | null; + trustedGroupId: string; }; export type AlbumUser = { @@ -130,6 +131,7 @@ export type User = { avatarColor: UserAvatarColor | null; profileImagePath: string; profileChangedAt: Date; + trustedGroupId: string; }; export type UserAdmin = User & { @@ -249,7 +251,7 @@ export type Person = { name: string; birthDate: Date | null; color: string | null; - faceAssetId: string | null; + thumbnailFaceAssetId: string | null; isHidden: boolean; thumbnailPath: string; }; @@ -274,7 +276,15 @@ export type AssetFace = { export type Plugin = Selectable; -const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const; +const userColumns = [ + 'id', + 'name', + 'email', + 'avatarColor', + 'profileImagePath', + 'profileChangedAt', + 'trustedGroupId', +] as const; const userWithPrefixColumns = [ 'user2.id', 'user2.name', @@ -282,6 +292,7 @@ const userWithPrefixColumns = [ 'user2.avatarColor', 'user2.profileImagePath', 'user2.profileChangedAt', + 'user2.trustedGroupId', ] as const; export const columns = { @@ -369,7 +380,15 @@ export const columns = { 'asset_file.isProgressive', 'asset_file.isTransparent', ], - authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'], + authUser: [ + 'user.id', + 'user.name', + 'user.email', + 'user.isAdmin', + 'user.quotaUsageInBytes', + 'user.quotaSizeInBytes', + 'user.trustedGroupId', + ], authApiKey: ['api_key.id', 'api_key.permissions'], authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'], user: userColumns, diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index fa56ded176..a4c9497518 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -171,7 +171,12 @@ const PeopleResponseSchema = z .describe('People response'); export class PeopleResponseDto extends createZodDto(PeopleResponseSchema) {} -export function mapPerson(person: MaybeDehydrated): PersonResponseDto { +export function mapPerson( + person: Pick< + MaybeDehydrated, + 'id' | 'name' | 'birthDate' | 'thumbnailPath' | 'isHidden' | 'isFavorite' | 'color' | 'updatedAt' + >, +): PersonResponseDto { return { id: person.id, name: person.name, diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 1d5fe64aa1..4b8b7c6d5a 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -346,10 +346,43 @@ const SyncPersonV1Schema = z }) .meta({ id: 'SyncPersonV1' }); +const SyncPersonV2Schema = SyncPersonV1Schema.omit({ + ownerId: true, + isHidden: true, + isFavorite: true, + faceAssetId: true, +}) + .extend({ trustedGroupId: z.uuidv4().describe('Trusted group ID') }) + .meta({ id: 'SyncPersonV2' }); + +export function syncPersonV2ToV1( + personV2: SyncPersonV2, + personUser: { ownerId: string; isHidden: boolean; isFavorite: boolean; thumbnailFaceAssetId: string | null }, +): SyncPersonV1 { + const { ownerId, isHidden, isFavorite, thumbnailFaceAssetId: faceAssetId } = personUser; + return { ...personV2, ownerId, isHidden, isFavorite, faceAssetId }; +} + const SyncPersonDeleteV1Schema = z .object({ personId: z.uuidv4().describe('Person ID') }) .meta({ id: 'SyncPersonDeleteV1' }); +const SyncPersonUserV1Schema = z + .object({ + personId: z.uuidv4().describe('Person ID'), + ownerId: z.uuidv4().describe('Owner ID'), + createdAt: isoDatetimeToDate.describe('Created at'), + updatedAt: isoDatetimeToDate.describe('Updated at'), + isHidden: z.boolean().describe('Is hidden'), + isFavorite: z.boolean().describe('Is favorite'), + thumbnailFaceAssetId: z.uuidv4().nullable().describe('Face asset ID'), + }) + .meta({ id: 'SyncPersonUserV1' }); + +const SyncPersonUserDeleteV1Schema = z + .object({ personId: z.uuidv4().describe('Person ID'), ownerId: z.uuidv4().describe('Owner ID') }) + .meta({ id: 'SyncPersonUserDeleteV1' }); + const SyncAssetFaceV1Schema = z .object({ id: z.uuidv4().describe('Asset face ID'), @@ -443,8 +476,14 @@ class SyncStackDeleteV1 extends createZodDto(SyncStackDeleteV1Schema) {} @ExtraModel() class SyncPersonV1 extends createZodDto(SyncPersonV1Schema) {} @ExtraModel() +class SyncPersonV2 extends createZodDto(SyncPersonV2Schema) {} +@ExtraModel() class SyncPersonDeleteV1 extends createZodDto(SyncPersonDeleteV1Schema) {} @ExtraModel() +class SyncPersonUserV1 extends createZodDto(SyncPersonUserV1Schema) {} +@ExtraModel() +class SyncPersonUserDeleteV1 extends createZodDto(SyncPersonUserDeleteV1Schema) {} +@ExtraModel() class SyncAssetFaceV1 extends createZodDto(SyncAssetFaceV1Schema) {} @ExtraModel() class SyncAssetFaceV2 extends createZodDto(SyncAssetFaceV2Schema) {} @@ -506,7 +545,10 @@ export type SyncItem = { [SyncEntityType.PartnerStackDeleteV1]: SyncStackDeleteV1; [SyncEntityType.PartnerStackV1]: SyncStackV1; [SyncEntityType.PersonV1]: SyncPersonV1; + [SyncEntityType.PersonV2]: SyncPersonV2; [SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1; + [SyncEntityType.PersonUserV1]: SyncPersonUserV1; + [SyncEntityType.PersonUserDeleteV1]: SyncPersonUserDeleteV1; [SyncEntityType.AssetFaceV1]: SyncAssetFaceV1; [SyncEntityType.AssetFaceV2]: SyncAssetFaceV2; [SyncEntityType.AssetFaceDeleteV1]: SyncAssetFaceDeleteV1; diff --git a/server/src/enum.ts b/server/src/enum.ts index 0d29244e09..8610c645f6 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -1015,6 +1015,8 @@ export enum SyncRequestType { StacksV1 = 'StacksV1', UsersV1 = 'UsersV1', PeopleV1 = 'PeopleV1', + PeopleV2 = 'PeopleV2', + PersonUsersV1 = 'PersonUsersV1', /** @deprecated */ AssetFacesV1 = 'AssetFacesV1', AssetFacesV2 = 'AssetFacesV2', @@ -1095,8 +1097,12 @@ export enum SyncEntityType { StackDeleteV1 = 'StackDeleteV1', PersonV1 = 'PersonV1', + PersonV2 = 'PersonV2', PersonDeleteV1 = 'PersonDeleteV1', + PersonUserV1 = 'PersonUserV1', + PersonUserDeleteV1 = 'PersonUserDeleteV1', + AssetFaceV1 = 'AssetFaceV1', AssetFaceV2 = 'AssetFaceV2', AssetFaceDeleteV1 = 'AssetFaceDeleteV1', diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index 94d3b4d003..0e9b830cca 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -189,12 +189,12 @@ where -- AccessRepository.person.checkOwnerAccess select - "person"."id" + "person_user"."personId" from - "person" + "person_user" where - "person"."id" in ($1) - and "person"."ownerId" = $2 + "person_user"."personId" in ($1) + and "person_user"."ownerId" = $2 -- AccessRepository.person.checkFaceOwnerAccess select diff --git a/server/src/queries/activity.repository.sql b/server/src/queries/activity.repository.sql index 228e5cb0ba..569fc2626f 100644 --- a/server/src/queries/activity.repository.sql +++ b/server/src/queries/activity.repository.sql @@ -15,7 +15,8 @@ from "user2"."email", "user2"."avatarColor", "user2"."profileImagePath", - "user2"."profileChangedAt" + "user2"."profileChangedAt", + "user2"."trustedGroupId" from ( select @@ -47,7 +48,8 @@ returning "email", "avatarColor", "profileImagePath", - "profileChangedAt" + "profileChangedAt", + "trustedGroupId" from "user" where diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index 35c82fdb73..d53b105a6d 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -30,7 +30,8 @@ select "email", "avatarColor", "profileImagePath", - "profileChangedAt" + "profileChangedAt", + "trustedGroupId" from ( select @@ -109,7 +110,8 @@ select "email", "avatarColor", "profileImagePath", - "profileChangedAt" + "profileChangedAt", + "trustedGroupId" from ( select @@ -206,7 +208,8 @@ select "email", "avatarColor", "profileImagePath", - "profileChangedAt" + "profileChangedAt", + "trustedGroupId" from ( select @@ -379,7 +382,8 @@ select "email", "avatarColor", "profileImagePath", - "profileChangedAt" + "profileChangedAt", + "trustedGroupId" from ( select diff --git a/server/src/queries/api.key.repository.sql b/server/src/queries/api.key.repository.sql index 43f3155ab8..4a465b1e2a 100644 --- a/server/src/queries/api.key.repository.sql +++ b/server/src/queries/api.key.repository.sql @@ -15,7 +15,8 @@ select "user"."email", "user"."isAdmin", "user"."quotaUsageInBytes", - "user"."quotaSizeInBytes" + "user"."quotaSizeInBytes", + "user"."trustedGroupId" from "user" where diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index aa04603913..d4b8e6067f 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -354,9 +354,11 @@ select "asset_file"."assetId" = "asset"."id" and "asset_file"."type" = $2 ) as agg - ) as "files" + ) as "files", + "user"."trustedGroupId" as "ownerTrustedGroupId" from "asset" + inner join "user" on "user"."id" = "asset"."ownerId" where "asset"."id" = $3 diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index cc694dd63a..0a1ea0cda0 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -188,9 +188,16 @@ select "asset_face" left join lateral ( select - "person".* + "person".*, + "person_user"."ownerId", + "person_user"."thumbnailFaceAssetId", + "person_user"."thumbnailPath", + "person_user"."isHidden", + "person_user"."isFavorite" from "person" + inner join "person_user" on "person_user"."personId" = "person"."id" + and "person_user"."ownerId" = "asset"."ownerId" where "asset_face"."personId" = "person"."id" ) as "person" on true diff --git a/server/src/queries/integrity.repository.sql b/server/src/queries/integrity.repository.sql index 8d8461e700..f75f1ca9bf 100644 --- a/server/src/queries/integrity.repository.sql +++ b/server/src/queries/integrity.repository.sql @@ -59,11 +59,11 @@ where -- IntegrityRepository.getPersonThumbnailPathsByPaths select - "person"."thumbnailPath" + "person_user"."thumbnailPath" from - "person" + "person_user" where - "person"."thumbnailPath" in $1 + "person_user"."thumbnailPath" in $1 -- IntegrityRepository.getAssetCount select diff --git a/server/src/queries/memory.repository.sql b/server/src/queries/memory.repository.sql index 44339cbcd9..f2cd367076 100644 --- a/server/src/queries/memory.repository.sql +++ b/server/src/queries/memory.repository.sql @@ -47,10 +47,11 @@ select $1 as "one" from "asset_face" - inner join "person" on "person"."id" = "asset_face"."personId" + inner join "person_user" on "person_user"."personId" = "asset_face"."personId" + and "person_user"."ownerId" = $2 where "asset_face"."assetId" = "asset"."id" - and "person"."isHidden" = $2 + and "person_user"."isHidden" = $3 ) order by "asset"."fileCreatedAt" asc @@ -61,7 +62,7 @@ from "memory" where "deletedAt" is null - and "ownerId" = $3 + and "ownerId" = $4 order by "memoryAt" desc @@ -86,10 +87,11 @@ select $1 as "one" from "asset_face" - inner join "person" on "person"."id" = "asset_face"."personId" + inner join "person_user" on "person_user"."personId" = "asset_face"."personId" + and "person_user"."ownerId" = $2 where "asset_face"."assetId" = "asset"."id" - and "person"."isHidden" = $2 + and "person_user"."isHidden" = $3 ) order by "asset"."fileCreatedAt" asc @@ -101,14 +103,14 @@ from where ( "showAt" is null - or "showAt" <= $3 + or "showAt" <= $4 ) and ( "hideAt" is null - or "hideAt" >= $4 + or "hideAt" >= $5 ) and "deletedAt" is null - and "ownerId" = $5 + and "ownerId" = $6 order by "memoryAt" desc diff --git a/server/src/queries/partner.repository.sql b/server/src/queries/partner.repository.sql index 213aa27d93..b6ecd62fa8 100644 --- a/server/src/queries/partner.repository.sql +++ b/server/src/queries/partner.repository.sql @@ -14,7 +14,8 @@ select "email", "avatarColor", "profileImagePath", - "profileChangedAt" + "profileChangedAt", + "trustedGroupId" from "user" as "sharedBy" where @@ -32,7 +33,8 @@ select "email", "avatarColor", "profileImagePath", - "profileChangedAt" + "profileChangedAt", + "trustedGroupId" from "user" as "sharedWith" where @@ -65,7 +67,8 @@ select "email", "avatarColor", "profileImagePath", - "profileChangedAt" + "profileChangedAt", + "trustedGroupId" from "user" as "sharedBy" where @@ -83,7 +86,8 @@ select "email", "avatarColor", "profileImagePath", - "profileChangedAt" + "profileChangedAt", + "trustedGroupId" from "user" as "sharedWith" where @@ -120,7 +124,8 @@ returning "email", "avatarColor", "profileImagePath", - "profileChangedAt" + "profileChangedAt", + "trustedGroupId" from "user" as "sharedBy" where @@ -138,7 +143,8 @@ returning "email", "avatarColor", "profileImagePath", - "profileChangedAt" + "profileChangedAt", + "trustedGroupId" from "user" as "sharedWith" where diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index a2f3f64442..2812dc6735 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -14,10 +14,10 @@ where -- PersonRepository.getFileSamples select - "id", + "personId", "thumbnailPath" from - "person" + "person_user" where "thumbnailPath" != '' limit @@ -25,20 +25,26 @@ limit -- PersonRepository.getAllForUser select - "person".* + "person".*, + "person_user"."thumbnailPath", + "person_user"."isFavorite", + "person_user"."isHidden" from "person" + inner join "person_user" on "person_user"."personId" = "person"."id" + and "person_user"."ownerId" = $1 inner join "asset_face" on "asset_face"."personId" = "person"."id" inner join "asset" on "asset_face"."assetId" = "asset"."id" and "asset"."visibility" = 'timeline' and "asset"."deletedAt" is null where - "person"."ownerId" = $1 - and "asset_face"."deletedAt" is null + "asset_face"."deletedAt" is null and "asset_face"."isVisible" is true - and "person"."isHidden" = $2 + and "person_user"."isHidden" = $2 group by - "person"."id" + "person"."id", + "person_user"."personId", + "person_user"."ownerId" having ( "person"."name" != $3 @@ -56,8 +62,8 @@ having )::int ) order by - "person"."isHidden" asc, - "person"."isFavorite" desc, + "person_user"."isHidden" asc, + "person_user"."isFavorite" desc, NULLIF(person.name, '') is null asc, count("asset_face"."assetId") desc, NULLIF(person.name, '') asc nulls last, @@ -67,20 +73,6 @@ limit offset $6 --- PersonRepository.getAllWithoutFaces -select - "person".* -from - "person" - left join "asset_face" on "asset_face"."personId" = "person"."id" -where - "asset_face"."deletedAt" is null - and "asset_face"."isVisible" is true -group by - "person"."id" -having - count("asset_face"."assetId") = $1 - -- PersonRepository.getFaces select "asset_face".*, @@ -90,19 +82,25 @@ select from ( select - "person".* + "person".*, + "person_user"."ownerId", + "person_user"."thumbnailFaceAssetId", + "person_user"."thumbnailPath", + "person_user"."isHidden", + "person_user"."isFavorite" from "person" + inner join "person_user" on "person_user"."personId" = "person"."id" where "person"."id" = "asset_face"."personId" + and "person_user"."ownerId" = $1 ) as obj ) as "person" from "asset_face" where - "asset_face"."assetId" = $1 + "asset_face"."assetId" = $2 and "asset_face"."deletedAt" is null - and "asset_face"."isVisible" = $2 order by "asset_face"."boundingBoxX1" asc @@ -115,17 +113,24 @@ select from ( select - "person".* + "person".*, + "person_user"."ownerId", + "person_user"."thumbnailFaceAssetId", + "person_user"."thumbnailPath", + "person_user"."isHidden", + "person_user"."isFavorite" from "person" + inner join "person_user" on "person_user"."personId" = "person"."id" where "person"."id" = "asset_face"."personId" + and "person_user"."ownerId" = $1 ) as obj ) as "person" from "asset_face" where - "asset_face"."id" = $1 + "asset_face"."id" = $2 and "asset_face"."deletedAt" is null -- PersonRepository.getFaceForFacialRecognitionJob @@ -167,37 +172,6 @@ where "asset_face"."id" = $1 and "asset_face"."deletedAt" is null --- PersonRepository.getDataForThumbnailGenerationJob -select - "person"."ownerId", - "asset_face"."boundingBoxX1" as "x1", - "asset_face"."boundingBoxY1" as "y1", - "asset_face"."boundingBoxX2" as "x2", - "asset_face"."boundingBoxY2" as "y2", - "asset_face"."imageWidth" as "oldWidth", - "asset_face"."imageHeight" as "oldHeight", - "asset"."type", - "asset"."originalPath", - "asset_exif"."orientation" as "exifOrientation", - ( - select - "asset_file"."path" - from - "asset_file" - where - "asset_file"."assetId" = "asset"."id" - and "asset_file"."type" = 'preview' - and "asset_file"."isEdited" = false - ) as "previewPath" -from - "person" - inner join "asset_face" on "asset_face"."id" = "person"."faceAssetId" - inner join "asset" on "asset_face"."assetId" = "asset"."id" - left join "asset_exif" on "asset_exif"."assetId" = "asset"."id" -where - "person"."id" = $1 - and "asset_face"."deletedAt" is null - -- PersonRepository.reassignFace update "asset_face" set @@ -205,6 +179,28 @@ set where "asset_face"."id" = $2 +-- PersonRepository.getById +select + "person".* +from + "person" +where + "person"."id" = $1 + +-- PersonRepository.getForOwner +select + "person_user".*, + "person"."id", + "person"."birthDate", + "person"."color", + "person"."name" +from + "person_user" + inner join "person" on "person"."id" = "person_user"."personId" +where + "person_user"."personId" = $1 + and "person_user"."ownerId" = $2 + -- PersonRepository.getByName with "similarity_threshold" as ( @@ -212,13 +208,17 @@ with set_config('pg_trgm.word_similarity_threshold', '0.5', true) as "thresh" ) select - "person".* + "person".*, + "person_user"."thumbnailPath", + "person_user"."isFavorite", + "person_user"."isHidden" from "similarity_threshold", "person" + inner join "person_user" on "person_user"."personId" = "person"."id" + and "person_user"."ownerId" = $1 where - "person"."ownerId" = $1 - and f_unaccent ("person"."name") %> f_unaccent ($2) + f_unaccent ("person"."name") %> f_unaccent ($2) order by f_unaccent ("person"."name") <->>> f_unaccent ($3) limit @@ -230,11 +230,10 @@ select distinct "person"."name" from "person" + inner join "person_user" on "person_user"."personId" = "person"."id" + and "person_user"."ownerId" = $1 where - ( - "person"."ownerId" = $1 - and "person"."name" != $2 - ) + "person"."name" != $2 -- PersonRepository.getStatistics select @@ -249,39 +248,6 @@ where and "asset_face"."isVisible" is true and "asset_face"."personId" = $1 --- PersonRepository.getNumberOfPeople -select - coalesce(count(*), 0) as "total", - coalesce( - count(*) filter ( - where - "isHidden" = $1 - ), - 0 - ) as "hidden" -from - "person" -where - exists ( - select - from - "asset_face" - where - "asset_face"."personId" = "person"."id" - and "asset_face"."deletedAt" is null - and "asset_face"."isVisible" = $2 - and exists ( - select - from - "asset" - where - "asset"."id" = "asset_face"."assetId" - and "asset"."visibility" = 'timeline' - and "asset"."deletedAt" is null - ) - ) - and "person"."ownerId" = $3 - -- PersonRepository.refreshFaces with "added_embeddings" as ( @@ -306,9 +272,15 @@ select from ( select - "person".* + "person".*, + "person_user"."ownerId", + "person_user"."thumbnailFaceAssetId", + "person_user"."thumbnailPath", + "person_user"."isHidden", + "person_user"."isFavorite" from "person" + inner join "person_user" on "person_user"."personId" = "person"."id" where "person"."id" = "asset_face"."personId" ) as obj @@ -348,15 +320,6 @@ set where "asset_face"."id" = $2 --- PersonRepository.getForPeopleDelete -select - "id", - "thumbnailPath" -from - "person" -where - "id" in ($1) - -- PersonRepository.getForFeatureFaceUpdate select "asset_face"."id" diff --git a/server/src/queries/person.user.repository.sql b/server/src/queries/person.user.repository.sql new file mode 100644 index 0000000000..92976c2db2 --- /dev/null +++ b/server/src/queries/person.user.repository.sql @@ -0,0 +1,132 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- PersonUserRepository.create +insert into + "person_user" ("ownerId", "personId") +values + ($1, $2) +returning + * + +-- PersonUserRepository.createAll +insert into + "person_user" ("ownerId", "personId") +values + ($1, $2) +returning + * + +-- PersonUserRepository.update +update "person_user" +set + "ownerId" = $1, + "personId" = $2 +where + "ownerId" = $3 + and "personId" = $4 +returning + * + +-- PersonUserRepository.delete +delete from "person_user" +where + "ownerId" = $1 + and "personId" = $2 + +-- PersonUserRepository.getForPeopleDelete +select + "personId", + "thumbnailPath" +from + "person_user" +where + "personId" in ($1) + +-- PersonUserRepository.getNumberOfPeople +select + coalesce(count(*), 0) as "total", + coalesce( + count(*) filter ( + where + "person_user"."isHidden" = $1 + ), + 0 + ) as "hidden" +from + "person_user" +where + exists ( + select + from + "asset_face" + where + "asset_face"."personId" = "person_user"."personId" + and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" = $2 + and exists ( + select + from + "asset" + where + "asset"."id" = "asset_face"."assetId" + and "asset"."visibility" = 'timeline' + and "asset"."deletedAt" is null + ) + ) + and "person_user"."ownerId" = $3 + +-- PersonUserRepository.getDataForThumbnailGenerationJob +select + "person_user"."ownerId", + "asset_face"."boundingBoxX1" as "x1", + "asset_face"."boundingBoxY1" as "y1", + "asset_face"."boundingBoxX2" as "x2", + "asset_face"."boundingBoxY2" as "y2", + "asset_face"."imageWidth" as "oldWidth", + "asset_face"."imageHeight" as "oldHeight", + "asset"."type", + "asset"."originalPath", + "asset_exif"."orientation" as "exifOrientation", + ( + select + "asset_file"."path" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = 'preview' + and "asset_file"."isEdited" = false + ) as "previewPath" +from + "person_user" + inner join "asset_face" on "asset_face"."id" = "person_user"."thumbnailFaceAssetId" + inner join "asset" on "asset_face"."assetId" = "asset"."id" + left join "asset_exif" on "asset_exif"."assetId" = "asset"."id" +where + "person_user"."personId" = $1 + and "person_user"."ownerId" = $2 + and "asset_face"."deletedAt" is null + +-- PersonUserRepository.getAllWithoutFaces +select + "person_user"."personId", + "person_user"."thumbnailPath" +from + "person_user" + left join "asset_face" on "asset_face"."personId" = "person_user"."personId" +where + "asset_face"."deletedAt" is null + and "asset_face"."isVisible" is not false +group by + "person_user"."personId", + "person_user"."ownerId" +having + count("asset_face"."assetId") = $1 + +-- PersonUserRepository.getThumbnailsForPerson +select + "thumbnailPath" +from + "person_user" +where + "personId" = $1 diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index efd7236bb5..55dd47c5ad 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -224,9 +224,10 @@ with "asset_face" inner join "asset" on "asset"."id" = "asset_face"."assetId" inner join "face_search" on "face_search"."faceId" = "asset_face"."id" + inner join "user" on "user"."id" = "asset"."ownerId" left join "person" on "person"."id" = "asset_face"."personId" where - "asset"."ownerId" = any ($2::uuid[]) + "user"."trustedGroupId" = ($2) and "asset"."deletedAt" is null order by "distance" diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index f68f2dbe95..bd4e5020b3 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -36,7 +36,8 @@ select "user"."email", "user"."isAdmin", "user"."quotaUsageInBytes", - "user"."quotaSizeInBytes" + "user"."quotaSizeInBytes", + "user"."trustedGroupId" from "user" where diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index 33bb9d0e04..64f097a208 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -135,7 +135,8 @@ from "email", "avatarColor", "profileImagePath", - "profileChangedAt" + "profileChangedAt", + "trustedGroupId" from "user" where @@ -205,7 +206,8 @@ from "email", "avatarColor", "profileImagePath", - "profileChangedAt" + "profileChangedAt", + "trustedGroupId" from "user" where @@ -255,7 +257,8 @@ select "user"."email", "user"."isAdmin", "user"."quotaUsageInBytes", - "user"."quotaSizeInBytes" + "user"."quotaSizeInBytes", + "user"."trustedGroupId" from "user" where @@ -294,7 +297,8 @@ select "user"."email", "user"."isAdmin", "user"."quotaUsageInBytes", - "user"."quotaSizeInBytes" + "user"."quotaSizeInBytes", + "user"."trustedGroupId" from "user" where diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 69f7981bf3..b5f2ef62da 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -1035,7 +1035,14 @@ from where "person_audit"."id" < $1 and "person_audit"."id" > $2 - and "ownerId" = $3 + and "trustedGroupId" = ( + select + "trustedGroupId" + from + "user" + where + "user"."id" = $3 + ) order by "person_audit"."id" asc @@ -1044,23 +1051,60 @@ select "id", "createdAt", "updatedAt", - "ownerId", "name", "birthDate", - "isHidden", - "isFavorite", "color", "updateId", - "faceAssetId" + "trustedGroupId" from "person" as "person" where "person"."updateId" < $1 and "person"."updateId" > $2 - and "ownerId" = $3 + and "trustedGroupId" = ( + select + "trustedGroupId" + from + "user" + where + "user"."id" = $3 + ) order by "person"."updateId" asc +-- SyncRepository.personUser.getDeletes +select + "id", + "personId", + "ownerId" +from + "person_user_audit" as "person_user_audit" +where + "person_user_audit"."id" < $1 + and "person_user_audit"."id" > $2 + and "ownerId" = $3 +order by + "person_user_audit"."id" asc + +-- SyncRepository.personUser.getUpserts +select + "personId", + "ownerId", + "createdAt", + "updatedAt", + "isHidden", + "isFavorite", + "thumbnailFaceAssetId", + "updateId" +from + "person_user" as "person_user" +where + "person_user"."updateId" < $1 + and "person_user"."updateId" > $2 + and "ownerId" = $3 +order by + "person_user"."updateId" asc + -- SyncRepository.stack.getDeletes select "id", diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index c5a4f139a7..cba803e774 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -8,6 +8,7 @@ select "avatarColor", "profileImagePath", "profileChangedAt", + "trustedGroupId", "createdAt", "updatedAt", "deletedAt", @@ -47,6 +48,7 @@ select "avatarColor", "profileImagePath", "profileChangedAt", + "trustedGroupId", "createdAt", "updatedAt", "deletedAt", @@ -126,6 +128,7 @@ select "avatarColor", "profileImagePath", "profileChangedAt", + "trustedGroupId", "createdAt", "updatedAt", "deletedAt", @@ -165,6 +168,7 @@ select "avatarColor", "profileImagePath", "profileChangedAt", + "trustedGroupId", "createdAt", "updatedAt", "deletedAt", @@ -190,6 +194,7 @@ select "avatarColor", "profileImagePath", "profileChangedAt", + "trustedGroupId", "createdAt", "updatedAt", "deletedAt", @@ -237,6 +242,7 @@ select "avatarColor", "profileImagePath", "profileChangedAt", + "trustedGroupId", "createdAt", "updatedAt", "deletedAt", @@ -275,6 +281,7 @@ select "avatarColor", "profileImagePath", "profileChangedAt", + "trustedGroupId", "createdAt", "updatedAt", "deletedAt", diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index b315538bf9..fd5c051a2c 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -425,18 +425,18 @@ class PersonAccess { @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) - async checkOwnerAccess(userId: string, personIds: Set) { + checkOwnerAccess(userId: string, personIds: Set) { if (personIds.size === 0) { return new Set(); } return this.db - .selectFrom('person') - .select('person.id') - .where('person.id', 'in', [...personIds]) - .where('person.ownerId', '=', userId) + .selectFrom('person_user') + .select('person_user.personId') + .where('person_user.personId', 'in', [...personIds]) + .where('person_user.ownerId', '=', userId) .execute() - .then((persons) => new Set(persons.map((person) => person.id))); + .then((people) => new Set(people.map((person) => person.personId))); } @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index bab0c44a41..6d44f835ef 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -152,6 +152,8 @@ export class AssetJobRepository { .select(withFaces) .select((eb) => withFiles(eb, AssetFileType.Sidecar)) .where('asset.id', '=', id) + .innerJoin('user', 'user.id', 'asset.ownerId') + .select('user.trustedGroupId as ownerTrustedGroupId') .executeTakeFirst(); } diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 1b5592d356..9d01712430 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -429,7 +429,7 @@ export class DatabaseRepository { .execute(); await tx - .updateTable('person') + .updateTable('person_user') .set((eb) => ({ thumbnailPath: eb.fn('REGEXP_REPLACE', ['thumbnailPath', source, target]) })) .execute(); diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 534a71b6d6..1f2a74dbe3 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -29,6 +29,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository import { OAuthRepository } from 'src/repositories/oauth.repository'; import { OcrRepository } from 'src/repositories/ocr.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; +import { PersonUserRepository } from 'src/repositories/person-user.repository'; import { PersonRepository } from 'src/repositories/person.repository'; import { PluginRepository } from 'src/repositories/plugin.repository'; import { ProcessRepository } from 'src/repositories/process.repository'; @@ -84,6 +85,7 @@ export const repositories = [ OAuthRepository, OcrRepository, PartnerRepository, + PersonUserRepository, PersonRepository, PluginRepository, ProcessRepository, diff --git a/server/src/repositories/integrity.repository.ts b/server/src/repositories/integrity.repository.ts index 227e69a074..a42627654b 100644 --- a/server/src/repositories/integrity.repository.ts +++ b/server/src/repositories/integrity.repository.ts @@ -88,9 +88,9 @@ export class IntegrityRepository { @GenerateSql({ params: [DummyValue.STRING] }) getPersonThumbnailPathsByPaths(paths: string[]) { return this.db - .selectFrom('person') - .select('person.thumbnailPath') - .where('person.thumbnailPath', 'in', paths) + .selectFrom('person_user') + .select('person_user.thumbnailPath') + .where('person_user.thumbnailPath', 'in', paths) .execute(); } diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts index 09aa5ad880..0e4a199ab2 100644 --- a/server/src/repositories/memory.repository.ts +++ b/server/src/repositories/memory.repository.ts @@ -73,10 +73,14 @@ export class MemoryRepository implements IBulkAsset { eb.exists( eb .selectFrom('asset_face') - .innerJoin('person', 'person.id', 'asset_face.personId') + .innerJoin('person_user', (join) => + join + .onRef('person_user.personId', '=', 'asset_face.personId') + .on('person_user.ownerId', '=', ownerId), + ) .select((eb) => eb.val(1).as('one')) .whereRef('asset_face.assetId', '=', 'asset.id') - .where('person.isHidden', '=', true), + .where('person_user.isHidden', '=', true), ), ), ) diff --git a/server/src/repositories/person-user.repository.ts b/server/src/repositories/person-user.repository.ts new file mode 100644 index 0000000000..a2d2632b52 --- /dev/null +++ b/server/src/repositories/person-user.repository.ts @@ -0,0 +1,191 @@ +import { Injectable } from '@nestjs/common'; +import { Insertable, Kysely, sql, Updateable } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { Chunked, DummyValue, GenerateSql } from 'src/decorators'; +import { AlbumUserRole, AssetFileType, AssetVisibility } from 'src/enum'; +import { DB } from 'src/schema'; +import { PersonUserTable } from 'src/schema/tables/person-user.table'; +import { removeUndefinedKeys, withFilePath } from 'src/utils/database'; + +type PersonUserId = { + personId: string; + ownerId: string; +}; + +export type GetAllPeopleOptions = { + ownerId?: string; + thumbnailPath?: string; + faceAssetId?: string | null; + isHidden?: boolean; +}; + +@Injectable() +export class PersonUserRepository { + constructor(@InjectKysely() private db: Kysely) {} + + getAll(options: GetAllPeopleOptions = {}) { + return this.db + .selectFrom('person_user') + .selectAll('person_user') + .$if(!!options.ownerId, (qb) => qb.where('person_user.ownerId', '=', options.ownerId!)) + .$if(options.thumbnailPath !== undefined, (qb) => + qb.where('person_user.thumbnailPath', '=', options.thumbnailPath!), + ) + .$if(options.faceAssetId === null, (qb) => qb.where('person_user.thumbnailFaceAssetId', 'is', null)) + .$if(!!options.faceAssetId, (qb) => qb.where('person_user.thumbnailFaceAssetId', '=', options.faceAssetId!)) + .$if(options.isHidden !== undefined, (qb) => qb.where('person_user.isHidden', '=', options.isHidden!)) + .stream(); + } + + get({ personId, ownerId }: PersonUserId) { + return this.db + .selectFrom('person_user') + .selectAll() + .where('person_user.personId', '=', personId) + .where('person_user.ownerId', '=', ownerId) + .executeTakeFirst(); + } + + @GenerateSql({ params: [{ ownerId: DummyValue.UUID, personId: DummyValue.UUID }] }) + create(personUser: Insertable) { + return this.db.insertInto('person_user').values(personUser).returningAll().executeTakeFirstOrThrow(); + } + + @GenerateSql({ params: [[{ ownerId: DummyValue.UUID, personId: DummyValue.UUID }]] }) + async createAll(personUsers: Insertable[]) { + if (personUsers.length === 0) { + return; + } + + await this.db.insertInto('person_user').values(personUsers).returningAll().execute(); + } + + @GenerateSql({ params: [{ ownerId: DummyValue.UUID, personId: DummyValue.UUID }, { role: AlbumUserRole.Viewer }] }) + update(dto: Updateable & PersonUserId) { + return this.db + .updateTable('person_user') + .set(dto) + .where('ownerId', '=', dto.ownerId) + .where('personId', '=', dto.personId) + .returningAll() + .executeTakeFirstOrThrow(); + } + + async updateAll(personUsers: Array & PersonUserId>) { + if (personUsers.length === 0) { + return; + } + + await this.db + .insertInto('person_user') + .values(personUsers) + .onConflict((oc) => + oc.columns(['personId', 'ownerId']).doUpdateSet((eb) => + removeUndefinedKeys( + { + isFavorite: eb.ref('excluded.isFavorite'), + isHidden: eb.ref('excluded.isHidden'), + thumbnailFaceAssetId: eb.ref('excluded.thumbnailFaceAssetId'), + thumbnailPath: eb.ref('excluded.thumbnailPath'), + }, + personUsers[0], + ), + ), + ) + .execute(); + } + + @GenerateSql({ params: [{ ownerId: DummyValue.UUID, personId: DummyValue.UUID }] }) + async delete({ ownerId, personId }: PersonUserId): Promise { + await this.db.deleteFrom('person_user').where('ownerId', '=', ownerId).where('personId', '=', personId).execute(); + } + + @GenerateSql({ params: [[DummyValue.UUID]] }) + @Chunked() + getForPeopleDelete(ids: string[]) { + if (ids.length === 0) { + return Promise.resolve([]); + } + return this.db + .selectFrom('person_user') + .select(['personId', 'thumbnailPath']) + .where('personId', 'in', ids) + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getNumberOfPeople(userId: string) { + const zero = sql.lit(0); + return this.db + .selectFrom('person_user') + .where((eb) => + eb.exists((eb) => + eb + .selectFrom('asset_face') + .whereRef('asset_face.personId', '=', 'person_user.personId') + .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), + ), + ), + ), + ) + .where('person_user.ownerId', '=', userId) + .select((eb) => eb.fn.coalesce(eb.fn.countAll(), zero).as('total')) + .select((eb) => + eb.fn.coalesce(eb.fn.countAll().filterWhere('person_user.isHidden', '=', true), zero).as('hidden'), + ) + .executeTakeFirstOrThrow(); + } + + @GenerateSql({ params: [{ personId: DummyValue.UUID, ownerId: DummyValue.UUID }] }) + getDataForThumbnailGenerationJob({ personId, ownerId }: PersonUserId) { + return this.db + .selectFrom('person_user') + .innerJoin('asset_face', 'asset_face.id', 'person_user.thumbnailFaceAssetId') + .innerJoin('asset', 'asset_face.assetId', 'asset.id') + .leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id') + .select([ + 'person_user.ownerId', + 'asset_face.boundingBoxX1 as x1', + 'asset_face.boundingBoxY1 as y1', + 'asset_face.boundingBoxX2 as x2', + 'asset_face.boundingBoxY2 as y2', + 'asset_face.imageWidth as oldWidth', + 'asset_face.imageHeight as oldHeight', + 'asset.type', + 'asset.originalPath', + 'asset_exif.orientation as exifOrientation', + ]) + .select((eb) => withFilePath(eb, AssetFileType.Preview).as('previewPath')) + .where('person_user.personId', '=', personId) + .where('person_user.ownerId', '=', ownerId) + .where('asset_face.deletedAt', 'is', null) + .executeTakeFirst(); + } + + @GenerateSql() + getAllWithoutFaces() { + return this.db + .selectFrom('person_user') + .select(['person_user.personId', 'person_user.thumbnailPath']) + .leftJoin('asset_face', 'asset_face.personId', 'person_user.personId') + .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', 'is not', false) + .having((eb) => eb.fn.count('asset_face.assetId'), '=', 0) + .groupBy('person_user.personId') + .groupBy('person_user.ownerId') + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getThumbnailsForPerson(id: string) { + return this.db.selectFrom('person_user').select('thumbnailPath').where('personId', '=', id).execute(); + } +} diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 0db03a18c7..43a485bdaa 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -4,12 +4,12 @@ import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { AssetFace } from 'src/database'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; -import { AssetFileType, AssetVisibility, SourceType, UserMetadataKey } from 'src/enum'; +import { AssetVisibility, SourceType, UserMetadataKey } from 'src/enum'; import { DB } from 'src/schema'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; import { PersonTable } from 'src/schema/tables/person.table'; -import { dummy, removeUndefinedKeys, withFilePath } from 'src/utils/database'; +import { dummy, removeUndefinedKeys } from 'src/utils/database'; import { paginationHelper, PaginationOptions } from 'src/utils/pagination'; export interface PersonSearchOptions { @@ -45,13 +45,6 @@ export interface DeleteFacesOptions { sourceType: SourceType; } -export interface GetAllPeopleOptions { - ownerId?: string; - thumbnailPath?: string; - faceAssetId?: string | null; - isHidden?: boolean; -} - export interface GetAllFacesOptions { personId?: string | null; assetId?: string; @@ -62,9 +55,21 @@ export type UnassignFacesOptions = DeleteFacesOptions; export type SelectFaceOptions = (keyof Selectable)[]; -const withPerson = (eb: ExpressionBuilder) => { +const withPerson = (eb: ExpressionBuilder, ownerId?: string) => { return jsonObjectFrom( - eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_face.personId'), + eb + .selectFrom('person') + .selectAll('person') + .whereRef('person.id', '=', 'asset_face.personId') + .innerJoin('person_user', 'person_user.personId', 'person.id') + .$if(ownerId !== undefined, (qb) => qb.where('person_user.ownerId', '=', ownerId!)) + .select([ + 'person_user.ownerId', + 'person_user.thumbnailFaceAssetId', + 'person_user.thumbnailPath', + 'person_user.isHidden', + 'person_user.isFavorite', + ]), ).as('person'); }; @@ -125,23 +130,11 @@ export class PersonRepository { .stream(); } - getAll(options: GetAllPeopleOptions = {}) { - return this.db - .selectFrom('person') - .selectAll('person') - .$if(!!options.ownerId, (qb) => qb.where('person.ownerId', '=', options.ownerId!)) - .$if(options.thumbnailPath !== undefined, (qb) => qb.where('person.thumbnailPath', '=', options.thumbnailPath!)) - .$if(options.faceAssetId === null, (qb) => qb.where('person.faceAssetId', 'is', null)) - .$if(!!options.faceAssetId, (qb) => qb.where('person.faceAssetId', '=', options.faceAssetId!)) - .$if(options.isHidden !== undefined, (qb) => qb.where('person.isHidden', '=', options.isHidden!)) - .stream(); - } - @GenerateSql() getFileSamples() { return this.db - .selectFrom('person') - .select(['id', 'thumbnailPath']) + .selectFrom('person_user') + .select(['personId', 'thumbnailPath']) .where('thumbnailPath', '!=', sql.lit('')) .limit(sql.lit(3)) .execute(); @@ -152,6 +145,10 @@ export class PersonRepository { const items = await this.db .selectFrom('person') .selectAll('person') + .innerJoin('person_user', (join) => + join.onRef('person_user.personId', '=', 'person.id').on('person_user.ownerId', '=', userId), + ) + .select(['person_user.thumbnailPath', 'person_user.isFavorite', 'person_user.isHidden']) .innerJoin('asset_face', 'asset_face.personId', 'person.id') .innerJoin('asset', (join) => join @@ -159,11 +156,10 @@ export class PersonRepository { .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') + .orderBy('person_user.isHidden', 'asc') + .orderBy('person_user.isFavorite', 'desc') .having((eb) => eb.or([ eb('person.name', '!=', ''), @@ -181,6 +177,8 @@ export class PersonRepository { ]), ) .groupBy('person.id') + .groupBy('person_user.personId') + .groupBy('person_user.ownerId') .$if(!!options?.closestFaceAssetId, (qb) => qb.orderBy((eb) => eb( @@ -188,7 +186,7 @@ export class PersonRepository { eb .selectFrom('face_search') .select('face_search.embedding') - .whereRef('face_search.faceId', '=', 'person.faceAssetId'), + .whereRef('face_search.faceId', '=', 'person_user.thumbnailFaceAssetId'), '<=>', (eb) => eb @@ -205,7 +203,7 @@ export class PersonRepository { .orderBy(sql`NULLIF(person.name, '')`, (om) => om.asc().nullsLast()) .orderBy('person.createdAt'), ) - .$if(!options?.withHidden, (qb) => qb.where('person.isHidden', '=', false)) + .$if(!options?.withHidden, (qb) => qb.where('person_user.isHidden', '=', false)) .offset(pagination.skip ?? 0) .limit(pagination.take + 1) .execute(); @@ -213,41 +211,25 @@ export class PersonRepository { return paginationHelper(items, pagination.take); } - @GenerateSql() - getAllWithoutFaces() { - return this.db - .selectFrom('person') - .selectAll('person') - .leftJoin('asset_face', 'asset_face.personId', 'person.id') - .where('asset_face.deletedAt', 'is', null) - .where('asset_face.isVisible', 'is', true) - .having((eb) => eb.fn.count('asset_face.assetId'), '=', 0) - .groupBy('person.id') - .execute(); - } - - @GenerateSql({ params: [DummyValue.UUID] }) - getFaces(assetId: string, options?: { isVisible?: boolean }) { - const isVisible = options === undefined ? true : options.isVisible; - + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) + getFaces(assetId: string, ownerId?: string) { return this.db .selectFrom('asset_face') .selectAll('asset_face') - .select(withPerson) + .select((eb) => withPerson(eb, ownerId)) .where('asset_face.assetId', '=', assetId) .where('asset_face.deletedAt', 'is', null) - .$if(isVisible !== undefined, (qb) => qb.where('asset_face.isVisible', '=', isVisible!)) .orderBy('asset_face.boundingBoxX1', 'asc') .execute(); } - @GenerateSql({ params: [DummyValue.UUID] }) - getFaceById(id: string) { + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) + getFaceById(id: string, ownerId: string) { // TODO return null instead of find or fail return this.db .selectFrom('asset_face') .selectAll('asset_face') - .select(withPerson) + .select((eb) => withPerson(eb, ownerId)) .where('asset_face.id', '=', id) .where('asset_face.deletedAt', 'is', null) .executeTakeFirstOrThrow(); @@ -272,31 +254,6 @@ export class PersonRepository { .executeTakeFirst(); } - @GenerateSql({ params: [DummyValue.UUID] }) - getDataForThumbnailGenerationJob(id: string) { - return this.db - .selectFrom('person') - .innerJoin('asset_face', 'asset_face.id', 'person.faceAssetId') - .innerJoin('asset', 'asset_face.assetId', 'asset.id') - .leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id') - .select([ - 'person.ownerId', - 'asset_face.boundingBoxX1 as x1', - 'asset_face.boundingBoxY1 as y1', - 'asset_face.boundingBoxX2 as x2', - 'asset_face.boundingBoxY2 as y2', - 'asset_face.imageWidth as oldWidth', - 'asset_face.imageHeight as oldHeight', - 'asset.type', - 'asset.originalPath', - 'asset_exif.orientation as exifOrientation', - ]) - .select((eb) => withFilePath(eb, AssetFileType.Preview).as('previewPath')) - .where('person.id', '=', id) - .where('asset_face.deletedAt', 'is', null) - .executeTakeFirst(); - } - @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) async reassignFace(assetFaceId: string, newPersonId: string): Promise { const result = await this.db @@ -308,6 +265,7 @@ export class PersonRepository { return Number(result.numChangedRows ?? 0); } + @GenerateSql({ params: [DummyValue.UUID] }) getById(personId: string) { return this.db // .selectFrom('person') @@ -316,6 +274,18 @@ export class PersonRepository { .executeTakeFirst(); } + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) + getForOwner(personId: string, ownerId: string) { + return this.db + .selectFrom('person_user') + .selectAll('person_user') + .where('person_user.personId', '=', personId) + .where('person_user.ownerId', '=', ownerId) + .innerJoin('person', 'person.id', 'person_user.personId') + .select(['person.id', 'person.birthDate', 'person.color', 'person.name']) + .executeTakeFirst(); + } + @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] }) getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions) { return this.db @@ -323,12 +293,15 @@ export class PersonRepository { db.selectNoFrom(sql`set_config('pg_trgm.word_similarity_threshold', '0.5', true)`.as('thresh')), ) .selectFrom(['similarity_threshold', 'person']) + .innerJoin('person_user', (join) => + join.onRef('person_user.personId', '=', 'person.id').on('person_user.ownerId', '=', userId), + ) .selectAll('person') - .where('person.ownerId', '=', userId) + .select(['person_user.thumbnailPath', 'person_user.isFavorite', 'person_user.isHidden']) .where(() => sql`f_unaccent("person"."name") %> f_unaccent(${personName})`) .orderBy(sql`f_unaccent("person"."name") <->>> f_unaccent(${personName})`) .limit(100) - .$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false)) + .$if(!withHidden, (qb) => qb.where('person_user.isHidden', '=', false)) .execute(); } @@ -337,9 +310,12 @@ export class PersonRepository { return this.db .selectFrom('person') .select(['person.id', 'person.name']) + .innerJoin('person_user', (join) => + join.onRef('person_user.personId', '=', 'person.id').on('person_user.ownerId', '=', userId), + ) .distinctOn((eb) => eb.fn('lower', ['person.name'])) - .where((eb) => eb.and([eb('person.ownerId', '=', userId), eb('person.name', '!=', '')])) - .$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false)) + .where('person.name', '!=', '') + .$if(!withHidden, (qb) => qb.where('person_user.isHidden', '=', false)) .execute(); } @@ -364,35 +340,6 @@ export class PersonRepository { }; } - @GenerateSql({ params: [DummyValue.UUID] }) - getNumberOfPeople(userId: string) { - const zero = sql.lit(0); - return this.db - .selectFrom('person') - .where((eb) => - eb.exists((eb) => - eb - .selectFrom('asset_face') - .whereRef('asset_face.personId', '=', 'person.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.visibility', '=', sql.lit(AssetVisibility.Timeline)) - .where('asset.deletedAt', 'is', null), - ), - ), - ), - ) - .where('person.ownerId', '=', userId) - .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')) - .executeTakeFirstOrThrow(); - } - create(person: Insertable) { return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow(); } @@ -453,10 +400,6 @@ export class PersonRepository { { name: eb.ref('excluded.name'), birthDate: eb.ref('excluded.birthDate'), - thumbnailPath: eb.ref('excluded.thumbnailPath'), - faceAssetId: eb.ref('excluded.faceAssetId'), - isHidden: eb.ref('excluded.isHidden'), - isFavorite: eb.ref('excluded.isFavorite'), color: eb.ref('excluded.color'), }, people[0], @@ -534,15 +477,6 @@ export class PersonRepository { } } - @GenerateSql({ params: [[DummyValue.UUID]] }) - @Chunked() - getForPeopleDelete(ids: string[]) { - if (ids.length === 0) { - return Promise.resolve([]); - } - return this.db.selectFrom('person').select(['id', 'thumbnailPath']).where('id', 'in', ids).execute(); - } - @GenerateSql({ params: [[], []] }) async updateVisibility(visible: AssetFace[], hidden: AssetFace[]): Promise { if (visible.length === 0 && hidden.length === 0) { diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 70ba09ef2c..55bba709cc 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -162,7 +162,9 @@ export type OcrSearchOptions = SearchDateOptions & SearchOcrOptions; export type LargeAssetSearchOptions = AssetSearchOptions & { minFileSize?: number }; -export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { +export interface FaceEmbeddingSearch { + embedding: string; + trustedGroupId: string; hasPerson?: boolean; numResults: number; maxDistance: number; @@ -334,14 +336,14 @@ export class SearchRepository { @GenerateSql({ params: [ { - userIds: [DummyValue.UUID], + trustedGroupId: [DummyValue.UUID], embedding: DummyValue.VECTOR, numResults: 10, maxDistance: 0.6, }, ], }) - searchFaces({ userIds, embedding, numResults, maxDistance, hasPerson, minBirthDate }: FaceEmbeddingSearch) { + searchFaces({ trustedGroupId, embedding, numResults, maxDistance, hasPerson, minBirthDate }: FaceEmbeddingSearch) { if (!z.int().min(1).max(1000).safeParse(numResults).success) { throw new Error(`Invalid value for 'numResults': ${numResults}`); } @@ -359,8 +361,9 @@ export class SearchRepository { ]) .innerJoin('asset', 'asset.id', 'asset_face.assetId') .innerJoin('face_search', 'face_search.faceId', 'asset_face.id') + .innerJoin('user', 'user.id', 'asset.ownerId') .leftJoin('person', 'person.id', 'asset_face.personId') - .where('asset.ownerId', '=', anyUuid(userIds)) + .where('user.trustedGroupId', '=', trustedGroupId) .where('asset.deletedAt', 'is', null) .$if(!!hasPerson, (qb) => qb.where('asset_face.personId', 'is not', null)) .$if(!!minBirthDate, (qb) => diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index eca8d18e66..99032fe1f6 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -65,6 +65,7 @@ export class SyncRepository { partnerAssetExif: PartnerAssetExifsSync; partnerStack: PartnerStackSync; person: PersonSync; + personUser: PersonUserSync; stack: StackSync; user: UserSync; userMetadata: UserMetadataSync; @@ -89,6 +90,7 @@ export class SyncRepository { this.partnerAssetExif = new PartnerAssetExifsSync(this.db); this.partnerStack = new PartnerStackSync(this.db); this.person = new PersonSync(this.db); + this.personUser = new PersonUserSync(this.db); this.stack = new StackSync(this.db); this.user = new UserSync(this.db); this.userMetadata = new UserMetadataSync(this.db); @@ -423,7 +425,9 @@ class PersonSync extends BaseSync { getDeletes(options: SyncQueryOptions) { return this.auditQuery('person_audit', options) .select(['id', 'personId']) - .where('ownerId', '=', options.userId) + .where('trustedGroupId', '=', (eb) => + eb.selectFrom('user').select('trustedGroupId').where('user.id', '=', options.userId), + ) .stream(); } @@ -434,22 +438,61 @@ class PersonSync extends BaseSync { @GenerateSql({ params: [dummyQueryOptions], stream: true }) getUpserts(options: SyncQueryOptions) { return this.upsertQuery('person', options) + .select(['id', 'createdAt', 'updatedAt', 'name', 'birthDate', 'color', 'updateId', 'trustedGroupId']) + .where('trustedGroupId', '=', (eb) => + eb.selectFrom('user').select('trustedGroupId').where('user.id', '=', options.userId), + ) + .stream(); + } + + getPersonUser({ personId, ownerId }: { personId: string; ownerId: string }) { + return this.db + .selectFrom('person_user') + .select(['ownerId', 'isFavorite', 'isHidden', 'thumbnailFaceAssetId']) + .where('personId', '=', personId) + .where('ownerId', '=', ownerId) + .executeTakeFirst(); + } +} + +class PersonUserSync extends BaseSync { + @GenerateSql({ params: [dummyQueryOptions], stream: true }) + getDeletes(options: SyncQueryOptions) { + return this.auditQuery('person_user_audit', options) + .select(['id', 'personId', 'ownerId']) + .where('ownerId', '=', options.userId) + .stream(); + } + + cleanupAuditTable(daysAgo: number) { + return this.auditCleanup('person_user_audit', daysAgo); + } + + @GenerateSql({ params: [dummyQueryOptions], stream: true }) + getUpserts(options: SyncQueryOptions) { + return this.upsertQuery('person_user', options) .select([ - 'id', + 'personId', + 'ownerId', 'createdAt', 'updatedAt', - 'ownerId', - 'name', - 'birthDate', 'isHidden', 'isFavorite', - 'color', + 'thumbnailFaceAssetId', 'updateId', - 'faceAssetId', ]) .where('ownerId', '=', options.userId) .stream(); } + + getPersonUser({ personId, ownerId }: { personId: string; ownerId: string }) { + return this.db + .selectFrom('person_user') + .select(['ownerId', 'isFavorite', 'isHidden', 'thumbnailFaceAssetId']) + .where('personId', '=', personId) + .where('ownerId', '=', ownerId) + .executeTakeFirst(); + } } class AssetFaceSync extends BaseSync { diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index 230f22478d..3560a65733 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -198,8 +198,21 @@ export const person_delete_audit = registerFunction({ language: 'PLPGSQL', body: ` BEGIN - INSERT INTO person_audit ("personId", "ownerId") - SELECT "id", "ownerId" + INSERT INTO person_audit ("personId", "trustedGroupId") + SELECT "id", "trustedGroupId" + FROM OLD; + RETURN NULL; + END`, +}); + +export const person_user_delete_audit = registerFunction({ + name: 'person_user_delete_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO person_user_audit ("personId", "ownerId") + SELECT "personId", "ownerId" FROM OLD; RETURN NULL; END`, diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 4e55ff4cf6..b96e4e3912 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -20,6 +20,7 @@ import { memory_delete_audit, partner_delete_audit, person_delete_audit, + person_user_delete_audit, stack_delete_audit, updated_at, user_delete_audit, @@ -62,6 +63,8 @@ import { OcrSearchTable } from 'src/schema/tables/ocr-search.table'; import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table'; import { PartnerTable } from 'src/schema/tables/partner.table'; import { PersonAuditTable } from 'src/schema/tables/person-audit.table'; +import { PersonUserAuditTable } from 'src/schema/tables/person-user-audit.table'; +import { PersonUserTable } from 'src/schema/tables/person-user.table'; import { PersonTable } from 'src/schema/tables/person.table'; import { PluginMethodTable } from 'src/schema/tables/plugin-method.table'; import { PluginTable } from 'src/schema/tables/plugin.table'; @@ -130,6 +133,8 @@ export class ImmichDatabase { PartnerTable, PersonTable, PersonAuditTable, + PersonUserTable, + PersonUserAuditTable, SessionTable, SharedLinkAssetTable, SharedLinkTable, @@ -170,6 +175,7 @@ export class ImmichDatabase { memory_asset_delete_audit, stack_delete_audit, person_delete_audit, + person_user_delete_audit, user_metadata_audit, asset_metadata_audit, asset_face_audit, @@ -243,6 +249,8 @@ export interface DB { person: PersonTable; person_audit: PersonAuditTable; + person_user: PersonUserTable; + person_user_audit: PersonUserAuditTable; session: SessionTable; session_sync_checkpoint: SessionSyncCheckpointTable; diff --git a/server/src/schema/migrations/1786022018310-PersonUserAndTrustedGroups.ts b/server/src/schema/migrations/1786022018310-PersonUserAndTrustedGroups.ts new file mode 100644 index 0000000000..2bbfc45ab4 --- /dev/null +++ b/server/src/schema/migrations/1786022018310-PersonUserAndTrustedGroups.ts @@ -0,0 +1,178 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION person_delete_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO person_audit ("personId", "trustedGroupId") + SELECT "id", "trustedGroupId" + FROM OLD; + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE OR REPLACE FUNCTION person_user_delete_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO person_user_audit ("personId", "ownerId") + SELECT "personId", "ownerId" + FROM OLD; + RETURN NULL; + END + $$;`.execute(db); + await sql`ALTER TABLE "user" ADD "trustedGroupId" uuid NOT NULL DEFAULT uuid_generate_v4();`.execute(db); + + await sql`CREATE TABLE "person_user" ( + "personId" uuid NOT NULL, + "ownerId" uuid NOT NULL, + "isHidden" boolean NOT NULL DEFAULT false, + "isFavorite" boolean NOT NULL DEFAULT false, + "thumbnailPath" character varying NOT NULL DEFAULT '', + "thumbnailFaceAssetId" uuid, + "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_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "person_user_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "person_user_thumbnailFaceAssetId_fkey" FOREIGN KEY ("thumbnailFaceAssetId") REFERENCES "asset_face" ("id") ON UPDATE NO ACTION ON DELETE SET NULL, + CONSTRAINT "person_user_pkey" PRIMARY KEY ("personId", "ownerId") +);`.execute(db); + await db + .insertInto('person_user') + .columns(['personId', 'ownerId', 'isHidden', 'isFavorite', 'thumbnailPath', 'thumbnailFaceAssetId', 'createdAt']) + .expression((eb) => + eb + .selectFrom('person') + .select([ + 'person.id', + 'person.ownerId', + 'person.isHidden', + 'person.isFavorite', + 'person.thumbnailPath', + 'person.faceAssetId', + 'person.createdAt', + ]), + ) + .execute(); + await sql`CREATE INDEX "person_user_personId_idx" ON "person_user" ("personId");`.execute(db); + await sql`CREATE INDEX "person_user_ownerId_idx" ON "person_user" ("ownerId");`.execute(db); + await sql`CREATE INDEX "person_user_thumbnailFaceAssetId_idx" ON "person_user" ("thumbnailFaceAssetId");`.execute(db); + await sql`CREATE INDEX "person_user_updateId_idx" ON "person_user" ("updateId");`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "person_user_delete_audit" + AFTER DELETE ON "person_user" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() <= 1) + EXECUTE FUNCTION person_user_delete_audit();`.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`ALTER TABLE "person" DROP CONSTRAINT "person_ownerId_fkey";`.execute(db); + await sql`ALTER TABLE "person" DROP CONSTRAINT "person_faceAssetId_fkey";`.execute(db); + await sql`ALTER TABLE "person" RENAME COLUMN "faceAssetId" TO "trustedGroupId";`.execute(db); + await db + .updateTable('person') + .from('user') + .set((eb) => ({ trustedGroupId: eb.ref('user.trustedGroupId') })) + .whereRef('person.ownerId', '=', 'user.id') + .execute(); + + await sql`ALTER TABLE "person" DROP COLUMN "ownerId";`.execute(db); + await sql`ALTER TABLE "person" DROP COLUMN "isHidden";`.execute(db); + await sql`ALTER TABLE "person" DROP COLUMN "isFavorite";`.execute(db); + await sql`ALTER TABLE "person" DROP COLUMN "thumbnailPath";`.execute(db); + await sql`CREATE INDEX "person_trustedGroupId_idx" ON "person" ("trustedGroupId");`.execute(db); + await sql`DROP INDEX "person_faceAssetId_idx";`.execute(db); + + await sql`CREATE TABLE "person_user_audit" ( + "id" uuid NOT NULL DEFAULT immich_uuid_v7(), + "personId" uuid NOT NULL, + "ownerId" uuid NOT NULL, + "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(), + CONSTRAINT "person_user_audit_pkey" PRIMARY KEY ("id") +);`.execute(db); + await db + .insertInto('person_user_audit') + .columns(['personId', 'ownerId', 'deletedAt']) + .expression((eb) => eb.selectFrom('person_audit').select(['person_audit.personId', 'person_audit.ownerId', 'person_audit.deletedAt'])) + .execute(); + + await sql`ALTER TABLE "person_audit" RENAME COLUMN "ownerId" TO "trustedGroupId";`.execute(db); + await db + .updateTable('person_audit') + .from('user') + .set((eb) => ({ trustedGroupId: eb.ref('user.trustedGroupId') })) + .whereRef('person_audit.trustedGroupId', '=', 'user.id') + .execute(); + + await sql`CREATE INDEX "person_audit_trustedGroupId_idx" ON "person_audit" ("trustedGroupId");`.execute(db); + await sql`DROP INDEX "person_audit_ownerId_idx";`.execute(db); + await sql`CREATE INDEX "person_user_audit_personId_idx" ON "person_user_audit" ("personId");`.execute(db); + await sql`CREATE INDEX "person_user_audit_ownerId_idx" ON "person_user_audit" ("ownerId");`.execute(db); + await sql`CREATE INDEX "person_user_audit_deletedAt_idx" ON "person_user_audit" ("deletedAt");`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"person_delete_audit","sql":"CREATE OR REPLACE FUNCTION person_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person_audit (\\"personId\\", \\"trustedGroupId\\")\\n SELECT \\"id\\", \\"trustedGroupId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_person_delete_audit';`.execute( + db, + ); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_person_user_delete_audit', '{"type":"function","name":"person_user_delete_audit","sql":"CREATE OR REPLACE FUNCTION person_user_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person_user_audit (\\"personId\\", \\"ownerId\\")\\n SELECT \\"personId\\", \\"ownerId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute( + db, + ); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_person_user_delete_audit', '{"type":"trigger","name":"person_user_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"person_user_delete_audit\\"\\n AFTER DELETE ON \\"person_user\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() <= 1)\\n EXECUTE FUNCTION person_user_delete_audit();"}'::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, + ); +} + +export async function down(_db: Kysely): Promise { + // TODO we probably won't support this? + // + // await sql`CREATE OR REPLACE FUNCTION public.person_delete_audit() + // RETURNS trigger + // LANGUAGE plpgsql + // AS $function$ + // BEGIN + // INSERT INTO person_audit ("personId", "ownerId") + // SELECT "id", "ownerId" + // FROM OLD; + // RETURN NULL; + // END + // $function$ + // `.execute(db); + // await sql`DROP TRIGGER "person_user_delete_audit" ON "person_user";`.execute(db); + // await sql`DROP FUNCTION person_user_delete_audit;`.execute(db); + // await sql`ALTER TABLE "person" RENAME COLUMN "trustedGroupId" TO "faceAssetId";`.execute(db); + // await sql`ALTER TABLE "person" ADD "ownerId" uuid NOT NULL;`.execute(db); + // await sql`ALTER TABLE "person" ADD "isHidden" boolean NOT NULL DEFAULT false;`.execute(db); + // await sql`ALTER TABLE "person" ADD "isFavorite" boolean NOT NULL DEFAULT false;`.execute(db); + // await sql`ALTER TABLE "person" ADD "thumbnailPath" character varying NOT NULL DEFAULT ''::character varying;`.execute( + // db, + // ); + // await sql`CREATE INDEX "person_faceAssetId_idx" ON "person" ("faceAssetId");`.execute(db); + // await sql`CREATE INDEX "person_ownerId_idx" ON "person" ("ownerId");`.execute(db); + // await sql`DROP INDEX "person_trustedGroupId_idx";`.execute(db); + // await sql`ALTER TABLE "person" ADD CONSTRAINT "person_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + // db, + // ); + // await sql`ALTER TABLE "person" ADD CONSTRAINT "person_faceAssetId_fkey" FOREIGN KEY ("faceAssetId") REFERENCES "asset_face" ("id") ON UPDATE NO ACTION ON DELETE SET NULL;`.execute( + // db, + // ); + // await sql`ALTER TABLE "person_audit" RENAME COLUMN "trustedGroupId" TO "ownerId";`.execute(db); + // await sql`CREATE INDEX "person_audit_ownerId_idx" ON "person_audit" ("ownerId");`.execute(db); + // await sql`DROP INDEX "person_audit_trustedGroupId_idx";`.execute(db); + // await sql`ALTER TABLE "user" DROP COLUMN "trustedGroupId";`.execute(db); + // await sql`DROP TABLE "person_user_audit";`.execute(db); + // await sql`DROP TABLE "person_user";`.execute(db); + // await sql`DROP TRIGGER "person_user_updatedAt" ON "person_user";`.execute(db); + // await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE FUNCTION person_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person_audit (\\"personId\\", \\"ownerId\\")\\n SELECT \\"id\\", \\"ownerId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;","name":"person_delete_audit","type":"function"}'::jsonb WHERE "name" = 'function_person_delete_audit';`.execute( + // db, + // ); + // await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_person_user_delete_audit';`.execute(db); + // await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_person_user_delete_audit';`.execute(db); + // await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_person_user_updatedAt';`.execute(db); +} diff --git a/server/src/schema/tables/person-audit.table.ts b/server/src/schema/tables/person-audit.table.ts index 4fb55f1744..1da3544638 100644 --- a/server/src/schema/tables/person-audit.table.ts +++ b/server/src/schema/tables/person-audit.table.ts @@ -10,7 +10,7 @@ export class PersonAuditTable { personId!: string; @Column({ type: 'uuid', index: true }) - ownerId!: string; + trustedGroupId!: string; @CreateDateColumn({ default: () => 'clock_timestamp()', index: true }) deletedAt!: Generated; diff --git a/server/src/schema/tables/person-user-audit.table.ts b/server/src/schema/tables/person-user-audit.table.ts new file mode 100644 index 0000000000..92aa985c46 --- /dev/null +++ b/server/src/schema/tables/person-user-audit.table.ts @@ -0,0 +1,17 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; + +@Table('person_user_audit') +export class PersonUserAuditTable { + @PrimaryGeneratedUuidV7Column() + id!: Generated; + + @Column({ type: 'uuid', index: true }) + personId!: string; + + @Column({ type: 'uuid', index: true }) + ownerId!: string; + + @CreateDateColumn({ default: () => 'clock_timestamp()', index: true }) + deletedAt!: Generated; +} 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 0000000000..2adc82932c --- /dev/null +++ b/server/src/schema/tables/person-user.table.ts @@ -0,0 +1,52 @@ +import { + AfterDeleteTrigger, + Column, + CreateDateColumn, + ForeignKeyColumn, + Generated, + Table, + Timestamp, + UpdateDateColumn, +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { person_user_delete_audit } from 'src/schema/functions'; +import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; +import { PersonTable } from 'src/schema/tables/person.table'; +import { UserTable } from 'src/schema/tables/user.table'; + +@Table('person_user') +@UpdatedAtTrigger('person_user_updatedAt') +@AfterDeleteTrigger({ + scope: 'statement', + function: person_user_delete_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() <= 1', +}) +export class PersonUserTable { + @ForeignKeyColumn(() => PersonTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true }) + personId!: string; + + @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true }) + ownerId!: string; + + @Column({ type: 'boolean', default: false }) + isHidden!: Generated; + + @Column({ type: 'boolean', default: false }) + isFavorite!: Generated; + + @Column({ default: '' }) + thumbnailPath!: Generated; + + @ForeignKeyColumn(() => AssetFaceTable, { onDelete: 'SET NULL', nullable: true }) + thumbnailFaceAssetId!: string | null; + + @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 35447acfd0..266382b897 100644 --- a/server/src/schema/tables/person.table.ts +++ b/server/src/schema/tables/person.table.ts @@ -3,7 +3,6 @@ import { Check, Column, CreateDateColumn, - ForeignKeyColumn, Generated, Index, PrimaryGeneratedColumn, @@ -13,8 +12,6 @@ import { } from '@immich/sql-tools'; import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { person_delete_audit } from 'src/schema/functions'; -import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; -import { UserTable } from 'src/schema/tables/user.table'; @Table('person') @Index({ @@ -40,30 +37,18 @@ export class PersonTable { @UpdateDateColumn() updatedAt!: Generated; - @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) - ownerId!: string; - @Column({ default: '' }) name!: Generated; - @Column({ default: '' }) - thumbnailPath!: Generated; - - @Column({ type: 'boolean', default: false }) - isHidden!: Generated; - @Column({ type: 'date', nullable: true }) birthDate!: Timestamp | null; - @ForeignKeyColumn(() => AssetFaceTable, { onDelete: 'SET NULL', nullable: true }) - faceAssetId!: string | null; - - @Column({ type: 'boolean', default: false }) - isFavorite!: Generated; - @Column({ type: 'character varying', nullable: true, default: null }) color!: string | null; @UpdateIdColumn({ index: true }) updateId!: Generated; + + @Column({ type: 'uuid', index: true, nullable: true }) + trustedGroupId!: string; } diff --git a/server/src/schema/tables/user.table.ts b/server/src/schema/tables/user.table.ts index 50d56d9067..ea654dbcd1 100644 --- a/server/src/schema/tables/user.table.ts +++ b/server/src/schema/tables/user.table.ts @@ -4,6 +4,7 @@ import { CreateDateColumn, DeleteDateColumn, Generated, + GeneratedColumn, Index, PrimaryGeneratedColumn, Table, @@ -82,4 +83,7 @@ export class UserTable { @UpdateIdColumn({ index: true }) updateId!: Generated; + + @GeneratedColumn({}) + trustedGroupId!: Generated; } diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 6ed8ce925a..ef9f373c9f 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -36,6 +36,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository import { OAuthRepository } from 'src/repositories/oauth.repository'; import { OcrRepository } from 'src/repositories/ocr.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; +import { PersonUserRepository } from 'src/repositories/person-user.repository'; import { PersonRepository } from 'src/repositories/person.repository'; import { PluginRepository } from 'src/repositories/plugin.repository'; import { ProcessRepository } from 'src/repositories/process.repository'; @@ -95,6 +96,7 @@ export const BASE_SERVICE_DEPENDENCIES = [ OAuthRepository, OcrRepository, PartnerRepository, + PersonUserRepository, PersonRepository, PluginRepository, ProcessRepository, @@ -155,6 +157,7 @@ export class BaseService { protected oauthRepository: OAuthRepository, protected ocrRepository: OcrRepository, protected partnerRepository: PartnerRepository, + protected personUserRepository: PersonUserRepository, protected personRepository: PersonRepository, protected pluginRepository: PluginRepository, protected processRepository: ProcessRepository, @@ -184,7 +187,7 @@ export class BaseService { configRepository, cryptoRepository, moveRepository, - personRepository, + personUserRepository, storageRepository, systemMetadataRepository, this.logger, @@ -224,6 +227,7 @@ export class BaseService { ctx.oauthRepository, ctx.ocrRepository, ctx.partnerRepository, + ctx.personUserRepository, ctx.personRepository, ctx.pluginRepository, ctx.processRepository, diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index a464c9e174..146350e11f 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -50,7 +50,7 @@ describe(JobService.name, () => { jobs: [], }, { - item: { name: JobName.PersonGenerateThumbnail, data: { id: 'asset-1' } }, + item: { name: JobName.PersonGenerateThumbnail, data: { personId: 'person-1', ownerId: 'user-1' } }, jobs: [], }, { diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 6d4f04df37..05036d4fef 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -124,10 +124,9 @@ export class JobService extends BaseService { } case JobName.PersonGenerateThumbnail: { - const { id } = item.data; - const person = await this.personRepository.getById(id); - if (person) { - this.websocketRepository.clientSend('on_person_thumbnail', person.ownerId, person.id); + const personUser = await this.personUserRepository.get(item.data); + if (personUser) { + this.websocketRepository.clientSend('on_person_thumbnail', personUser.ownerId, personUser.personId); } break; } diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 290bf947a1..988f6e0d94 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -24,12 +24,12 @@ import { MediaService } from 'src/services/media.service'; import { AudioStreamInfo, JobCounts, RawImageInfo, VideoFormat, VideoStreamInfo } from 'src/types'; import { AssetFaceFactory } from 'test/factories/asset-face.factory'; import { AssetFactory } from 'test/factories/asset.factory'; -import { PersonFactory } from 'test/factories/person.factory'; +import { PersonUserFactory } from 'test/factories/person-user.factory'; import { probeStub } from 'test/fixtures/media.stub'; import { personThumbnailStub } from 'test/fixtures/person.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { getForGenerateThumbnail } from 'test/mappers'; -import { factory, newUuid } from 'test/small.factory'; +import { factory } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; const fullsizeBuffer = Buffer.from('embedded image data'); @@ -53,10 +53,9 @@ describe(MediaService.name, () => { describe('handleQueueGenerateThumbnails', () => { it('should queue all assets', async () => { const asset = AssetFactory.create(); - const person = PersonFactory.create({ faceAssetId: newUuid() }); + const personUser = PersonUserFactory.create(); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - - mocks.person.getAll.mockReturnValue(makeStream([person])); + mocks.personUser.getAll.mockReturnValue(makeStream([personUser])); await sut.handleQueueGenerateThumbnails({ force: true }); @@ -68,11 +67,11 @@ describe(MediaService.name, () => { }, ]); - expect(mocks.person.getAll).toHaveBeenCalledWith(undefined); + expect(mocks.personUser.getAll).toHaveBeenCalledWith(undefined); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonGenerateThumbnail, - data: { id: person.id }, + data: { personId: personUser.personId, ownerId: personUser.ownerId }, }, ]); }); @@ -80,7 +79,7 @@ describe(MediaService.name, () => { it('should queue trashed assets when force is true', async () => { const asset = AssetFactory.create({ status: AssetStatus.Trashed, deletedAt: new Date() }); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream()); + mocks.personUser.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: true }); @@ -96,7 +95,7 @@ describe(MediaService.name, () => { it('should queue archived assets when force is true', async () => { const asset = AssetFactory.create({ visibility: AssetVisibility.Archive }); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream()); + mocks.personUser.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: true }); @@ -110,26 +109,27 @@ describe(MediaService.name, () => { }); it('should queue all people with missing thumbnail path', async () => { - const [person1, person2] = [ - PersonFactory.create({ thumbnailPath: undefined }), - PersonFactory.create({ thumbnailPath: undefined }), + const [personUser1, personUser2] = [ + PersonUserFactory.create({ thumbnailPath: undefined, thumbnailFaceAssetId: null }), + PersonUserFactory.create({ thumbnailPath: undefined, thumbnailFaceAssetId: null }), ]; mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([AssetFactory.create()])); - mocks.person.getAll.mockReturnValue(makeStream([person1, person2])); + mocks.personUser.getAll.mockReturnValue(makeStream([personUser1, personUser2])); mocks.person.getRandomFace.mockResolvedValueOnce(AssetFaceFactory.create()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); - expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.personUser.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); expect(mocks.person.getRandomFace).toHaveBeenCalled(); - expect(mocks.person.update).toHaveBeenCalledTimes(1); + expect(mocks.personUser.update).toHaveBeenCalledTimes(1); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonGenerateThumbnail, data: { - id: person1.id, + personId: personUser1.personId, + ownerId: personUser1.ownerId, }, }, ]); @@ -138,7 +138,7 @@ describe(MediaService.name, () => { it('should queue all assets with missing resize path', async () => { const asset = AssetFactory.create(); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream()); + mocks.personUser.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); @@ -149,20 +149,20 @@ describe(MediaService.name, () => { }, ]); - expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.personUser.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); it('should queue all assets with missing preview', async () => { const asset = AssetFactory.create(); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream()); + mocks.personUser.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetGenerateThumbnails, data: { id: asset.id } }, ]); - expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.personUser.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); it('should queue all assets with missing thumbhash', async () => { @@ -170,7 +170,7 @@ describe(MediaService.name, () => { .files([AssetFileType.Thumbnail, AssetFileType.Preview]) .build(); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream()); + mocks.personUser.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); @@ -178,14 +178,14 @@ describe(MediaService.name, () => { { name: JobName.AssetGenerateThumbnails, data: { id: asset.id } }, ]); - expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.personUser.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); it('should queue all assets with missing fullsize when feature is enabled', async () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } }); const asset = { id: factory.uuid(), isEdited: false }; mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream()); + mocks.personUser.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: true }); @@ -196,26 +196,26 @@ describe(MediaService.name, () => { }, ]); - expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.personUser.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); it('should not queue assets with missing fullsize when feature is disabled', async () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } }); const asset = { id: factory.uuid(), isEdited: false }; mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream()); + mocks.personUser.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); expect(mocks.job.queueAll).toHaveBeenCalledWith([]); - expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.personUser.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); it('should queue assets with edits but missing edited thumbnails', async () => { const asset = AssetFactory.from().edit().build(); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream()); + mocks.personUser.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); @@ -226,27 +226,27 @@ describe(MediaService.name, () => { }, ]); - expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.personUser.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); it('should not queue assets with missing edited fullsize when feature is disabled', async () => { const asset = AssetFactory.from().edit().build(); mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } }); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream()); + mocks.personUser.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); expect(mocks.job.queueAll).toHaveBeenCalledWith([]); - expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.personUser.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); it('should queue assets with missing fullsize when force is true, regardless of setting', async () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } }); const asset = { id: factory.uuid(), isEdited: false }; mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream()); + mocks.personUser.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: true }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: true, fullsizeEnabled: false }); @@ -257,13 +257,13 @@ describe(MediaService.name, () => { }, ]); - expect(mocks.person.getAll).toHaveBeenCalled(); + expect(mocks.personUser.getAll).toHaveBeenCalled(); }); it('should queue both regular and edited thumbnails for assets with edits when force is true', async () => { const asset = AssetFactory.from().edit().build(); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream()); + mocks.personUser.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: true }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: true, fullsizeEnabled: false }); @@ -278,24 +278,26 @@ describe(MediaService.name, () => { }, ]); - expect(mocks.person.getAll).toHaveBeenCalledWith(undefined); + expect(mocks.personUser.getAll).toHaveBeenCalledWith(undefined); }); }); describe('handleQueueMigration', () => { it('should remove empty directories and queue jobs', async () => { const asset = AssetFactory.create(); - const person = PersonFactory.create(); + const personUser = PersonUserFactory.create(); mocks.assetJob.streamForMigrationJob.mockReturnValue(makeStream([asset])); mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts); - mocks.person.getAll.mockReturnValue(makeStream([person])); + mocks.personUser.getAll.mockReturnValue(makeStream([personUser])); await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.Success); expect(mocks.storage.removeEmptyDirs).toHaveBeenCalledTimes(2); expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.AssetFileMigration, data: { id: asset.id } }]); - expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.PersonFileMigration, data: { id: person.id } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { name: JobName.PersonFileMigration, data: { personId: personUser.personId, ownerId: personUser.ownerId } }, + ]); }); }); @@ -1509,46 +1511,36 @@ describe(MediaService.name, () => { describe('handleGeneratePersonThumbnail', () => { it('should generate a thumbnail even if machine learning is disabled', async () => { mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); - mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle); + mocks.personUser.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle); mocks.media.generateThumbnail.mockResolvedValue(); mocks.media.decodeImage.mockResolvedValue({ data: Buffer.from(''), info: { width: 1000, height: 1000 } as OutputInfo, }); - await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.Success); + await expect(sut.handleGeneratePersonThumbnail({ personId: 'person-1', ownerId: 'user-1' })).resolves.toBe( + JobStatus.Success, + ); expect(mocks.media.generateThumbnail).toHaveBeenCalled(); }); it('should skip a person not found', async () => { - await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); - expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); - }); - - it('should skip a person without a face asset id', async () => { - const person = PersonFactory.create({ faceAssetId: null }); - mocks.person.getById.mockResolvedValue(person); - await sut.handleGeneratePersonThumbnail({ id: person.id }); - expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); - }); - - it('should skip a person with face not found', async () => { - await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); + await sut.handleGeneratePersonThumbnail({ personId: 'person-1', ownerId: 'user-1' }); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); }); it('should generate a thumbnail', async () => { - const person = PersonFactory.create(); + const { personId, ownerId } = PersonUserFactory.create(); - mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle); + mocks.personUser.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle); mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 1000, height: 1000 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect(sut.handleGeneratePersonThumbnail({ personId, ownerId })).resolves.toBe(JobStatus.Success); - expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(person.id); + expect(mocks.personUser.getDataForThumbnailGenerationJob).toHaveBeenCalledWith({ personId, ownerId }); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailMiddle.originalPath, { colorspace: Colorspace.P3, @@ -1579,21 +1571,21 @@ describe(MediaService.name, () => { }, expect.any(String), ); - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, thumbnailPath: expect.any(String) }); + expect(mocks.personUser.update).toHaveBeenCalledWith({ personId, ownerId, thumbnailPath: expect.any(String) }); }); it('should use preview path if video', async () => { - const person = PersonFactory.create(); + const { personId, ownerId } = PersonUserFactory.create(); - mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.videoThumbnail); + mocks.personUser.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.videoThumbnail); mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 1000, height: 1000 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect(sut.handleGeneratePersonThumbnail({ personId, ownerId })).resolves.toBe(JobStatus.Success); - expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(person.id); + expect(mocks.personUser.getDataForThumbnailGenerationJob).toHaveBeenCalledWith({ personId, ownerId }); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.decodeImage).toHaveBeenCalledWith(expect.any(String), { colorspace: Colorspace.P3, @@ -1624,19 +1616,19 @@ describe(MediaService.name, () => { }, expect.any(String), ); - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, thumbnailPath: expect.any(String) }); + expect(mocks.personUser.update).toHaveBeenCalledWith({ personId, ownerId, thumbnailPath: expect.any(String) }); }); it('should generate a thumbnail without going negative', async () => { - const person = PersonFactory.create(); + const { personId, ownerId } = PersonUserFactory.create(); - mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailStart); + mocks.personUser.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailStart); mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 2160, height: 3840 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect(sut.handleGeneratePersonThumbnail({ personId, ownerId })).resolves.toBe(JobStatus.Success); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailStart.originalPath, { colorspace: Colorspace.P3, @@ -1670,16 +1662,18 @@ describe(MediaService.name, () => { }); it('should generate a thumbnail without overflowing', async () => { - const person = PersonFactory.create(); + const person = PersonUserFactory.create(); - mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailEnd); - mocks.person.update.mockResolvedValue(person); + mocks.personUser.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailEnd); + mocks.personUser.update.mockResolvedValue(person); mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 1000, height: 1000 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect( + sut.handleGeneratePersonThumbnail({ personId: person.personId, ownerId: person.ownerId }), + ).resolves.toBe(JobStatus.Success); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailEnd.originalPath, { colorspace: Colorspace.P3, @@ -1713,16 +1707,18 @@ describe(MediaService.name, () => { }); it('should handle negative coordinates', async () => { - const person = PersonFactory.create(); + const person = PersonUserFactory.create(); - mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.negativeCoordinate); - mocks.person.update.mockResolvedValue(person); + mocks.personUser.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.negativeCoordinate); + mocks.personUser.update.mockResolvedValue(person); mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 4624, height: 3080 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect( + sut.handleGeneratePersonThumbnail({ personId: person.personId, ownerId: person.ownerId }), + ).resolves.toBe(JobStatus.Success); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.negativeCoordinate.originalPath, { colorspace: Colorspace.P3, @@ -1756,16 +1752,18 @@ describe(MediaService.name, () => { }); it('should handle overflowing coordinate', async () => { - const person = PersonFactory.create(); + const person = PersonUserFactory.create(); - mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.overflowingCoordinate); - mocks.person.update.mockResolvedValue(person); + mocks.personUser.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.overflowingCoordinate); + mocks.personUser.update.mockResolvedValue(person); mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 4624, height: 3080 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect( + sut.handleGeneratePersonThumbnail({ personId: person.personId, ownerId: person.ownerId }), + ).resolves.toBe(JobStatus.Success); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.overflowingCoordinate.originalPath, { colorspace: Colorspace.P3, @@ -1799,11 +1797,11 @@ describe(MediaService.name, () => { }); it('should use embedded preview if enabled and raw image', async () => { - const person = PersonFactory.create(); + const person = PersonUserFactory.create(); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); - mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail); - mocks.person.update.mockResolvedValue(person); + mocks.personUser.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail); + mocks.personUser.update.mockResolvedValue(person); mocks.media.generateThumbnail.mockResolvedValue(); const extracted = Buffer.from(''); const data = Buffer.from(''); @@ -1812,7 +1810,9 @@ describe(MediaService.name, () => { mocks.media.decodeImage.mockResolvedValue({ data, info }); mocks.media.getImageMetadata.mockResolvedValue({ width: 2160, height: 3840, isTransparent: false }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect( + sut.handleGeneratePersonThumbnail({ personId: person.personId, ownerId: person.ownerId }), + ).resolves.toBe(JobStatus.Success); expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath); expect(mocks.media.decodeImage).toHaveBeenCalledWith(extracted, { @@ -1847,31 +1847,31 @@ describe(MediaService.name, () => { }); it('should not use embedded preview if enabled and not raw image', async () => { - const person = PersonFactory.create(); + const { personId, ownerId } = PersonUserFactory.create(); - mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle); + mocks.personUser.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle); mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 2160, height: 3840 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect(sut.handleGeneratePersonThumbnail({ personId, ownerId })).resolves.toBe(JobStatus.Success); expect(mocks.media.extract).not.toHaveBeenCalled(); expect(mocks.media.generateThumbnail).toHaveBeenCalled(); }); it('should not use embedded preview if enabled and raw image if not exists', async () => { - const person = PersonFactory.create(); + const { personId, ownerId } = PersonUserFactory.create(); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); - mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail); + mocks.personUser.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail); mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 2160, height: 3840 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect(sut.handleGeneratePersonThumbnail({ personId, ownerId })).resolves.toBe(JobStatus.Success); expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, { @@ -1883,10 +1883,10 @@ describe(MediaService.name, () => { }); it('should not use embedded preview if enabled and raw image if low resolution', async () => { - const person = PersonFactory.create(); + const { personId, ownerId } = PersonUserFactory.create(); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); - mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail); + mocks.personUser.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail); mocks.media.generateThumbnail.mockResolvedValue(); const extracted = Buffer.from(''); const data = Buffer.from(''); @@ -1895,7 +1895,7 @@ describe(MediaService.name, () => { mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg }); mocks.media.getImageMetadata.mockResolvedValue({ width: 1000, height: 1000, isTransparent: false }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect(sut.handleGeneratePersonThumbnail({ personId, ownerId })).resolves.toBe(JobStatus.Success); expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, { @@ -1911,7 +1911,7 @@ describe(MediaService.name, () => { it('should queue all video assets', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); mocks.assetJob.streamForVideoConversion.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream()); + mocks.personUser.getAll.mockReturnValue(makeStream()); await sut.handleQueueVideoConversion({ force: true }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 956636dd51..137f8a20ef 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -96,19 +96,26 @@ export class MediaService extends BaseService { await queueAll(); - const people = this.personRepository.getAll(force ? undefined : { thumbnailPath: '' }); + const people = this.personUserRepository.getAll(force ? undefined : { thumbnailPath: '' }); for await (const person of people) { - if (!person.faceAssetId) { - const face = await this.personRepository.getRandomFace(person.id); + if (!person.thumbnailFaceAssetId) { + const face = await this.personRepository.getRandomFace(person.personId); if (!face) { continue; } - await this.personRepository.update({ id: person.id, faceAssetId: face.id }); + await this.personUserRepository.update({ + personId: person.personId, + ownerId: person.ownerId, + thumbnailFaceAssetId: face.id, + }); } - jobs.push({ name: JobName.PersonGenerateThumbnail, data: { id: person.id } }); + jobs.push({ + name: JobName.PersonGenerateThumbnail, + data: { personId: person.personId, ownerId: person.ownerId }, + }); if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { await queueAll(); } @@ -140,8 +147,8 @@ export class MediaService extends BaseService { await this.jobRepository.queueAll(jobs); jobs = []; - for await (const person of this.personRepository.getAll()) { - jobs.push({ name: JobName.PersonFileMigration, data: { id: person.id } }); + for await (const { personId, ownerId } of this.personUserRepository.getAll()) { + jobs.push({ name: JobName.PersonFileMigration, data: { personId, ownerId } }); if (jobs.length === JOBS_ASSET_PAGINATION_SIZE) { await this.jobRepository.queueAll(jobs); @@ -409,19 +416,22 @@ export class MediaService extends BaseService { } @OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration }) - async handleGeneratePersonThumbnail({ id }: JobOf): Promise { + async handleGeneratePersonThumbnail({ + personId, + ownerId, + }: JobOf): Promise { const { image } = await this.getConfig({ withCache: true }); - const data = await this.personRepository.getDataForThumbnailGenerationJob(id); + const data = await this.personUserRepository.getDataForThumbnailGenerationJob({ personId, ownerId }); if (!data) { - this.logger.error(`Could not generate person thumbnail for ${id}: missing data`); + this.logger.error(`Could not generate person thumbnail for ${personId}: missing data`); return JobStatus.Failed; } - const { ownerId, x1, y1, x2, y2, oldWidth, oldHeight, exifOrientation, previewPath, originalPath } = data; + const { x1, y1, x2, y2, oldWidth, oldHeight, exifOrientation, previewPath, originalPath } = data; let inputImage: string | Buffer; if (data.type === AssetType.Video) { if (!previewPath) { - this.logger.error(`Could not generate person thumbnail for video ${id}: missing preview path`); + this.logger.error(`Could not generate person thumbnail for video ${personId}: missing preview path`); return JobStatus.Failed; } inputImage = previewPath; @@ -439,7 +449,7 @@ export class MediaService extends BaseService { orientation: Buffer.isBuffer(inputImage) && exifOrientation ? Number(exifOrientation) : undefined, }); - const thumbnailPath = StorageCore.getPersonThumbnailPath({ id, ownerId }); + const thumbnailPath = StorageCore.getPersonThumbnailPath({ id: personId, ownerId }); this.storageCore.ensureFolders(thumbnailPath); const thumbnailOptions: GenerateThumbnailOptions = { @@ -462,7 +472,7 @@ export class MediaService extends BaseService { }; await this.mediaRepository.generateThumbnail(decodedImage, thumbnailOptions, thumbnailPath); - await this.personRepository.update({ id, thumbnailPath }); + await this.personUserRepository.update({ personId, ownerId, thumbnailPath }); return JobStatus.Success; } @@ -856,7 +866,7 @@ export class MediaService extends BaseService { : undefined; const originalDimensions = getDimensions(asset.exifInfo!); - const assetFaces = await this.personRepository.getFaces(asset.id, {}); + const assetFaces = await this.personRepository.getFaces(asset.id); const ocrData = await this.ocrRepository.getByAssetId(asset.id, {}); const faceStatuses = checkFaceVisibility(assetFaces, originalDimensions, cropBox); diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 57c029961e..2ffbdbc0e1 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1434,13 +1434,13 @@ describe(MetadataService.name, () => { ], [], ); - expect(mocks.person.updateAll).toHaveBeenCalledWith([ - { id: 'random-uuid', ownerId: asset.ownerId, faceAssetId: 'random-uuid' }, + expect(mocks.personUser.updateAll).toHaveBeenCalledWith([ + { personId: 'random-uuid', ownerId: asset.ownerId, thumbnailFaceAssetId: 'random-uuid' }, ]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonGenerateThumbnail, - data: { id: person.id }, + data: { personId: 'random-uuid', ownerId: asset.ownerId }, }, ]); }); @@ -1476,7 +1476,7 @@ describe(MetadataService.name, () => { ], [], ); - expect(mocks.person.updateAll).not.toHaveBeenCalled(); + expect(mocks.personUser.updateAll).not.toHaveBeenCalled(); expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); }); @@ -1565,13 +1565,13 @@ describe(MetadataService.name, () => { ], [], ); - expect(mocks.person.updateAll).toHaveBeenCalledWith([ - { id: 'random-uuid', ownerId: asset.ownerId, faceAssetId: 'random-uuid' }, + expect(mocks.personUser.updateAll).toHaveBeenCalledWith([ + { personId: 'random-uuid', ownerId: asset.ownerId, thumbnailFaceAssetId: 'random-uuid' }, ]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonGenerateThumbnail, - data: { id: person.id }, + data: { personId: 'random-uuid', ownerId: asset.ownerId }, }, ]); }, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 37dd92e27d..d7b0f97144 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -28,7 +28,7 @@ import { ReverseGeocodeResult } from 'src/repositories/map.repository'; import { ImmichTags } from 'src/repositories/metadata.repository'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; -import { PersonTable } from 'src/schema/tables/person.table'; +import { PersonUserTable } from 'src/schema/tables/person-user.table'; import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; import { getAssetFiles } from 'src/utils/asset.util'; @@ -908,7 +908,13 @@ export class MetadataService extends BaseService { } private async applyTaggedFaces( - asset: { id: string; ownerId: string; faces: { id: string; sourceType: SourceType }[]; originalPath: string }, + asset: { + id: string; + ownerId: string; + ownerTrustedGroupId: string; + faces: { id: string; sourceType: SourceType }[]; + originalPath: string; + }, tags: ImmichTags, ) { if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) { @@ -918,8 +924,8 @@ export class MetadataService extends BaseService { const facesToAdd: (Insertable & { assetId: string })[] = []; const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true }); const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id])); - const missing: (Insertable & { ownerId: string })[] = []; - const missingWithFaceAsset: { id: string; ownerId: string; faceAssetId: string }[] = []; + const missing: (Insertable & { name: string; trustedGroupId: string })[] = []; + const missingWithFaceAsset: { personId: string; ownerId: string; thumbnailFaceAssetId: string }[] = []; const adjustedRegionInfo = this.orientRegionInfo(tags.RegionInfo, tags.Orientation); const imageWidth = adjustedRegionInfo.AppliedToDimensions.W; @@ -953,16 +959,20 @@ export class MetadataService extends BaseService { facesToAdd.push(face); if (!existingNameMap.has(loweredName)) { - missing.push({ id: personId, ownerId: asset.ownerId, name: region.Name }); - missingWithFaceAsset.push({ id: personId, ownerId: asset.ownerId, faceAssetId: face.id }); + missing.push({ + personId, + ownerId: asset.ownerId, + trustedGroupId: asset.ownerTrustedGroupId, + name: region.Name, + }); + missingWithFaceAsset.push({ personId, ownerId: asset.ownerId, thumbnailFaceAssetId: face.id }); } } if (missing.length > 0) { - this.logger.debugFn(() => `Creating missing persons: ${missing.map((p) => `${p.name}/${p.id}`)}`); - const newPersonIds = await this.personRepository.createAll(missing); - const jobs = newPersonIds.map((id) => ({ name: JobName.PersonGenerateThumbnail, data: { id } }) as const); - await this.jobRepository.queueAll(jobs); + this.logger.debugFn(() => `Creating missing persons: ${missing.map((p) => `${p.name}/${p.personId}`)}`); + await this.personRepository.createAll(missing); + await this.personUserRepository.createAll(missing); } const facesToRemove = asset.faces.filter((face) => face.sourceType === SourceType.Exif).map((face) => face.id); @@ -981,7 +991,11 @@ export class MetadataService extends BaseService { } if (missingWithFaceAsset.length > 0) { - await this.personRepository.updateAll(missingWithFaceAsset); + await this.personUserRepository.updateAll(missingWithFaceAsset); + const jobs = missing.map( + ({ personId, ownerId }) => ({ name: JobName.PersonGenerateThumbnail, data: { personId, ownerId } }) as const, + ); + await this.jobRepository.queueAll(jobs); } } diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index e6a11786af..f018d3297d 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -8,6 +8,7 @@ import { ImmichFileResponse } from 'src/utils/file'; import { AssetFaceFactory } from 'test/factories/asset-face.factory'; import { AssetFactory } from 'test/factories/asset.factory'; import { AuthFactory } from 'test/factories/auth.factory'; +import { PersonUserFactory } from 'test/factories/person-user.factory'; import { PersonFactory } from 'test/factories/person.factory'; import { UserFactory } from 'test/factories/user.factory'; import { authStub } from 'test/fixtures/auth.stub'; @@ -18,6 +19,7 @@ import { getForAssetFace, getForDetectedFaces, getForFacialRecognitionJob, + getForPerson, } from 'test/mappers'; import { newDate, newUuid } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -37,13 +39,16 @@ describe(PersonService.name, () => { describe('getAll', () => { it('should get all hidden and visible people with thumbnails', async () => { const auth = AuthFactory.create(); - const [person, hiddenPerson] = [PersonFactory.create(), PersonFactory.create({ isHidden: true })]; + const [person, hiddenPerson] = [ + PersonFactory.create(), + PersonFactory.from().personUser({ isHidden: true }).build(), + ]; mocks.person.getAllForUser.mockResolvedValue({ - items: [person, hiddenPerson], + items: [getForPerson(person), getForPerson(hiddenPerson)], hasNextPage: false, }); - mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); + mocks.personUser.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); await expect(sut.getAll(auth, { withHidden: true, page: 1, size: 10 })).resolves.toEqual({ hasNextPage: false, total: 2, @@ -63,13 +68,16 @@ describe(PersonService.name, () => { it('should get all visible people and favorites should be first in the array', async () => { const auth = AuthFactory.create(); - const [isFavorite, person] = [PersonFactory.create({ isFavorite: true }), PersonFactory.create()]; + const [isFavorite, person] = [ + PersonFactory.from().personUser({ isFavorite: true }).build(), + PersonFactory.create(), + ]; mocks.person.getAllForUser.mockResolvedValue({ - items: [isFavorite, person], + items: [getForPerson(isFavorite), getForPerson(person)], hasNextPage: false, }); - mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); + mocks.personUser.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); await expect(sut.getAll(auth, { withHidden: false, page: 1, size: 10 })).resolves.toEqual({ hasNextPage: false, total: 2, @@ -108,10 +116,10 @@ describe(PersonService.name, () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getForOwner.mockResolvedValue({ ...person, ...person.personUser }); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); await expect(sut.getById(auth, person.id)).resolves.toEqual(expect.objectContaining({ id: person.id })); - expect(mocks.person.getById).toHaveBeenCalledWith(person.id); + expect(mocks.person.getForOwner).toHaveBeenCalledWith(person.id, auth.user.id); expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); }); }); @@ -138,29 +146,29 @@ describe(PersonService.name, () => { it('should throw an error when person has no thumbnail', async () => { const auth = AuthFactory.create(); - const person = PersonFactory.create({ thumbnailPath: '' }); + const person = PersonUserFactory.create({ thumbnailPath: '' }); - mocks.person.getById.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - await expect(sut.getThumbnail(auth, person.id)).rejects.toBeInstanceOf(NotFoundException); + mocks.personUser.get.mockResolvedValue(person); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personId])); + await expect(sut.getThumbnail(auth, person.personId)).rejects.toBeInstanceOf(NotFoundException); expect(mocks.storage.createReadStream).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personId])); }); it('should serve the thumbnail', async () => { const auth = AuthFactory.create(); - const person = PersonFactory.create(); + const person = PersonUserFactory.create(); - mocks.person.getById.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - await expect(sut.getThumbnail(auth, person.id)).resolves.toEqual( + mocks.personUser.get.mockResolvedValue(person); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personId])); + await expect(sut.getThumbnail(auth, person.personId)).resolves.toEqual( new ImmichFileResponse({ path: person.thumbnailPath, contentType: 'image/jpeg', cacheControl: CacheControl.PrivateWithoutCache, }), ); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personId])); }); }); @@ -204,13 +212,14 @@ describe(PersonService.name, () => { const person = PersonFactory.create({ birthDate: new Date('1976-06-30') }); mocks.person.update.mockResolvedValue(person); + mocks.personUser.update.mockResolvedValue(person.personUser); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); await expect(sut.update(auth, person.id, { birthDate: '1976-06-30' })).resolves.toEqual({ id: person.id, name: person.name, birthDate: '1976-06-30', - thumbnailPath: person.thumbnailPath, + thumbnailPath: person.personUser.thumbnailPath, isHidden: false, isFavorite: false, updatedAt: expect.any(String), @@ -223,40 +232,51 @@ describe(PersonService.name, () => { it('should update a person visibility', async () => { const auth = AuthFactory.create(); - const person = PersonFactory.create({ isHidden: true }); + const person = PersonFactory.from().personUser({ ownerId: auth.user.id, isHidden: true }).build(); mocks.person.update.mockResolvedValue(person); + mocks.personUser.update.mockResolvedValue(person.personUser); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); await expect(sut.update(auth, person.id, { isHidden: true })).resolves.toEqual( expect.objectContaining({ isHidden: true }), ); - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, isHidden: true }); + expect(mocks.personUser.update).toHaveBeenCalledWith({ + personId: person.id, + ownerId: person.personUser.ownerId, + isHidden: true, + }); expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); }); it('should update a person favorite status', async () => { const auth = AuthFactory.create(); - const person = PersonFactory.create({ isFavorite: true }); + const person = PersonFactory.from().personUser({ ownerId: auth.user.id, isFavorite: true }).build(); mocks.person.update.mockResolvedValue(person); + mocks.personUser.update.mockResolvedValue(person.personUser); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); await expect(sut.update(auth, person.id, { isFavorite: true })).resolves.toEqual( expect.objectContaining({ isFavorite: true }), ); - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, isFavorite: true }); + expect(mocks.personUser.update).toHaveBeenCalledWith({ + personId: person.id, + ownerId: person.personUser.ownerId, + isFavorite: true, + }); expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); }); it("should update a person's thumbnailPath", async () => { const face = AssetFaceFactory.create(); const auth = AuthFactory.create(); - const person = PersonFactory.create(); + const person = PersonFactory.from().personUser({ ownerId: auth.user.id }).build(); mocks.person.update.mockResolvedValue(person); + mocks.personUser.update.mockResolvedValue(person.personUser); mocks.person.getForFeatureFaceUpdate.mockResolvedValue(face); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([face.assetId])); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); @@ -265,14 +285,18 @@ describe(PersonService.name, () => { expect.objectContaining({ id: person.id }), ); - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, faceAssetId: face.id }); + expect(mocks.personUser.update).toHaveBeenCalledWith({ + personId: person.id, + ownerId: person.personUser.ownerId, + thumbnailFaceAssetId: face.id, + }); expect(mocks.person.getForFeatureFaceUpdate).toHaveBeenCalledWith({ assetId: face.assetId, personId: person.id, }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.PersonGenerateThumbnail, - data: { id: person.id }, + data: { personId: person.id, ownerId: person.personUser.ownerId }, }); expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); }); @@ -318,10 +342,10 @@ describe(PersonService.name, () => { it('should reassign a face', async () => { const face = AssetFaceFactory.create(); const auth = AuthFactory.create(); - const person = PersonFactory.create(); + const person = PersonFactory.from().personUser({ thumbnailFaceAssetId: null }).build(); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getForOwner.mockResolvedValue({ ...person, ...person.personUser }); mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id])); mocks.person.getFacesByIds.mockResolvedValue([getForAssetFace(face)]); mocks.person.reassignFace.mockResolvedValue(1); @@ -339,7 +363,7 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonGenerateThumbnail, - data: { id: person.id }, + data: { personId: person.id, ownerId: auth.user.id }, }, ]); }); @@ -347,7 +371,7 @@ describe(PersonService.name, () => { describe('handlePersonMigration', () => { it('should not move person files', async () => { - await expect(sut.handlePersonMigration(PersonFactory.create())).resolves.toBe(JobStatus.Failed); + await expect(sut.handlePersonMigration(PersonUserFactory.create())).resolves.toBe(JobStatus.Failed); }); }); @@ -378,7 +402,7 @@ describe(PersonService.name, () => { it('should create a manual face and initialize the person feature photo creation', async () => { const auth = AuthFactory.create(); const asset = AssetFactory.create(); - const person = PersonFactory.create({ faceAssetId: null }); + const person = PersonFactory.from().personUser({ thumbnailFaceAssetId: null }).build(); const featureFace = AssetFaceFactory.create({ assetId: asset.id, personId: person.id, @@ -388,9 +412,9 @@ describe(PersonService.name, () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); mocks.asset.getById.mockResolvedValue(getForAsset(asset)); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getForOwner.mockResolvedValue({ ...person, ...person.personUser }); mocks.person.getRandomFace.mockResolvedValue(featureFace); - mocks.person.update.mockResolvedValue({ ...person, faceAssetId: featureFace.id }); + mocks.personUser.update.mockResolvedValue(person.personUser); await expect( sut.createFace(auth, { @@ -418,21 +442,25 @@ describe(PersonService.name, () => { sourceType: SourceType.Manual, }); expect(mocks.person.getRandomFace).toHaveBeenCalledWith(person.id); - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, faceAssetId: featureFace.id }); + expect(mocks.personUser.update).toHaveBeenCalledWith({ + personId: person.id, + ownerId: person.personUser.ownerId, + thumbnailFaceAssetId: featureFace.id, + }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.PersonGenerateThumbnail, data: { id: person.id } }, + { name: JobName.PersonGenerateThumbnail, data: { personId: person.id, ownerId: person.personUser.ownerId } }, ]); }); it('should not update the person feature photo if one already exists', async () => { const auth = AuthFactory.create(); const asset = AssetFactory.create(); - const person = PersonFactory.create({ faceAssetId: newUuid() }); + const person = PersonFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); mocks.asset.getById.mockResolvedValue(getForAsset(asset)); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getForOwner.mockResolvedValue({ ...person, ...person.personUser }); await expect( sut.createFace(auth, { @@ -449,14 +477,14 @@ describe(PersonService.name, () => { expect(mocks.person.createAssetFace).toHaveBeenCalledOnce(); expect(mocks.person.getRandomFace).not.toHaveBeenCalled(); - expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.personUser.update).not.toHaveBeenCalled(); expect(mocks.job.queueAll).not.toHaveBeenCalled(); }); it('should reject creating a face on an asset the user does not own', async () => { const auth = AuthFactory.create(); const asset = AssetFactory.create(); - const person = PersonFactory.create({ faceAssetId: null }); + const person = PersonFactory.from().personUser({ thumbnailFaceAssetId: null }).build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set()); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); @@ -483,11 +511,11 @@ describe(PersonService.name, () => { const person = PersonFactory.create(); mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create()); - await sut.createNewFeaturePhoto([person.id]); + await sut.createNewFeaturePhoto([person.id], person.personUser.ownerId); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonGenerateThumbnail, - data: { id: person.id }, + data: { personId: person.id, ownerId: person.personUser.ownerId }, }, ]); }); @@ -502,14 +530,14 @@ describe(PersonService.name, () => { mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id])); mocks.person.getFaceById.mockResolvedValue(getForAssetFace(face)); mocks.person.reassignFace.mockResolvedValue(1); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getForOwner.mockResolvedValue({ ...person, ...person.personUser }); await expect(sut.reassignFacesById(AuthFactory.create(), person.id, { id: face.id })).resolves.toEqual({ birthDate: person.birthDate, - isHidden: person.isHidden, - isFavorite: person.isFavorite, + isHidden: person.personUser.isHidden, + isFavorite: person.personUser.isFavorite, id: person.id, name: person.name, - thumbnailPath: person.thumbnailPath, + thumbnailPath: person.personUser.thumbnailPath, updatedAt: expect.any(String), }); @@ -539,23 +567,25 @@ describe(PersonService.name, () => { describe('createPerson', () => { it('should create a new person', async () => { const auth = AuthFactory.create(); + const person = PersonFactory.create(); - mocks.person.create.mockResolvedValue(PersonFactory.create()); + mocks.person.create.mockResolvedValue(person); await expect(sut.create(auth, {})).resolves.toBeDefined(); - expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: auth.user.id }); + expect(mocks.person.create).toHaveBeenCalledWith({ trustedGroupId: auth.user.trustedGroupId }); + expect(mocks.personUser.create).toHaveBeenCalledWith({ personId: person.id, ownerId: auth.user.id }); }); }); describe('handlePersonCleanup', () => { it('should delete people without faces', async () => { - const person = PersonFactory.create(); + const person = PersonUserFactory.create(); - mocks.person.getAllWithoutFaces.mockResolvedValue([person]); + mocks.personUser.getAllWithoutFaces.mockResolvedValue([person]); await sut.handlePersonCleanup(); - expect(mocks.person.delete).toHaveBeenCalledWith([person.id]); + expect(mocks.person.delete).toHaveBeenCalledWith([person.personId]); expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath); }); }); @@ -588,15 +618,15 @@ describe(PersonService.name, () => { it('should queue all assets', async () => { const asset = AssetFactory.create(); - const person = PersonFactory.create(); + const person = PersonUserFactory.create(); mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset])); - mocks.person.getAllWithoutFaces.mockResolvedValue([person]); + mocks.personUser.getAllWithoutFaces.mockResolvedValue([person]); await sut.handleQueueDetectFaces({ force: true }); expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MachineLearning }); - expect(mocks.person.delete).toHaveBeenCalledWith([person.id]); + expect(mocks.person.delete).toHaveBeenCalledWith([person.personId]); expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true }); expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath); expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true); @@ -631,12 +661,11 @@ describe(PersonService.name, () => { it('should delete existing people and faces if forced', async () => { const asset = AssetFactory.create(); const face = AssetFaceFactory.from().person().build(); - const person = PersonFactory.create(); + const person = PersonUserFactory.create(); - mocks.person.getAll.mockReturnValue(makeStream([face.person!, person])); mocks.person.getAllFaces.mockReturnValue(makeStream([face])); mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset])); - mocks.person.getAllWithoutFaces.mockResolvedValue([person]); + mocks.personUser.getAllWithoutFaces.mockResolvedValue([person]); mocks.person.deleteFaces.mockResolvedValue(); await sut.handleQueueDetectFaces({ force: true }); @@ -648,7 +677,7 @@ describe(PersonService.name, () => { data: { id: asset.id }, }, ]); - expect(mocks.person.delete).toHaveBeenCalledWith([person.id]); + expect(mocks.person.delete).toHaveBeenCalledWith([person.personId]); expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath); expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true }); }); @@ -698,7 +727,7 @@ describe(PersonService.name, () => { delayed: 0, }); mocks.person.getAllFaces.mockReturnValue(makeStream([face])); - mocks.person.getAllWithoutFaces.mockResolvedValue([]); + mocks.personUser.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({}); @@ -728,9 +757,8 @@ describe(PersonService.name, () => { failed: 0, delayed: 0, }); - mocks.person.getAll.mockReturnValue(makeStream()); mocks.person.getAllFaces.mockReturnValue(makeStream([face])); - mocks.person.getAllWithoutFaces.mockResolvedValue([]); + mocks.personUser.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true }); @@ -759,9 +787,8 @@ describe(PersonService.name, () => { failed: 0, delayed: 0, }); - mocks.person.getAll.mockReturnValue(makeStream()); mocks.person.getAllFaces.mockReturnValue(makeStream([face])); - mocks.person.getAllWithoutFaces.mockResolvedValue([]); + mocks.personUser.getAllWithoutFaces.mockResolvedValue([]); mocks.person.unassignFaces.mockResolvedValue(); await sut.handleQueueRecognizeFaces({ force: false, nightly: true }); @@ -790,7 +817,7 @@ describe(PersonService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ lastRun: lastRun.toISOString() }); mocks.person.getLatestFaceDate.mockResolvedValue(new Date(lastRun.getTime() - 1).toISOString()); mocks.person.getAllFaces.mockReturnValue(makeStream([AssetFaceFactory.create()])); - mocks.person.getAllWithoutFaces.mockResolvedValue([]); + mocks.personUser.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); @@ -804,7 +831,7 @@ describe(PersonService.name, () => { it('should delete existing people if forced', async () => { const face = AssetFaceFactory.from().person().build(); - const person = PersonFactory.create(); + const person = PersonUserFactory.create(); mocks.job.getJobCounts.mockResolvedValue({ active: 1, @@ -814,9 +841,8 @@ describe(PersonService.name, () => { failed: 0, delayed: 0, }); - mocks.person.getAll.mockReturnValue(makeStream([face.person!, person])); mocks.person.getAllFaces.mockReturnValue(makeStream([face])); - mocks.person.getAllWithoutFaces.mockResolvedValue([person]); + mocks.personUser.getAllWithoutFaces.mockResolvedValue([person]); mocks.person.unassignFaces.mockResolvedValue(); await sut.handleQueueRecognizeFaces({ force: true }); @@ -829,7 +855,7 @@ describe(PersonService.name, () => { data: { id: face.id, deferred: false }, }, ]); - expect(mocks.person.delete).toHaveBeenCalledWith([person.id]); + expect(mocks.person.delete).toHaveBeenCalledWith([person.personId]); expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath); expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: false }); }); @@ -1028,6 +1054,7 @@ describe(PersonService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset)); + mocks.user.get.mockResolvedValue(asset.owner); mocks.person.create.mockResolvedValue(primaryFace.person!); await sut.handleRecognizeFaces({ id: noPerson1.id }); @@ -1061,6 +1088,7 @@ describe(PersonService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson, asset)); + mocks.user.get.mockResolvedValue(asset.owner); mocks.person.create.mockResolvedValue(face.person!); await sut.handleRecognizeFaces({ id: noPerson.id }); @@ -1094,6 +1122,7 @@ describe(PersonService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson, asset)); + mocks.user.get.mockResolvedValue(asset.owner); mocks.person.create.mockResolvedValue(face.person!); await sut.handleRecognizeFaces({ id: noPerson.id }); @@ -1123,13 +1152,18 @@ describe(PersonService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset)); + mocks.user.get.mockResolvedValue(asset.owner); mocks.person.create.mockResolvedValue(person); await sut.handleRecognizeFaces({ id: noPerson1.id }); expect(mocks.person.create).toHaveBeenCalledWith({ + trustedGroupId: asset.owner.trustedGroupId, + }); + expect(mocks.personUser.create).toHaveBeenCalledWith({ + personId: person.id, ownerId: asset.ownerId, - faceAssetId: noPerson1.id, + thumbnailFaceAssetId: noPerson1.id, }); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ faceIds: [noPerson1.id], @@ -1144,6 +1178,7 @@ describe(PersonService.name, () => { mocks.search.searchFaces.mockResolvedValue(faces); mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(face, asset)); + mocks.user.get.mockResolvedValue(asset.owner); mocks.person.create.mockResolvedValue(PersonFactory.create()); await sut.handleRecognizeFaces({ id: face.id }); @@ -1166,6 +1201,7 @@ describe(PersonService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); mocks.search.searchFaces.mockResolvedValue(faces); mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset)); + mocks.user.get.mockResolvedValue(asset.owner); mocks.person.create.mockResolvedValue(PersonFactory.create()); await sut.handleRecognizeFaces({ id: noPerson1.id }); @@ -1191,6 +1227,7 @@ describe(PersonService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); mocks.search.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]); mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset)); + mocks.user.get.mockResolvedValue(asset.owner); mocks.person.create.mockResolvedValue(PersonFactory.create()); await sut.handleRecognizeFaces({ id: noPerson1.id, deferred: true }); @@ -1226,6 +1263,9 @@ describe(PersonService.name, () => { mocks.person.getById.mockResolvedValueOnce(person); mocks.person.getById.mockResolvedValueOnce(mergePerson); + mocks.personUser.getThumbnailsForPerson.mockResolvedValue([ + { thumbnailPath: mergePerson.personUser.thumbnailPath }, + ]); mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id])); mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id])); @@ -1251,6 +1291,9 @@ describe(PersonService.name, () => { mocks.person.getById.mockResolvedValueOnce(person); mocks.person.getById.mockResolvedValueOnce(mergePerson); mocks.person.update.mockResolvedValue({ ...person, name: mergePerson.name }); + mocks.personUser.getThumbnailsForPerson.mockResolvedValue([ + { thumbnailPath: mergePerson.personUser.thumbnailPath }, + ]); mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id])); mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id])); @@ -1344,8 +1387,10 @@ describe(PersonService.name, () => { it('should map a face', () => { const user = UserFactory.create(); const auth = AuthFactory.create({ id: user.id }); - const person = PersonFactory.create({ ownerId: user.id }); - const face = AssetFaceFactory.from().person(person).build(); + const person = PersonFactory.from().personUser({ ownerId: user.id }).build(); + const face = AssetFaceFactory.from() + .person(person, (builder) => builder.personUser(person.personUser)) + .build(); expect(mapFaces(getForAssetFace(face), auth)).toEqual({ boundingBoxX1: 100, @@ -1356,7 +1401,7 @@ describe(PersonService.name, () => { imageHeight: 500, imageWidth: 400, sourceType: SourceType.MachineLearning, - person: mapPerson(person), + person: mapPerson(getForPerson(person)), }); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 8e0cd2ff01..8f66e03266 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -1,7 +1,6 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Insertable, Updateable } from 'kysely'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; -import { Person } from 'src/database'; import { Chunked, OnJob } from 'src/decorators'; import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -38,6 +37,7 @@ import { BoundingBox } from 'src/repositories/machine-learning.repository'; import { UpdateFacesData } from 'src/repositories/person.repository'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; +import { PersonTable } from 'src/schema/tables/person.table'; import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; import { getDimensions } from 'src/utils/asset.util'; @@ -57,17 +57,17 @@ export class PersonService extends BaseService { }; if (closestPersonId) { - const person = await this.personRepository.getById(closestPersonId); - if (!person?.faceAssetId) { + const person = await this.personUserRepository.get({ personId: closestPersonId, ownerId: auth.user.id }); + if (!person?.thumbnailFaceAssetId) { throw new NotFoundException('Person not found'); } - closestFaceAssetId = person.faceAssetId; + closestFaceAssetId = person.thumbnailFaceAssetId; } const { items, hasNextPage } = await this.personRepository.getAllForUser(pagination, auth.user.id, { withHidden, closestFaceAssetId, }); - const { total, hidden } = await this.personRepository.getNumberOfPeople(auth.user.id); + const { total, hidden } = await this.personUserRepository.getNumberOfPeople(auth.user.id); return { people: items.map((person) => mapPerson(person)), @@ -79,7 +79,7 @@ export class PersonService extends BaseService { async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise { await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personId] }); - const person = await this.findOrFail(personId); + const person = await this.findOrFail(personId, auth.user.id); const result: PersonResponseDto[] = []; const changeFeaturePhoto: string[] = []; for (const data of dto.data) { @@ -87,10 +87,10 @@ export class PersonService extends BaseService { for (const face of faces) { await this.requireAccess({ auth, permission: Permission.PersonCreate, ids: [face.id] }); - if (person.faceAssetId === null) { - changeFeaturePhoto.push(person.id); + if (person.thumbnailFaceAssetId === null) { + changeFeaturePhoto.push(person.personId); } - if (face.person && face.person.faceAssetId === face.id) { + if (face.person && face.person.thumbnailFaceAssetId === face.id) { changeFeaturePhoto.push(face.person.id); } @@ -101,7 +101,7 @@ export class PersonService extends BaseService { } if (changeFeaturePhoto.length > 0) { // Remove duplicates - await this.createNewFeaturePhoto([...new Set(changeFeaturePhoto)]); + await this.createNewFeaturePhoto([...new Set(changeFeaturePhoto)], auth.user.id); } return result; } @@ -109,30 +109,30 @@ export class PersonService extends BaseService { async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise { await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personId] }); await this.requireAccess({ auth, permission: Permission.PersonCreate, ids: [dto.id] }); - const face = await this.personRepository.getFaceById(dto.id); - const person = await this.findOrFail(personId); + const face = await this.personRepository.getFaceById(dto.id, auth.user.id); + const person = await this.findOrFail(personId, auth.user.id); await this.personRepository.reassignFace(face.id, personId); - if (person.faceAssetId === null) { - await this.createNewFeaturePhoto([person.id]); + if (person.thumbnailFaceAssetId === null) { + await this.createNewFeaturePhoto([person.id], auth.user.id); } - if (face.person && face.person.faceAssetId === face.id) { - await this.createNewFeaturePhoto([face.person.id]); + if (face.person && face.person.thumbnailFaceAssetId === face.id) { + await this.createNewFeaturePhoto([face.person.id], auth.user.id); } - return mapPerson(await this.findOrFail(personId)); + return mapPerson(await this.findOrFail(personId, auth.user.id)); } async getFacesById(auth: AuthDto, dto: FaceDto): Promise { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.id] }); - const faces = await this.personRepository.getFaces(dto.id); + const faces = await this.personRepository.getFaces(dto.id, auth.user.id); const asset = await this.assetRepository.getForFaces(dto.id); const assetDimensions = getDimensions(asset); return faces.map((face) => mapFaces(face, auth, asset.edits, assetDimensions)); } - async createNewFeaturePhoto(changeFeaturePhoto: string[]) { + async createNewFeaturePhoto(changeFeaturePhoto: string[], ownerId: string) { this.logger.debug( `Changing feature photos for ${changeFeaturePhoto.length} ${changeFeaturePhoto.length > 1 ? 'people' : 'person'}`, ); @@ -142,8 +142,8 @@ export class PersonService extends BaseService { const assetFace = await this.personRepository.getRandomFace(personId); if (assetFace) { - await this.personRepository.update({ id: personId, faceAssetId: assetFace.id }); - jobs.push({ name: JobName.PersonGenerateThumbnail, data: { id: personId } }); + await this.personUserRepository.update({ personId, ownerId, thumbnailFaceAssetId: assetFace.id }); + jobs.push({ name: JobName.PersonGenerateThumbnail, data: { personId, ownerId } }); } } @@ -152,7 +152,7 @@ export class PersonService extends BaseService { async getById(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] }); - return mapPerson(await this.findOrFail(id)); + return mapPerson(await this.findOrFail(id, auth.user.id)); } async getStatistics(auth: AuthDto, id: string): Promise { @@ -162,7 +162,7 @@ export class PersonService extends BaseService { async getThumbnail(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] }); - const person = await this.personRepository.getById(id); + const person = await this.personUserRepository.get({ personId: id, ownerId: auth.user.id }); if (!person || !person.thumbnailPath) { throw new NotFoundException(); } @@ -176,15 +176,19 @@ export class PersonService extends BaseService { async create(auth: AuthDto, dto: PersonCreateDto): Promise { const person = await this.personRepository.create({ - ownerId: auth.user.id, name: dto.name, birthDate: dto.birthDate, + color: dto.color, + trustedGroupId: auth.user.trustedGroupId, + }); + const personUser = await this.personUserRepository.create({ + personId: person.id, + ownerId: auth.user.id, isHidden: dto.isHidden, isFavorite: dto.isFavorite, - color: dto.color, }); - return mapPerson(person); + return mapPerson({ ...person, ...personUser }); } async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise { @@ -205,19 +209,27 @@ export class PersonService extends BaseService { const person = await this.personRepository.update({ id, - faceAssetId: faceId, name, birthDate, - isHidden, - isFavorite, color, }); + const personUser = await this.personUserRepository.update({ + personId: id, + ownerId: auth.user.id, + thumbnailFaceAssetId: faceId, + isHidden, + isFavorite, + }); + if (assetId) { - await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id } }); + await this.jobRepository.queue({ + name: JobName.PersonGenerateThumbnail, + data: { personId: personUser.personId, ownerId: personUser.ownerId }, + }); } - return mapPerson(person); + return mapPerson({ ...person, ...personUser }); } delete(auth: AuthDto, id: string): Promise { @@ -246,20 +258,20 @@ export class PersonService extends BaseService { async deleteAll(auth: AuthDto, { ids }: BulkIdsDto): Promise { await this.requireAccess({ auth, permission: Permission.PersonDelete, ids }); - const people = await this.personRepository.getForPeopleDelete(ids); + const people = await this.personUserRepository.getForPeopleDelete(ids); await this.removeAllPeople(people); } @Chunked() - private async removeAllPeople(people: { id: string; thumbnailPath: string }[]) { + private async removeAllPeople(people: { personId: string; thumbnailPath: string }[]) { await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath))); - await this.personRepository.delete(people.map((person) => person.id)); + await this.personRepository.delete(people.map(({ personId }) => personId)); this.logger.debug(`Deleted ${people.length} people`); } @OnJob({ name: JobName.PersonCleanup, queue: QueueName.BackgroundTask }) async handlePersonCleanup(): Promise { - const people = await this.personRepository.getAllWithoutFaces(); + const people = await this.personUserRepository.getAllWithoutFaces(); await this.removeAllPeople(people); return JobStatus.Success; } @@ -484,8 +496,14 @@ export class PersonService extends BaseService { return JobStatus.Skipped; } + const owner = await this.userRepository.get(face.asset.ownerId, {}); + if (!owner) { + this.logger.warn('Owner of asset the face belongs to not found'); + return JobStatus.Failed; + } + const matches = await this.searchRepository.searchFaces({ - userIds: [face.asset.ownerId], + trustedGroupId: owner.trustedGroupId, embedding: face.faceSearch.embedding, maxDistance: machineLearning.facialRecognition.maxDistance, numResults: machineLearning.facialRecognition.minFaces, @@ -512,7 +530,7 @@ export class PersonService extends BaseService { let personId = matches.find((match) => match.personId)?.personId; if (!personId) { const matchWithPerson = await this.searchRepository.searchFaces({ - userIds: [face.asset.ownerId], + trustedGroupId: owner.trustedGroupId, embedding: face.faceSearch.embedding, maxDistance: machineLearning.facialRecognition.maxDistance, numResults: 1, @@ -527,9 +545,14 @@ export class PersonService extends BaseService { if (isCore && !personId) { this.logger.log(`Creating new person for face ${id}`); - const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id }); - await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id: newPerson.id } }); + const newPerson = await this.personRepository.create({ trustedGroupId: owner.trustedGroupId }); + await this.personUserRepository.create({ + personId: newPerson.id, + ownerId: face.asset.ownerId, + thumbnailFaceAssetId: face.id, + }); personId = newPerson.id; + await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { personId, ownerId: owner.id } }); } if (personId) { @@ -541,13 +564,16 @@ export class PersonService extends BaseService { } @OnJob({ name: JobName.PersonFileMigration, queue: QueueName.Migration }) - async handlePersonMigration({ id }: JobOf): Promise { - const person = await this.personRepository.getById(id); + async handlePersonMigration(data: JobOf): Promise { + const person = await this.personUserRepository.get(data); if (!person) { return JobStatus.Failed; } - await this.storageCore.movePersonFile(person, PersonPathType.Face); + await this.storageCore.movePersonFile( + { id: person.personId, ownerId: person.ownerId, thumbnailPath: person.thumbnailPath }, + PersonPathType.Face, + ); return JobStatus.Success; } @@ -559,7 +585,10 @@ export class PersonService extends BaseService { } await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [id] }); - let primaryPerson = await this.findOrFail(id); + let primaryPerson = await this.personRepository.getById(id); + if (!primaryPerson) { + throw new BadRequestException('Person not found'); + } const primaryName = primaryPerson.name || primaryPerson.id; const results: BulkIdResponseDto[] = []; @@ -584,7 +613,7 @@ export class PersonService extends BaseService { continue; } - const update: Updateable & { id: string } = { id: primaryPerson.id }; + const update: Updateable & { id: string } = { id: primaryPerson.id }; if (!primaryPerson.name && mergePerson.name) { update.name = mergePerson.name; } @@ -602,7 +631,10 @@ export class PersonService extends BaseService { this.logger.log(`Merging ${mergeName} into ${primaryName}`); await this.personRepository.reassignFaces(mergeData); - await this.removeAllPeople([mergePerson]); + const thumbnails = await this.personUserRepository.getThumbnailsForPerson(mergePerson.id); + await this.removeAllPeople( + thumbnails.map(({ thumbnailPath }) => ({ personId: mergePerson.id, thumbnailPath })), + ); this.logger.log(`Merged ${mergeName} into ${primaryName}`); results.push({ id: mergeId, success: true }); @@ -614,8 +646,8 @@ export class PersonService extends BaseService { return results; } - private async findOrFail(id: string) { - const person = await this.personRepository.getById(id); + private async findOrFail(id: string, ownerId: string) { + const person = await this.personRepository.getForOwner(id, ownerId); if (!person) { throw new BadRequestException('Person not found'); } @@ -631,7 +663,7 @@ export class PersonService extends BaseService { const [asset, person] = await Promise.all([ this.assetRepository.getById(dto.assetId, { edits: true, exifInfo: true }), - this.findOrFail(dto.personId), + this.findOrFail(dto.personId, auth.user.id), ]); if (!asset) { @@ -690,8 +722,8 @@ export class PersonService extends BaseService { sourceType: SourceType.Manual, }); - if (!person.faceAssetId) { - await this.createNewFeaturePhoto([person.id]); + if (!person.thumbnailFaceAssetId) { + await this.createNewFeaturePhoto([person.id], person.ownerId); } } diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index e4f2dcacf7..2ff4771765 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -242,6 +242,7 @@ export class StorageTemplateService extends BaseService { oldPath, newPath, assetInfo: { sizeInBytes: fileSizeInByte, checksum }, + ownerId: asset.ownerId, }); const sidecarPath = getAssetFile(asset.files, AssetFileType.Sidecar, { isEdited: false })?.path; @@ -251,6 +252,7 @@ export class StorageTemplateService extends BaseService { pathType: AssetFileType.Sidecar, oldPath: sidecarPath, newPath: `${newPath}.xmp`, + ownerId: asset.ownerId, }); } } catch (error: any) { diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index e3842b1503..ca4cebe5ac 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -10,6 +10,7 @@ import { syncAlbumV2ToV1, SyncAssetV2, SyncItem, + syncPersonV2ToV1, SyncStreamDto, } from 'src/dtos/sync.dto'; import { JobName, QueueName, SyncEntityType, SyncRequestType } from 'src/enum'; @@ -72,7 +73,9 @@ export const SYNC_TYPES_ORDER = [ SyncRequestType.PartnerAssetExifsV1, SyncRequestType.MemoriesV1, SyncRequestType.MemoryToAssetsV1, + SyncRequestType.PeopleV2, SyncRequestType.PeopleV1, + SyncRequestType.PersonUsersV1, SyncRequestType.AssetFacesV1, SyncRequestType.AssetFacesV2, SyncRequestType.UserMetadataV1, @@ -187,6 +190,8 @@ export class SyncService extends BaseService { [SyncRequestType.StacksV1]: () => this.syncStackV1(options, response, checkpointMap), [SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(options, response, checkpointMap, session.id), [SyncRequestType.PeopleV1]: () => this.syncPeopleV1(options, response, checkpointMap), + [SyncRequestType.PeopleV2]: () => this.syncPeopleV2(options, response, checkpointMap), + [SyncRequestType.PersonUsersV1]: () => this.syncPersonUsersV1(options, response, checkpointMap), [SyncRequestType.AssetFacesV2]: () => this.syncAssetFacesV2(options, response, checkpointMap), [SyncRequestType.UserMetadataV1]: () => this.syncUserMetadataV1(options, response, checkpointMap), [SyncRequestType.AssetOcrV1]: () => this.syncAssetOcrV1(options, response, checkpointMap, auth), @@ -220,6 +225,7 @@ export class SyncService extends BaseService { await this.syncRepository.memory.cleanupAuditTable(pruneThreshold); await this.syncRepository.memoryToAsset.cleanupAuditTable(pruneThreshold); await this.syncRepository.partner.cleanupAuditTable(pruneThreshold); + await this.syncRepository.personUser.cleanupAuditTable(pruneThreshold); await this.syncRepository.person.cleanupAuditTable(pruneThreshold); await this.syncRepository.stack.cleanupAuditTable(pruneThreshold); await this.syncRepository.user.cleanupAuditTable(pruneThreshold); @@ -834,6 +840,37 @@ export class SyncService extends BaseService { const upsertType = SyncEntityType.PersonV1; const upserts = this.syncRepository.person.getUpserts({ ...options, ack: checkpointMap[upsertType] }); + for await (const { updateId, ...data } of upserts) { + const personUser = await this.syncRepository.person.getPersonUser({ personId: data.id, ownerId: options.userId }); + if (personUser) { + send(response, { type: upsertType, ids: [updateId], data: syncPersonV2ToV1(data, personUser) }); + } + } + } + + private async syncPeopleV2(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) { + const deleteType = SyncEntityType.PersonDeleteV1; + const deletes = this.syncRepository.person.getDeletes({ ...options, ack: checkpointMap[deleteType] }); + for await (const { id, ...data } of deletes) { + send(response, { type: deleteType, ids: [id], data }); + } + + const upsertType = SyncEntityType.PersonV2; + const upserts = this.syncRepository.person.getUpserts({ ...options, ack: checkpointMap[upsertType] }); + for await (const { updateId, ...data } of upserts) { + send(response, { type: upsertType, ids: [updateId], data }); + } + } + + private async syncPersonUsersV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) { + const deleteType = SyncEntityType.PersonUserDeleteV1; + const deletes = this.syncRepository.personUser.getDeletes({ ...options, ack: checkpointMap[deleteType] }); + for await (const { id, ...data } of deletes) { + send(response, { type: deleteType, ids: [id], data }); + } + + const upsertType = SyncEntityType.PersonUserV1; + const upserts = this.syncRepository.personUser.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { send(response, { type: upsertType, ids: [updateId], data }); } diff --git a/server/src/types.ts b/server/src/types.ts index 27995e841f..4e21c0b7c3 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -349,6 +349,11 @@ export interface IIntegrityPathWithChecksumJob { items: { path: string; reportId: string | null; checksum?: string | null }[]; } +export interface IPersonJob { + personId: string; + ownerId: string; +} + export interface JobCounts { active: number; completed: number; @@ -385,7 +390,7 @@ export type JobItem = // Migration | { name: JobName.FileMigrationQueueAll; data?: IBaseJob } | { name: JobName.AssetFileMigration; data: IEntityJob } - | { name: JobName.PersonFileMigration; data: IEntityJob } + | { name: JobName.PersonFileMigration; data: IPersonJob } // Metadata Extraction | { name: JobName.AssetExtractMetadataQueueAll; data: IBaseJob } @@ -404,7 +409,7 @@ export type JobItem = | { name: JobName.AssetDetectFaces; data: IEntityJob } | { name: JobName.FacialRecognitionQueueAll; data: INightlyJob } | { name: JobName.FacialRecognition; data: IDeferrableJob } - | { name: JobName.PersonGenerateThumbnail; data: IEntityJob } + | { name: JobName.PersonGenerateThumbnail; data: IPersonJob } // Smart Search | { name: JobName.SmartSearchQueueAll; data: IBaseJob } diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 0f4d8775b6..5dc272c440 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -247,7 +247,21 @@ export function withFacesAndPeople( .selectFrom('asset_face') .leftJoinLateral( (eb) => - eb.selectFrom('person').selectAll('person').whereRef('asset_face.personId', '=', 'person.id').as('person'), + eb + .selectFrom('person') + .selectAll('person') + .whereRef('asset_face.personId', '=', 'person.id') + .innerJoin('person_user', (join) => + join.onRef('person_user.personId', '=', 'person.id').onRef('person_user.ownerId', '=', 'asset.ownerId'), + ) + .select([ + 'person_user.ownerId', + 'person_user.thumbnailFaceAssetId', + 'person_user.thumbnailPath', + 'person_user.isHidden', + 'person_user.isFavorite', + ]) + .as('person'), (join) => join.onTrue(), ) .selectAll('asset_face') diff --git a/server/test/factories/asset-face.factory.ts b/server/test/factories/asset-face.factory.ts index b2286cad54..6c6376b8ee 100644 --- a/server/test/factories/asset-face.factory.ts +++ b/server/test/factories/asset-face.factory.ts @@ -3,7 +3,7 @@ import { SourceType } from 'src/enum'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { build } from 'test/factories/builder.factory'; import { PersonFactory } from 'test/factories/person.factory'; -import { AssetFaceLike, FactoryBuilder, PersonLike } from 'test/factories/types'; +import { AssetFaceLike, FactoryBuilder, PersonLike, PersonUserLike } from 'test/factories/types'; import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; export class AssetFaceFactory { @@ -35,8 +35,11 @@ export class AssetFaceFactory { }); } - person(dto: PersonLike = {}, builder?: FactoryBuilder) { + person(dto: PersonLike & { personUser?: PersonUserLike } = {}, builder?: FactoryBuilder) { this.#person = build(PersonFactory.from(dto), builder); + if (dto.personUser && !builder) { + this.#person.personUser(dto.personUser); + } this.value.personId = this.#person.build().id; return this; } diff --git a/server/test/factories/auth.factory.ts b/server/test/factories/auth.factory.ts index fd38c42649..b3d94d84e8 100644 --- a/server/test/factories/auth.factory.ts +++ b/server/test/factories/auth.factory.ts @@ -40,7 +40,7 @@ export class AuthFactory { } build(): AuthDto { - const { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes } = this.#user.build(); + const { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes, trustedGroupId } = this.#user.build(); return { user: { @@ -50,6 +50,7 @@ export class AuthFactory { email, quotaUsageInBytes, quotaSizeInBytes, + trustedGroupId, }, sharedLink: this.#sharedLink?.build(), apiKey: this.#apiKey?.build(), diff --git a/server/test/factories/person-user.factory.ts b/server/test/factories/person-user.factory.ts new file mode 100644 index 0000000000..d2179311ac --- /dev/null +++ b/server/test/factories/person-user.factory.ts @@ -0,0 +1,47 @@ +import { Selectable } from 'kysely'; +import { PersonUserTable } from 'src/schema/tables/person-user.table'; +import { build } from 'test/factories/builder.factory'; +import { FactoryBuilder, PersonUserLike, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class PersonUserFactory { + #owner: UserFactory; + + private constructor(private readonly value: Selectable) { + this.#owner = UserFactory.from({ id: value.ownerId }); + } + + static create(dto: PersonUserLike = {}) { + return PersonUserFactory.from(dto).build(); + } + + static from(dto: PersonUserLike = {}) { + return new PersonUserFactory({ + createdAt: newDate(), + personId: newUuid(), + ownerId: newUuid(), + isFavorite: false, + isHidden: false, + updatedAt: newDate(), + updateId: newUuidV7(), + thumbnailFaceAssetId: newUuid(), + thumbnailPath: '/data/thumbs/person-thumbnail.jpg', + ...dto, + }); + } + + user(dto: UserLike = {}, builder?: FactoryBuilder) { + const user = build(UserFactory.from(dto), builder); + this.value.ownerId = user.build().id; + this.#owner = user; + return this; + } + + build() { + return { + ...this.value, + user: this.#owner.build(), + }; + } +} diff --git a/server/test/factories/person.factory.ts b/server/test/factories/person.factory.ts index 8e016e5398..17b009289a 100644 --- a/server/test/factories/person.factory.ts +++ b/server/test/factories/person.factory.ts @@ -1,9 +1,13 @@ import { Selectable } from 'kysely'; import { PersonTable } from 'src/schema/tables/person.table'; -import { PersonLike } from 'test/factories/types'; +import { build } from 'test/factories/builder.factory'; +import { PersonUserFactory } from 'test/factories/person-user.factory'; +import { FactoryBuilder, PersonLike, PersonUserLike } from 'test/factories/types'; import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; export class PersonFactory { + #personUser!: PersonUserFactory; + private constructor(private readonly value: Selectable) {} static create(dto: PersonLike = {}) { @@ -15,20 +19,21 @@ export class PersonFactory { birthDate: null, color: null, createdAt: newDate(), - faceAssetId: null, id: newUuid(), - isFavorite: false, - isHidden: false, name: 'person', - ownerId: newUuid(), - thumbnailPath: '/data/thumbs/person-thumbnail.jpg', updatedAt: newDate(), updateId: newUuidV7(), + trustedGroupId: newUuid(), ...dto, - }); + }).personUser(); + } + + personUser(dto: PersonUserLike = {}, builder?: FactoryBuilder) { + this.#personUser = build(PersonUserFactory.from({ ...dto, personId: this.value.id }), builder); + return this; } build() { - return { ...this.value }; + return { ...this.value, personUser: this.#personUser.build() }; } } diff --git a/server/test/factories/types.ts b/server/test/factories/types.ts index 5c8c8ee2c2..fabb5b1d96 100644 --- a/server/test/factories/types.ts +++ b/server/test/factories/types.ts @@ -11,6 +11,7 @@ import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { MemoryTable } from 'src/schema/tables/memory.table'; import { PartnerTable } from 'src/schema/tables/partner.table'; +import { PersonUserTable } from 'src/schema/tables/person-user.table'; import { PersonTable } from 'src/schema/tables/person.table'; import { SessionTable } from 'src/schema/tables/session.table'; import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; @@ -29,6 +30,7 @@ export type SharedLinkLike = Partial>; export type UserLike = Partial>; export type AssetFaceLike = Partial>; export type PersonLike = Partial>; +export type PersonUserLike = Partial>; export type StackLike = Partial>; export type MemoryLike = Partial>; export type PartnerLike = Partial>; diff --git a/server/test/factories/user.factory.ts b/server/test/factories/user.factory.ts index 125ce91e86..b8acbb8228 100644 --- a/server/test/factories/user.factory.ts +++ b/server/test/factories/user.factory.ts @@ -35,6 +35,7 @@ export class UserFactory { status: UserStatus.Active, profileChangedAt: newDate(), updateId: newUuidV7(), + trustedGroupId: newUuid(), ...dto, }); } diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index 88a2278898..30b8aa0ffe 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -9,6 +9,7 @@ const authUser = { isAdmin: true, quotaSizeInBytes: null, quotaUsageInBytes: 0, + trustedGroupId: 'admin_trusted_group_id', }, user1: { id: 'user-id', @@ -17,6 +18,7 @@ const authUser = { isAdmin: false, quotaSizeInBytes: null, quotaUsageInBytes: 0, + trustedGroupId: 'user1_trusted_group_id', }, }; @@ -36,6 +38,7 @@ export const authStub = { isAdmin: false, quotaSizeInBytes: null, quotaUsageInBytes: 0, + trustedGroupId: 'user2_trusted_group_id', }, session: { id: 'token-id', diff --git a/server/test/mappers.ts b/server/test/mappers.ts index 40ae78fe26..bf074fa3c4 100644 --- a/server/test/mappers.ts +++ b/server/test/mappers.ts @@ -9,6 +9,7 @@ import { AlbumFactory } from 'test/factories/album.factory'; import { AssetFaceFactory } from 'test/factories/asset-face.factory'; import { AssetFactory } from 'test/factories/asset.factory'; import { MemoryFactory } from 'test/factories/memory.factory'; +import { PersonFactory } from 'test/factories/person.factory'; import { SharedLinkFactory } from 'test/factories/shared-link.factory'; import { StackFactory } from 'test/factories/stack.factory'; import { UserFactory } from 'test/factories/user.factory'; @@ -97,7 +98,7 @@ export const getForAsset = (asset: ReturnType) => { ...asset, faces: asset.faces.map((face) => ({ ...getDehydrated(face), - person: face.person ? getDehydrated(face.person) : null, + person: face.person ? getDehydrated(getForPerson(face.person)) : null, })), owner: getDehydrated(asset.owner), stack: asset.stack @@ -109,6 +110,15 @@ export const getForAsset = (asset: ReturnType) => { }; }; +export const getForPerson = (person: ReturnType) => ({ + ...person, + ownerId: person.personUser.ownerId, + isFavorite: person.personUser.isFavorite, + isHidden: person.personUser.isHidden, + thumbnailFaceAssetId: person.personUser.thumbnailFaceAssetId, + thumbnailPath: person.personUser.thumbnailPath, +}); + export const getForPartner = ( partner: Selectable & Record<'sharedWith' | 'sharedBy', ReturnType>, ) => ({ @@ -136,6 +146,7 @@ export const getForMetadataExtraction = (asset: ReturnType export const getForAssetFace = (face: ReturnType) => ({ ...face, - person: face.person ? getDehydrated(face.person) : null, + person: face.person ? getDehydrated(getForPerson(face.person)) : null, }); export const getForDetectedFaces = (asset: ReturnType) => ({ diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index f0f14757b2..98946351b2 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -42,6 +42,7 @@ import { MetadataRepository } from 'src/repositories/metadata.repository'; import { NotificationRepository } from 'src/repositories/notification.repository'; import { OcrRepository } from 'src/repositories/ocr.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; +import { PersonUserRepository } from 'src/repositories/person-user.repository'; import { PersonRepository } from 'src/repositories/person.repository'; import { PluginRepository } from 'src/repositories/plugin.repository'; import { SearchRepository } from 'src/repositories/search.repository'; @@ -67,6 +68,7 @@ import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; import { MemoryTable } from 'src/schema/tables/memory.table'; +import { PersonUserTable } from 'src/schema/tables/person-user.table'; import { PersonTable } from 'src/schema/tables/person.table'; import { SessionTable } from 'src/schema/tables/session.table'; import { StackTable } from 'src/schema/tables/stack.table'; @@ -262,12 +264,18 @@ export class MediumTestContext = return { jobStatus, result }; } - async newPerson(dto: Partial> & { ownerId: string }) { + async newPerson(dto: Partial> & { trustedGroupId: string }) { const person = mediumFactory.personInsert(dto); const result = await this.get(PersonRepository).create(person); return { person, result }; } + async newPersonUser(dto: Partial> & { personId: string; ownerId: string }) { + const personUser = mediumFactory.personUserInsert(dto); + const result = await this.get(PersonUserRepository).create(personUser); + return { personUser, result }; + } + async newSession(dto: Partial> & { userId: string }) { const session = mediumFactory.sessionInsert(dto); const result = await this.get(SessionRepository).create(session); @@ -281,6 +289,7 @@ export class MediumTestContext = session, user: { id: user.id, + trustedGroupId: user.trustedGroupId, name: user.name, email: user.email, }, @@ -451,6 +460,7 @@ const newRealRepository = (key: T, db: Kysely case OcrRepository: case PartnerRepository: case PersonRepository: + case PersonUserRepository: case SearchRepository: case SessionRepository: case SharedLinkRepository: @@ -519,6 +529,7 @@ const newMockRepository = (key: ClassConstructor) => { case OcrRepository: case PartnerRepository: case PersonRepository: + case PersonUserRepository: case SessionRepository: case SyncRepository: case SyncCheckpointRepository: @@ -675,18 +686,13 @@ const assetJobStatusInsert = ( }; }; -const personInsert = (person: Partial> & { ownerId: string }) => { +const personInsert = (person: Partial> & { trustedGroupId: string }) => { const defaults = { birthDate: person.birthDate || null, color: person.color || null, createdAt: person.createdAt || newDate(), - faceAssetId: person.faceAssetId || null, id: person.id || newUuid(), - isFavorite: person.isFavorite || false, - isHidden: person.isHidden || false, name: person.name || 'Test Name', - ownerId: person.ownerId || newUuid(), - thumbnailPath: person.thumbnailPath || '/path/to/thumbnail.jpg', }; return { ...defaults, @@ -694,6 +700,20 @@ const personInsert = (person: Partial> & { ownerId: stri }; }; +const personUserInsert = (personUser: Partial>) => { + const defaults = { + ownerId: newUuid(), + personId: newUuid(), + createdAt: newDate(), + isFavorite: false, + isHidden: false, + thumbnailFaceAssetId: null, + thumbnailPath: '/path/to/thumbnail.jpg', + } satisfies Insertable; + + return { ...defaults, ...personUser }; +}; + const sha256 = (value: string) => createHash('sha256').update(value).digest(); const sessionInsert = ({ @@ -732,6 +752,7 @@ const userInsert = (user: Partial> = {}) => { avatarColor: null, quotaSizeInBytes: null, quotaUsageInBytes: 0, + trustedGroupId: newUuid(), }; return { ...defaults, ...user, id }; @@ -835,6 +856,7 @@ export const mediumFactory = { albumInsert, faceInsert, personInsert, + personUserInsert, sessionInsert, syncStream, userInsert, diff --git a/server/test/medium/specs/repositories/person.repository.spec.ts b/server/test/medium/specs/repositories/person-user.repository.spec.ts similarity index 77% rename from server/test/medium/specs/repositories/person.repository.spec.ts rename to server/test/medium/specs/repositories/person-user.repository.spec.ts index 1ff9ade1d1..e717edfb7c 100644 --- a/server/test/medium/specs/repositories/person.repository.spec.ts +++ b/server/test/medium/specs/repositories/person-user.repository.spec.ts @@ -1,7 +1,7 @@ import { Kysely } from 'kysely'; import { AssetFileType } from 'src/enum'; import { LoggingRepository } from 'src/repositories/logging.repository'; -import { PersonRepository } from 'src/repositories/person.repository'; +import { PersonUserRepository } from 'src/repositories/person-user.repository'; import { DB } from 'src/schema'; import { BaseService } from 'src/services/base.service'; import { newMediumService } from 'test/medium.factory'; @@ -15,22 +15,21 @@ const setup = (db?: Kysely) => { real: [], mock: [LoggingRepository], }); - return { ctx, sut: ctx.get(PersonRepository) }; + return { ctx, sut: ctx.get(PersonUserRepository) }; }; beforeAll(async () => { defaultDatabase = await getKyselyDB(); }); -describe(PersonRepository.name, () => { +describe(PersonUserRepository.name, () => { describe('getDataForThumbnailGenerationJob', () => { it('should not return the edited preview path', async () => { const { ctx, sut } = setup(); const { user } = await ctx.newUser(); const { asset } = await ctx.newAsset({ ownerId: user.id }); - const { person } = await ctx.newPerson({ ownerId: user.id }); - + const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId }); const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personId: person.id, @@ -40,8 +39,7 @@ describe(PersonRepository.name, () => { boundingBoxY2: 90, }); - // there's a circular dependency between assetFace and person, so we need to update the person after creating the assetFace - await ctx.database.updateTable('person').set({ faceAssetId: assetFace.id }).where('id', '=', person.id).execute(); + await ctx.newPersonUser({ ownerId: user.id, personId: person.id, thumbnailFaceAssetId: assetFace.id }); await ctx.newAssetFile({ assetId: asset.id, @@ -56,7 +54,7 @@ describe(PersonRepository.name, () => { isEdited: false, }); - const result = await sut.getDataForThumbnailGenerationJob(person.id); + const result = await sut.getDataForThumbnailGenerationJob({ personId: person.id, ownerId: user.id }); expect(result).toEqual( expect.objectContaining({ diff --git a/server/test/medium/specs/services/person.service.spec.ts b/server/test/medium/specs/services/person.service.spec.ts index 39805580f6..882dc9b7a0 100644 --- a/server/test/medium/specs/services/person.service.spec.ts +++ b/server/test/medium/specs/services/person.service.spec.ts @@ -7,6 +7,7 @@ import { AssetRepository } from 'src/repositories/asset.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; import { JobRepository } from 'src/repositories/job.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { PersonUserRepository } from 'src/repositories/person-user.repository'; import { PersonRepository } from 'src/repositories/person.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; import { DB } from 'src/schema'; @@ -20,7 +21,14 @@ let defaultDatabase: Kysely; const setup = (db?: Kysely) => { return newMediumService(PersonService, { database: db || defaultDatabase, - real: [AccessRepository, DatabaseRepository, PersonRepository, AssetRepository, AssetEditRepository], + real: [ + AccessRepository, + DatabaseRepository, + PersonUserRepository, + PersonRepository, + AssetRepository, + AssetEditRepository, + ], mock: [JobRepository, LoggingRepository, StorageRepository], }); }; @@ -43,7 +51,8 @@ describe(PersonService.name, () => { const personRepo = ctx.get(PersonRepository); const storageMock = ctx.getMock(StorageRepository); const { user } = await ctx.newUser(); - const { person } = await ctx.newPerson({ ownerId: user.id }); + const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId }); + const { personUser } = await ctx.newPersonUser({ personId: person.id, ownerId: user.id }); const auth = factory.auth({ user }); storageMock.unlink.mockResolvedValue(); @@ -51,7 +60,7 @@ describe(PersonService.name, () => { await expect(sut.delete(auth, person.id)).resolves.toBeUndefined(); await expect(personRepo.getById(person.id)).resolves.toBeUndefined(); - expect(storageMock.unlink).toHaveBeenCalledWith(person.thumbnailPath); + expect(storageMock.unlink).toHaveBeenCalledWith(personUser.thumbnailPath); }); }); @@ -68,8 +77,10 @@ describe(PersonService.name, () => { const storageMock = ctx.getMock(StorageRepository); const personRepo = ctx.get(PersonRepository); const { user } = await ctx.newUser(); - const { person: person1 } = await ctx.newPerson({ ownerId: user.id }); - const { person: person2 } = await ctx.newPerson({ ownerId: user.id }); + const { person: person1 } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId }); + const { personUser: personUser1 } = await ctx.newPersonUser({ personId: person1.id, ownerId: user.id }); + const { person: person2 } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId }); + const { personUser: personUser2 } = await ctx.newPersonUser({ personId: person2.id, ownerId: user.id }); const auth = factory.auth({ user }); storageMock.unlink.mockResolvedValue(); @@ -78,8 +89,8 @@ describe(PersonService.name, () => { await expect(personRepo.getById(person2.id)).resolves.toBeUndefined(); expect(storageMock.unlink).toHaveBeenCalledTimes(2); - expect(storageMock.unlink).toHaveBeenCalledWith(person1.thumbnailPath); - expect(storageMock.unlink).toHaveBeenCalledWith(person2.thumbnailPath); + expect(storageMock.unlink).toHaveBeenCalledWith(personUser1.thumbnailPath); + expect(storageMock.unlink).toHaveBeenCalledWith(personUser2.thumbnailPath); }); }); @@ -87,7 +98,8 @@ describe(PersonService.name, () => { it('should store and retrieve the face as-is when there are no edits', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); - const { person } = await ctx.newPerson({ ownerId: user.id }); + const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId }); + await ctx.newPersonUser({ personId: person.id, ownerId: user.id }); const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 200 }); await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 }); ctx.getMock(JobRepository).queueAll.mockResolvedValue(); @@ -127,7 +139,8 @@ describe(PersonService.name, () => { it('should properly transform the coordinates when the asset is edited (Crop)', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); - const { person } = await ctx.newPerson({ ownerId: user.id }); + const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId }); + await ctx.newPersonUser({ personId: person.id, ownerId: user.id }); const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 200 }); await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 }); ctx.getMock(JobRepository).queueAll.mockResolvedValue(); @@ -199,7 +212,8 @@ describe(PersonService.name, () => { it('should properly transform the coordinates when the asset is edited (Rotate 90)', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); - const { person } = await ctx.newPerson({ ownerId: user.id }); + const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId }); + await ctx.newPersonUser({ personId: person.id, ownerId: user.id }); const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 100, height: 200 }); await ctx.newExif({ assetId: asset.id, exifImageWidth: 200, exifImageHeight: 100 }); ctx.getMock(JobRepository).queueAll.mockResolvedValue(); @@ -264,7 +278,8 @@ describe(PersonService.name, () => { it('should properly transform the coordinates when the asset is edited (Mirror Horizontal)', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); - const { person } = await ctx.newPerson({ ownerId: user.id }); + const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId }); + await ctx.newPersonUser({ personId: person.id, ownerId: user.id }); const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 100 }); await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 200 }); ctx.getMock(JobRepository).queueAll.mockResolvedValue(); @@ -329,7 +344,8 @@ describe(PersonService.name, () => { it('should properly transform the coordinates when the asset is edited (Crop + Rotate)', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); - const { person } = await ctx.newPerson({ ownerId: user.id }); + const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId }); + await ctx.newPersonUser({ personId: person.id, ownerId: user.id }); const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 150 }); await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 }); ctx.getMock(JobRepository).queueAll.mockResolvedValue(); @@ -403,7 +419,8 @@ describe(PersonService.name, () => { it('should properly transform the coordinates when the asset is edited (Crop + Mirror)', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); - const { person } = await ctx.newPerson({ ownerId: user.id }); + const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId }); + await ctx.newPersonUser({ personId: person.id, ownerId: user.id }); const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 100 }); await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 200 }); ctx.getMock(JobRepository).queueAll.mockResolvedValue(); @@ -477,7 +494,8 @@ describe(PersonService.name, () => { it('should properly transform the coordinates when the asset is edited (Rotate + Mirror)', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); - const { person } = await ctx.newPerson({ ownerId: user.id }); + const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId }); + await ctx.newPersonUser({ personId: person.id, ownerId: user.id }); const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 150 }); await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 150 }); ctx.getMock(JobRepository).queueAll.mockResolvedValue(); @@ -548,7 +566,8 @@ describe(PersonService.name, () => { it('should properly transform the coordinates when the asset is edited (Crop + Rotate + Mirror)', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); - const { person } = await ctx.newPerson({ ownerId: user.id }); + const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId }); + await ctx.newPersonUser({ personId: person.id, ownerId: user.id }); const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 100 }); await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 }); ctx.getMock(JobRepository).queueAll.mockResolvedValue(); @@ -628,7 +647,8 @@ describe(PersonService.name, () => { it('should properly transform the coordinates with multiple mirrors in sequence', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); - const { person } = await ctx.newPerson({ ownerId: user.id }); + const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId }); + await ctx.newPersonUser({ personId: person.id, ownerId: user.id }); const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 100, height: 100 }); await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 100 }); ctx.getMock(JobRepository).queueAll.mockResolvedValue(); @@ -699,7 +719,8 @@ describe(PersonService.name, () => { it('should properly handle exif orientation when creating a face on an edited asset', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); - const { person } = await ctx.newPerson({ ownerId: user.id }); + const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId }); + await ctx.newPersonUser({ personId: person.id, ownerId: user.id }); const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 100, height: 100 }); await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 100, orientation: '6' }); ctx.getMock(JobRepository).queueAll.mockResolvedValue(); diff --git a/server/test/medium/specs/services/search.service.spec.ts b/server/test/medium/specs/services/search.service.spec.ts index 044cd8d4f5..18ab17f6e0 100644 --- a/server/test/medium/specs/services/search.service.spec.ts +++ b/server/test/medium/specs/services/search.service.spec.ts @@ -68,7 +68,8 @@ describe(SearchService.name, () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); const { asset } = await ctx.newAsset({ ownerId: user.id }); - const { person } = await ctx.newPerson({ ownerId: user.id }); + const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId }); + await ctx.newPersonUser({ personId: person.id, ownerId: user.id }); await ctx.newAssetFace({ assetId: asset.id, personId: person.id }); const auth = factory.auth({ user: { id: user.id } }); @@ -81,7 +82,8 @@ describe(SearchService.name, () => { it('should return zero when no assets match the personIds filter', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); - const { person } = await ctx.newPerson({ ownerId: user.id }); + const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId }); + await ctx.newPersonUser({ personId: person.id, ownerId: user.id }); const auth = factory.auth({ user: { id: user.id } }); diff --git a/server/test/medium/specs/sync/sync-asset-face.spec.ts b/server/test/medium/specs/sync/sync-asset-face.spec.ts index 74d4c536f1..0aa5fa8321 100644 --- a/server/test/medium/specs/sync/sync-asset-face.spec.ts +++ b/server/test/medium/specs/sync/sync-asset-face.spec.ts @@ -22,7 +22,8 @@ describe(SyncEntityType.AssetFaceV2, () => { it('should detect and sync the first asset face', async () => { const { auth, ctx } = await setup(); const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); - const { person } = await ctx.newPerson({ ownerId: auth.user.id }); + const { person } = await ctx.newPerson({ trustedGroupId: auth.user.trustedGroupId }); + await ctx.newPersonUser({ personId: person.id, ownerId: auth.user.id }); const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personId: person.id }); const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]); @@ -102,7 +103,8 @@ describe(SyncEntityType.AssetFaceV2, () => { it('should detect and sync the first asset face', async () => { const { auth, ctx } = await setup(); const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); - const { person } = await ctx.newPerson({ ownerId: auth.user.id }); + const { person } = await ctx.newPerson({ trustedGroupId: auth.user.trustedGroupId }); + await ctx.newPersonUser({ personId: person.id, ownerId: auth.user.id }); const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personId: person.id }); const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]); @@ -181,7 +183,8 @@ describe(SyncEntityType.AssetFaceV2, () => { const { auth, ctx } = await setup(); const personRepo = ctx.get(PersonRepository); const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); - const { person } = await ctx.newPerson({ ownerId: auth.user.id }); + const { person } = await ctx.newPerson({ trustedGroupId: auth.user.trustedGroupId }); + await ctx.newPersonUser({ personId: person.id, ownerId: auth.user.id }); const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personId: person.id }); let response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]); diff --git a/server/test/medium/specs/sync/sync-person-user.spec.ts b/server/test/medium/specs/sync/sync-person-user.spec.ts new file mode 100644 index 0000000000..e072ef0007 --- /dev/null +++ b/server/test/medium/specs/sync/sync-person-user.spec.ts @@ -0,0 +1,94 @@ +import { Kysely } from 'kysely'; +import { SyncEntityType, SyncRequestType } from 'src/enum'; +import { PersonUserRepository } from 'src/repositories/person-user.repository'; +import { DB } from 'src/schema'; +import { SyncTestContext } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const ctx = new SyncTestContext(db || defaultDatabase); + const { auth, user, session } = await ctx.newSyncAuthUser(); + return { auth, user, session, ctx }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(SyncEntityType.PersonUserV1, () => { + it('should detect and sync the first person user', async () => { + const { auth, ctx } = await setup(); + const { person } = await ctx.newPerson({ trustedGroupId: auth.user.trustedGroupId }); + const { personUser } = await ctx.newPersonUser({ personId: person.id, ownerId: auth.user.id }); + + const response = await ctx.syncStream(auth, [SyncRequestType.PersonUsersV1]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + personId: person.id, + ownerId: auth.user.id, + isHidden: personUser.isHidden, + isFavorite: personUser.isFavorite, + thumbnailFaceAssetId: personUser.thumbnailFaceAssetId, + }), + type: 'PersonUserV1', + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.PersonUsersV1]); + }); + + it('should detect and sync a deleted person user', async () => { + const { auth, ctx } = await setup(); + const personUserRepo = ctx.get(PersonUserRepository); + const { person } = await ctx.newPerson({ trustedGroupId: auth.user.trustedGroupId }); + await ctx.newPersonUser({ personId: person.id, ownerId: auth.user.id }); + await personUserRepo.delete({ personId: person.id, ownerId: auth.user.id }); + + const response = await ctx.syncStream(auth, [SyncRequestType.PersonUsersV1]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + personId: person.id, + ownerId: auth.user.id, + }, + type: 'PersonUserDeleteV1', + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.PersonUsersV1]); + }); + + it('should not sync a personUser or personUser delete for an unrelated user', async () => { + const { auth, ctx } = await setup(); + const personUserRepo = ctx.get(PersonUserRepository); + const { user: user2 } = await ctx.newUser(); + const { session } = await ctx.newSession({ userId: user2.id }); + const { person } = await ctx.newPerson({ trustedGroupId: user2.trustedGroupId }); + await ctx.newPersonUser({ personId: person.id, ownerId: user2.id }); + const auth2 = factory.auth({ session, user: user2 }); + + expect(await ctx.syncStream(auth2, [SyncRequestType.PersonUsersV1])).toEqual([ + expect.objectContaining({ type: SyncEntityType.PersonUserV1 }), + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.PersonUsersV1]); + + await personUserRepo.delete({ personId: person.id, ownerId: user2.id }); + + expect(await ctx.syncStream(auth2, [SyncRequestType.PersonUsersV1])).toEqual([ + expect.objectContaining({ type: SyncEntityType.PersonUserDeleteV1 }), + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.PersonUsersV1]); + }); +}); diff --git a/server/test/medium/specs/sync/sync-person.spec.ts b/server/test/medium/specs/sync/sync-person.spec.ts index 6fdb5a58f2..0008830bde 100644 --- a/server/test/medium/specs/sync/sync-person.spec.ts +++ b/server/test/medium/specs/sync/sync-person.spec.ts @@ -18,10 +18,82 @@ beforeAll(async () => { defaultDatabase = await getKyselyDB(); }); +describe(SyncEntityType.PersonV2, () => { + it('should detect and sync the first person', async () => { + const { auth, ctx } = await setup(); + const { person } = await ctx.newPerson({ trustedGroupId: auth.user.trustedGroupId }); + + const response = await ctx.syncStream(auth, [SyncRequestType.PeopleV2]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: person.id, + name: person.name, + birthDate: person.birthDate, + trustedGroupId: auth.user.trustedGroupId, + color: person.color, + }), + type: 'PersonV2', + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.PeopleV2]); + }); + + it('should detect and sync a deleted person', async () => { + const { auth, ctx } = await setup(); + const personRepo = ctx.get(PersonRepository); + const { person } = await ctx.newPerson({ trustedGroupId: auth.user.trustedGroupId }); + await personRepo.delete([person.id]); + + const response = await ctx.syncStream(auth, [SyncRequestType.PeopleV2]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + personId: person.id, + }, + type: 'PersonDeleteV1', + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.PeopleV2]); + }); + + it('should not sync a person or person delete for an unrelated user', async () => { + const { auth, ctx } = await setup(); + const personRepo = ctx.get(PersonRepository); + const { user: user2 } = await ctx.newUser(); + const { session } = await ctx.newSession({ userId: user2.id }); + const { person } = await ctx.newPerson({ trustedGroupId: user2.trustedGroupId }); + const auth2 = factory.auth({ session, user: user2 }); + + expect(await ctx.syncStream(auth2, [SyncRequestType.PeopleV2])).toEqual([ + expect.objectContaining({ type: SyncEntityType.PersonV2 }), + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.PeopleV1]); + + await personRepo.delete([person.id]); + + expect(await ctx.syncStream(auth2, [SyncRequestType.PeopleV2])).toEqual([ + expect.objectContaining({ type: SyncEntityType.PersonDeleteV1 }), + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.PeopleV2]); + }); +}); + describe(SyncEntityType.PersonV1, () => { it('should detect and sync the first person', async () => { const { auth, ctx } = await setup(); - const { person } = await ctx.newPerson({ ownerId: auth.user.id }); + const { person } = await ctx.newPerson({ trustedGroupId: auth.user.trustedGroupId }); + const { personUser } = await ctx.newPersonUser({ personId: person.id, ownerId: auth.user.id }); const response = await ctx.syncStream(auth, [SyncRequestType.PeopleV1]); expect(response).toEqual([ @@ -30,10 +102,10 @@ describe(SyncEntityType.PersonV1, () => { data: expect.objectContaining({ id: person.id, name: person.name, - isHidden: person.isHidden, + isHidden: personUser.isHidden, birthDate: person.birthDate, - faceAssetId: person.faceAssetId, - isFavorite: person.isFavorite, + faceAssetId: personUser.thumbnailFaceAssetId, + isFavorite: personUser.isFavorite, ownerId: auth.user.id, color: person.color, }), @@ -49,7 +121,7 @@ describe(SyncEntityType.PersonV1, () => { it('should detect and sync a deleted person', async () => { const { auth, ctx } = await setup(); const personRepo = ctx.get(PersonRepository); - const { person } = await ctx.newPerson({ ownerId: auth.user.id }); + const { person } = await ctx.newPerson({ trustedGroupId: auth.user.trustedGroupId }); await personRepo.delete([person.id]); const response = await ctx.syncStream(auth, [SyncRequestType.PeopleV1]); @@ -73,7 +145,8 @@ describe(SyncEntityType.PersonV1, () => { const personRepo = ctx.get(PersonRepository); const { user: user2 } = await ctx.newUser(); const { session } = await ctx.newSession({ userId: user2.id }); - const { person } = await ctx.newPerson({ ownerId: user2.id }); + const { person } = await ctx.newPerson({ trustedGroupId: user2.trustedGroupId }); + await ctx.newPersonUser({ personId: person.id, ownerId: user2.id }); const auth2 = factory.auth({ session, user: user2 }); expect(await ctx.syncStream(auth2, [SyncRequestType.PeopleV1])).toEqual([ diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index ae83727d70..0170dd3396 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -91,9 +91,10 @@ const authUserFactory = (authUser: Partial = {}) => { email = 'test@immich.cloud', quotaUsageInBytes = 0, quotaSizeInBytes = null, + trustedGroupId = newUuid(), } = authUser; - return { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes }; + return { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes, trustedGroupId }; }; const queueStatisticsFactory = (dto?: Partial) => ({ @@ -109,6 +110,7 @@ const queueStatisticsFactory = (dto?: Partial) => ({ const userAdminFactory = (user: Partial = {}) => { const { id = newUuid(), + trustedGroupId = newUuid(), name = 'Test User', email = 'test@immich.cloud', profileImagePath = '', @@ -128,6 +130,7 @@ const userAdminFactory = (user: Partial = {}) => { } = user; return { id, + trustedGroupId, name, email, profileImagePath, diff --git a/server/test/utils.ts b/server/test/utils.ts index 2f32f3b412..9c66d9fdb6 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -48,6 +48,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository import { OAuthRepository } from 'src/repositories/oauth.repository'; import { OcrRepository } from 'src/repositories/ocr.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; +import { PersonUserRepository } from 'src/repositories/person-user.repository'; import { PersonRepository } from 'src/repositories/person.repository'; import { PluginRepository } from 'src/repositories/plugin.repository'; import { ProcessRepository } from 'src/repositories/process.repository'; @@ -259,6 +260,7 @@ export type ServiceOverrides = { oauth: OAuthRepository; partner: PartnerRepository; person: PersonRepository; + personUser: PersonUserRepository; plugin: PluginRepository; process: ProcessRepository; search: SearchRepository; @@ -342,6 +344,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 }), @@ -410,6 +413,7 @@ export const newTestService = ( overrides.oauth || (mocks.oauth as As), overrides.ocr || (mocks.ocr as As), overrides.partner || (mocks.partner as As), + overrides.personUser || (mocks.personUser as As), overrides.person || (mocks.person as As), overrides.plugin || (mocks.plugin as As), overrides.process || (mocks.process as As),