diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index b2ba244ab9..6a9a01d41d 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -225,6 +225,13 @@ jobs: github_token: ${{ steps.token.outputs.token }} working_directory: ./mobile + - name: Setup Ruby + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + with: + ruby-version: '3.3' + bundler-cache: true + working-directory: ./mobile/ios + - name: Install dependencies run: mise //mobile:install:ci @@ -235,18 +242,6 @@ jobs: working-directory: ./mobile run: flutter build ios --config-only --no-codesign - - name: Setup Ruby - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 - with: - ruby-version: '3.3' - bundler-cache: true - working-directory: ./mobile/ios - - - name: Install CocoaPods dependencies - working-directory: ./mobile/ios - run: | - pod install - - name: Create API Key env: API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} diff --git a/mobile/mise.toml b/mobile/mise.toml index 08a26c0c3b..f021cd8747 100644 --- a/mobile/mise.toml +++ b/mobile/mise.toml @@ -29,7 +29,7 @@ sources = [ ] outputs = { auto = true } run = [ - "dart run build_runner build", + "dart run build_runner build", "dart format lib/routing/router.gr.dart", ] depends = ["//:open-api-dart"] @@ -99,7 +99,11 @@ run = "dart run drift_dev make-migrations" alias = "install" description = "Install flutter dependencies" depends = ["//:open-api-dart"] -run = "flutter pub get" +run = [ + # This receives passed `--enforce-lockfile` args + "flutter pub get", + { task = "install:ios" }, +] [tasks."install:ci"] description = "Install flutter dependencies in CI" @@ -132,6 +136,19 @@ run = [ ] wait_for = ["//:i18n:format-fix"] +[tasks."install:ios"] +description = "Install CocoaPods dependencies" +hide = true +sources = ["ios/Podfile", "ios/Podfile.lock", ".flutter-plugin-dependencies"] +outputs = ["ios/Pods/Manifest.lock"] +run = ''' +echo "Running pod install" +if [ "$(uname)" != "Darwin" ]; then + exit 0 +fi +cd ios && pod install +''' + [tasks."analyze:dart"] description = "Run Dart analysis" hide = true 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..eff40f7a31 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 & { @@ -241,7 +243,7 @@ export type Exif = Omit, 'updatedAt' | 'updateId' | ' export type Person = { createdAt: Date; - id: string; + personId: string; ownerId: string; updatedAt: Date; updateId: string; @@ -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, @@ -552,6 +571,17 @@ export const columns = { 'asset_exif.tags', 'asset_exif.timeZone', ], + person: [ + 'person_user.personId', + 'person_user.userId', + 'person_user.name', + 'person_user.birthDate', + 'person_user.thumbnailPath', + 'person_user.isHidden', + 'person_user.isFavorite', + 'person_user.color', + 'person_user.updatedAt', + ], } as const; export type LockableProperty = (typeof lockableProperties)[number]; diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index fa56ded176..6b1cf6c35a 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -171,9 +171,14 @@ const PeopleResponseSchema = z .describe('People response'); export class PeopleResponseDto extends createZodDto(PeopleResponseSchema) {} -export function mapPerson(person: MaybeDehydrated): PersonResponseDto { +export function mapPerson( + person: Pick< + MaybeDehydrated, + 'personId' | 'name' | 'birthDate' | 'thumbnailPath' | 'isHidden' | 'isFavorite' | 'color' | 'updatedAt' + >, +): PersonResponseDto { return { - id: person.id, + id: person.personId, name: person.name, birthDate: asDateString(person.birthDate), thumbnailPath: person.thumbnailPath, 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/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 2ee61d82dd..8be6bdae34 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -219,6 +219,19 @@ export const person_delete_audit = registerFunction({ END`, }); +export const person_group_delete_audit = registerFunction({ + name: 'person_group_delete_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO person_group_audit ("personId", "trustedGroupId") + SELECT "personId", "trustedGroupId" + FROM OLD; + RETURN NULL; + END`, +}); + export const user_metadata_audit = registerFunction({ name: 'user_metadata_audit', returnType: 'TRIGGER', diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 171fefb50f..93216e04e9 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -21,6 +21,7 @@ import { memory_delete_audit, partner_delete_audit, person_delete_audit, + person_group_delete_audit, stack_delete_audit, updated_at, user_delete_audit, @@ -63,6 +64,8 @@ import { OcrSearchTable } from 'src/schema/tables/ocr-search.table'; import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table'; import { PartnerTable } from 'src/schema/tables/partner.table'; import { PersonAuditTable } from 'src/schema/tables/person-audit.table'; +import { PersonGroupAuditTable } from 'src/schema/tables/person-group-audit.table'; +import { PersonGroupTable } from 'src/schema/tables/person-group.table'; import { PersonTable } from 'src/schema/tables/person.table'; import { PluginMethodTable } from 'src/schema/tables/plugin-method.table'; import { PluginTable } from 'src/schema/tables/plugin.table'; @@ -131,6 +134,8 @@ export class ImmichDatabase { PartnerTable, PersonTable, PersonAuditTable, + PersonGroupTable, + PersonGroupAuditTable, SessionTable, SharedLinkAssetTable, SharedLinkTable, @@ -171,6 +176,7 @@ export class ImmichDatabase { memory_asset_delete_audit, stack_delete_audit, person_delete_audit, + person_group_delete_audit, user_metadata_audit, asset_metadata_audit, asset_face_audit, @@ -245,6 +251,8 @@ export interface DB { person: PersonTable; person_audit: PersonAuditTable; + person_group: PersonGroupTable; + person_group_audit: PersonGroupAuditTable; session: SessionTable; session_sync_checkpoint: SessionSyncCheckpointTable; diff --git a/server/src/schema/tables/person-group-audit.table.ts b/server/src/schema/tables/person-group-audit.table.ts new file mode 100644 index 0000000000..a626ca9b93 --- /dev/null +++ b/server/src/schema/tables/person-group-audit.table.ts @@ -0,0 +1,17 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; + +@Table('person_group_audit') +export class PersonGroupAuditTable { + @PrimaryGeneratedUuidV7Column() + id!: Generated; + + @Column({ type: 'uuid', index: true }) + personId!: string; + + @Column({ type: 'uuid', index: true }) + trustedGroupId!: string; + + @CreateDateColumn({ default: () => 'clock_timestamp()', index: true }) + deletedAt!: Generated; +} diff --git a/server/src/schema/tables/person-group.table.ts b/server/src/schema/tables/person-group.table.ts new file mode 100644 index 0000000000..a9ea91c798 --- /dev/null +++ b/server/src/schema/tables/person-group.table.ts @@ -0,0 +1,38 @@ +import { + AfterDeleteTrigger, + CreateDateColumn, + ForeignKeyColumn, + Generated, + Table, + Timestamp, + UpdateDateColumn, +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { person_group_delete_audit } from 'src/schema/functions'; +import { PersonTable } from 'src/schema/tables/person.table'; +import { TrustedGroupTable } from 'src/schema/tables/trusted-group.table'; + +@Table('person_group') +@UpdatedAtTrigger('person_group_updatedAt') +@AfterDeleteTrigger({ + scope: 'statement', + function: person_group_delete_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() <= 1', +}) +export class PersonGroupTable { + @ForeignKeyColumn(() => PersonTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true }) + id!: string; + + @ForeignKeyColumn(() => TrustedGroupTable) + trustedGroupId!: string; + + @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..a72c4d3a8b 100644 --- a/server/src/schema/tables/person.table.ts +++ b/server/src/schema/tables/person.table.ts @@ -6,7 +6,6 @@ import { ForeignKeyColumn, Generated, Index, - PrimaryGeneratedColumn, Table, Timestamp, UpdateDateColumn, @@ -14,6 +13,7 @@ import { import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { person_delete_audit } from 'src/schema/functions'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; +import { PersonGroupTable } from 'src/schema/tables/person-group.table'; import { UserTable } from 'src/schema/tables/user.table'; @Table('person') @@ -31,8 +31,13 @@ import { UserTable } from 'src/schema/tables/user.table'; }) @Check({ name: 'person_birthDate_chk', expression: `"birthDate" <= CURRENT_DATE` }) export class PersonTable { - @PrimaryGeneratedColumn('uuid') - id!: Generated; + @ForeignKeyColumn(() => PersonGroupTable, { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + primary: true, + nullable: false, + }) + personId!: Generated; @CreateDateColumn() createdAt!: Generated; @@ -40,7 +45,7 @@ export class PersonTable { @UpdateDateColumn() updatedAt!: Generated; - @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) + @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', primary: true, nullable: false }) ownerId!: string; @Column({ default: '' }) diff --git a/server/src/schema/tables/trusted-group.table.ts b/server/src/schema/tables/trusted-group.table.ts new file mode 100644 index 0000000000..7303c08904 --- /dev/null +++ b/server/src/schema/tables/trusted-group.table.ts @@ -0,0 +1,25 @@ +import { + CreateDateColumn, + Generated, + PrimaryGeneratedColumn, + Table, + Timestamp, + UpdateDateColumn, +} from '@immich/sql-tools'; +import { UpdateIdColumn, UpdatedAtTrigger } from 'src/decorators'; + +@Table('trusted_group') +@UpdatedAtTrigger('trusted_group_updatedAt') +export class TrustedGroupTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @CreateDateColumn() + createdAt!: Generated; + + @UpdateDateColumn() + updatedAt!: Generated; + + @UpdateIdColumn({ index: true }) + updateId!: Generated; +} diff --git a/server/src/schema/tables/user.table.ts b/server/src/schema/tables/user.table.ts index 50d56d9067..ce880ab99b 100644 --- a/server/src/schema/tables/user.table.ts +++ b/server/src/schema/tables/user.table.ts @@ -3,6 +3,7 @@ import { Column, CreateDateColumn, DeleteDateColumn, + ForeignKeyColumn, Generated, Index, PrimaryGeneratedColumn, @@ -14,6 +15,7 @@ import { ColumnType } from 'kysely'; import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { UserAvatarColor, UserStatus } from 'src/enum'; import { user_delete_audit } from 'src/schema/functions'; +import { TrustedGroupTable } from 'src/schema/tables/trusted-group.table'; @Table('user') @UpdatedAtTrigger('user_updatedAt') @@ -82,4 +84,7 @@ export class UserTable { @UpdateIdColumn({ index: true }) updateId!: Generated; + + @ForeignKeyColumn(() => TrustedGroupTable) + trustedGroupId!: string; } 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 d64e0088eb..a8a554dc61 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,25 +196,25 @@ 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).toHaveBeenCalledTimes(1); - 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 }); @@ -225,14 +225,14 @@ 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 }); @@ -241,14 +241,14 @@ describe(MediaService.name, () => { { name: JobName.AssetEditThumbnailGeneration, data: { id: asset.id } }, ]); - 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 }); @@ -259,13 +259,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 }); @@ -280,24 +280,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 } }, + ]); }); }); @@ -1511,46 +1513,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, @@ -1581,21 +1573,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, @@ -1626,19 +1618,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, @@ -1672,16 +1664,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, @@ -1715,16 +1709,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, @@ -1758,16 +1754,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, @@ -1801,11 +1799,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(''); @@ -1814,7 +1812,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, { @@ -1849,31 +1849,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, { @@ -1885,10 +1885,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(''); @@ -1897,7 +1897,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, { @@ -1913,7 +1913,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 19b1688e88..c7c01d4d35 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -84,23 +84,29 @@ export class MediaService extends BaseService { jobs.push({ name: JobName.AssetEditThumbnailGeneration, data: { id: asset.id } }); } } - await this.jobRepository.queueAll(jobs); } - for await (const people of batched(this.personRepository.getAll(force ? undefined : { thumbnailPath: '' }))) { + for await (const people of batched(this.personUserRepository.getAll(force ? undefined : { thumbnailPath: '' }))) { const jobs: JobItem[] = []; for (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 }, + }); } await this.jobRepository.queueAll(jobs); @@ -123,9 +129,9 @@ export class MediaService extends BaseService { ); } - for await (const people of batched(this.personRepository.getAll())) { + for await (const people of batched(this.personUserRepository.getAll())) { await this.jobRepository.queueAll( - people.map((person) => ({ name: JobName.PersonFileMigration, data: { id: person.id } })), + people.map(({ personId, ownerId }) => ({ name: JobName.PersonFileMigration, data: { personId, ownerId } })), ); } @@ -387,19 +393,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; @@ -417,7 +426,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 = { @@ -440,7 +449,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; } @@ -828,7 +837,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 a95d1f1497..af4c0d2625 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 { JobOf } from 'src/types'; import { getAssetFiles } from 'src/utils/asset.util'; @@ -893,7 +893,13 @@ export class MetadataService extends BaseService { } private async applyTaggedFaces( - asset: { id: string; ownerId: string; faces: { id: string; sourceType: SourceType }[]; originalPath: string }, + asset: { + id: string; + ownerId: string; + ownerTrustedGroupId: string; + faces: { id: string; sourceType: SourceType }[]; + originalPath: string; + }, tags: ImmichTags, ) { if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) { @@ -903,8 +909,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; @@ -938,16 +944,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); @@ -966,7 +976,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 7c08c36e17..c26b377dcd 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Insertable, Updateable } from 'kysely'; -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'; @@ -37,6 +36,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'; @@ -56,17 +56,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)), @@ -78,7 +78,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) { @@ -86,10 +86,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); } @@ -100,7 +100,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; } @@ -108,30 +108,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'}`, ); @@ -141,8 +141,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 } }); } } @@ -151,7 +151,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 { @@ -161,7 +161,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(); } @@ -175,15 +175,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 { @@ -204,19 +208,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 { @@ -245,20 +257,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; } @@ -470,8 +482,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, @@ -498,7 +516,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, @@ -513,9 +531,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) { @@ -527,13 +550,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; } @@ -545,7 +571,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[] = []; @@ -570,7 +599,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; } @@ -588,7 +617,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 }); @@ -600,8 +632,8 @@ export class PersonService extends BaseService { return results; } - private findOrFail(id: string) { - return findOrFail(() => this.personRepository.getById(id), 'Person'); + private findOrFail(id: string, ownerId: string) { + return findOrFail(() => this.personRepository.getForOwner(id, ownerId), 'Person'); } // TODO return a asset face response @@ -613,7 +645,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) { @@ -672,8 +704,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 de731f46c6..dc4fa64a2c 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/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]); + }); +});