diff --git a/e2e/test-assets b/e2e/test-assets index 18736fc27a..37f60ea537 160000 --- a/e2e/test-assets +++ b/e2e/test-assets @@ -1 +1 @@ -Subproject commit 18736fc27a80c99c68e856cdb4f842bc81ed3445 +Subproject commit 37f60ea537c0228f5f92e4f42dc42f0bb39a6d7f diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index b20a0694c5..2d764aa153 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -479,6 +479,7 @@ Class | Method | HTTP request | Description - [SyncAssetFaceDeleteV1](doc//SyncAssetFaceDeleteV1.md) - [SyncAssetFaceV1](doc//SyncAssetFaceV1.md) - [SyncAssetV1](doc//SyncAssetV1.md) + - [SyncAuthUserV1](doc//SyncAuthUserV1.md) - [SyncEntityType](doc//SyncEntityType.md) - [SyncMemoryAssetDeleteV1](doc//SyncMemoryAssetDeleteV1.md) - [SyncMemoryAssetV1](doc//SyncMemoryAssetV1.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 545955a184..8b8acc0042 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -260,6 +260,7 @@ part 'model/sync_asset_exif_v1.dart'; part 'model/sync_asset_face_delete_v1.dart'; part 'model/sync_asset_face_v1.dart'; part 'model/sync_asset_v1.dart'; +part 'model/sync_auth_user_v1.dart'; part 'model/sync_entity_type.dart'; part 'model/sync_memory_asset_delete_v1.dart'; part 'model/sync_memory_asset_v1.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 55d6f4108b..d9cae66dd3 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -576,6 +576,8 @@ class ApiClient { return SyncAssetFaceV1.fromJson(value); case 'SyncAssetV1': return SyncAssetV1.fromJson(value); + case 'SyncAuthUserV1': + return SyncAuthUserV1.fromJson(value); case 'SyncEntityType': return SyncEntityTypeTypeTransformer().decode(value); case 'SyncMemoryAssetDeleteV1': diff --git a/mobile/openapi/lib/model/sync_auth_user_v1.dart b/mobile/openapi/lib/model/sync_auth_user_v1.dart new file mode 100644 index 0000000000..1dab7f47e3 --- /dev/null +++ b/mobile/openapi/lib/model/sync_auth_user_v1.dart @@ -0,0 +1,215 @@ +// +// 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 SyncAuthUserV1 { + /// Returns a new [SyncAuthUserV1] instance. + SyncAuthUserV1({ + required this.avatarColor, + required this.deletedAt, + required this.email, + required this.hasProfileImage, + required this.id, + required this.isAdmin, + required this.name, + required this.oauthId, + required this.pinCode, + required this.profileChangedAt, + required this.quotaSizeInBytes, + required this.quotaUsageInBytes, + required this.storageLabel, + }); + + UserAvatarColor? avatarColor; + + DateTime? deletedAt; + + String email; + + bool hasProfileImage; + + String id; + + bool isAdmin; + + String name; + + String oauthId; + + String? pinCode; + + DateTime profileChangedAt; + + int? quotaSizeInBytes; + + int quotaUsageInBytes; + + String? storageLabel; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAuthUserV1 && + other.avatarColor == avatarColor && + other.deletedAt == deletedAt && + other.email == email && + other.hasProfileImage == hasProfileImage && + other.id == id && + other.isAdmin == isAdmin && + other.name == name && + other.oauthId == oauthId && + other.pinCode == pinCode && + other.profileChangedAt == profileChangedAt && + other.quotaSizeInBytes == quotaSizeInBytes && + other.quotaUsageInBytes == quotaUsageInBytes && + other.storageLabel == storageLabel; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (avatarColor == null ? 0 : avatarColor!.hashCode) + + (deletedAt == null ? 0 : deletedAt!.hashCode) + + (email.hashCode) + + (hasProfileImage.hashCode) + + (id.hashCode) + + (isAdmin.hashCode) + + (name.hashCode) + + (oauthId.hashCode) + + (pinCode == null ? 0 : pinCode!.hashCode) + + (profileChangedAt.hashCode) + + (quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) + + (quotaUsageInBytes.hashCode) + + (storageLabel == null ? 0 : storageLabel!.hashCode); + + @override + String toString() => 'SyncAuthUserV1[avatarColor=$avatarColor, deletedAt=$deletedAt, email=$email, hasProfileImage=$hasProfileImage, id=$id, isAdmin=$isAdmin, name=$name, oauthId=$oauthId, pinCode=$pinCode, profileChangedAt=$profileChangedAt, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, storageLabel=$storageLabel]'; + + Map toJson() { + final json = {}; + if (this.avatarColor != null) { + json[r'avatarColor'] = this.avatarColor; + } else { + // json[r'avatarColor'] = null; + } + if (this.deletedAt != null) { + json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String(); + } else { + // json[r'deletedAt'] = null; + } + json[r'email'] = this.email; + json[r'hasProfileImage'] = this.hasProfileImage; + json[r'id'] = this.id; + json[r'isAdmin'] = this.isAdmin; + json[r'name'] = this.name; + json[r'oauthId'] = this.oauthId; + if (this.pinCode != null) { + json[r'pinCode'] = this.pinCode; + } else { + // json[r'pinCode'] = null; + } + json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String(); + if (this.quotaSizeInBytes != null) { + json[r'quotaSizeInBytes'] = this.quotaSizeInBytes; + } else { + // json[r'quotaSizeInBytes'] = null; + } + json[r'quotaUsageInBytes'] = this.quotaUsageInBytes; + if (this.storageLabel != null) { + json[r'storageLabel'] = this.storageLabel; + } else { + // json[r'storageLabel'] = null; + } + return json; + } + + /// Returns a new [SyncAuthUserV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAuthUserV1? fromJson(dynamic value) { + upgradeDto(value, "SyncAuthUserV1"); + if (value is Map) { + final json = value.cast(); + + return SyncAuthUserV1( + avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']), + deletedAt: mapDateTime(json, r'deletedAt', r''), + email: mapValueOfType(json, r'email')!, + hasProfileImage: mapValueOfType(json, r'hasProfileImage')!, + id: mapValueOfType(json, r'id')!, + isAdmin: mapValueOfType(json, r'isAdmin')!, + name: mapValueOfType(json, r'name')!, + oauthId: mapValueOfType(json, r'oauthId')!, + pinCode: mapValueOfType(json, r'pinCode'), + profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!, + quotaSizeInBytes: mapValueOfType(json, r'quotaSizeInBytes'), + quotaUsageInBytes: mapValueOfType(json, r'quotaUsageInBytes')!, + storageLabel: mapValueOfType(json, r'storageLabel'), + ); + } + 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 = SyncAuthUserV1.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 = SyncAuthUserV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAuthUserV1-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] = SyncAuthUserV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'avatarColor', + 'deletedAt', + 'email', + 'hasProfileImage', + 'id', + 'isAdmin', + 'name', + 'oauthId', + 'pinCode', + 'profileChangedAt', + 'quotaSizeInBytes', + 'quotaUsageInBytes', + 'storageLabel', + }; +} + diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index 65ed78105c..5368126923 100644 --- a/mobile/openapi/lib/model/sync_entity_type.dart +++ b/mobile/openapi/lib/model/sync_entity_type.dart @@ -23,6 +23,7 @@ class SyncEntityType { String toJson() => value; + static const authUserV1 = SyncEntityType._(r'AuthUserV1'); static const userV1 = SyncEntityType._(r'UserV1'); static const userDeleteV1 = SyncEntityType._(r'UserDeleteV1'); static const assetV1 = SyncEntityType._(r'AssetV1'); @@ -67,6 +68,7 @@ class SyncEntityType { /// List of all possible values in this [enum][SyncEntityType]. static const values = [ + authUserV1, userV1, userDeleteV1, assetV1, @@ -146,6 +148,7 @@ class SyncEntityTypeTypeTransformer { SyncEntityType? decode(dynamic data, {bool allowNull = true}) { if (data != null) { switch (data) { + case r'AuthUserV1': return SyncEntityType.authUserV1; case r'UserV1': return SyncEntityType.userV1; case r'UserDeleteV1': return SyncEntityType.userDeleteV1; case r'AssetV1': return SyncEntityType.assetV1; diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart index 800b3f4485..8a1857366e 100644 --- a/mobile/openapi/lib/model/sync_request_type.dart +++ b/mobile/openapi/lib/model/sync_request_type.dart @@ -30,6 +30,7 @@ class SyncRequestType { static const albumAssetExifsV1 = SyncRequestType._(r'AlbumAssetExifsV1'); static const assetsV1 = SyncRequestType._(r'AssetsV1'); static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1'); + static const authUsersV1 = SyncRequestType._(r'AuthUsersV1'); static const memoriesV1 = SyncRequestType._(r'MemoriesV1'); static const memoryToAssetsV1 = SyncRequestType._(r'MemoryToAssetsV1'); static const partnersV1 = SyncRequestType._(r'PartnersV1'); @@ -51,6 +52,7 @@ class SyncRequestType { albumAssetExifsV1, assetsV1, assetExifsV1, + authUsersV1, memoriesV1, memoryToAssetsV1, partnersV1, @@ -107,6 +109,7 @@ class SyncRequestTypeTypeTransformer { case r'AlbumAssetExifsV1': return SyncRequestType.albumAssetExifsV1; case r'AssetsV1': return SyncRequestType.assetsV1; case r'AssetExifsV1': return SyncRequestType.assetExifsV1; + case r'AuthUsersV1': return SyncRequestType.authUsersV1; case r'MemoriesV1': return SyncRequestType.memoriesV1; case r'MemoryToAssetsV1': return SyncRequestType.memoryToAssetsV1; case r'PartnersV1': return SyncRequestType.partnersV1; diff --git a/mobile/openapi/lib/model/sync_user_v1.dart b/mobile/openapi/lib/model/sync_user_v1.dart index b9b41bb723..c01ddcc9fc 100644 --- a/mobile/openapi/lib/model/sync_user_v1.dart +++ b/mobile/openapi/lib/model/sync_user_v1.dart @@ -13,12 +13,15 @@ part of openapi.api; class SyncUserV1 { /// Returns a new [SyncUserV1] instance. SyncUserV1({ + required this.avatarColor, required this.deletedAt, required this.email, required this.id, required this.name, }); + UserAvatarColor? avatarColor; + DateTime? deletedAt; String email; @@ -29,6 +32,7 @@ class SyncUserV1 { @override bool operator ==(Object other) => identical(this, other) || other is SyncUserV1 && + other.avatarColor == avatarColor && other.deletedAt == deletedAt && other.email == email && other.id == id && @@ -37,16 +41,22 @@ class SyncUserV1 { @override int get hashCode => // ignore: unnecessary_parenthesis + (avatarColor == null ? 0 : avatarColor!.hashCode) + (deletedAt == null ? 0 : deletedAt!.hashCode) + (email.hashCode) + (id.hashCode) + (name.hashCode); @override - String toString() => 'SyncUserV1[deletedAt=$deletedAt, email=$email, id=$id, name=$name]'; + String toString() => 'SyncUserV1[avatarColor=$avatarColor, deletedAt=$deletedAt, email=$email, id=$id, name=$name]'; Map toJson() { final json = {}; + if (this.avatarColor != null) { + json[r'avatarColor'] = this.avatarColor; + } else { + // json[r'avatarColor'] = null; + } if (this.deletedAt != null) { json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String(); } else { @@ -67,6 +77,7 @@ class SyncUserV1 { final json = value.cast(); return SyncUserV1( + avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']), deletedAt: mapDateTime(json, r'deletedAt', r''), email: mapValueOfType(json, r'email')!, id: mapValueOfType(json, r'id')!, @@ -118,6 +129,7 @@ class SyncUserV1 { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'avatarColor', 'deletedAt', 'email', 'id', diff --git a/mobile/test/fixtures/sync_stream.stub.dart b/mobile/test/fixtures/sync_stream.stub.dart index de2d58bc9d..6c47b9172e 100644 --- a/mobile/test/fixtures/sync_stream.stub.dart +++ b/mobile/test/fixtures/sync_stream.stub.dart @@ -9,6 +9,7 @@ abstract final class SyncStreamStub { email: "admin@admin", id: "1", name: "Admin", + avatarColor: null, ), ack: "1", ); @@ -19,6 +20,7 @@ abstract final class SyncStreamStub { email: "user@user", id: "5", name: "User", + avatarColor: null, ), ack: "5", ); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index cd61f3e004..270a560fb0 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -13978,8 +13978,79 @@ ], "type": "object" }, + "SyncAuthUserV1": { + "properties": { + "avatarColor": { + "allOf": [ + { + "$ref": "#/components/schemas/UserAvatarColor" + } + ], + "nullable": true + }, + "deletedAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "email": { + "type": "string" + }, + "hasProfileImage": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "isAdmin": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "oauthId": { + "type": "string" + }, + "pinCode": { + "nullable": true, + "type": "string" + }, + "profileChangedAt": { + "format": "date-time", + "type": "string" + }, + "quotaSizeInBytes": { + "nullable": true, + "type": "integer" + }, + "quotaUsageInBytes": { + "type": "integer" + }, + "storageLabel": { + "nullable": true, + "type": "string" + } + }, + "required": [ + "avatarColor", + "deletedAt", + "email", + "hasProfileImage", + "id", + "isAdmin", + "name", + "oauthId", + "pinCode", + "profileChangedAt", + "quotaSizeInBytes", + "quotaUsageInBytes", + "storageLabel" + ], + "type": "object" + }, "SyncEntityType": { "enum": [ + "AuthUserV1", "UserV1", "UserDeleteV1", "AssetV1", @@ -14242,6 +14313,7 @@ "AlbumAssetExifsV1", "AssetsV1", "AssetExifsV1", + "AuthUsersV1", "MemoriesV1", "MemoryToAssetsV1", "PartnersV1", @@ -14364,6 +14436,14 @@ }, "SyncUserV1": { "properties": { + "avatarColor": { + "allOf": [ + { + "$ref": "#/components/schemas/UserAvatarColor" + } + ], + "nullable": true + }, "deletedAt": { "format": "date-time", "nullable": true, @@ -14380,6 +14460,7 @@ } }, "required": [ + "avatarColor", "deletedAt", "email", "id", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 81d279407c..bcbe757066 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -4099,6 +4099,7 @@ export enum Error2 { NotFound = "not_found" } export enum SyncEntityType { + AuthUserV1 = "AuthUserV1", UserV1 = "UserV1", UserDeleteV1 = "UserDeleteV1", AssetV1 = "AssetV1", @@ -4149,6 +4150,7 @@ export enum SyncRequestType { AlbumAssetExifsV1 = "AlbumAssetExifsV1", AssetsV1 = "AssetsV1", AssetExifsV1 = "AssetExifsV1", + AuthUsersV1 = "AuthUsersV1", MemoriesV1 = "MemoriesV1", MemoryToAssetsV1 = "MemoryToAssetsV1", PartnersV1 = "PartnersV1", diff --git a/server/src/database.ts b/server/src/database.ts index dc99fc5b31..53c39b7383 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -356,6 +356,7 @@ export const columns = { ], syncAlbumUser: ['album_user.albumsId as albumId', 'album_user.usersId as userId', 'album_user.role'], syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'], + syncUser: ['id', 'name', 'email', 'avatarColor', 'deletedAt', 'updateId'], stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'], syncAssetExif: [ 'asset_exif.assetId', diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index c8b1a7dde9..8614bd5776 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -10,6 +10,7 @@ import { MemoryType, SyncEntityType, SyncRequestType, + UserAvatarColor, UserMetadataKey, } from 'src/enum'; import { UserMetadata } from 'src/types'; @@ -58,9 +59,25 @@ export class SyncUserV1 { id!: string; name!: string; email!: string; + @ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', nullable: true }) + avatarColor!: UserAvatarColor | null; deletedAt!: Date | null; } +@ExtraModel() +export class SyncAuthUserV1 extends SyncUserV1 { + isAdmin!: boolean; + pinCode!: string | null; + oauthId!: string; + storageLabel!: string | null; + @ApiProperty({ type: 'integer' }) + quotaSizeInBytes!: number | null; + @ApiProperty({ type: 'integer' }) + quotaUsageInBytes!: number; + hasProfileImage!: boolean; + profileChangedAt!: Date; +} + @ExtraModel() export class SyncUserDeleteV1 { userId!: string; @@ -301,6 +318,7 @@ export class SyncAckV1 {} export class SyncResetV1 {} export type SyncItem = { + [SyncEntityType.AuthUserV1]: SyncAuthUserV1; [SyncEntityType.UserV1]: SyncUserV1; [SyncEntityType.UserDeleteV1]: SyncUserDeleteV1; [SyncEntityType.PartnerV1]: SyncPartnerV1; diff --git a/server/src/enum.ts b/server/src/enum.ts index f2eae615ab..75a96dfe67 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -559,6 +559,7 @@ export enum SyncRequestType { AlbumAssetExifsV1 = 'AlbumAssetExifsV1', AssetsV1 = 'AssetsV1', AssetExifsV1 = 'AssetExifsV1', + AuthUsersV1 = 'AuthUsersV1', MemoriesV1 = 'MemoriesV1', MemoryToAssetsV1 = 'MemoryToAssetsV1', PartnersV1 = 'PartnersV1', @@ -573,6 +574,8 @@ export enum SyncRequestType { } export enum SyncEntityType { + AuthUserV1 = 'AuthUserV1', + UserV1 = 'UserV1', UserDeleteV1 = 'UserDeleteV1', diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 7502b79f57..7c7774a020 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -444,6 +444,29 @@ where order by "asset_face"."updateId" asc +-- SyncRepository.authUser.getUpserts +select + "id", + "name", + "email", + "avatarColor", + "deletedAt", + "updateId", + "isAdmin", + "pinCode", + "oauthId", + "storageLabel", + "quotaSizeInBytes", + "quotaUsageInBytes", + "profileImagePath", + "profileChangedAt" +from + "user" +where + "updatedAt" < now() - interval '1 millisecond' +order by + "updateId" asc + -- SyncRepository.memory.getDeletes select "id", @@ -871,6 +894,7 @@ select "id", "name", "email", + "avatarColor", "deletedAt", "updateId" from diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index dba52d25a0..486984118d 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -43,6 +43,7 @@ export class SyncRepository { asset: AssetSync; assetExif: AssetExifSync; assetFace: AssetFaceSync; + authUser: AuthUserSync; memory: MemorySync; memoryToAsset: MemoryToAssetSync; partner: PartnerSync; @@ -63,6 +64,7 @@ export class SyncRepository { this.asset = new AssetSync(this.db); this.assetExif = new AssetExifSync(this.db); this.assetFace = new AssetFaceSync(this.db); + this.authUser = new AuthUserSync(this.db); this.memory = new MemorySync(this.db); this.memoryToAsset = new MemoryToAssetSync(this.db); this.partner = new PartnerSync(this.db); @@ -367,6 +369,27 @@ class AssetSync extends BaseSync { } } +class AuthUserSync extends BaseSync { + @GenerateSql({ params: [], stream: true }) + getUpserts(ack?: SyncAck) { + return this.db + .selectFrom('user') + .select(columns.syncUser) + .select([ + 'isAdmin', + 'pinCode', + 'oauthId', + 'storageLabel', + 'quotaSizeInBytes', + 'quotaUsageInBytes', + 'profileImagePath', + 'profileChangedAt', + ]) + .$call(this.upsertTableFilters(ack)) + .stream(); + } +} + class PersonSync extends BaseSync { @GenerateSql({ params: [DummyValue.UUID], stream: true }) getDeletes(userId: string, ack?: SyncAck) { @@ -693,11 +716,7 @@ class UserSync extends BaseSync { @GenerateSql({ params: [], stream: true }) getUpserts(ack?: SyncAck) { - return this.db - .selectFrom('user') - .select(['id', 'name', 'email', 'deletedAt', 'updateId']) - .$call(this.upsertTableFilters(ack)) - .stream(); + return this.db.selectFrom('user').select(columns.syncUser).$call(this.upsertTableFilters(ack)).stream(); } } diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index fb582ab038..57b953f12e 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -54,6 +54,7 @@ const sendEntityBackfillCompleteAck = (response: Writable, ackType: SyncEntityTy const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] }; export const SYNC_TYPES_ORDER = [ + SyncRequestType.AuthUsersV1, SyncRequestType.UsersV1, SyncRequestType.PartnersV1, SyncRequestType.AssetsV1, @@ -140,6 +141,7 @@ export class SyncService extends BaseService { const checkpointMap: CheckpointMap = Object.fromEntries(checkpoints.map(({ type, ack }) => [type, fromAck(ack)])); const handlers: Record Promise> = { + [SyncRequestType.AuthUsersV1]: () => this.syncAuthUsersV1(response, checkpointMap), [SyncRequestType.UsersV1]: () => this.syncUsersV1(response, checkpointMap), [SyncRequestType.PartnersV1]: () => this.syncPartnersV1(response, checkpointMap, auth), [SyncRequestType.AssetsV1]: () => this.syncAssetsV1(response, checkpointMap, auth), @@ -169,6 +171,14 @@ export class SyncService extends BaseService { response.end(); } + private async syncAuthUsersV1(response: Writable, checkpointMap: CheckpointMap) { + const upsertType = SyncEntityType.AuthUserV1; + const upserts = this.syncRepository.authUser.getUpserts(checkpointMap[upsertType]); + for await (const { updateId, profileImagePath, ...data } of upserts) { + send(response, { type: upsertType, ids: [updateId], data: { ...data, hasProfileImage: !!profileImagePath } }); + } + } + private async syncUsersV1(response: Writable, checkpointMap: CheckpointMap) { const deleteType = SyncEntityType.UserDeleteV1; const deletes = this.syncRepository.user.getDeletes(checkpointMap[deleteType]); diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index d6038b6b84..1b669e83e4 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -507,7 +507,14 @@ const userInsert = (user: Partial> = {}) => { deletedAt: null, isAdmin: false, profileImagePath: '', + profileChangedAt: newDate(), shouldChangePassword: true, + storageLabel: null, + pinCode: null, + oauthId: '', + avatarColor: null, + quotaSizeInBytes: null, + quotaUsageInBytes: 0, }; return { ...defaults, ...user, id }; diff --git a/server/test/medium/specs/sync/sync-auth-user.spec.ts b/server/test/medium/specs/sync/sync-auth-user.spec.ts new file mode 100644 index 0000000000..80ce8b37fa --- /dev/null +++ b/server/test/medium/specs/sync/sync-auth-user.spec.ts @@ -0,0 +1,87 @@ +import { Kysely } from 'kysely'; +import { SyncEntityType, SyncRequestType } from 'src/enum'; +import { UserRepository } from 'src/repositories/user.repository'; +import { DB } from 'src/schema'; +import { SyncTestContext } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const ctx = new SyncTestContext(db || defaultDatabase); + const { auth, user, session } = await ctx.newSyncAuthUser(); + return { auth, user, session, ctx }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(SyncEntityType.AuthUserV1, () => { + it('should detect and sync the first user', async () => { + const { auth, user, ctx } = await setup(await getKyselyDB()); + + const response = await ctx.syncStream(auth, [SyncRequestType.AuthUsersV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + id: user.id, + isAdmin: user.isAdmin, + deletedAt: user.deletedAt, + name: user.name, + avatarColor: user.avatarColor, + email: user.email, + pinCode: user.pinCode, + hasProfileImage: false, + profileChangedAt: (user.profileChangedAt as Date).toISOString(), + oauthId: user.oauthId, + quotaSizeInBytes: user.quotaSizeInBytes, + quotaUsageInBytes: user.quotaUsageInBytes, + storageLabel: user.storageLabel, + }, + type: 'AuthUserV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + await expect(ctx.syncStream(auth, [SyncRequestType.AuthUsersV1])).resolves.toEqual([]); + }); + + it('should sync a change and then another change to that same user', async () => { + const { auth, user, ctx } = await setup(await getKyselyDB()); + + const userRepo = ctx.get(UserRepository); + + const response = await ctx.syncStream(auth, [SyncRequestType.AuthUsersV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: user.id, + isAdmin: false, + }), + type: 'AuthUserV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + + await userRepo.update(user.id, { isAdmin: true }); + + const newResponse = await ctx.syncStream(auth, [SyncRequestType.AuthUsersV1]); + expect(newResponse).toHaveLength(1); + expect(newResponse).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: user.id, + isAdmin: true, + }), + type: 'AuthUserV1', + }, + ]); + }); +}); diff --git a/server/test/medium/specs/sync/sync-user.spec.ts b/server/test/medium/specs/sync/sync-user.spec.ts index 24137e3aea..72661e119c 100644 --- a/server/test/medium/specs/sync/sync-user.spec.ts +++ b/server/test/medium/specs/sync/sync-user.spec.ts @@ -37,6 +37,7 @@ describe(SyncEntityType.UserV1, () => { email: user.email, id: user.id, name: user.name, + avatarColor: user.avatarColor, }, type: 'UserV1', }, @@ -49,8 +50,7 @@ describe(SyncEntityType.UserV1, () => { it('should detect and sync a soft deleted user', async () => { const { auth, ctx } = await setup(await getKyselyDB()); - const deletedAt = new Date().toISOString(); - const { user: deleted } = await ctx.newUser({ deletedAt }); + const { user: deleted } = await ctx.newUser({ deletedAt: new Date().toISOString() }); const response = await ctx.syncStream(auth, [SyncRequestType.UsersV1]); @@ -59,22 +59,12 @@ describe(SyncEntityType.UserV1, () => { expect.arrayContaining([ { ack: expect.any(String), - data: { - deletedAt: null, - email: auth.user.email, - id: auth.user.id, - name: auth.user.name, - }, + data: expect.objectContaining({ id: auth.user.id }), type: 'UserV1', }, { ack: expect.any(String), - data: { - deletedAt, - email: deleted.email, - id: deleted.id, - name: deleted.name, - }, + data: expect.objectContaining({ id: deleted.id }), type: 'UserV1', }, ]), @@ -85,7 +75,7 @@ describe(SyncEntityType.UserV1, () => { }); it('should detect and sync a deleted user', async () => { - const { auth, ctx } = await setup(await getKyselyDB()); + const { auth, user: authUser, ctx } = await setup(await getKyselyDB()); const userRepo = ctx.get(UserRepository); @@ -104,12 +94,7 @@ describe(SyncEntityType.UserV1, () => { }, { ack: expect.any(String), - data: { - deletedAt: null, - email: auth.user.email, - id: auth.user.id, - name: auth.user.name, - }, + data: expect.objectContaining({ id: authUser.id }), type: 'UserV1', }, ]); @@ -119,7 +104,7 @@ describe(SyncEntityType.UserV1, () => { }); it('should sync a user and then an update to that same user', async () => { - const { auth, ctx } = await setup(await getKyselyDB()); + const { auth, user, ctx } = await setup(await getKyselyDB()); const userRepo = ctx.get(UserRepository); @@ -128,12 +113,7 @@ describe(SyncEntityType.UserV1, () => { expect(response).toEqual([ { ack: expect.any(String), - data: { - deletedAt: null, - email: auth.user.email, - id: auth.user.id, - name: auth.user.name, - }, + data: expect.objectContaining({ id: user.id }), type: 'UserV1', }, ]); @@ -147,12 +127,7 @@ describe(SyncEntityType.UserV1, () => { expect(newResponse).toEqual([ { ack: expect.any(String), - data: { - deletedAt: null, - email: auth.user.email, - id: auth.user.id, - name: updated.name, - }, + data: expect.objectContaining({ id: user.id, name: updated.name }), type: 'UserV1', }, ]);