From 25e2d374902d5280e738346687201d4486109fee Mon Sep 17 00:00:00 2001 From: Daimolean <92239625+wuzihao051119@users.noreply.github.com> Date: Fri, 25 Jul 2025 23:04:28 +0800 Subject: [PATCH] fix(server): use UserMetadataKey enum instead of string (#20209) * fix(server): use UserMetadataKey enum instead of string * fix: mobile --- .../repositories/sync_stream.repository.dart | 12 +-- mobile/openapi/README.md | 1 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api_client.dart | 2 + mobile/openapi/lib/api_helper.dart | 3 + .../model/sync_user_metadata_delete_v1.dart | 4 +- .../lib/model/sync_user_metadata_v1.dart | 4 +- .../openapi/lib/model/user_metadata_key.dart | 88 +++++++++++++++++++ open-api/immich-openapi-specs.json | 20 ++++- server/src/dtos/sync.dto.ts | 6 +- .../tables/user-metadata-audit.table.ts | 3 +- 11 files changed, 129 insertions(+), 15 deletions(-) create mode 100644 mobile/openapi/lib/model/user_metadata_key.dart diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index 067f840174..54bc01cfa2 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -20,8 +20,8 @@ import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole; -import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole; +import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey; +import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole, UserMetadataKey; class SyncStreamRepository extends DriftDatabaseRepository { final Logger _logger = Logger('DriftSyncStreamRepository'); @@ -647,11 +647,11 @@ extension on api.AssetVisibility { }; } -extension on String { +extension on api.UserMetadataKey { UserMetadataKey toUserMetadataKey() => switch (this) { - "onboarding" => UserMetadataKey.onboarding, - "preferences" => UserMetadataKey.preferences, - "license" => UserMetadataKey.license, + api.UserMetadataKey.onboarding => UserMetadataKey.onboarding, + api.UserMetadataKey.preferences => UserMetadataKey.preferences, + api.UserMetadataKey.license => UserMetadataKey.license, _ => throw Exception('Unknown UserMetadataKey value: $this'), }; } diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 62c48bf292..3181b03a47 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -557,6 +557,7 @@ Class | Method | HTTP request | Description - [UserAdminUpdateDto](doc//UserAdminUpdateDto.md) - [UserAvatarColor](doc//UserAvatarColor.md) - [UserLicense](doc//UserLicense.md) + - [UserMetadataKey](doc//UserMetadataKey.md) - [UserPreferencesResponseDto](doc//UserPreferencesResponseDto.md) - [UserPreferencesUpdateDto](doc//UserPreferencesUpdateDto.md) - [UserResponseDto](doc//UserResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 8b8acc0042..8c1fa1a80a 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -338,6 +338,7 @@ part 'model/user_admin_response_dto.dart'; part 'model/user_admin_update_dto.dart'; part 'model/user_avatar_color.dart'; part 'model/user_license.dart'; +part 'model/user_metadata_key.dart'; part 'model/user_preferences_response_dto.dart'; part 'model/user_preferences_update_dto.dart'; part 'model/user_response_dto.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index d9cae66dd3..bd306cb216 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -732,6 +732,8 @@ class ApiClient { return UserAvatarColorTypeTransformer().decode(value); case 'UserLicense': return UserLicense.fromJson(value); + case 'UserMetadataKey': + return UserMetadataKeyTypeTransformer().decode(value); case 'UserPreferencesResponseDto': return UserPreferencesResponseDto.fromJson(value); case 'UserPreferencesUpdateDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 1618f4a670..098d32f4f4 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -151,6 +151,9 @@ String parameterToString(dynamic value) { if (value is UserAvatarColor) { return UserAvatarColorTypeTransformer().encode(value).toString(); } + if (value is UserMetadataKey) { + return UserMetadataKeyTypeTransformer().encode(value).toString(); + } if (value is UserStatus) { return UserStatusTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/sync_user_metadata_delete_v1.dart b/mobile/openapi/lib/model/sync_user_metadata_delete_v1.dart index e9dd733295..f39acc617b 100644 --- a/mobile/openapi/lib/model/sync_user_metadata_delete_v1.dart +++ b/mobile/openapi/lib/model/sync_user_metadata_delete_v1.dart @@ -17,7 +17,7 @@ class SyncUserMetadataDeleteV1 { required this.userId, }); - String key; + UserMetadataKey key; String userId; @@ -51,7 +51,7 @@ class SyncUserMetadataDeleteV1 { final json = value.cast(); return SyncUserMetadataDeleteV1( - key: mapValueOfType(json, r'key')!, + key: UserMetadataKey.fromJson(json[r'key'])!, userId: mapValueOfType(json, r'userId')!, ); } diff --git a/mobile/openapi/lib/model/sync_user_metadata_v1.dart b/mobile/openapi/lib/model/sync_user_metadata_v1.dart index 0b060dc17c..cf39b6d960 100644 --- a/mobile/openapi/lib/model/sync_user_metadata_v1.dart +++ b/mobile/openapi/lib/model/sync_user_metadata_v1.dart @@ -18,7 +18,7 @@ class SyncUserMetadataV1 { required this.value, }); - String key; + UserMetadataKey key; String userId; @@ -57,7 +57,7 @@ class SyncUserMetadataV1 { final json = value.cast(); return SyncUserMetadataV1( - key: mapValueOfType(json, r'key')!, + key: UserMetadataKey.fromJson(json[r'key'])!, userId: mapValueOfType(json, r'userId')!, value: mapValueOfType(json, r'value')!, ); diff --git a/mobile/openapi/lib/model/user_metadata_key.dart b/mobile/openapi/lib/model/user_metadata_key.dart new file mode 100644 index 0000000000..845b5ae9bb --- /dev/null +++ b/mobile/openapi/lib/model/user_metadata_key.dart @@ -0,0 +1,88 @@ +// +// 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 UserMetadataKey { + /// Instantiate a new enum with the provided [value]. + const UserMetadataKey._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const preferences = UserMetadataKey._(r'preferences'); + static const license = UserMetadataKey._(r'license'); + static const onboarding = UserMetadataKey._(r'onboarding'); + + /// List of all possible values in this [enum][UserMetadataKey]. + static const values = [ + preferences, + license, + onboarding, + ]; + + static UserMetadataKey? fromJson(dynamic value) => UserMetadataKeyTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = UserMetadataKey.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [UserMetadataKey] to String, +/// and [decode] dynamic data back to [UserMetadataKey]. +class UserMetadataKeyTypeTransformer { + factory UserMetadataKeyTypeTransformer() => _instance ??= const UserMetadataKeyTypeTransformer._(); + + const UserMetadataKeyTypeTransformer._(); + + String encode(UserMetadataKey data) => data.value; + + /// Decodes a [dynamic value][data] to a UserMetadataKey. + /// + /// 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. + UserMetadataKey? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'preferences': return UserMetadataKey.preferences; + case r'license': return UserMetadataKey.license; + case r'onboarding': return UserMetadataKey.onboarding; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [UserMetadataKeyTypeTransformer] instance. + static UserMetadataKeyTypeTransformer? _instance; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 53a91a4014..35f84ec25a 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -14403,7 +14403,11 @@ "SyncUserMetadataDeleteV1": { "properties": { "key": { - "type": "string" + "allOf": [ + { + "$ref": "#/components/schemas/UserMetadataKey" + } + ] }, "userId": { "type": "string" @@ -14418,7 +14422,11 @@ "SyncUserMetadataV1": { "properties": { "key": { - "type": "string" + "allOf": [ + { + "$ref": "#/components/schemas/UserMetadataKey" + } + ] }, "userId": { "type": "string" @@ -16132,6 +16140,14 @@ ], "type": "object" }, + "UserMetadataKey": { + "enum": [ + "preferences", + "license", + "onboarding" + ], + "type": "string" + }, "UserPreferencesResponseDto": { "properties": { "albums": { diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 8614bd5776..92aea8f5e9 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -301,14 +301,16 @@ export class SyncAssetFaceDeleteV1 { @ExtraModel() export class SyncUserMetadataV1 { userId!: string; - key!: string; + @ValidateEnum({ enum: UserMetadataKey, name: 'UserMetadataKey' }) + key!: UserMetadataKey; value!: UserMetadata[UserMetadataKey]; } @ExtraModel() export class SyncUserMetadataDeleteV1 { userId!: string; - key!: string; + @ValidateEnum({ enum: UserMetadataKey, name: 'UserMetadataKey' }) + key!: UserMetadataKey; } @ExtraModel() diff --git a/server/src/schema/tables/user-metadata-audit.table.ts b/server/src/schema/tables/user-metadata-audit.table.ts index de7d21c874..63f503ab85 100644 --- a/server/src/schema/tables/user-metadata-audit.table.ts +++ b/server/src/schema/tables/user-metadata-audit.table.ts @@ -1,4 +1,5 @@ import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; +import { UserMetadataKey } from 'src/enum'; import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('user_metadata_audit') @@ -10,7 +11,7 @@ export class UserMetadataAuditTable { userId!: string; @Column({ indexName: 'IDX_user_metadata_audit_key' }) - key!: string; + key!: UserMetadataKey; @CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_user_metadata_audit_deleted_at' }) deletedAt!: Generated;