diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index e3f6a74856..85409a7934 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -424,6 +424,9 @@ Class | Method | HTTP request | Description - [SyncAckDeleteDto](doc//SyncAckDeleteDto.md) - [SyncAckDto](doc//SyncAckDto.md) - [SyncAckSetDto](doc//SyncAckSetDto.md) + - [SyncAssetDeleteV1](doc//SyncAssetDeleteV1.md) + - [SyncAssetExifV1](doc//SyncAssetExifV1.md) + - [SyncAssetV1](doc//SyncAssetV1.md) - [SyncEntityType](doc//SyncEntityType.md) - [SyncPartnerDeleteV1](doc//SyncPartnerDeleteV1.md) - [SyncPartnerV1](doc//SyncPartnerV1.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 04dc43f88c..eddd63a732 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -231,6 +231,9 @@ part 'model/stack_update_dto.dart'; part 'model/sync_ack_delete_dto.dart'; part 'model/sync_ack_dto.dart'; part 'model/sync_ack_set_dto.dart'; +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_partner_delete_v1.dart'; part 'model/sync_partner_v1.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 4d837ccb9d..783e5e375e 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -518,6 +518,12 @@ class ApiClient { return SyncAckDto.fromJson(value); case 'SyncAckSetDto': return SyncAckSetDto.fromJson(value); + case 'SyncAssetDeleteV1': + return SyncAssetDeleteV1.fromJson(value); + case 'SyncAssetExifV1': + return SyncAssetExifV1.fromJson(value); + case 'SyncAssetV1': + return SyncAssetV1.fromJson(value); case 'SyncEntityType': return SyncEntityTypeTypeTransformer().decode(value); case 'SyncPartnerDeleteV1': diff --git a/mobile/openapi/lib/model/sync_asset_delete_v1.dart b/mobile/openapi/lib/model/sync_asset_delete_v1.dart new file mode 100644 index 0000000000..c1787caf04 --- /dev/null +++ b/mobile/openapi/lib/model/sync_asset_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 SyncAssetDeleteV1 { + /// Returns a new [SyncAssetDeleteV1] instance. + SyncAssetDeleteV1({ + required this.assetId, + }); + + String assetId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAssetDeleteV1 && + other.assetId == assetId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode); + + @override + String toString() => 'SyncAssetDeleteV1[assetId=$assetId]'; + + Map<String, dynamic> toJson() { + final json = <String, dynamic>{}; + json[r'assetId'] = this.assetId; + return json; + } + + /// Returns a new [SyncAssetDeleteV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAssetDeleteV1? fromJson(dynamic value) { + upgradeDto(value, "SyncAssetDeleteV1"); + if (value is Map) { + final json = value.cast<String, dynamic>(); + + return SyncAssetDeleteV1( + assetId: mapValueOfType<String>(json, r'assetId')!, + ); + } + return null; + } + + static List<SyncAssetDeleteV1> listFromJson(dynamic json, {bool growable = false,}) { + final result = <SyncAssetDeleteV1>[]; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncAssetDeleteV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map<String, SyncAssetDeleteV1> mapFromJson(dynamic json) { + final map = <String, SyncAssetDeleteV1>{}; + if (json is Map && json.isNotEmpty) { + json = json.cast<String, dynamic>(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncAssetDeleteV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAssetDeleteV1-objects as value to a dart map + static Map<String, List<SyncAssetDeleteV1>> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = <String, List<SyncAssetDeleteV1>>{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast<String, dynamic>(); + for (final entry in json.entries) { + map[entry.key] = SyncAssetDeleteV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = <String>{ + 'assetId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_asset_exif_v1.dart b/mobile/openapi/lib/model/sync_asset_exif_v1.dart new file mode 100644 index 0000000000..b0fef28b76 --- /dev/null +++ b/mobile/openapi/lib/model/sync_asset_exif_v1.dart @@ -0,0 +1,387 @@ +// +// 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 SyncAssetExifV1 { + /// Returns a new [SyncAssetExifV1] instance. + SyncAssetExifV1({ + required this.assetId, + required this.city, + required this.country, + required this.dateTimeOriginal, + required this.description, + required this.exifImageHeight, + required this.exifImageWidth, + required this.exposureTime, + required this.fNumber, + required this.fileSizeInByte, + required this.focalLength, + required this.fps, + required this.iso, + required this.latitude, + required this.lensModel, + required this.longitude, + required this.make, + required this.model, + required this.modifyDate, + required this.orientation, + required this.profileDescription, + required this.projectionType, + required this.rating, + required this.state, + required this.timeZone, + }); + + String assetId; + + String? city; + + String? country; + + DateTime? dateTimeOriginal; + + String? description; + + int? exifImageHeight; + + int? exifImageWidth; + + String? exposureTime; + + int? fNumber; + + int? fileSizeInByte; + + int? focalLength; + + int? fps; + + int? iso; + + int? latitude; + + String? lensModel; + + int? longitude; + + String? make; + + String? model; + + DateTime? modifyDate; + + String? orientation; + + String? profileDescription; + + String? projectionType; + + int? rating; + + String? state; + + String? timeZone; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAssetExifV1 && + other.assetId == assetId && + other.city == city && + other.country == country && + other.dateTimeOriginal == dateTimeOriginal && + other.description == description && + other.exifImageHeight == exifImageHeight && + other.exifImageWidth == exifImageWidth && + other.exposureTime == exposureTime && + other.fNumber == fNumber && + other.fileSizeInByte == fileSizeInByte && + other.focalLength == focalLength && + other.fps == fps && + other.iso == iso && + other.latitude == latitude && + other.lensModel == lensModel && + other.longitude == longitude && + other.make == make && + other.model == model && + other.modifyDate == modifyDate && + other.orientation == orientation && + other.profileDescription == profileDescription && + other.projectionType == projectionType && + other.rating == rating && + other.state == state && + other.timeZone == timeZone; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (city == null ? 0 : city!.hashCode) + + (country == null ? 0 : country!.hashCode) + + (dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) + + (description == null ? 0 : description!.hashCode) + + (exifImageHeight == null ? 0 : exifImageHeight!.hashCode) + + (exifImageWidth == null ? 0 : exifImageWidth!.hashCode) + + (exposureTime == null ? 0 : exposureTime!.hashCode) + + (fNumber == null ? 0 : fNumber!.hashCode) + + (fileSizeInByte == null ? 0 : fileSizeInByte!.hashCode) + + (focalLength == null ? 0 : focalLength!.hashCode) + + (fps == null ? 0 : fps!.hashCode) + + (iso == null ? 0 : iso!.hashCode) + + (latitude == null ? 0 : latitude!.hashCode) + + (lensModel == null ? 0 : lensModel!.hashCode) + + (longitude == null ? 0 : longitude!.hashCode) + + (make == null ? 0 : make!.hashCode) + + (model == null ? 0 : model!.hashCode) + + (modifyDate == null ? 0 : modifyDate!.hashCode) + + (orientation == null ? 0 : orientation!.hashCode) + + (profileDescription == null ? 0 : profileDescription!.hashCode) + + (projectionType == null ? 0 : projectionType!.hashCode) + + (rating == null ? 0 : rating!.hashCode) + + (state == null ? 0 : state!.hashCode) + + (timeZone == null ? 0 : timeZone!.hashCode); + + @override + String toString() => 'SyncAssetExifV1[assetId=$assetId, city=$city, country=$country, dateTimeOriginal=$dateTimeOriginal, description=$description, exifImageHeight=$exifImageHeight, exifImageWidth=$exifImageWidth, exposureTime=$exposureTime, fNumber=$fNumber, fileSizeInByte=$fileSizeInByte, focalLength=$focalLength, fps=$fps, iso=$iso, latitude=$latitude, lensModel=$lensModel, longitude=$longitude, make=$make, model=$model, modifyDate=$modifyDate, orientation=$orientation, profileDescription=$profileDescription, projectionType=$projectionType, rating=$rating, state=$state, timeZone=$timeZone]'; + + Map<String, dynamic> toJson() { + final json = <String, dynamic>{}; + json[r'assetId'] = this.assetId; + if (this.city != null) { + json[r'city'] = this.city; + } else { + // json[r'city'] = null; + } + if (this.country != null) { + json[r'country'] = this.country; + } else { + // json[r'country'] = null; + } + if (this.dateTimeOriginal != null) { + json[r'dateTimeOriginal'] = this.dateTimeOriginal!.toUtc().toIso8601String(); + } else { + // json[r'dateTimeOriginal'] = null; + } + if (this.description != null) { + json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } + if (this.exifImageHeight != null) { + json[r'exifImageHeight'] = this.exifImageHeight; + } else { + // json[r'exifImageHeight'] = null; + } + if (this.exifImageWidth != null) { + json[r'exifImageWidth'] = this.exifImageWidth; + } else { + // json[r'exifImageWidth'] = null; + } + if (this.exposureTime != null) { + json[r'exposureTime'] = this.exposureTime; + } else { + // json[r'exposureTime'] = null; + } + if (this.fNumber != null) { + json[r'fNumber'] = this.fNumber; + } else { + // json[r'fNumber'] = null; + } + if (this.fileSizeInByte != null) { + json[r'fileSizeInByte'] = this.fileSizeInByte; + } else { + // json[r'fileSizeInByte'] = null; + } + if (this.focalLength != null) { + json[r'focalLength'] = this.focalLength; + } else { + // json[r'focalLength'] = null; + } + if (this.fps != null) { + json[r'fps'] = this.fps; + } else { + // json[r'fps'] = null; + } + if (this.iso != null) { + json[r'iso'] = this.iso; + } else { + // json[r'iso'] = null; + } + if (this.latitude != null) { + json[r'latitude'] = this.latitude; + } else { + // json[r'latitude'] = null; + } + if (this.lensModel != null) { + json[r'lensModel'] = this.lensModel; + } else { + // json[r'lensModel'] = null; + } + if (this.longitude != null) { + json[r'longitude'] = this.longitude; + } else { + // json[r'longitude'] = null; + } + if (this.make != null) { + json[r'make'] = this.make; + } else { + // json[r'make'] = null; + } + if (this.model != null) { + json[r'model'] = this.model; + } else { + // json[r'model'] = null; + } + if (this.modifyDate != null) { + json[r'modifyDate'] = this.modifyDate!.toUtc().toIso8601String(); + } else { + // json[r'modifyDate'] = null; + } + if (this.orientation != null) { + json[r'orientation'] = this.orientation; + } else { + // json[r'orientation'] = null; + } + if (this.profileDescription != null) { + json[r'profileDescription'] = this.profileDescription; + } else { + // json[r'profileDescription'] = null; + } + if (this.projectionType != null) { + json[r'projectionType'] = this.projectionType; + } else { + // json[r'projectionType'] = null; + } + if (this.rating != null) { + json[r'rating'] = this.rating; + } else { + // json[r'rating'] = null; + } + if (this.state != null) { + json[r'state'] = this.state; + } else { + // json[r'state'] = null; + } + if (this.timeZone != null) { + json[r'timeZone'] = this.timeZone; + } else { + // json[r'timeZone'] = null; + } + return json; + } + + /// Returns a new [SyncAssetExifV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAssetExifV1? fromJson(dynamic value) { + upgradeDto(value, "SyncAssetExifV1"); + if (value is Map) { + final json = value.cast<String, dynamic>(); + + return SyncAssetExifV1( + assetId: mapValueOfType<String>(json, r'assetId')!, + city: mapValueOfType<String>(json, r'city'), + country: mapValueOfType<String>(json, r'country'), + dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', r''), + description: mapValueOfType<String>(json, r'description'), + exifImageHeight: mapValueOfType<int>(json, r'exifImageHeight'), + exifImageWidth: mapValueOfType<int>(json, r'exifImageWidth'), + exposureTime: mapValueOfType<String>(json, r'exposureTime'), + fNumber: mapValueOfType<int>(json, r'fNumber'), + fileSizeInByte: mapValueOfType<int>(json, r'fileSizeInByte'), + focalLength: mapValueOfType<int>(json, r'focalLength'), + fps: mapValueOfType<int>(json, r'fps'), + iso: mapValueOfType<int>(json, r'iso'), + latitude: mapValueOfType<int>(json, r'latitude'), + lensModel: mapValueOfType<String>(json, r'lensModel'), + longitude: mapValueOfType<int>(json, r'longitude'), + make: mapValueOfType<String>(json, r'make'), + model: mapValueOfType<String>(json, r'model'), + modifyDate: mapDateTime(json, r'modifyDate', r''), + orientation: mapValueOfType<String>(json, r'orientation'), + profileDescription: mapValueOfType<String>(json, r'profileDescription'), + projectionType: mapValueOfType<String>(json, r'projectionType'), + rating: mapValueOfType<int>(json, r'rating'), + state: mapValueOfType<String>(json, r'state'), + timeZone: mapValueOfType<String>(json, r'timeZone'), + ); + } + return null; + } + + static List<SyncAssetExifV1> listFromJson(dynamic json, {bool growable = false,}) { + final result = <SyncAssetExifV1>[]; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncAssetExifV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map<String, SyncAssetExifV1> mapFromJson(dynamic json) { + final map = <String, SyncAssetExifV1>{}; + if (json is Map && json.isNotEmpty) { + json = json.cast<String, dynamic>(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncAssetExifV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAssetExifV1-objects as value to a dart map + static Map<String, List<SyncAssetExifV1>> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = <String, List<SyncAssetExifV1>>{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast<String, dynamic>(); + for (final entry in json.entries) { + map[entry.key] = SyncAssetExifV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = <String>{ + 'assetId', + 'city', + 'country', + 'dateTimeOriginal', + 'description', + 'exifImageHeight', + 'exifImageWidth', + 'exposureTime', + 'fNumber', + 'fileSizeInByte', + 'focalLength', + 'fps', + 'iso', + 'latitude', + 'lensModel', + 'longitude', + 'make', + 'model', + 'modifyDate', + 'orientation', + 'profileDescription', + 'projectionType', + 'rating', + 'state', + 'timeZone', + }; +} + diff --git a/mobile/openapi/lib/model/sync_asset_v1.dart b/mobile/openapi/lib/model/sync_asset_v1.dart new file mode 100644 index 0000000000..6f9d7d7eaf --- /dev/null +++ b/mobile/openapi/lib/model/sync_asset_v1.dart @@ -0,0 +1,279 @@ +// +// 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 SyncAssetV1 { + /// Returns a new [SyncAssetV1] instance. + SyncAssetV1({ + required this.checksum, + required this.deletedAt, + required this.fileCreatedAt, + required this.fileModifiedAt, + required this.id, + required this.isFavorite, + required this.isVisible, + required this.localDateTime, + required this.ownerId, + required this.thumbhash, + required this.type, + }); + + String checksum; + + DateTime? deletedAt; + + DateTime? fileCreatedAt; + + DateTime? fileModifiedAt; + + String id; + + bool isFavorite; + + bool isVisible; + + DateTime? localDateTime; + + String ownerId; + + String? thumbhash; + + SyncAssetV1TypeEnum type; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAssetV1 && + other.checksum == checksum && + other.deletedAt == deletedAt && + other.fileCreatedAt == fileCreatedAt && + other.fileModifiedAt == fileModifiedAt && + other.id == id && + other.isFavorite == isFavorite && + other.isVisible == isVisible && + other.localDateTime == localDateTime && + other.ownerId == ownerId && + other.thumbhash == thumbhash && + other.type == type; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (checksum.hashCode) + + (deletedAt == null ? 0 : deletedAt!.hashCode) + + (fileCreatedAt == null ? 0 : fileCreatedAt!.hashCode) + + (fileModifiedAt == null ? 0 : fileModifiedAt!.hashCode) + + (id.hashCode) + + (isFavorite.hashCode) + + (isVisible.hashCode) + + (localDateTime == null ? 0 : localDateTime!.hashCode) + + (ownerId.hashCode) + + (thumbhash == null ? 0 : thumbhash!.hashCode) + + (type.hashCode); + + @override + String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isFavorite=$isFavorite, isVisible=$isVisible, localDateTime=$localDateTime, ownerId=$ownerId, thumbhash=$thumbhash, type=$type]'; + + Map<String, dynamic> toJson() { + final json = <String, dynamic>{}; + json[r'checksum'] = this.checksum; + if (this.deletedAt != null) { + json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String(); + } else { + // json[r'deletedAt'] = null; + } + if (this.fileCreatedAt != null) { + json[r'fileCreatedAt'] = this.fileCreatedAt!.toUtc().toIso8601String(); + } else { + // json[r'fileCreatedAt'] = null; + } + if (this.fileModifiedAt != null) { + json[r'fileModifiedAt'] = this.fileModifiedAt!.toUtc().toIso8601String(); + } else { + // json[r'fileModifiedAt'] = null; + } + json[r'id'] = this.id; + json[r'isFavorite'] = this.isFavorite; + json[r'isVisible'] = this.isVisible; + if (this.localDateTime != null) { + json[r'localDateTime'] = this.localDateTime!.toUtc().toIso8601String(); + } else { + // json[r'localDateTime'] = null; + } + json[r'ownerId'] = this.ownerId; + if (this.thumbhash != null) { + json[r'thumbhash'] = this.thumbhash; + } else { + // json[r'thumbhash'] = null; + } + json[r'type'] = this.type; + return json; + } + + /// Returns a new [SyncAssetV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAssetV1? fromJson(dynamic value) { + upgradeDto(value, "SyncAssetV1"); + if (value is Map) { + final json = value.cast<String, dynamic>(); + + return SyncAssetV1( + checksum: mapValueOfType<String>(json, r'checksum')!, + deletedAt: mapDateTime(json, r'deletedAt', r''), + fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r''), + fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r''), + id: mapValueOfType<String>(json, r'id')!, + isFavorite: mapValueOfType<bool>(json, r'isFavorite')!, + isVisible: mapValueOfType<bool>(json, r'isVisible')!, + localDateTime: mapDateTime(json, r'localDateTime', r''), + ownerId: mapValueOfType<String>(json, r'ownerId')!, + thumbhash: mapValueOfType<String>(json, r'thumbhash'), + type: SyncAssetV1TypeEnum.fromJson(json[r'type'])!, + ); + } + return null; + } + + static List<SyncAssetV1> listFromJson(dynamic json, {bool growable = false,}) { + final result = <SyncAssetV1>[]; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncAssetV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map<String, SyncAssetV1> mapFromJson(dynamic json) { + final map = <String, SyncAssetV1>{}; + if (json is Map && json.isNotEmpty) { + json = json.cast<String, dynamic>(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncAssetV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAssetV1-objects as value to a dart map + static Map<String, List<SyncAssetV1>> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = <String, List<SyncAssetV1>>{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast<String, dynamic>(); + for (final entry in json.entries) { + map[entry.key] = SyncAssetV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = <String>{ + 'checksum', + 'deletedAt', + 'fileCreatedAt', + 'fileModifiedAt', + 'id', + 'isFavorite', + 'isVisible', + 'localDateTime', + 'ownerId', + 'thumbhash', + 'type', + }; +} + + +class SyncAssetV1TypeEnum { + /// Instantiate a new enum with the provided [value]. + const SyncAssetV1TypeEnum._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const IMAGE = SyncAssetV1TypeEnum._(r'IMAGE'); + static const VIDEO = SyncAssetV1TypeEnum._(r'VIDEO'); + static const AUDIO = SyncAssetV1TypeEnum._(r'AUDIO'); + static const OTHER = SyncAssetV1TypeEnum._(r'OTHER'); + + /// List of all possible values in this [enum][SyncAssetV1TypeEnum]. + static const values = <SyncAssetV1TypeEnum>[ + IMAGE, + VIDEO, + AUDIO, + OTHER, + ]; + + static SyncAssetV1TypeEnum? fromJson(dynamic value) => SyncAssetV1TypeEnumTypeTransformer().decode(value); + + static List<SyncAssetV1TypeEnum> listFromJson(dynamic json, {bool growable = false,}) { + final result = <SyncAssetV1TypeEnum>[]; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncAssetV1TypeEnum.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [SyncAssetV1TypeEnum] to String, +/// and [decode] dynamic data back to [SyncAssetV1TypeEnum]. +class SyncAssetV1TypeEnumTypeTransformer { + factory SyncAssetV1TypeEnumTypeTransformer() => _instance ??= const SyncAssetV1TypeEnumTypeTransformer._(); + + const SyncAssetV1TypeEnumTypeTransformer._(); + + String encode(SyncAssetV1TypeEnum data) => data.value; + + /// Decodes a [dynamic value][data] to a SyncAssetV1TypeEnum. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + SyncAssetV1TypeEnum? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'IMAGE': return SyncAssetV1TypeEnum.IMAGE; + case r'VIDEO': return SyncAssetV1TypeEnum.VIDEO; + case r'AUDIO': return SyncAssetV1TypeEnum.AUDIO; + case r'OTHER': return SyncAssetV1TypeEnum.OTHER; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [SyncAssetV1TypeEnumTypeTransformer] instance. + static SyncAssetV1TypeEnumTypeTransformer? _instance; +} + + diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index 5d130f7f93..5e52a10e7a 100644 --- a/mobile/openapi/lib/model/sync_entity_type.dart +++ b/mobile/openapi/lib/model/sync_entity_type.dart @@ -27,6 +27,12 @@ class SyncEntityType { static const userDeleteV1 = SyncEntityType._(r'UserDeleteV1'); static const partnerV1 = SyncEntityType._(r'PartnerV1'); static const partnerDeleteV1 = SyncEntityType._(r'PartnerDeleteV1'); + static const assetV1 = SyncEntityType._(r'AssetV1'); + static const assetDeleteV1 = SyncEntityType._(r'AssetDeleteV1'); + static const assetExifV1 = SyncEntityType._(r'AssetExifV1'); + static const partnerAssetV1 = SyncEntityType._(r'PartnerAssetV1'); + static const partnerAssetDeleteV1 = SyncEntityType._(r'PartnerAssetDeleteV1'); + static const partnerAssetExifV1 = SyncEntityType._(r'PartnerAssetExifV1'); /// List of all possible values in this [enum][SyncEntityType]. static const values = <SyncEntityType>[ @@ -34,6 +40,12 @@ class SyncEntityType { userDeleteV1, partnerV1, partnerDeleteV1, + assetV1, + assetDeleteV1, + assetExifV1, + partnerAssetV1, + partnerAssetDeleteV1, + partnerAssetExifV1, ]; static SyncEntityType? fromJson(dynamic value) => SyncEntityTypeTypeTransformer().decode(value); @@ -76,6 +88,12 @@ class SyncEntityTypeTypeTransformer { case r'UserDeleteV1': return SyncEntityType.userDeleteV1; case r'PartnerV1': return SyncEntityType.partnerV1; case r'PartnerDeleteV1': return SyncEntityType.partnerDeleteV1; + case r'AssetV1': return SyncEntityType.assetV1; + case r'AssetDeleteV1': return SyncEntityType.assetDeleteV1; + case r'AssetExifV1': return SyncEntityType.assetExifV1; + case r'PartnerAssetV1': return SyncEntityType.partnerAssetV1; + case r'PartnerAssetDeleteV1': return SyncEntityType.partnerAssetDeleteV1; + case r'PartnerAssetExifV1': return SyncEntityType.partnerAssetExifV1; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart index c35b17dea1..08f977ad57 100644 --- a/mobile/openapi/lib/model/sync_request_type.dart +++ b/mobile/openapi/lib/model/sync_request_type.dart @@ -25,11 +25,19 @@ class SyncRequestType { static const usersV1 = SyncRequestType._(r'UsersV1'); static const partnersV1 = SyncRequestType._(r'PartnersV1'); + static const assetsV1 = SyncRequestType._(r'AssetsV1'); + static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1'); + static const partnerAssetsV1 = SyncRequestType._(r'PartnerAssetsV1'); + static const partnerAssetExifsV1 = SyncRequestType._(r'PartnerAssetExifsV1'); /// List of all possible values in this [enum][SyncRequestType]. static const values = <SyncRequestType>[ usersV1, partnersV1, + assetsV1, + assetExifsV1, + partnerAssetsV1, + partnerAssetExifsV1, ]; static SyncRequestType? fromJson(dynamic value) => SyncRequestTypeTypeTransformer().decode(value); @@ -70,6 +78,10 @@ class SyncRequestTypeTypeTransformer { switch (data) { case r'UsersV1': return SyncRequestType.usersV1; case r'PartnersV1': return SyncRequestType.partnersV1; + case r'AssetsV1': return SyncRequestType.assetsV1; + case r'AssetExifsV1': return SyncRequestType.assetExifsV1; + case r'PartnerAssetsV1': return SyncRequestType.partnerAssetsV1; + case r'PartnerAssetExifsV1': return SyncRequestType.partnerAssetExifsV1; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c1921da82d..d503e565df 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -12049,12 +12049,228 @@ ], "type": "object" }, + "SyncAssetDeleteV1": { + "properties": { + "assetId": { + "type": "string" + } + }, + "required": [ + "assetId" + ], + "type": "object" + }, + "SyncAssetExifV1": { + "properties": { + "assetId": { + "type": "string" + }, + "city": { + "nullable": true, + "type": "string" + }, + "country": { + "nullable": true, + "type": "string" + }, + "dateTimeOriginal": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "description": { + "nullable": true, + "type": "string" + }, + "exifImageHeight": { + "nullable": true, + "type": "integer" + }, + "exifImageWidth": { + "nullable": true, + "type": "integer" + }, + "exposureTime": { + "nullable": true, + "type": "string" + }, + "fNumber": { + "nullable": true, + "type": "integer" + }, + "fileSizeInByte": { + "nullable": true, + "type": "integer" + }, + "focalLength": { + "nullable": true, + "type": "integer" + }, + "fps": { + "nullable": true, + "type": "integer" + }, + "iso": { + "nullable": true, + "type": "integer" + }, + "latitude": { + "nullable": true, + "type": "integer" + }, + "lensModel": { + "nullable": true, + "type": "string" + }, + "longitude": { + "nullable": true, + "type": "integer" + }, + "make": { + "nullable": true, + "type": "string" + }, + "model": { + "nullable": true, + "type": "string" + }, + "modifyDate": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "orientation": { + "nullable": true, + "type": "string" + }, + "profileDescription": { + "nullable": true, + "type": "string" + }, + "projectionType": { + "nullable": true, + "type": "string" + }, + "rating": { + "nullable": true, + "type": "integer" + }, + "state": { + "nullable": true, + "type": "string" + }, + "timeZone": { + "nullable": true, + "type": "string" + } + }, + "required": [ + "assetId", + "city", + "country", + "dateTimeOriginal", + "description", + "exifImageHeight", + "exifImageWidth", + "exposureTime", + "fNumber", + "fileSizeInByte", + "focalLength", + "fps", + "iso", + "latitude", + "lensModel", + "longitude", + "make", + "model", + "modifyDate", + "orientation", + "profileDescription", + "projectionType", + "rating", + "state", + "timeZone" + ], + "type": "object" + }, + "SyncAssetV1": { + "properties": { + "checksum": { + "type": "string" + }, + "deletedAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "fileCreatedAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "fileModifiedAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "id": { + "type": "string" + }, + "isFavorite": { + "type": "boolean" + }, + "isVisible": { + "type": "boolean" + }, + "localDateTime": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "ownerId": { + "type": "string" + }, + "thumbhash": { + "nullable": true, + "type": "string" + }, + "type": { + "enum": [ + "IMAGE", + "VIDEO", + "AUDIO", + "OTHER" + ], + "type": "string" + } + }, + "required": [ + "checksum", + "deletedAt", + "fileCreatedAt", + "fileModifiedAt", + "id", + "isFavorite", + "isVisible", + "localDateTime", + "ownerId", + "thumbhash", + "type" + ], + "type": "object" + }, "SyncEntityType": { "enum": [ "UserV1", "UserDeleteV1", "PartnerV1", - "PartnerDeleteV1" + "PartnerDeleteV1", + "AssetV1", + "AssetDeleteV1", + "AssetExifV1", + "PartnerAssetV1", + "PartnerAssetDeleteV1", + "PartnerAssetExifV1" ], "type": "string" }, @@ -12095,7 +12311,11 @@ "SyncRequestType": { "enum": [ "UsersV1", - "PartnersV1" + "PartnersV1", + "AssetsV1", + "AssetExifsV1", + "PartnerAssetsV1", + "PartnerAssetExifsV1" ], "type": "string" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index fb70a42e84..85f80eec99 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -3647,11 +3647,21 @@ export enum SyncEntityType { UserV1 = "UserV1", UserDeleteV1 = "UserDeleteV1", PartnerV1 = "PartnerV1", - PartnerDeleteV1 = "PartnerDeleteV1" + PartnerDeleteV1 = "PartnerDeleteV1", + AssetV1 = "AssetV1", + AssetDeleteV1 = "AssetDeleteV1", + AssetExifV1 = "AssetExifV1", + PartnerAssetV1 = "PartnerAssetV1", + PartnerAssetDeleteV1 = "PartnerAssetDeleteV1", + PartnerAssetExifV1 = "PartnerAssetExifV1" } export enum SyncRequestType { UsersV1 = "UsersV1", - PartnersV1 = "PartnersV1" + PartnersV1 = "PartnersV1", + AssetsV1 = "AssetsV1", + AssetExifsV1 = "AssetExifsV1", + PartnerAssetsV1 = "PartnerAssetsV1", + PartnerAssetExifsV1 = "PartnerAssetExifsV1" } export enum TranscodeHWAccel { Nvenc = "nvenc", diff --git a/server/src/database.ts b/server/src/database.ts index 08ed7240db..e899200579 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -117,4 +117,46 @@ export const columns = { userDto: ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'], tagDto: ['id', 'value', 'createdAt', 'updatedAt', 'color', 'parentId'], apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'], + syncAsset: [ + 'id', + 'ownerId', + 'thumbhash', + 'checksum', + 'fileCreatedAt', + 'fileModifiedAt', + 'localDateTime', + 'type', + 'deletedAt', + 'isFavorite', + 'isVisible', + 'updateId', + ], + syncAssetExif: [ + 'exif.assetId', + 'exif.description', + 'exif.exifImageWidth', + 'exif.exifImageHeight', + 'exif.fileSizeInByte', + 'exif.orientation', + 'exif.dateTimeOriginal', + 'exif.modifyDate', + 'exif.timeZone', + 'exif.latitude', + 'exif.longitude', + 'exif.projectionType', + 'exif.city', + 'exif.state', + 'exif.country', + 'exif.make', + 'exif.model', + 'exif.lensModel', + 'exif.fNumber', + 'exif.focalLength', + 'exif.iso', + 'exif.exposureTime', + 'exif.profileDescription', + 'exif.rating', + 'exif.fps', + 'exif.updateId', + ], } as const; diff --git a/server/src/db.d.ts b/server/src/db.d.ts index a27faac9b6..85aade2c9b 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -119,6 +119,13 @@ export interface AssetJobStatus { thumbnailAt: Timestamp | null; } +export interface AssetsAudit { + deletedAt: Generated<Timestamp>; + id: Generated<string>; + assetId: string; + ownerId: string; +} + export interface Assets { checksum: Buffer; createdAt: Generated<Timestamp>; @@ -168,6 +175,8 @@ export interface Audit { export interface Exif { assetId: string; + updateId: Generated<string>; + updatedAt: Generated<Timestamp>; autoStackId: string | null; bitsPerSample: number | null; city: string | null; @@ -459,6 +468,7 @@ export interface DB { asset_job_status: AssetJobStatus; asset_stack: AssetStack; assets: Assets; + assets_audit: AssetsAudit; audit: Audit; exif: Exif; face_search: FaceSearch; diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 9a963e1e98..b12a4378fe 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -102,7 +102,7 @@ const mapStack = (entity: AssetEntity) => { }; // if an asset is jsonified in the DB before being returned, its buffer fields will be hex-encoded strings -const hexOrBufferToBase64 = (encoded: string | Buffer) => { +export const hexOrBufferToBase64 = (encoded: string | Buffer) => { if (typeof encoded === 'string') { return Buffer.from(encoded.slice(2), 'hex').toString('base64'); } diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index d191c82bb3..a035f8ecb9 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsInt, IsPositive, IsString } from 'class-validator'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; -import { SyncEntityType, SyncRequestType } from 'src/enum'; +import { AssetType, SyncEntityType, SyncRequestType } from 'src/enum'; import { Optional, ValidateDate, ValidateUUID } from 'src/validation'; export class AssetFullSyncDto { @@ -56,11 +56,73 @@ export class SyncPartnerDeleteV1 { sharedWithId!: string; } +export class SyncAssetV1 { + id!: string; + ownerId!: string; + thumbhash!: string | null; + checksum!: string; + fileCreatedAt!: Date | null; + fileModifiedAt!: Date | null; + localDateTime!: Date | null; + type!: AssetType; + deletedAt!: Date | null; + isFavorite!: boolean; + isVisible!: boolean; +} + +export class SyncAssetDeleteV1 { + assetId!: string; +} + +export class SyncAssetExifV1 { + assetId!: string; + description!: string | null; + @ApiProperty({ type: 'integer' }) + exifImageWidth!: number | null; + @ApiProperty({ type: 'integer' }) + exifImageHeight!: number | null; + @ApiProperty({ type: 'integer' }) + fileSizeInByte!: number | null; + orientation!: string | null; + dateTimeOriginal!: Date | null; + modifyDate!: Date | null; + timeZone!: string | null; + @ApiProperty({ type: 'integer' }) + latitude!: number | null; + @ApiProperty({ type: 'integer' }) + longitude!: number | null; + projectionType!: string | null; + city!: string | null; + state!: string | null; + country!: string | null; + make!: string | null; + model!: string | null; + lensModel!: string | null; + @ApiProperty({ type: 'integer' }) + fNumber!: number | null; + @ApiProperty({ type: 'integer' }) + focalLength!: number | null; + @ApiProperty({ type: 'integer' }) + iso!: number | null; + exposureTime!: string | null; + profileDescription!: string | null; + @ApiProperty({ type: 'integer' }) + rating!: number | null; + @ApiProperty({ type: 'integer' }) + fps!: number | null; +} + export type SyncItem = { [SyncEntityType.UserV1]: SyncUserV1; [SyncEntityType.UserDeleteV1]: SyncUserDeleteV1; [SyncEntityType.PartnerV1]: SyncPartnerV1; [SyncEntityType.PartnerDeleteV1]: SyncPartnerDeleteV1; + [SyncEntityType.AssetV1]: SyncAssetV1; + [SyncEntityType.AssetDeleteV1]: SyncAssetDeleteV1; + [SyncEntityType.AssetExifV1]: SyncAssetExifV1; + [SyncEntityType.PartnerAssetV1]: SyncAssetV1; + [SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1; + [SyncEntityType.PartnerAssetExifV1]: SyncAssetExifV1; }; const responseDtos = [ @@ -69,6 +131,9 @@ const responseDtos = [ SyncUserDeleteV1, SyncPartnerV1, SyncPartnerDeleteV1, + SyncAssetV1, + SyncAssetDeleteV1, + SyncAssetExifV1, ]; export const extraSyncModels = responseDtos; diff --git a/server/src/entities/asset-audit.entity.ts b/server/src/entities/asset-audit.entity.ts new file mode 100644 index 0000000000..0172d15ce6 --- /dev/null +++ b/server/src/entities/asset-audit.entity.ts @@ -0,0 +1,19 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm'; + +@Entity('assets_audit') +export class AssetAuditEntity { + @PrimaryColumn({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + id!: string; + + @Index('IDX_assets_audit_asset_id') + @Column({ type: 'uuid' }) + assetId!: string; + + @Index('IDX_assets_audit_owner_id') + @Column({ type: 'uuid' }) + ownerId!: string; + + @Index('IDX_assets_audit_deleted_at') + @CreateDateColumn({ type: 'timestamptz', default: () => 'clock_timestamp()' }) + deletedAt!: Date; +} diff --git a/server/src/entities/exif.entity.ts b/server/src/entities/exif.entity.ts index c9c29d732a..5b402109a5 100644 --- a/server/src/entities/exif.entity.ts +++ b/server/src/entities/exif.entity.ts @@ -1,5 +1,5 @@ import { AssetEntity } from 'src/entities/asset.entity'; -import { Index, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm'; +import { Index, JoinColumn, OneToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm'; import { Column } from 'typeorm/decorator/columns/Column.js'; import { Entity } from 'typeorm/decorator/entity/Entity.js'; @@ -12,6 +12,13 @@ export class ExifEntity { @PrimaryColumn() assetId!: string; + @UpdateDateColumn({ type: 'timestamptz', default: () => 'clock_timestamp()' }) + updatedAt?: Date; + + @Index('IDX_asset_exif_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + /* General info */ @Column({ type: 'text', default: '' }) description!: string; // or caption diff --git a/server/src/enum.ts b/server/src/enum.ts index 47154e3258..55e435a70b 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -549,11 +549,24 @@ export enum DatabaseLock { export enum SyncRequestType { UsersV1 = 'UsersV1', PartnersV1 = 'PartnersV1', + AssetsV1 = 'AssetsV1', + AssetExifsV1 = 'AssetExifsV1', + PartnerAssetsV1 = 'PartnerAssetsV1', + PartnerAssetExifsV1 = 'PartnerAssetExifsV1', } export enum SyncEntityType { UserV1 = 'UserV1', UserDeleteV1 = 'UserDeleteV1', + PartnerV1 = 'PartnerV1', PartnerDeleteV1 = 'PartnerDeleteV1', + + AssetV1 = 'AssetV1', + AssetDeleteV1 = 'AssetDeleteV1', + AssetExifV1 = 'AssetExifV1', + + PartnerAssetV1 = 'PartnerAssetV1', + PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1', + PartnerAssetExifV1 = 'PartnerAssetExifV1', } diff --git a/server/src/migrations/1741191762113-AssetAuditTable.ts b/server/src/migrations/1741191762113-AssetAuditTable.ts new file mode 100644 index 0000000000..c02408c384 --- /dev/null +++ b/server/src/migrations/1741191762113-AssetAuditTable.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AssetAuditTable1741191762113 implements MigrationInterface { + name = 'AssetAuditTable1741191762113' + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`CREATE TABLE "assets_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "assetId" uuid NOT NULL, "ownerId" uuid NOT NULL, "deletedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT clock_timestamp(), CONSTRAINT "PK_99bd5c015f81a641927a32b4212" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_assets_audit_asset_id" ON "assets_audit" ("assetId") `); + await queryRunner.query(`CREATE INDEX "IDX_assets_audit_owner_id" ON "assets_audit" ("ownerId") `); + await queryRunner.query(`CREATE INDEX "IDX_assets_audit_deleted_at" ON "assets_audit" ("deletedAt") `); + await queryRunner.query(`CREATE OR REPLACE FUNCTION assets_delete_audit() RETURNS TRIGGER AS + $$ + BEGIN + INSERT INTO assets_audit ("assetId", "ownerId") + SELECT "id", "ownerId" + FROM OLD; + RETURN NULL; + END; + $$ LANGUAGE plpgsql` + ); + await queryRunner.query(`CREATE OR REPLACE TRIGGER assets_delete_audit + AFTER DELETE ON assets + REFERENCING OLD TABLE AS OLD + FOR EACH STATEMENT + EXECUTE FUNCTION assets_delete_audit(); + `); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`DROP TRIGGER assets_delete_audit`); + await queryRunner.query(`DROP FUNCTION assets_delete_audit`); + await queryRunner.query(`DROP INDEX "IDX_assets_audit_deleted_at"`); + await queryRunner.query(`DROP INDEX "IDX_assets_audit_owner_id"`); + await queryRunner.query(`DROP INDEX "IDX_assets_audit_asset_id"`); + await queryRunner.query(`DROP TABLE "assets_audit"`); + } +} diff --git a/server/src/migrations/1741280328985-FixAssetAndUserCascadeConditions.ts b/server/src/migrations/1741280328985-FixAssetAndUserCascadeConditions.ts new file mode 100644 index 0000000000..20215c1b59 --- /dev/null +++ b/server/src/migrations/1741280328985-FixAssetAndUserCascadeConditions.ts @@ -0,0 +1,50 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class FixAssetAndUserCascadeConditions1741280328985 implements MigrationInterface { + name = 'FixAssetAndUserCascadeConditions1741280328985'; + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(` + CREATE OR REPLACE TRIGGER assets_delete_audit + AFTER DELETE ON assets + REFERENCING OLD TABLE AS OLD + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION assets_delete_audit();`); + await queryRunner.query(` + CREATE OR REPLACE TRIGGER users_delete_audit + AFTER DELETE ON users + REFERENCING OLD TABLE AS OLD + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION users_delete_audit();`); + await queryRunner.query(` + CREATE OR REPLACE TRIGGER partners_delete_audit + AFTER DELETE ON partners + REFERENCING OLD TABLE AS OLD + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION partners_delete_audit();`); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(` + CREATE OR REPLACE TRIGGER assets_delete_audit + AFTER DELETE ON assets + REFERENCING OLD TABLE AS OLD + FOR EACH STATEMENT + EXECUTE FUNCTION assets_delete_audit();`); + await queryRunner.query(` + CREATE OR REPLACE TRIGGER users_delete_audit + AFTER DELETE ON users + REFERENCING OLD TABLE AS OLD + FOR EACH STATEMENT + EXECUTE FUNCTION users_delete_audit();`); + await queryRunner.query(` + CREATE OR REPLACE TRIGGER partners_delete_audit + AFTER DELETE ON partners + REFERENCING OLD TABLE AS OLD + FOR EACH STATEMENT + EXECUTE FUNCTION partners_delete_audit();`); + } +} diff --git a/server/src/migrations/1741281344519-AddExifUpdateId.ts b/server/src/migrations/1741281344519-AddExifUpdateId.ts new file mode 100644 index 0000000000..eb32836a1d --- /dev/null +++ b/server/src/migrations/1741281344519-AddExifUpdateId.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddExifUpdateId1741281344519 implements MigrationInterface { + name = 'AddExifUpdateId1741281344519'; + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `ALTER TABLE "exif" ADD "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT clock_timestamp()`, + ); + await queryRunner.query(`ALTER TABLE "exif" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7()`); + await queryRunner.query(`CREATE INDEX "IDX_asset_exif_update_id" ON "exif" ("updateId") `); + await queryRunner.query(` + create trigger asset_exif_updated_at + before update on exif + for each row execute procedure updated_at() + `); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`DROP INDEX "public"."IDX_asset_exif_update_id"`); + await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "updatedAt"`); + await queryRunner.query(`DROP TRIGGER asset_exif_updated_at on exif`); + } +} diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index c0b778bb50..1fd8f55f31 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -420,8 +420,8 @@ from ) as "stacked_assets" on "asset_stack"."id" is not null where "assets"."ownerId" = $1::uuid - and "isVisible" = $2 - and "updatedAt" <= $3 + and "assets"."isVisible" = $2 + and "assets"."updatedAt" <= $3 and "assets"."id" > $4 order by "assets"."id" @@ -450,7 +450,7 @@ from ) as "stacked_assets" on "asset_stack"."id" is not null where "assets"."ownerId" = any ($1::uuid[]) - and "isVisible" = $2 - and "updatedAt" > $3 + and "assets"."isVisible" = $2 + and "assets"."updatedAt" > $3 limit $4 diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 5f020ba836..1e6429b32d 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -551,7 +551,7 @@ export class AssetRepository { return this.getById(asset.id, { exifInfo: true, faces: { person: true } }) as Promise<AssetEntity>; } - async remove(asset: AssetEntity): Promise<void> { + async remove(asset: { id: string }): Promise<void> { await this.db.deleteFrom('assets').where('id', '=', asUuid(asset.id)).execute(); } @@ -968,8 +968,8 @@ export class AssetRepository { ) .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')) .where('assets.ownerId', '=', asUuid(ownerId)) - .where('isVisible', '=', true) - .where('updatedAt', '<=', updatedUntil) + .where('assets.isVisible', '=', true) + .where('assets.updatedAt', '<=', updatedUntil) .$if(!!lastId, (qb) => qb.where('assets.id', '>', lastId!)) .orderBy('assets.id') .limit(limit) @@ -996,8 +996,8 @@ export class AssetRepository { ) .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')) .where('assets.ownerId', '=', anyUuid(options.userIds)) - .where('isVisible', '=', true) - .where('updatedAt', '>', options.updatedAfter) + .where('assets.isVisible', '=', true) + .where('assets.updatedAt', '>', options.updatedAfter) .limit(options.limit) .execute() as any as Promise<AssetEntity[]>; } diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index f2c5a1fc16..bc3205c0a3 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -1,10 +1,14 @@ import { Injectable } from '@nestjs/common'; -import { Insertable, Kysely, sql } from 'kysely'; +import { Insertable, Kysely, SelectQueryBuilder, sql } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; import { DB, SessionSyncCheckpoints } from 'src/db'; import { SyncEntityType } from 'src/enum'; import { SyncAck } from 'src/types'; +type auditTables = 'users_audit' | 'partners_audit' | 'assets_audit'; +type upsertTables = 'users' | 'partners' | 'assets' | 'exif'; + @Injectable() export class SyncRepository { constructor(@InjectKysely() private db: Kysely<DB>) {} @@ -41,9 +45,7 @@ export class SyncRepository { return this.db .selectFrom('users') .select(['id', 'name', 'email', 'deletedAt', 'updateId']) - .$if(!!ack, (qb) => qb.where('updateId', '>', ack!.updateId)) - .where('updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'")) - .orderBy(['updateId asc']) + .$call((qb) => this.upsertTableFilters(qb, ack)) .stream(); } @@ -51,9 +53,7 @@ export class SyncRepository { return this.db .selectFrom('users_audit') .select(['id', 'userId']) - .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId)) - .where('deletedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'")) - .orderBy(['id asc']) + .$call((qb) => this.auditTableFilters(qb, ack)) .stream(); } @@ -61,10 +61,8 @@ export class SyncRepository { return this.db .selectFrom('partners') .select(['sharedById', 'sharedWithId', 'inTimeline', 'updateId']) - .$if(!!ack, (qb) => qb.where('updateId', '>', ack!.updateId)) .where((eb) => eb.or([eb('sharedById', '=', userId), eb('sharedWithId', '=', userId)])) - .where('updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'")) - .orderBy(['updateId asc']) + .$call((qb) => this.upsertTableFilters(qb, ack)) .stream(); } @@ -72,10 +70,93 @@ export class SyncRepository { return this.db .selectFrom('partners_audit') .select(['id', 'sharedById', 'sharedWithId']) - .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId)) .where((eb) => eb.or([eb('sharedById', '=', userId), eb('sharedWithId', '=', userId)])) - .where('deletedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'")) - .orderBy(['id asc']) + .$call((qb) => this.auditTableFilters(qb, ack)) .stream(); } + + getAssetUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('assets') + .select(columns.syncAsset) + .where('ownerId', '=', userId) + .$call((qb) => this.upsertTableFilters(qb, ack)) + .stream(); + } + + getPartnerAssetsUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('assets') + .select(columns.syncAsset) + .where('ownerId', 'in', (eb) => + eb.selectFrom('partners').select(['sharedById']).where('sharedWithId', '=', userId), + ) + .$call((qb) => this.upsertTableFilters(qb, ack)) + .stream(); + } + + getAssetDeletes(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('assets_audit') + .select(['id', 'assetId']) + .where('ownerId', '=', userId) + .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId)) + .$call((qb) => this.auditTableFilters(qb, ack)) + .stream(); + } + + getPartnerAssetDeletes(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('assets_audit') + .select(['id', 'assetId']) + .where('ownerId', 'in', (eb) => + eb.selectFrom('partners').select(['sharedById']).where('sharedWithId', '=', userId), + ) + .$call((qb) => this.auditTableFilters(qb, ack)) + .stream(); + } + + getAssetExifsUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('exif') + .select(columns.syncAssetExif) + .where('assetId', 'in', (eb) => eb.selectFrom('assets').select('id').where('ownerId', '=', userId)) + .$call((qb) => this.upsertTableFilters(qb, ack)) + .stream(); + } + + getPartnerAssetExifsUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('exif') + .select(columns.syncAssetExif) + .where('assetId', 'in', (eb) => + eb + .selectFrom('assets') + .select('id') + .where('ownerId', 'in', (eb) => + eb.selectFrom('partners').select(['sharedById']).where('sharedWithId', '=', userId), + ), + ) + .$call((qb) => this.upsertTableFilters(qb, ack)) + .stream(); + } + + private auditTableFilters<T extends keyof Pick<DB, auditTables>, D>(qb: SelectQueryBuilder<DB, T, D>, ack?: SyncAck) { + const builder = qb as SelectQueryBuilder<DB, auditTables, D>; + return builder + .where('deletedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'")) + .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId)) + .orderBy(['id asc']) as SelectQueryBuilder<DB, T, D>; + } + + private upsertTableFilters<T extends keyof Pick<DB, upsertTables>, D>( + qb: SelectQueryBuilder<DB, T, D>, + ack?: SyncAck, + ) { + const builder = qb as SelectQueryBuilder<DB, upsertTables, D>; + return builder + .where('updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'")) + .$if(!!ack, (qb) => qb.where('updateId', '>', ack!.updateId)) + .orderBy(['updateId asc']) as SelectQueryBuilder<DB, T, D>; + } } diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 45b1b7ff84..c88348b39e 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -4,7 +4,7 @@ import { DateTime } from 'luxon'; import { Writable } from 'node:stream'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; import { SessionSyncCheckpoints } from 'src/db'; -import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; +import { AssetResponseDto, hexOrBufferToBase64, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetDeltaSyncDto, @@ -22,10 +22,14 @@ import { setIsEqual } from 'src/utils/set'; import { fromAck, serialize } from 'src/utils/sync'; const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] }; -const SYNC_TYPES_ORDER = [ +export const SYNC_TYPES_ORDER = [ // SyncRequestType.UsersV1, SyncRequestType.PartnersV1, + SyncRequestType.AssetsV1, + SyncRequestType.AssetExifsV1, + SyncRequestType.PartnerAssetsV1, + SyncRequestType.PartnerAssetExifsV1, ]; const throwSessionRequired = () => { @@ -49,17 +53,22 @@ export class SyncService extends BaseService { return throwSessionRequired(); } - const checkpoints: Insertable<SessionSyncCheckpoints>[] = []; + const checkpoints: Record<string, Insertable<SessionSyncCheckpoints>> = {}; for (const ack of dto.acks) { const { type } = fromAck(ack); // TODO proper ack validation via class validator if (!Object.values(SyncEntityType).includes(type)) { throw new BadRequestException(`Invalid ack type: ${type}`); } - checkpoints.push({ sessionId, type, ack }); + + if (checkpoints[type]) { + throw new BadRequestException('Only one ack per type is allowed'); + } + + checkpoints[type] = { sessionId, type, ack }; } - await this.syncRepository.upsertCheckpoints(checkpoints); + await this.syncRepository.upsertCheckpoints(Object.values(checkpoints)); } async deleteAcks(auth: AuthDto, dto: SyncAckDeleteDto) { @@ -115,6 +124,87 @@ export class SyncService extends BaseService { break; } + case SyncRequestType.AssetsV1: { + const deletes = this.syncRepository.getAssetDeletes( + auth.user.id, + checkpointMap[SyncEntityType.AssetDeleteV1], + ); + for await (const { id, ...data } of deletes) { + response.write(serialize({ type: SyncEntityType.AssetDeleteV1, updateId: id, data })); + } + + const upserts = this.syncRepository.getAssetUpserts(auth.user.id, checkpointMap[SyncEntityType.AssetV1]); + for await (const { updateId, checksum, thumbhash, ...data } of upserts) { + response.write( + serialize({ + type: SyncEntityType.AssetV1, + updateId, + data: { + ...data, + checksum: hexOrBufferToBase64(checksum), + thumbhash: thumbhash ? hexOrBufferToBase64(thumbhash) : null, + }, + }), + ); + } + + break; + } + + case SyncRequestType.PartnerAssetsV1: { + const deletes = this.syncRepository.getPartnerAssetDeletes( + auth.user.id, + checkpointMap[SyncEntityType.PartnerAssetDeleteV1], + ); + for await (const { id, ...data } of deletes) { + response.write(serialize({ type: SyncEntityType.PartnerAssetDeleteV1, updateId: id, data })); + } + + const upserts = this.syncRepository.getPartnerAssetsUpserts( + auth.user.id, + checkpointMap[SyncEntityType.PartnerAssetV1], + ); + for await (const { updateId, checksum, thumbhash, ...data } of upserts) { + response.write( + serialize({ + type: SyncEntityType.PartnerAssetV1, + updateId, + data: { + ...data, + checksum: hexOrBufferToBase64(checksum), + thumbhash: thumbhash ? hexOrBufferToBase64(thumbhash) : null, + }, + }), + ); + } + + break; + } + + case SyncRequestType.AssetExifsV1: { + const upserts = this.syncRepository.getAssetExifsUpserts( + auth.user.id, + checkpointMap[SyncEntityType.AssetExifV1], + ); + for await (const { updateId, ...data } of upserts) { + response.write(serialize({ type: SyncEntityType.AssetExifV1, updateId, data })); + } + + break; + } + + case SyncRequestType.PartnerAssetExifsV1: { + const upserts = this.syncRepository.getPartnerAssetExifsUpserts( + auth.user.id, + checkpointMap[SyncEntityType.PartnerAssetExifV1], + ); + for await (const { updateId, ...data } of upserts) { + response.write(serialize({ type: SyncEntityType.PartnerAssetExifV1, updateId, data })); + } + + break; + } + default: { this.logger.warn(`Unsupported sync type: ${type}`); break; diff --git a/server/test/factory.ts b/server/test/factory.ts index 9cb2c818ab..66c2fbb50c 100644 --- a/server/test/factory.ts +++ b/server/test/factory.ts @@ -55,7 +55,7 @@ class CustomWritable extends Writable { } } -type Asset = Insertable<Assets>; +type Asset = Partial<Insertable<Assets>>; type User = Partial<Insertable<Users>>; type Session = Omit<Insertable<Sessions>, 'token'> & { token?: string }; type Partner = Insertable<Partners>; @@ -160,10 +160,6 @@ export class TestFactory { } async create() { - for (const asset of this.assets) { - await this.context.createAsset(asset); - } - for (const user of this.users) { await this.context.createUser(user); } @@ -176,6 +172,10 @@ export class TestFactory { await this.context.createSession(session); } + for (const asset of this.assets) { + await this.context.createAsset(asset); + } + return this.context; } } @@ -212,7 +212,7 @@ export class TestContext { versionHistory: VersionHistoryRepository; view: ViewRepository; - private constructor(private db: Kysely<DB>) { + private constructor(public db: Kysely<DB>) { const logger = newLoggingRepositoryMock() as unknown as LoggingRepository; const config = new ConfigRepository(); diff --git a/server/test/medium/specs/audit.database.spec.ts b/server/test/medium/specs/audit.database.spec.ts new file mode 100644 index 0000000000..5332193e4c --- /dev/null +++ b/server/test/medium/specs/audit.database.spec.ts @@ -0,0 +1,74 @@ +import { TestContext, TestFactory } from 'test/factory'; +import { getKyselyDB } from 'test/utils'; + +describe('audit', () => { + let context: TestContext; + + beforeAll(async () => { + const db = await getKyselyDB(); + context = await TestContext.from(db).create(); + }); + + describe('partners_audit', () => { + it('should not cascade user deletes to partners_audit', async () => { + const user1 = TestFactory.user(); + const user2 = TestFactory.user(); + + await context + .getFactory() + .withUser(user1) + .withUser(user2) + .withPartner({ sharedById: user1.id, sharedWithId: user2.id }) + .create(); + + await context.user.delete(user1, true); + + await expect( + context.db.selectFrom('partners_audit').select(['id']).where('sharedById', '=', user1.id).execute(), + ).resolves.toHaveLength(0); + }); + }); + + describe('assets_audit', () => { + it('should not cascade user deletes to assets_audit', async () => { + const user = TestFactory.user(); + const asset = TestFactory.asset({ ownerId: user.id }); + + await context.getFactory().withUser(user).withAsset(asset).create(); + + await context.user.delete(user, true); + + await expect( + context.db.selectFrom('assets_audit').select(['id']).where('assetId', '=', asset.id).execute(), + ).resolves.toHaveLength(0); + }); + }); + + describe('exif', () => { + it('should automatically set updatedAt and updateId when the row is updated', async () => { + const user = TestFactory.user(); + const asset = TestFactory.asset({ ownerId: user.id }); + const exif = { assetId: asset.id, make: 'Canon' }; + + await context.getFactory().withUser(user).withAsset(asset).create(); + await context.asset.upsertExif(exif); + + const before = await context.db + .selectFrom('exif') + .select(['updatedAt', 'updateId']) + .where('assetId', '=', asset.id) + .executeTakeFirstOrThrow(); + + await context.asset.upsertExif({ assetId: asset.id, make: 'Canon 2' }); + + const after = await context.db + .selectFrom('exif') + .select(['updatedAt', 'updateId']) + .where('assetId', '=', asset.id) + .executeTakeFirstOrThrow(); + + expect(before.updateId).not.toEqual(after.updateId); + expect(before.updatedAt).not.toEqual(after.updatedAt); + }); + }); +}); diff --git a/server/test/medium/specs/sync.service.spec.ts b/server/test/medium/specs/sync.service.spec.ts index b33b010258..574ddde93c 100644 --- a/server/test/medium/specs/sync.service.spec.ts +++ b/server/test/medium/specs/sync.service.spec.ts @@ -1,6 +1,6 @@ import { AuthDto } from 'src/dtos/auth.dto'; -import { SyncRequestType } from 'src/enum'; -import { SyncService } from 'src/services/sync.service'; +import { SyncEntityType, SyncRequestType } from 'src/enum'; +import { SYNC_TYPES_ORDER, SyncService } from 'src/services/sync.service'; import { TestContext, TestFactory } from 'test/factory'; import { getKyselyDB, newTestService } from 'test/utils'; @@ -33,7 +33,15 @@ const setup = async () => { }; describe(SyncService.name, () => { - describe.concurrent('users', () => { + it('should have all the types in the ordering variable', () => { + for (const key in SyncRequestType) { + expect(SYNC_TYPES_ORDER).includes(key); + } + + expect(SYNC_TYPES_ORDER.length).toBe(Object.keys(SyncRequestType).length); + }); + + describe.concurrent(SyncEntityType.UserV1, () => { it('should detect and sync the first user', async () => { const { context, auth, sut, testSync } = await setup(); @@ -189,7 +197,7 @@ describe(SyncService.name, () => { }); }); - describe.concurrent('partners', () => { + describe.concurrent(SyncEntityType.PartnerV1, () => { it('should detect and sync the first partner', async () => { const { auth, context, sut, testSync } = await setup(); @@ -349,7 +357,7 @@ describe(SyncService.name, () => { ); }); - it('should not sync a partner for an unrelated user', async () => { + it('should not sync a partner or partner delete for an unrelated user', async () => { const { auth, context, testSync } = await setup(); const user2 = await context.createUser(); @@ -357,9 +365,436 @@ describe(SyncService.name, () => { await context.createPartner({ sharedById: user2.id, sharedWithId: user3.id }); - const response = await testSync(auth, [SyncRequestType.PartnersV1]); + expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0); + + await context.partner.remove({ sharedById: user2.id, sharedWithId: user3.id }); + + expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0); + }); + + it('should not sync a partner delete after a user is deleted', async () => { + const { auth, context, testSync } = await setup(); + + const user2 = await context.createUser(); + await context.createPartner({ sharedById: user2.id, sharedWithId: auth.user.id }); + await context.user.delete({ id: user2.id }, true); + + expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0); + }); + }); + + describe.concurrent(SyncEntityType.AssetV1, () => { + it('should detect and sync the first asset', async () => { + const { auth, context, sut, testSync } = await setup(); + + const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA='; + const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA='; + const date = new Date().toISOString(); + + const asset = TestFactory.asset({ + ownerId: auth.user.id, + checksum: Buffer.from(checksum, 'base64'), + thumbhash: Buffer.from(thumbhash, 'base64'), + fileCreatedAt: date, + fileModifiedAt: date, + deletedAt: null, + }); + await context.createAsset(asset); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]); + + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + id: asset.id, + ownerId: asset.ownerId, + thumbhash, + checksum, + deletedAt: null, + fileCreatedAt: date, + fileModifiedAt: date, + isFavorite: false, + isVisible: true, + localDateTime: null, + type: asset.type, + }, + type: 'AssetV1', + }, + ]), + ); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should detect and sync a deleted asset', async () => { + const { auth, context, sut, testSync } = await setup(); + + const asset = TestFactory.asset({ ownerId: auth.user.id }); + await context.createAsset(asset); + await context.asset.remove(asset); + + const response = await testSync(auth, [SyncRequestType.AssetsV1]); + + expect(response).toHaveLength(1); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + assetId: asset.id, + }, + type: 'AssetDeleteV1', + }, + ]), + ); + + const acks = response.map(({ ack }) => ack); + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should not sync an asset or asset delete for an unrelated user', async () => { + const { auth, context, testSync } = await setup(); + + const user2 = await context.createUser(); + const session = TestFactory.session({ userId: user2.id }); + const auth2 = TestFactory.auth({ session, user: user2 }); + + const asset = TestFactory.asset({ ownerId: user2.id }); + await context.createAsset(asset); + + expect(await testSync(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1); + expect(await testSync(auth, [SyncRequestType.AssetsV1])).toHaveLength(0); + + await context.asset.remove(asset); + expect(await testSync(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1); + expect(await testSync(auth, [SyncRequestType.AssetsV1])).toHaveLength(0); + }); + }); + + describe.concurrent(SyncRequestType.PartnerAssetsV1, () => { + it('should detect and sync the first partner asset', async () => { + const { auth, context, sut, testSync } = await setup(); + + const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA='; + const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA='; + const date = new Date().toISOString(); + + const user2 = await context.createUser(); + + const asset = TestFactory.asset({ + ownerId: user2.id, + checksum: Buffer.from(checksum, 'base64'), + thumbhash: Buffer.from(thumbhash, 'base64'), + fileCreatedAt: date, + fileModifiedAt: date, + deletedAt: null, + }); + await context.createAsset(asset); + await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id }); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); + + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + id: asset.id, + ownerId: asset.ownerId, + thumbhash, + checksum, + deletedAt: null, + fileCreatedAt: date, + fileModifiedAt: date, + isFavorite: false, + isVisible: true, + localDateTime: null, + type: asset.type, + }, + type: SyncEntityType.PartnerAssetV1, + }, + ]), + ); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should detect and sync a deleted partner asset', async () => { + const { auth, context, sut, testSync } = await setup(); + + const user2 = await context.createUser(); + const asset = TestFactory.asset({ ownerId: user2.id }); + await context.createAsset(asset); + await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id }); + await context.asset.remove(asset); + + const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); + + expect(response).toHaveLength(1); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + assetId: asset.id, + }, + type: SyncEntityType.PartnerAssetDeleteV1, + }, + ]), + ); + + const acks = response.map(({ ack }) => ack); + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should not sync a deleted partner asset due to a user delete', async () => { + const { auth, context, testSync } = await setup(); + + const user2 = await context.createUser(); + await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id }); + await context.createAsset({ ownerId: user2.id }); + await context.user.delete({ id: user2.id }, true); + + const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); expect(response).toHaveLength(0); }); + + it('should not sync a deleted partner asset due to a partner delete (unshare)', async () => { + const { auth, context, testSync } = await setup(); + + const user2 = await context.createUser(); + await context.createAsset({ ownerId: user2.id }); + const partner = { sharedById: user2.id, sharedWithId: auth.user.id }; + await context.partner.create(partner); + + await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(1); + + await context.partner.remove(partner); + + await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); + }); + + it('should not sync an asset or asset delete for own user', async () => { + const { auth, context, testSync } = await setup(); + + const user2 = await context.createUser(); + const asset = await context.createAsset({ ownerId: auth.user.id }); + const partner = { sharedById: user2.id, sharedWithId: auth.user.id }; + await context.partner.create(partner); + + await expect(testSync(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); + await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); + + await context.asset.remove(asset); + + await expect(testSync(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); + await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); + }); + + it('should not sync an asset or asset delete for unrelated user', async () => { + const { auth, context, testSync } = await setup(); + + const user2 = await context.createUser(); + const session = TestFactory.session({ userId: user2.id }); + const auth2 = TestFactory.auth({ session, user: user2 }); + const asset = await context.createAsset({ ownerId: user2.id }); + + await expect(testSync(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); + await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); + + await context.asset.remove(asset); + + await expect(testSync(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); + await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); + }); + }); + + describe.concurrent(SyncRequestType.AssetExifsV1, () => { + it('should detect and sync the first asset exif', async () => { + const { auth, context, sut, testSync } = await setup(); + + const asset = TestFactory.asset({ ownerId: auth.user.id }); + const exif = { assetId: asset.id, make: 'Canon' }; + + await context.createAsset(asset); + await context.asset.upsertExif(exif); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.AssetExifsV1]); + + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + assetId: asset.id, + city: null, + country: null, + dateTimeOriginal: null, + description: '', + exifImageHeight: null, + exifImageWidth: null, + exposureTime: null, + fNumber: null, + fileSizeInByte: null, + focalLength: null, + fps: null, + iso: null, + latitude: null, + lensModel: null, + longitude: null, + make: 'Canon', + model: null, + modifyDate: null, + orientation: null, + profileDescription: null, + projectionType: null, + rating: null, + state: null, + timeZone: null, + }, + type: SyncEntityType.AssetExifV1, + }, + ]), + ); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetExifsV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should only sync asset exif for own user', async () => { + const { auth, context, testSync } = await setup(); + + const user2 = await context.createUser(); + const session = TestFactory.session({ userId: user2.id }); + const auth2 = TestFactory.auth({ session, user: user2 }); + + await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id }); + const asset = TestFactory.asset({ ownerId: user2.id }); + const exif = { assetId: asset.id, make: 'Canon' }; + + await context.createAsset(asset); + await context.asset.upsertExif(exif); + + await expect(testSync(auth2, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1); + await expect(testSync(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(0); + }); + }); + + describe.concurrent(SyncRequestType.PartnerAssetExifsV1, () => { + it('should detect and sync the first partner asset exif', async () => { + const { auth, context, sut, testSync } = await setup(); + + const user2 = await context.createUser(); + await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id }); + const asset = TestFactory.asset({ ownerId: user2.id }); + await context.createAsset(asset); + const exif = { assetId: asset.id, make: 'Canon' }; + await context.asset.upsertExif(exif); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]); + + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + assetId: asset.id, + city: null, + country: null, + dateTimeOriginal: null, + description: '', + exifImageHeight: null, + exifImageWidth: null, + exposureTime: null, + fNumber: null, + fileSizeInByte: null, + focalLength: null, + fps: null, + iso: null, + latitude: null, + lensModel: null, + longitude: null, + make: 'Canon', + model: null, + modifyDate: null, + orientation: null, + profileDescription: null, + projectionType: null, + rating: null, + state: null, + timeZone: null, + }, + type: SyncEntityType.PartnerAssetExifV1, + }, + ]), + ); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should not sync partner asset exif for own user', async () => { + const { auth, context, testSync } = await setup(); + + const user2 = await context.createUser(); + await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id }); + const asset = TestFactory.asset({ ownerId: auth.user.id }); + const exif = { assetId: asset.id, make: 'Canon' }; + await context.createAsset(asset); + await context.asset.upsertExif(exif); + + await expect(testSync(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1); + await expect(testSync(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0); + }); + + it('should not sync partner asset exif for unrelated user', async () => { + const { auth, context, testSync } = await setup(); + + const user2 = await context.createUser(); + const user3 = await context.createUser(); + const session = TestFactory.session({ userId: user3.id }); + const authUser3 = TestFactory.auth({ session, user: user3 }); + await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id }); + const asset = TestFactory.asset({ ownerId: user3.id }); + const exif = { assetId: asset.id, make: 'Canon' }; + await context.createAsset(asset); + await context.asset.upsertExif(exif); + + await expect(testSync(authUser3, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1); + await expect(testSync(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0); + }); }); }); diff --git a/server/test/repositories/sync.repository.mock.ts b/server/test/repositories/sync.repository.mock.ts index 6d94f6e039..c7fb154db7 100644 --- a/server/test/repositories/sync.repository.mock.ts +++ b/server/test/repositories/sync.repository.mock.ts @@ -11,5 +11,11 @@ export const newSyncRepositoryMock = (): Mocked<RepositoryInterface<SyncReposito getUserDeletes: vitest.fn(), getPartnerUpserts: vitest.fn(), getPartnerDeletes: vitest.fn(), + getPartnerAssetsUpserts: vitest.fn(), + getPartnerAssetDeletes: vitest.fn(), + getAssetDeletes: vitest.fn(), + getAssetUpserts: vitest.fn(), + getAssetExifsUpserts: vitest.fn(), + getPartnerAssetExifsUpserts: vitest.fn(), }; };