From 4b7fb162ee3a37a18f098732aae48d265456868e Mon Sep 17 00:00:00 2001 From: wuzihao051119 Date: Sun, 6 Jul 2025 18:37:50 +0800 Subject: [PATCH] feat(server): people sync --- mobile/openapi/README.md | 4 + mobile/openapi/lib/api.dart | 4 + mobile/openapi/lib/api_client.dart | 8 + .../openapi/lib/model/sync_entity_type.dart | 12 ++ .../lib/model/sync_face_delete_v1.dart | 99 +++++++++ mobile/openapi/lib/model/sync_face_v1.dart | 203 ++++++++++++++++++ .../lib/model/sync_person_delete_v1.dart | 99 +++++++++ mobile/openapi/lib/model/sync_person_v1.dart | 191 ++++++++++++++++ .../openapi/lib/model/sync_request_type.dart | 6 + open-api/immich-openapi-specs.json | 153 +++++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 6 + server/src/database.ts | 3 + server/src/dtos/sync.dto.ts | 54 +++++ server/src/enum.ts | 8 + server/src/queries/sync.repository.sql | 90 ++++++++ server/src/repositories/sync.repository.ts | 82 +++++++ server/src/schema/functions.ts | 27 +++ server/src/schema/index.ts | 10 + .../1751796648426-PersonSyncChanges.ts | 81 +++++++ .../schema/tables/asset-face-audit.table.ts | 14 ++ server/src/schema/tables/asset-face.table.ts | 20 ++ .../src/schema/tables/person-audit.table.ts | 17 ++ server/src/schema/tables/person.table.ts | 8 + server/src/services/sync.service.ts | 34 ++- 24 files changed, 1232 insertions(+), 1 deletion(-) create mode 100644 mobile/openapi/lib/model/sync_face_delete_v1.dart create mode 100644 mobile/openapi/lib/model/sync_face_v1.dart create mode 100644 mobile/openapi/lib/model/sync_person_delete_v1.dart create mode 100644 mobile/openapi/lib/model/sync_person_v1.dart create mode 100644 server/src/schema/migrations/1751796648426-PersonSyncChanges.ts create mode 100644 server/src/schema/tables/asset-face-audit.table.ts create mode 100644 server/src/schema/tables/person-audit.table.ts diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 1beaec0ae6..24a8cf246f 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -475,12 +475,16 @@ Class | Method | HTTP request | Description - [SyncAssetExifV1](doc//SyncAssetExifV1.md) - [SyncAssetV1](doc//SyncAssetV1.md) - [SyncEntityType](doc//SyncEntityType.md) + - [SyncFaceDeleteV1](doc//SyncFaceDeleteV1.md) + - [SyncFaceV1](doc//SyncFaceV1.md) - [SyncMemoryAssetDeleteV1](doc//SyncMemoryAssetDeleteV1.md) - [SyncMemoryAssetV1](doc//SyncMemoryAssetV1.md) - [SyncMemoryDeleteV1](doc//SyncMemoryDeleteV1.md) - [SyncMemoryV1](doc//SyncMemoryV1.md) - [SyncPartnerDeleteV1](doc//SyncPartnerDeleteV1.md) - [SyncPartnerV1](doc//SyncPartnerV1.md) + - [SyncPersonDeleteV1](doc//SyncPersonDeleteV1.md) + - [SyncPersonV1](doc//SyncPersonV1.md) - [SyncRequestType](doc//SyncRequestType.md) - [SyncStackDeleteV1](doc//SyncStackDeleteV1.md) - [SyncStackV1](doc//SyncStackV1.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 3998961720..5d3d3fb6be 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -258,12 +258,16 @@ part 'model/sync_asset_delete_v1.dart'; part 'model/sync_asset_exif_v1.dart'; part 'model/sync_asset_v1.dart'; part 'model/sync_entity_type.dart'; +part 'model/sync_face_delete_v1.dart'; +part 'model/sync_face_v1.dart'; part 'model/sync_memory_asset_delete_v1.dart'; part 'model/sync_memory_asset_v1.dart'; part 'model/sync_memory_delete_v1.dart'; part 'model/sync_memory_v1.dart'; part 'model/sync_partner_delete_v1.dart'; part 'model/sync_partner_v1.dart'; +part 'model/sync_person_delete_v1.dart'; +part 'model/sync_person_v1.dart'; part 'model/sync_request_type.dart'; part 'model/sync_stack_delete_v1.dart'; part 'model/sync_stack_v1.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 0edc2638bc..2a43a438e8 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -572,6 +572,10 @@ class ApiClient { return SyncAssetV1.fromJson(value); case 'SyncEntityType': return SyncEntityTypeTypeTransformer().decode(value); + case 'SyncFaceDeleteV1': + return SyncFaceDeleteV1.fromJson(value); + case 'SyncFaceV1': + return SyncFaceV1.fromJson(value); case 'SyncMemoryAssetDeleteV1': return SyncMemoryAssetDeleteV1.fromJson(value); case 'SyncMemoryAssetV1': @@ -584,6 +588,10 @@ class ApiClient { return SyncPartnerDeleteV1.fromJson(value); case 'SyncPartnerV1': return SyncPartnerV1.fromJson(value); + case 'SyncPersonDeleteV1': + return SyncPersonDeleteV1.fromJson(value); + case 'SyncPersonV1': + return SyncPersonV1.fromJson(value); case 'SyncRequestType': return SyncRequestTypeTypeTransformer().decode(value); case 'SyncStackDeleteV1': diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index ecaadc9c31..b3fc46670b 100644 --- a/mobile/openapi/lib/model/sync_entity_type.dart +++ b/mobile/openapi/lib/model/sync_entity_type.dart @@ -56,6 +56,10 @@ class SyncEntityType { static const memoryToAssetDeleteV1 = SyncEntityType._(r'MemoryToAssetDeleteV1'); static const stackV1 = SyncEntityType._(r'StackV1'); static const stackDeleteV1 = SyncEntityType._(r'StackDeleteV1'); + static const personV1 = SyncEntityType._(r'PersonV1'); + static const personDeleteV1 = SyncEntityType._(r'PersonDeleteV1'); + static const faceV1 = SyncEntityType._(r'FaceV1'); + static const faceDeleteV1 = SyncEntityType._(r'FaceDeleteV1'); static const syncAckV1 = SyncEntityType._(r'SyncAckV1'); /// List of all possible values in this [enum][SyncEntityType]. @@ -93,6 +97,10 @@ class SyncEntityType { memoryToAssetDeleteV1, stackV1, stackDeleteV1, + personV1, + personDeleteV1, + faceV1, + faceDeleteV1, syncAckV1, ]; @@ -165,6 +173,10 @@ class SyncEntityTypeTypeTransformer { case r'MemoryToAssetDeleteV1': return SyncEntityType.memoryToAssetDeleteV1; case r'StackV1': return SyncEntityType.stackV1; case r'StackDeleteV1': return SyncEntityType.stackDeleteV1; + case r'PersonV1': return SyncEntityType.personV1; + case r'PersonDeleteV1': return SyncEntityType.personDeleteV1; + case r'FaceV1': return SyncEntityType.faceV1; + case r'FaceDeleteV1': return SyncEntityType.faceDeleteV1; case r'SyncAckV1': return SyncEntityType.syncAckV1; default: if (!allowNull) { diff --git a/mobile/openapi/lib/model/sync_face_delete_v1.dart b/mobile/openapi/lib/model/sync_face_delete_v1.dart new file mode 100644 index 0000000000..7f4f2bc26f --- /dev/null +++ b/mobile/openapi/lib/model/sync_face_delete_v1.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncFaceDeleteV1 { + /// Returns a new [SyncFaceDeleteV1] instance. + SyncFaceDeleteV1({ + required this.faceId, + }); + + String faceId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncFaceDeleteV1 && + other.faceId == faceId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (faceId.hashCode); + + @override + String toString() => 'SyncFaceDeleteV1[faceId=$faceId]'; + + Map toJson() { + final json = {}; + json[r'faceId'] = this.faceId; + return json; + } + + /// Returns a new [SyncFaceDeleteV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncFaceDeleteV1? fromJson(dynamic value) { + upgradeDto(value, "SyncFaceDeleteV1"); + if (value is Map) { + final json = value.cast(); + + return SyncFaceDeleteV1( + faceId: mapValueOfType(json, r'faceId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncFaceDeleteV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncFaceDeleteV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncFaceDeleteV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncFaceDeleteV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'faceId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_face_v1.dart b/mobile/openapi/lib/model/sync_face_v1.dart new file mode 100644 index 0000000000..eafecfbc5d --- /dev/null +++ b/mobile/openapi/lib/model/sync_face_v1.dart @@ -0,0 +1,203 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncFaceV1 { + /// Returns a new [SyncFaceV1] instance. + SyncFaceV1({ + required this.assetId, + required this.boundingBoxX1, + required this.boundingBoxX2, + required this.boundingBoxY1, + required this.boundingBoxY2, + required this.createdAt, + required this.deletedAt, + required this.id, + required this.imageHeight, + required this.imageWidth, + required this.personId, + required this.sourceType, + required this.updatedAt, + }); + + String assetId; + + int boundingBoxX1; + + int boundingBoxX2; + + int boundingBoxY1; + + int boundingBoxY2; + + DateTime createdAt; + + DateTime? deletedAt; + + String id; + + int imageHeight; + + int imageWidth; + + String? personId; + + SourceType sourceType; + + DateTime updatedAt; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncFaceV1 && + other.assetId == assetId && + other.boundingBoxX1 == boundingBoxX1 && + other.boundingBoxX2 == boundingBoxX2 && + other.boundingBoxY1 == boundingBoxY1 && + other.boundingBoxY2 == boundingBoxY2 && + other.createdAt == createdAt && + other.deletedAt == deletedAt && + other.id == id && + other.imageHeight == imageHeight && + other.imageWidth == imageWidth && + other.personId == personId && + other.sourceType == sourceType && + other.updatedAt == updatedAt; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (boundingBoxX1.hashCode) + + (boundingBoxX2.hashCode) + + (boundingBoxY1.hashCode) + + (boundingBoxY2.hashCode) + + (createdAt.hashCode) + + (deletedAt == null ? 0 : deletedAt!.hashCode) + + (id.hashCode) + + (imageHeight.hashCode) + + (imageWidth.hashCode) + + (personId == null ? 0 : personId!.hashCode) + + (sourceType.hashCode) + + (updatedAt.hashCode); + + @override + String toString() => 'SyncFaceV1[assetId=$assetId, boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, createdAt=$createdAt, deletedAt=$deletedAt, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth, personId=$personId, sourceType=$sourceType, updatedAt=$updatedAt]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'boundingBoxX1'] = this.boundingBoxX1; + json[r'boundingBoxX2'] = this.boundingBoxX2; + json[r'boundingBoxY1'] = this.boundingBoxY1; + json[r'boundingBoxY2'] = this.boundingBoxY2; + json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); + if (this.deletedAt != null) { + json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String(); + } else { + // json[r'deletedAt'] = null; + } + json[r'id'] = this.id; + json[r'imageHeight'] = this.imageHeight; + json[r'imageWidth'] = this.imageWidth; + if (this.personId != null) { + json[r'personId'] = this.personId; + } else { + // json[r'personId'] = null; + } + json[r'sourceType'] = this.sourceType; + json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); + return json; + } + + /// Returns a new [SyncFaceV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncFaceV1? fromJson(dynamic value) { + upgradeDto(value, "SyncFaceV1"); + if (value is Map) { + final json = value.cast(); + + return SyncFaceV1( + assetId: mapValueOfType(json, r'assetId')!, + boundingBoxX1: mapValueOfType(json, r'boundingBoxX1')!, + boundingBoxX2: mapValueOfType(json, r'boundingBoxX2')!, + boundingBoxY1: mapValueOfType(json, r'boundingBoxY1')!, + boundingBoxY2: mapValueOfType(json, r'boundingBoxY2')!, + createdAt: mapDateTime(json, r'createdAt', r'')!, + deletedAt: mapDateTime(json, r'deletedAt', r''), + id: mapValueOfType(json, r'id')!, + imageHeight: mapValueOfType(json, r'imageHeight')!, + imageWidth: mapValueOfType(json, r'imageWidth')!, + personId: mapValueOfType(json, r'personId'), + sourceType: SourceType.fromJson(json[r'sourceType'])!, + updatedAt: mapDateTime(json, r'updatedAt', r'')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncFaceV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncFaceV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncFaceV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncFaceV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'boundingBoxX1', + 'boundingBoxX2', + 'boundingBoxY1', + 'boundingBoxY2', + 'createdAt', + 'deletedAt', + 'id', + 'imageHeight', + 'imageWidth', + 'personId', + 'sourceType', + 'updatedAt', + }; +} + diff --git a/mobile/openapi/lib/model/sync_person_delete_v1.dart b/mobile/openapi/lib/model/sync_person_delete_v1.dart new file mode 100644 index 0000000000..002f5c5b83 --- /dev/null +++ b/mobile/openapi/lib/model/sync_person_delete_v1.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncPersonDeleteV1 { + /// Returns a new [SyncPersonDeleteV1] instance. + SyncPersonDeleteV1({ + required this.personId, + }); + + String personId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncPersonDeleteV1 && + other.personId == personId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (personId.hashCode); + + @override + String toString() => 'SyncPersonDeleteV1[personId=$personId]'; + + Map toJson() { + final json = {}; + json[r'personId'] = this.personId; + return json; + } + + /// Returns a new [SyncPersonDeleteV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncPersonDeleteV1? fromJson(dynamic value) { + upgradeDto(value, "SyncPersonDeleteV1"); + if (value is Map) { + final json = value.cast(); + + return SyncPersonDeleteV1( + personId: mapValueOfType(json, r'personId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncPersonDeleteV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncPersonDeleteV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncPersonDeleteV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncPersonDeleteV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'personId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_person_v1.dart b/mobile/openapi/lib/model/sync_person_v1.dart new file mode 100644 index 0000000000..e86c22f64b --- /dev/null +++ b/mobile/openapi/lib/model/sync_person_v1.dart @@ -0,0 +1,191 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncPersonV1 { + /// Returns a new [SyncPersonV1] instance. + SyncPersonV1({ + required this.birthDate, + required this.color, + required this.createdAt, + required this.faceAssetId, + required this.id, + required this.isFavorite, + required this.isHidden, + required this.name, + required this.ownerId, + required this.thumbnailPath, + required this.updatedAt, + }); + + DateTime? birthDate; + + String? color; + + DateTime createdAt; + + String? faceAssetId; + + String id; + + bool isFavorite; + + bool isHidden; + + String name; + + String ownerId; + + String thumbnailPath; + + DateTime updatedAt; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncPersonV1 && + other.birthDate == birthDate && + other.color == color && + other.createdAt == createdAt && + other.faceAssetId == faceAssetId && + other.id == id && + other.isFavorite == isFavorite && + other.isHidden == isHidden && + other.name == name && + other.ownerId == ownerId && + other.thumbnailPath == thumbnailPath && + other.updatedAt == updatedAt; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (birthDate == null ? 0 : birthDate!.hashCode) + + (color == null ? 0 : color!.hashCode) + + (createdAt.hashCode) + + (faceAssetId == null ? 0 : faceAssetId!.hashCode) + + (id.hashCode) + + (isFavorite.hashCode) + + (isHidden.hashCode) + + (name.hashCode) + + (ownerId.hashCode) + + (thumbnailPath.hashCode) + + (updatedAt.hashCode); + + @override + String toString() => 'SyncPersonV1[birthDate=$birthDate, color=$color, createdAt=$createdAt, faceAssetId=$faceAssetId, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, ownerId=$ownerId, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; + + Map toJson() { + final json = {}; + if (this.birthDate != null) { + json[r'birthDate'] = this.birthDate!.toUtc().toIso8601String(); + } else { + // json[r'birthDate'] = null; + } + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; + } + json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); + if (this.faceAssetId != null) { + json[r'faceAssetId'] = this.faceAssetId; + } else { + // json[r'faceAssetId'] = null; + } + json[r'id'] = this.id; + json[r'isFavorite'] = this.isFavorite; + json[r'isHidden'] = this.isHidden; + json[r'name'] = this.name; + json[r'ownerId'] = this.ownerId; + json[r'thumbnailPath'] = this.thumbnailPath; + json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); + return json; + } + + /// Returns a new [SyncPersonV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncPersonV1? fromJson(dynamic value) { + upgradeDto(value, "SyncPersonV1"); + if (value is Map) { + final json = value.cast(); + + return SyncPersonV1( + birthDate: mapDateTime(json, r'birthDate', r''), + color: mapValueOfType(json, r'color'), + createdAt: mapDateTime(json, r'createdAt', r'')!, + faceAssetId: mapValueOfType(json, r'faceAssetId'), + id: mapValueOfType(json, r'id')!, + isFavorite: mapValueOfType(json, r'isFavorite')!, + isHidden: mapValueOfType(json, r'isHidden')!, + name: mapValueOfType(json, r'name')!, + ownerId: mapValueOfType(json, r'ownerId')!, + thumbnailPath: mapValueOfType(json, r'thumbnailPath')!, + updatedAt: mapDateTime(json, r'updatedAt', r'')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncPersonV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncPersonV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncPersonV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncPersonV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'birthDate', + 'color', + 'createdAt', + 'faceAssetId', + 'id', + 'isFavorite', + 'isHidden', + 'name', + 'ownerId', + 'thumbnailPath', + 'updatedAt', + }; +} + diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart index c13d791d84..ffb7faff87 100644 --- a/mobile/openapi/lib/model/sync_request_type.dart +++ b/mobile/openapi/lib/model/sync_request_type.dart @@ -36,6 +36,8 @@ class SyncRequestType { static const partnerAssetsV1 = SyncRequestType._(r'PartnerAssetsV1'); static const partnerAssetExifsV1 = SyncRequestType._(r'PartnerAssetExifsV1'); static const partnerStacksV1 = SyncRequestType._(r'PartnerStacksV1'); + static const peopleV1 = SyncRequestType._(r'PeopleV1'); + static const facesV1 = SyncRequestType._(r'FacesV1'); static const stacksV1 = SyncRequestType._(r'StacksV1'); static const usersV1 = SyncRequestType._(r'UsersV1'); @@ -54,6 +56,8 @@ class SyncRequestType { partnerAssetsV1, partnerAssetExifsV1, partnerStacksV1, + peopleV1, + facesV1, stacksV1, usersV1, ]; @@ -107,6 +111,8 @@ class SyncRequestTypeTypeTransformer { case r'PartnerAssetsV1': return SyncRequestType.partnerAssetsV1; case r'PartnerAssetExifsV1': return SyncRequestType.partnerAssetExifsV1; case r'PartnerStacksV1': return SyncRequestType.partnerStacksV1; + case r'PeopleV1': return SyncRequestType.peopleV1; + case r'FacesV1': return SyncRequestType.facesV1; case r'StacksV1': return SyncRequestType.stacksV1; case r'UsersV1': return SyncRequestType.usersV1; default: diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 7a44a5cf6f..ba203aab11 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -13830,10 +13830,93 @@ "MemoryToAssetDeleteV1", "StackV1", "StackDeleteV1", + "PersonV1", + "PersonDeleteV1", + "FaceV1", + "FaceDeleteV1", "SyncAckV1" ], "type": "string" }, + "SyncFaceDeleteV1": { + "properties": { + "faceId": { + "type": "string" + } + }, + "required": [ + "faceId" + ], + "type": "object" + }, + "SyncFaceV1": { + "properties": { + "assetId": { + "type": "string" + }, + "boundingBoxX1": { + "type": "integer" + }, + "boundingBoxX2": { + "type": "integer" + }, + "boundingBoxY1": { + "type": "integer" + }, + "boundingBoxY2": { + "type": "integer" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "deletedAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "id": { + "type": "string" + }, + "imageHeight": { + "type": "integer" + }, + "imageWidth": { + "type": "integer" + }, + "personId": { + "nullable": true, + "type": "string" + }, + "sourceType": { + "allOf": [ + { + "$ref": "#/components/schemas/SourceType" + } + ] + }, + "updatedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "assetId", + "boundingBoxX1", + "boundingBoxX2", + "boundingBoxY1", + "boundingBoxY2", + "createdAt", + "deletedAt", + "id", + "imageHeight", + "imageWidth", + "personId", + "sourceType", + "updatedAt" + ], + "type": "object" + }, "SyncMemoryAssetDeleteV1": { "properties": { "assetId": { @@ -13979,6 +14062,74 @@ ], "type": "object" }, + "SyncPersonDeleteV1": { + "properties": { + "personId": { + "type": "string" + } + }, + "required": [ + "personId" + ], + "type": "object" + }, + "SyncPersonV1": { + "properties": { + "birthDate": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "color": { + "nullable": true, + "type": "string" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "faceAssetId": { + "nullable": true, + "type": "string" + }, + "id": { + "type": "string" + }, + "isFavorite": { + "type": "boolean" + }, + "isHidden": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "ownerId": { + "type": "string" + }, + "thumbnailPath": { + "type": "string" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "birthDate", + "color", + "createdAt", + "faceAssetId", + "id", + "isFavorite", + "isHidden", + "name", + "ownerId", + "thumbnailPath", + "updatedAt" + ], + "type": "object" + }, "SyncRequestType": { "enum": [ "AlbumsV1", @@ -13994,6 +14145,8 @@ "PartnerAssetsV1", "PartnerAssetExifsV1", "PartnerStacksV1", + "PeopleV1", + "FacesV1", "StacksV1", "UsersV1" ], diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 9eb9990d2c..adcfb8a107 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -4095,6 +4095,10 @@ export enum SyncEntityType { MemoryToAssetDeleteV1 = "MemoryToAssetDeleteV1", StackV1 = "StackV1", StackDeleteV1 = "StackDeleteV1", + PersonV1 = "PersonV1", + PersonDeleteV1 = "PersonDeleteV1", + FaceV1 = "FaceV1", + FaceDeleteV1 = "FaceDeleteV1", SyncAckV1 = "SyncAckV1" } export enum SyncRequestType { @@ -4111,6 +4115,8 @@ export enum SyncRequestType { PartnerAssetsV1 = "PartnerAssetsV1", PartnerAssetExifsV1 = "PartnerAssetExifsV1", PartnerStacksV1 = "PartnerStacksV1", + PeopleV1 = "PeopleV1", + FacesV1 = "FacesV1", StacksV1 = "StacksV1", UsersV1 = "UsersV1" } diff --git a/server/src/database.ts b/server/src/database.ts index acd6980985..3adce389bf 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -259,6 +259,9 @@ export type Person = { export type AssetFace = { id: string; + createdAt: Date; + updatedAt: Date; + updateId: string; deletedAt: Date | null; assetId: string; boundingBoxX1: number; diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index db1fd29418..0488d25339 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -8,6 +8,7 @@ import { AssetType, AssetVisibility, MemoryType, + SourceType, SyncEntityType, SyncRequestType, } from 'src/enum'; @@ -233,6 +234,55 @@ export class SyncStackDeleteV1 { stackId!: string; } +@ExtraModel() +export class SyncPersonV1 { + id!: string; + createdAt!: Date; + updatedAt!: Date; + ownerId!: string; + name!: string; + thumbnailPath!: string; + isHidden!: boolean; + birthDate!: Date | null; + faceAssetId!: string | null; + isFavorite!: boolean; + color!: string | null; +} + +@ExtraModel() +export class SyncPersonDeleteV1 { + personId!: string; +} + +@ExtraModel() +export class SyncFaceV1 { + id!: string; + personId!: string | null; + assetId!: string; + @ApiProperty({ type: 'integer' }) + imageHeight!: number; + @ApiProperty({ type: 'integer' }) + imageWidth!: number; + @ApiProperty({ type: 'integer' }) + boundingBoxX1!: number; + @ApiProperty({ type: 'integer' }) + boundingBoxY1!: number; + @ApiProperty({ type: 'integer' }) + boundingBoxX2!: number; + @ApiProperty({ type: 'integer' }) + boundingBoxY2!: number; + @ApiProperty({ enumName: 'SourceType', enum: SourceType }) + sourceType!: SourceType; + createdAt!: Date; + updatedAt!: Date; + deletedAt!: Date | null; +} + +@ExtraModel() +export class SyncFaceDeleteV1 { + faceId!: string; +} + @ExtraModel() export class SyncAckV1 {} @@ -270,6 +320,10 @@ export type SyncItem = { [SyncEntityType.PartnerStackBackfillV1]: SyncStackV1; [SyncEntityType.PartnerStackDeleteV1]: SyncStackDeleteV1; [SyncEntityType.PartnerStackV1]: SyncStackV1; + [SyncEntityType.PersonV1]: SyncPersonV1; + [SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1; + [SyncEntityType.FaceV1]: SyncFaceV1; + [SyncEntityType.FaceDeleteV1]: SyncFaceDeleteV1; [SyncEntityType.SyncAckV1]: SyncAckV1; }; diff --git a/server/src/enum.ts b/server/src/enum.ts index d211420ab5..fd1cca04b1 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -586,6 +586,8 @@ export enum SyncRequestType { PartnerAssetsV1 = 'PartnerAssetsV1', PartnerAssetExifsV1 = 'PartnerAssetExifsV1', PartnerStacksV1 = 'PartnerStacksV1', + PeopleV1 = 'PeopleV1', + FacesV1 = 'FacesV1', StacksV1 = 'StacksV1', UsersV1 = 'UsersV1', } @@ -635,6 +637,12 @@ export enum SyncEntityType { StackV1 = 'StackV1', StackDeleteV1 = 'StackDeleteV1', + PersonV1 = 'PersonV1', + PersonDeleteV1 = 'PersonDeleteV1', + + FaceV1 = 'FaceV1', + FaceDeleteV1 = 'FaceDeleteV1', + SyncAckV1 = 'SyncAckV1', } diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 50a89655cb..2f5f5602c3 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -403,6 +403,62 @@ where order by "updateId" asc +-- SyncRepository.face.getDeletes +select + "id", + "faceId" +from + "asset_faces_audit" +where + "faceId" in ( + select + "id" + from + "asset_faces" + where + "assetId" in ( + select + "id" + from + "assets" + where + "ownerId" = $1 + ) + ) + and "deletedAt" < now() - interval '1 millisecond' +order by + "id" asc + +-- SyncRepository.face.getUpserts +select + "id", + "personId", + "assetId", + "imageHeight", + "imageWidth", + "boundingBoxX1", + "boundingBoxY1", + "boundingBoxX2", + "boundingBoxY2", + "sourceType", + "createdAt", + "updatedAt", + "deletedAt", + "updateId" +from + "asset_faces" +where + "assetId" in ( + select + from + "assets" + where + "ownerId" = $1 + ) + and "updatedAt" < now() - interval '1 millisecond' +order by + "updateId" asc + -- SyncRepository.memory.getDeletes select "id", @@ -749,6 +805,40 @@ where order by "updateId" asc +-- SyncRepository.person.getDeletes +select + "id", + "personId" +from + "people_audit" +where + "userId" = $1 + and "deletedAt" < now() - interval '1 millisecond' +order by + "id" asc + +-- SyncRepository.person.getUpserts +select + "id", + "createdAt", + "updatedAt", + "ownerId", + "name", + "thumbnailPath", + "isHidden", + "birthDate", + "faceAssetId", + "isFavorite", + "color", + "updateId" +from + "person" +where + "ownerId" = $1 + and "updatedAt" < now() - interval '1 millisecond' +order by + "updateId" asc + -- SyncRepository.stack.getDeletes select "id", diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index 699aaec83d..ae625621bc 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -15,6 +15,8 @@ type AuditTables = | 'album_assets_audit' | 'memories_audit' | 'memory_assets_audit' + | 'people_audit' + | 'asset_faces_audit' | 'stacks_audit'; type UpsertTables = | 'users' @@ -25,6 +27,8 @@ type UpsertTables = | 'albums_shared_users_users' | 'memories' | 'memories_assets_assets' + | 'person' + | 'asset_faces' | 'asset_stack'; @Injectable() @@ -36,12 +40,14 @@ export class SyncRepository { albumUser: AlbumUserSync; asset: AssetSync; assetExif: AssetExifSync; + face: FaceSync; memory: MemorySync; memoryToAsset: MemoryToAssetSync; partner: PartnerSync; partnerAsset: PartnerAssetsSync; partnerAssetExif: PartnerAssetExifsSync; partnerStack: PartnerStackSync; + person: PersonSync; stack: StackSync; user: UserSync; @@ -53,12 +59,14 @@ export class SyncRepository { this.albumUser = new AlbumUserSync(this.db); this.asset = new AssetSync(this.db); this.assetExif = new AssetExifSync(this.db); + this.face = new FaceSync(this.db); this.memory = new MemorySync(this.db); this.memoryToAsset = new MemoryToAssetSync(this.db); this.partner = new PartnerSync(this.db); this.partnerAsset = new PartnerAssetsSync(this.db); this.partnerAssetExif = new PartnerAssetExifsSync(this.db); this.partnerStack = new PartnerStackSync(this.db); + this.person = new PersonSync(this.db); this.stack = new StackSync(this.db); this.user = new UserSync(this.db); } @@ -370,6 +378,45 @@ class AssetExifSync extends BaseSync { } } +class FaceSync extends BaseSync { + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getDeletes(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('asset_faces_audit') + .select(['id', 'faceId']) + .where('faceId', 'in', (eb) => eb.selectFrom('asset_faces').select('id').where( + 'assetId', 'in', (eb) => eb.selectFrom('assets').select('id').where('ownerId', '=', userId) + )) + .$call((qb) => this.auditTableFilters(qb, ack)) + .stream(); + } + + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('asset_faces') + .select([ + 'id', + 'personId', + 'assetId', + 'imageHeight', + 'imageWidth', + 'boundingBoxX1', + 'boundingBoxY1', + 'boundingBoxX2', + 'boundingBoxY2', + 'sourceType', + 'createdAt', + 'updatedAt', + 'deletedAt', + ]) + .select('updateId') + .where('assetId', 'in', (eb) => eb.selectFrom('assets').where('ownerId', '=', userId)) + .$call((qb) => this.upsertTableFilters(qb, ack)) + .stream(); + } +} + class MemorySync extends BaseSync { @GenerateSql({ params: [DummyValue.UUID], stream: true }) getDeletes(userId: string, ack?: SyncAck) { @@ -539,6 +586,41 @@ class PartnerAssetExifsSync extends BaseSync { } } +class PersonSync extends BaseSync { + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getDeletes(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('people_audit') + .select(['id', 'personId']) + .where('userId', '=', userId) + .$call((qb) => this.auditTableFilters(qb, ack)) + .stream(); + } + + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('person') + .select([ + 'id', + 'createdAt', + 'updatedAt', + 'ownerId', + 'name', + 'thumbnailPath', + 'isHidden', + 'birthDate', + 'faceAssetId', + 'isFavorite', + 'color', + ]) + .select('updateId') + .where('ownerId', '=', userId) + .$call((qb) => this.upsertTableFilters(qb, ack)) + .stream(); + } +} + class StackSync extends BaseSync { @GenerateSql({ params: [DummyValue.UUID], stream: true }) getDeletes(userId: string, ack?: SyncAck) { diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index d4c97e1966..ed9b878544 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -218,3 +218,30 @@ export const stacks_delete_audit = registerFunction({ END`, synchronize: false, }); + +export const people_delete_audit = registerFunction({ + name: 'people_delete_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO people_audit ("personId", "userId") + SELECT "id", "ownerId" + FROM OLD; + RETURN NULL; + END`, + synchronize: false, +}); + +export const asset_faces_delete_audit = registerFunction({ + name: 'asset_faces_delete_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO asset_faces_audit ("faceId") + SELECT "id" FROM OLD; + RETURN NULL; + END`, + synchronize: false, +}); diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 6512ccc225..13f92f31ed 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -3,6 +3,7 @@ import { album_user_after_insert, album_users_delete_audit, albums_delete_audit, + asset_faces_delete_audit, assets_delete_audit, f_concat_ws, f_unaccent, @@ -11,6 +12,7 @@ import { memories_delete_audit, memory_assets_delete_audit, partners_delete_audit, + people_delete_audit, stacks_delete_audit, updated_at, users_delete_audit, @@ -24,6 +26,7 @@ import { AlbumUserTable } from 'src/schema/tables/album-user.table'; import { AlbumTable } from 'src/schema/tables/album.table'; import { ApiKeyTable } from 'src/schema/tables/api-key.table'; import { AssetAuditTable } from 'src/schema/tables/asset-audit.table'; +import { AssetFaceAuditTable } from 'src/schema/tables/asset-face-audit.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetFileTable } from 'src/schema/tables/asset-files.table'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; @@ -42,6 +45,7 @@ import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-coun import { NotificationTable } from 'src/schema/tables/notification.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 { PersonTable } from 'src/schema/tables/person.table'; import { SessionTable } from 'src/schema/tables/session.table'; import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table'; @@ -73,6 +77,7 @@ export class ImmichDatabase { AlbumTable, ApiKeyTable, AssetAuditTable, + AssetFaceAuditTable, AssetFaceTable, AssetJobStatusTable, AssetTable, @@ -91,6 +96,7 @@ export class ImmichDatabase { NotificationTable, PartnerAuditTable, PartnerTable, + PersonAuditTable, PersonTable, SessionTable, SharedLinkAssetTable, @@ -124,6 +130,8 @@ export class ImmichDatabase { memories_delete_audit, memory_assets_delete_audit, stacks_delete_audit, + people_delete_audit, + asset_faces_delete_audit, ]; enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum]; @@ -145,6 +153,7 @@ export interface DB { album_users_audit: AlbumUserAuditTable; api_keys: ApiKeyTable; asset_faces: AssetFaceTable; + asset_faces_audit: AssetFaceAuditTable; asset_files: AssetFileTable; asset_job_status: AssetJobStatusTable; asset_stack: StackTable; @@ -166,6 +175,7 @@ export interface DB { partners_audit: PartnerAuditTable; partners: PartnerTable; person: PersonTable; + people_audit: PersonAuditTable; sessions: SessionTable; session_sync_checkpoints: SessionSyncCheckpointTable; shared_link__asset: SharedLinkAssetTable; diff --git a/server/src/schema/migrations/1751796648426-PersonSyncChanges.ts b/server/src/schema/migrations/1751796648426-PersonSyncChanges.ts new file mode 100644 index 0000000000..9efaed98d3 --- /dev/null +++ b/server/src/schema/migrations/1751796648426-PersonSyncChanges.ts @@ -0,0 +1,81 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION people_delete_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO people_audit ("personId", "userId") + SELECT "id", "ownerId" + FROM OLD; + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE OR REPLACE FUNCTION asset_faces_delete_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO asset_faces_audit ("faceId") + SELECT "id" FROM OLD; + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE TABLE "asset_faces_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "faceId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp());`.execute( + db, + ); + await sql`CREATE TABLE "people_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "personId" uuid NOT NULL, "userId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp());`.execute( + db, + ); + await sql`ALTER TABLE "asset_faces" ADD "createdAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db); + await sql`ALTER TABLE "asset_faces" ADD "updatedAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db); + await sql`ALTER TABLE "asset_faces" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db); + await sql`ALTER TABLE "asset_faces_audit" ADD CONSTRAINT "PK_c4240b0610ffac3d326d45d4559" PRIMARY KEY ("id");`.execute( + db, + ); + await sql`ALTER TABLE "people_audit" ADD CONSTRAINT "PK_8f107fb7bfb5e31592d75f9d5c7" PRIMARY KEY ("id");`.execute(db); + await sql`CREATE INDEX "IDX_asset_faces_audit_face_id" ON "asset_faces_audit" ("faceId")`.execute(db); + await sql`CREATE INDEX "IDX_asset_faces_audit_deleted_at" ON "asset_faces_audit" ("deletedAt")`.execute(db); + await sql`CREATE INDEX "IDX_asset_faces_update_id" ON "asset_faces" ("updateId")`.execute(db); + await sql`CREATE INDEX "IDX_people_audit_person_id" ON "people_audit" ("personId")`.execute(db); + await sql`CREATE INDEX "IDX_people_audit_user_id" ON "people_audit" ("userId")`.execute(db); + await sql`CREATE INDEX "IDX_people_audit_deleted_at" ON "people_audit" ("deletedAt")`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "people_delete_audit" + AFTER DELETE ON "person" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION people_delete_audit();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "asset_faces_delete_audit" + AFTER DELETE ON "asset_faces" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() <= 1) + EXECUTE FUNCTION asset_faces_delete_audit();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "asset_faces_updated_at" + BEFORE UPDATE ON "asset_faces" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "asset_faces_delete_audit" ON "asset_faces";`.execute(db); + await sql`DROP TRIGGER "asset_faces_updated_at" ON "asset_faces";`.execute(db); + await sql`DROP TRIGGER "people_delete_audit" ON "person";`.execute(db); + await sql`DROP INDEX "IDX_asset_faces_update_id";`.execute(db); + await sql`DROP INDEX "IDX_asset_faces_audit_face_id";`.execute(db); + await sql`DROP INDEX "IDX_asset_faces_audit_deleted_at";`.execute(db); + await sql`DROP INDEX "IDX_people_audit_person_id";`.execute(db); + await sql`DROP INDEX "IDX_people_audit_user_id";`.execute(db); + await sql`DROP INDEX "IDX_people_audit_deleted_at";`.execute(db); + await sql`ALTER TABLE "asset_faces_audit" DROP CONSTRAINT "PK_c4240b0610ffac3d326d45d4559";`.execute(db); + await sql`ALTER TABLE "people_audit" DROP CONSTRAINT "PK_8f107fb7bfb5e31592d75f9d5c7";`.execute(db); + await sql`ALTER TABLE "asset_faces" DROP COLUMN "createdAt";`.execute(db); + await sql`ALTER TABLE "asset_faces" DROP COLUMN "updatedAt";`.execute(db); + await sql`ALTER TABLE "asset_faces" DROP COLUMN "updateId";`.execute(db); + await sql`DROP TABLE "asset_faces_audit";`.execute(db); + await sql`DROP TABLE "people_audit";`.execute(db); + await sql`DROP FUNCTION people_delete_audit;`.execute(db); + await sql`DROP FUNCTION asset_faces_delete_audit;`.execute(db); +} diff --git a/server/src/schema/tables/asset-face-audit.table.ts b/server/src/schema/tables/asset-face-audit.table.ts new file mode 100644 index 0000000000..4767463ecd --- /dev/null +++ b/server/src/schema/tables/asset-face-audit.table.ts @@ -0,0 +1,14 @@ +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; +import { Column, CreateDateColumn, Table } from 'src/sql-tools'; + +@Table('asset_faces_audit') +export class AssetFaceAuditTable { + @PrimaryGeneratedUuidV7Column() + id!: string; + + @Column({ type: 'uuid', indexName: 'IDX_asset_faces_audit_face_id' }) + faceId!: string; + + @CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_asset_faces_audit_deleted_at' }) + deletedAt!: Date; +} diff --git a/server/src/schema/tables/asset-face.table.ts b/server/src/schema/tables/asset-face.table.ts index 329783d6df..04e7f8a365 100644 --- a/server/src/schema/tables/asset-face.table.ts +++ b/server/src/schema/tables/asset-face.table.ts @@ -1,9 +1,13 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { SourceType } from 'src/enum'; import { asset_face_source_type } from 'src/schema/enums'; +import { asset_faces_delete_audit } from 'src/schema/functions'; import { AssetTable } from 'src/schema/tables/asset.table'; import { PersonTable } from 'src/schema/tables/person.table'; import { + AfterDeleteTrigger, Column, + CreateDateColumn, DeleteDateColumn, ForeignKeyColumn, Generated, @@ -16,6 +20,13 @@ import { @Table({ name: 'asset_faces' }) @Index({ name: 'IDX_asset_faces_assetId_personId', columns: ['assetId', 'personId'] }) @Index({ columns: ['personId', 'assetId'] }) +@UpdatedAtTrigger('asset_faces_updated_at') +@AfterDeleteTrigger({ + scope: 'statement', + function: asset_faces_delete_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() <= 1', +}) export class AssetFaceTable { @PrimaryGeneratedColumn() id!: Generated; @@ -58,6 +69,15 @@ export class AssetFaceTable { @Column({ default: SourceType.MACHINE_LEARNING, enum: asset_face_source_type }) sourceType!: Generated; + @CreateDateColumn() + createdAt!: Generated; + + @CreateDateColumn() + updatedAt!: Generated; + + @UpdateIdColumn({ indexName: 'IDX_asset_faces_update_id' }) + updateId!: Generated; + @DeleteDateColumn() deletedAt!: Timestamp | null; } diff --git a/server/src/schema/tables/person-audit.table.ts b/server/src/schema/tables/person-audit.table.ts new file mode 100644 index 0000000000..005031218c --- /dev/null +++ b/server/src/schema/tables/person-audit.table.ts @@ -0,0 +1,17 @@ +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; +import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; + +@Table('people_audit') +export class PersonAuditTable { + @PrimaryGeneratedUuidV7Column() + id!: Generated; + + @Column({ type: 'uuid', indexName: 'IDX_people_audit_person_id' }) + personId!: string; + + @Column({ type: 'uuid', indexName: 'IDX_people_audit_user_id' }) + userId!: string; + + @CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_people_audit_deleted_at' }) + deletedAt!: Generated; +} diff --git a/server/src/schema/tables/person.table.ts b/server/src/schema/tables/person.table.ts index 5835b2528c..0a5b65cfbc 100644 --- a/server/src/schema/tables/person.table.ts +++ b/server/src/schema/tables/person.table.ts @@ -1,7 +1,9 @@ import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { people_delete_audit } from 'src/schema/functions'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { UserTable } from 'src/schema/tables/user.table'; import { + AfterDeleteTrigger, Check, Column, CreateDateColumn, @@ -15,6 +17,12 @@ import { @Table('person') @UpdatedAtTrigger('person_updated_at') +@AfterDeleteTrigger({ + scope: 'statement', + function: people_delete_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() = 0', +}) @Check({ name: 'CHK_b0f82b0ed662bfc24fbb58bb45', expression: `"birthDate" <= CURRENT_DATE` }) export class PersonTable { @PrimaryGeneratedColumn('uuid') diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index e3322de2e1..97a8bb865e 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -67,6 +67,8 @@ export const SYNC_TYPES_ORDER = [ SyncRequestType.AssetExifsV1, SyncRequestType.AlbumAssetExifsV1, SyncRequestType.PartnerAssetExifsV1, + SyncRequestType.PeopleV1, + SyncRequestType.FacesV1, SyncRequestType.MemoriesV1, SyncRequestType.MemoryToAssetsV1, ]; @@ -141,6 +143,8 @@ export class SyncService extends BaseService { [SyncRequestType.MemoryToAssetsV1]: () => this.syncMemoryAssetsV1(response, checkpointMap, auth), [SyncRequestType.StacksV1]: () => this.syncStackV1(response, checkpointMap, auth), [SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(response, checkpointMap, auth, sessionId), + [SyncRequestType.PeopleV1]: () => this.syncPeopleV1(response, checkpointMap, auth), + [SyncRequestType.FacesV1]: () => this.syncFacesV1(response, checkpointMap, auth), }; for (const type of SYNC_TYPES_ORDER.filter((type) => dto.types.includes(type))) { @@ -488,7 +492,7 @@ export class SyncService extends BaseService { private async syncMemoriesV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { const deleteType = SyncEntityType.MemoryDeleteV1; - const deletes = this.syncRepository.memory.getDeletes(auth.user.id, checkpointMap[SyncEntityType.MemoryDeleteV1]); + const deletes = this.syncRepository.memory.getDeletes(auth.user.id, checkpointMap[deleteType]); for await (const { id, ...data } of deletes) { send(response, { type: deleteType, ids: [id], data }); } @@ -576,6 +580,34 @@ export class SyncService extends BaseService { } } + private async syncPeopleV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { + const deleteType = SyncEntityType.PersonDeleteV1; + const deletes = this.syncRepository.person.getDeletes(auth.user.id, checkpointMap[deleteType]); + for await (const { id, ...data } of deletes) { + send(response, { type: deleteType, ids: [id], data }); + } + + const upsertType = SyncEntityType.PersonV1; + const upserts = this.syncRepository.person.getUpserts(auth.user.id, checkpointMap[upsertType]); + for await (const { updateId, ...data } of upserts) { + send(response, { type: upsertType, ids: [updateId], data }); + } + } + + private async syncFacesV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { + const deleteType = SyncEntityType.FaceDeleteV1; + const deletes = this.syncRepository.face.getDeletes(auth.user.id, checkpointMap[deleteType]); + for await (const { id, ...data } of deletes) { + send(response, { type: deleteType, ids: [id], data }); + } + + const upsertType = SyncEntityType.FaceV1; + const upserts = this.syncRepository.face.getUpserts(auth.user.id, checkpointMap[upsertType]); + for await (const { updateId, ...data } of upserts) { + send(response, { type: upsertType, ids: [updateId], data }); + } + } + private async upsertBackfillCheckpoint(item: { type: SyncEntityType; sessionId: string; createId: string }) { const { type, sessionId, createId } = item; await this.syncCheckpointRepository.upsertAll([