From 0fc6d6982466b73ecc284ee3ec2c99120bee8acc Mon Sep 17 00:00:00 2001 From: Jason Rasmussen <jrasm91@gmail.com> Date: Mon, 27 May 2024 22:16:53 -0400 Subject: [PATCH] feat(server): user preferences (#9736) * refactor(server): user endpoints * feat(server): user preferences * mobile: user preference * wording --------- Co-authored-by: Alex <alex.tran1502@gmail.com> --- docs/docs/administration/server-commands.md | 1 - e2e/src/api/specs/user-admin.e2e-spec.ts | 91 +++---- e2e/src/api/specs/user.e2e-spec.ts | 42 ++- e2e/src/fixtures.ts | 6 - e2e/src/responses.ts | 1 - mobile/lib/entities/user.entity.dart | 8 +- .../providers/authentication.provider.dart | 6 +- mobile/lib/providers/user.provider.dart | 3 +- .../lib/routing/tab_navigation_observer.dart | 4 +- mobile/openapi/README.md | Bin 27685 -> 28457 bytes mobile/openapi/lib/api.dart | Bin 9821 -> 10053 bytes mobile/openapi/lib/api/user_api.dart | Bin 20999 -> 27808 bytes mobile/openapi/lib/api_client.dart | Bin 26388 -> 26904 bytes mobile/openapi/lib/model/avatar_response.dart | Bin 0 -> 2752 bytes mobile/openapi/lib/model/avatar_update.dart | Bin 0 -> 3144 bytes mobile/openapi/lib/model/memory_response.dart | Bin 0 -> 2761 bytes mobile/openapi/lib/model/memory_update.dart | Bin 0 -> 3157 bytes .../lib/model/user_admin_create_dto.dart | Bin 6377 -> 5602 bytes .../lib/model/user_admin_response_dto.dart | Bin 8043 -> 7268 bytes .../lib/model/user_admin_update_dto.dart | Bin 7750 -> 6241 bytes .../model/user_preferences_response_dto.dart | Bin 0 -> 3234 bytes .../model/user_preferences_update_dto.dart | Bin 0 -> 4054 bytes .../openapi/lib/model/user_update_me_dto.dart | Bin 6002 -> 4493 bytes open-api/immich-openapi-specs.json | 246 ++++++++++++++++-- open-api/typescript-sdk/README.md | 11 +- open-api/typescript-sdk/src/fetch-client.ts | 69 ++++- .../src/controllers/user-admin.controller.ts | 17 ++ server/src/controllers/user.controller.ts | 16 ++ server/src/dtos/user-preferences.dto.ts | 47 ++++ server/src/dtos/user.dto.ts | 24 +- server/src/services/user-admin.service.ts | 54 ++-- server/src/services/user.service.ts | 38 ++- server/src/utils/preferences.ts | 10 + .../navigation-bar/account-info-panel.svelte | 19 +- .../memories-settings.svelte | 12 +- .../user-settings-list.svelte | 2 +- web/src/lib/stores/user.store.ts | 4 +- web/src/lib/utils/auth.ts | 18 +- .../(user)/photos/[[assetId=id]]/+page.svelte | 4 +- 39 files changed, 532 insertions(+), 221 deletions(-) create mode 100644 mobile/openapi/lib/model/avatar_response.dart create mode 100644 mobile/openapi/lib/model/avatar_update.dart create mode 100644 mobile/openapi/lib/model/memory_response.dart create mode 100644 mobile/openapi/lib/model/memory_update.dart create mode 100644 mobile/openapi/lib/model/user_preferences_response_dto.dart create mode 100644 mobile/openapi/lib/model/user_preferences_update_dto.dart create mode 100644 server/src/dtos/user-preferences.dto.ts diff --git a/docs/docs/administration/server-commands.md b/docs/docs/administration/server-commands.md index 2594da44b2..355ee10e39 100644 --- a/docs/docs/administration/server-commands.md +++ b/docs/docs/administration/server-commands.md @@ -77,7 +77,6 @@ immich-admin list-users deletedAt: null, updatedAt: 2023-09-21T15:42:28.129Z, oauthId: '', - memoriesEnabled: true } ] ``` diff --git a/e2e/src/api/specs/user-admin.e2e-spec.ts b/e2e/src/api/specs/user-admin.e2e-spec.ts index ac2b3e693a..a041d98419 100644 --- a/e2e/src/api/specs/user-admin.e2e-spec.ts +++ b/e2e/src/api/specs/user-admin.e2e-spec.ts @@ -1,4 +1,11 @@ -import { LoginResponseDto, deleteUserAdmin, getMyUser, getUserAdmin, login } from '@immich/sdk'; +import { + LoginResponseDto, + deleteUserAdmin, + getMyUser, + getUserAdmin, + getUserPreferencesAdmin, + login, +} from '@immich/sdk'; import { Socket } from 'socket.io-client'; import { createUserDto, uuidDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; @@ -103,15 +110,7 @@ describe('/admin/users', () => { expect(body).toEqual(errorDto.forbidden); }); - for (const key of [ - 'password', - 'email', - 'name', - 'quotaSizeInBytes', - 'shouldChangePassword', - 'memoriesEnabled', - 'notify', - ]) { + for (const key of ['password', 'email', 'name', 'quotaSizeInBytes', 'shouldChangePassword', 'notify']) { it(`should not allow null ${key}`, async () => { const { status, body } = await request(app) .post(`/admin/users`) @@ -139,23 +138,6 @@ describe('/admin/users', () => { }); expect(status).toBe(201); }); - - it('should create a user without memories enabled', async () => { - const { status, body } = await request(app) - .post(`/admin/users`) - .send({ - email: 'no-memories@immich.cloud', - password: 'Password123', - name: 'No Memories', - memoriesEnabled: false, - }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(body).toMatchObject({ - email: 'no-memories@immich.cloud', - memoriesEnabled: false, - }); - expect(status).toBe(201); - }); }); describe('PUT /admin/users/:id', () => { @@ -173,7 +155,7 @@ describe('/admin/users', () => { expect(body).toEqual(errorDto.forbidden); }); - for (const key of ['password', 'email', 'name', 'shouldChangePassword', 'memoriesEnabled']) { + for (const key of ['password', 'email', 'name', 'shouldChangePassword']) { it(`should not allow null ${key}`, async () => { const { status, body } = await request(app) .put(`/admin/users/${uuidDto.notFound}`) @@ -221,22 +203,6 @@ describe('/admin/users', () => { expect(before.updatedAt).not.toEqual(body.updatedAt); }); - it('should update memories enabled', async () => { - const before = await getUserAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); - const { status, body } = await request(app) - .put(`/admin/users/${admin.userId}`) - .send({ memoriesEnabled: false }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ - ...before, - updatedAt: expect.anything(), - memoriesEnabled: false, - }); - expect(before.updatedAt).not.toEqual(body.updatedAt); - }); - it('should update password', async () => { const { status, body } = await request(app) .put(`/admin/users/${nonAdmin.userId}`) @@ -254,6 +220,43 @@ describe('/admin/users', () => { }); }); + describe('PUT /admin/users/:id/preferences', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).put(`/admin/users/${userToDelete.userId}/preferences`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should update memories enabled', async () => { + const before = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); + expect(before).toMatchObject({ memories: { enabled: true } }); + + const { status, body } = await request(app) + .put(`/admin/users/${admin.userId}/preferences`) + .send({ memories: { enabled: false } }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ memories: { enabled: false } }); + + const after = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); + expect(after).toMatchObject({ memories: { enabled: false } }); + }); + + it('should update the avatar color', async () => { + const { status, body } = await request(app) + .put(`/admin/users/${admin.userId}/preferences`) + .send({ avatar: { color: 'orange' } }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual({ avatar: { color: 'orange' }, memories: { enabled: false } }); + + const after = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); + expect(after).toEqual({ avatar: { color: 'orange' }, memories: { enabled: false } }); + }); + }); + describe('DELETE /admin/users/:id', () => { it('should require authentication', async () => { const { status, body } = await request(app).delete(`/admin/users/${userToDelete.userId}`); diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts index 0cc08479d3..ccf7d6dd3a 100644 --- a/e2e/src/api/specs/user.e2e-spec.ts +++ b/e2e/src/api/specs/user.e2e-spec.ts @@ -1,4 +1,4 @@ -import { LoginResponseDto, SharedLinkType, deleteUserAdmin, getMyUser, login } from '@immich/sdk'; +import { LoginResponseDto, SharedLinkType, deleteUserAdmin, getMyPreferences, getMyUser, login } from '@immich/sdk'; import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; @@ -69,7 +69,6 @@ describe('/users', () => { expect(body).toMatchObject({ id: admin.userId, email: 'admin@immich.cloud', - memoriesEnabled: true, quotaUsageInBytes: 0, }); }); @@ -82,7 +81,7 @@ describe('/users', () => { expect(body).toEqual(errorDto.unauthorized); }); - for (const key of ['email', 'name', 'memoriesEnabled', 'avatarColor']) { + for (const key of ['email', 'name']) { it(`should not allow null ${key}`, async () => { const dto = { [key]: null }; const { status, body } = await request(app) @@ -110,24 +109,6 @@ describe('/users', () => { }); }); - it('should update memories enabled', async () => { - const before = await getMyUser({ headers: asBearerAuth(admin.accessToken) }); - const { status, body } = await request(app) - .put(`/users/me`) - .send({ memoriesEnabled: false }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ - ...before, - updatedAt: expect.anything(), - memoriesEnabled: false, - }); - - const after = await getMyUser({ headers: asBearerAuth(admin.accessToken) }); - expect(after.memoriesEnabled).toBe(false); - }); - /** @deprecated */ it('should allow a user to change their password (deprecated)', async () => { const user = await getMyUser({ headers: asBearerAuth(nonAdmin.accessToken) }); @@ -176,6 +157,24 @@ describe('/users', () => { }); }); + describe('PUT /users/me/preferences', () => { + it('should update memories enabled', async () => { + const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); + expect(before).toMatchObject({ memories: { enabled: true } }); + + const { status, body } = await request(app) + .put(`/users/me/preferences`) + .send({ memories: { enabled: false } }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ memories: { enabled: false } }); + + const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); + expect(after).toMatchObject({ memories: { enabled: false } }); + }); + }); + describe('GET /users/:id', () => { it('should require authentication', async () => { const { status } = await request(app).get(`/users/${admin.userId}`); @@ -194,7 +193,6 @@ describe('/users', () => { expect(body).not.toMatchObject({ shouldChangePassword: expect.anything(), - memoriesEnabled: expect.anything(), storageLabel: expect.anything(), }); }); diff --git a/e2e/src/fixtures.ts b/e2e/src/fixtures.ts index 031985c5fb..9e311c896d 100644 --- a/e2e/src/fixtures.ts +++ b/e2e/src/fixtures.ts @@ -1,5 +1,3 @@ -import { UserAvatarColor } from '@immich/sdk'; - export const uuidDto = { invalid: 'invalid-uuid', // valid uuid v4 @@ -70,8 +68,6 @@ export const userDto = { updatedAt: new Date('2021-01-01'), tags: [], assets: [], - memoriesEnabled: true, - avatarColor: UserAvatarColor.Primary, quotaSizeInBytes: null, quotaUsageInBytes: 0, }, @@ -88,8 +84,6 @@ export const userDto = { updatedAt: new Date('2021-01-01'), tags: [], assets: [], - memoriesEnabled: true, - avatarColor: UserAvatarColor.Primary, quotaSizeInBytes: null, quotaUsageInBytes: 0, }, diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index afe3334a7f..b7dcfca1ee 100644 --- a/e2e/src/responses.ts +++ b/e2e/src/responses.ts @@ -68,7 +68,6 @@ export const signupResponseDto = { updatedAt: expect.any(String), deletedAt: null, oauthId: '', - memoriesEnabled: true, quotaUsageInBytes: 0, quotaSizeInBytes: null, status: 'active', diff --git a/mobile/lib/entities/user.entity.dart b/mobile/lib/entities/user.entity.dart index b6adcf5d87..55a19fe496 100644 --- a/mobile/lib/entities/user.entity.dart +++ b/mobile/lib/entities/user.entity.dart @@ -27,8 +27,10 @@ class User { Id get isarId => fastHash(id); - User.fromUserDto(UserAdminResponseDto dto) - : id = dto.id, + User.fromUserDto( + UserAdminResponseDto dto, + UserPreferencesResponseDto? preferences, + ) : id = dto.id, updatedAt = dto.updatedAt, email = dto.email, name = dto.name, @@ -36,7 +38,7 @@ class User { isPartnerSharedWith = false, profileImagePath = dto.profileImagePath, isAdmin = dto.isAdmin, - memoryEnabled = dto.memoriesEnabled ?? false, + memoryEnabled = preferences?.memories.enabled ?? false, avatarColor = dto.avatarColor.toAvatarColor(), inTimeline = false, quotaUsageInBytes = dto.quotaUsageInBytes ?? 0, diff --git a/mobile/lib/providers/authentication.provider.dart b/mobile/lib/providers/authentication.provider.dart index 073ee09db1..b5fb25bf20 100644 --- a/mobile/lib/providers/authentication.provider.dart +++ b/mobile/lib/providers/authentication.provider.dart @@ -177,8 +177,10 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> { retResult = false; } else { UserAdminResponseDto? userResponseDto; + UserPreferencesResponseDto? userPreferences; try { userResponseDto = await _apiService.userApi.getMyUser(); + userPreferences = await _apiService.userApi.getMyPreferences(); } on ApiException catch (error, stackTrace) { _log.severe( "Error getting user information from the server [API EXCEPTION]", @@ -201,13 +203,13 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> { Store.put(StoreKey.deviceIdHash, fastHash(deviceId)); Store.put( StoreKey.currentUser, - User.fromUserDto(userResponseDto), + User.fromUserDto(userResponseDto, userPreferences), ); Store.put(StoreKey.serverUrl, serverUrl); Store.put(StoreKey.accessToken, accessToken); shouldChangePassword = userResponseDto.shouldChangePassword; - user = User.fromUserDto(userResponseDto); + user = User.fromUserDto(userResponseDto, userPreferences); retResult = true; } else { diff --git a/mobile/lib/providers/user.provider.dart b/mobile/lib/providers/user.provider.dart index bf052ebbba..2767615526 100644 --- a/mobile/lib/providers/user.provider.dart +++ b/mobile/lib/providers/user.provider.dart @@ -21,10 +21,11 @@ class CurrentUserProvider extends StateNotifier<User?> { refresh() async { try { final user = await _apiService.userApi.getMyUser(); + final userPreferences = await _apiService.userApi.getMyPreferences(); if (user != null) { Store.put( StoreKey.currentUser, - User.fromUserDto(user), + User.fromUserDto(user, userPreferences), ); } } catch (_) {} diff --git a/mobile/lib/routing/tab_navigation_observer.dart b/mobile/lib/routing/tab_navigation_observer.dart index 8825e2ef02..6c0f36050b 100644 --- a/mobile/lib/routing/tab_navigation_observer.dart +++ b/mobile/lib/routing/tab_navigation_observer.dart @@ -58,6 +58,8 @@ class TabNavigationObserver extends AutoRouterObserver { try { final userResponseDto = await ref.read(apiServiceProvider).userApi.getMyUser(); + final userPreferences = + await ref.read(apiServiceProvider).userApi.getMyPreferences(); if (userResponseDto == null) { return; @@ -65,7 +67,7 @@ class TabNavigationObserver extends AutoRouterObserver { Store.put( StoreKey.currentUser, - User.fromUserDto(userResponseDto), + User.fromUserDto(userResponseDto, userPreferences), ); ref.read(serverInfoProvider.notifier).getServerVersion(); } catch (e) { diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 273585c368c90230e296d94cb9cc25bad0079344..cdc75d4f28f2eaf421ce27cfe018d4f6990c30bb 100644 GIT binary patch delta 467 zcmZ2_gK_0O#tmYQ+yO<YX{kl2dC958lNFuBd2=fZkVGar3iIWr>LUv!+6!+MbWC6r zN>42bElw>$HbBNnfg4$^jgt}&hUP*iWkxh>MJBIt=FmaXsi{z-prsWM8lt78pkE5K zrC1-+8IvD4vrV4lyl^s`6F-p6g<`0TlcZ>AK}uptDo8t$(UW;yP#v{d-{m)lw_{mi zNn%k@YH>k+UU6!yMoNCNzCNm;UT%semx8WBG(<QQY&wcch|p$5-<Ry7Pzm4E-29?S zn7NZbhh$Gq3>Dt&8G2Nh0~U0XKSqd7R>)-Gf(az2PM+v4%8wqr8#6>i5K4+6wz-t# KZ)VI?VFm#8HLI}z delta 51 zcmV-30L=fX-T|f90k9T9vwlHa0kc;_BLS1>M4z(@M)wG_R!`sulX+GPlWkW?v))&* J6SER*7Xvm36z~85 diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index d7223a1ecf38cabb8f2f634c24c5aa2c40a5427c..94303a768fefcdea817e7be24fe119f8985f31ca 100644 GIT binary patch delta 117 zcmccXbJTAG8!umCSz<|IQG8KqaY24w@nk_!Q68vJX+cV22}oeF9xwZ5US4mu$wHzc zlM95|CkF`oz}TCo30v`V6cnYVr52^;O)d~qnWV(Z4dW-L7EfNsCJz+ad_k#~82|yO BCXfIC delta 30 mcmX@=ch_eF8}DW>-XONg`-NR5iz!QNwh@ux-@HS)ml*)91`0a> diff --git a/mobile/openapi/lib/api/user_api.dart b/mobile/openapi/lib/api/user_api.dart index 3c1a3ff4e7bebb952c45462a3102e6f6fa936503..246ea422c55fd66938ed394488f05c4641e0b0ca 100644 GIT binary patch delta 1204 zcmZo)!noij<Ay3J9{qx%)U?#1)V$=>;>i~owI-`s2=b?=miSf%z$GR-T14^b=cXd5 zoxH&A$mBT7PkN!nsYM8t#X+gX1^Ic!sV*h?_I3(r<`ip8{-~kBi)?i9W;t0aCT=7X zCQmdML*W-_drzL?Vgz#7=B-i!;*;mv3JB^~XQtF5xd-HS&&m6g6eh3X<eF@!!;WG^ zqEQJql0P@!F*0YI{9gA4ilU1eqA2`_dNwHhjpCx4<4tw(JN5!c_~vVd$Ar+s)pK$I zCyNG>H$w|j5=%hA6su5*8iYWZ$rCu$yi36<Py#DFvn0c#q@=(zFD+jKE#%<_Vl(d} zr=Kv$nqrJtw3&Rs2PLpJpYd7CNUCdPV$fWJnhvmdg(T-6<k6XI>%pOp6n-c{ffj<B z8~x1*MU(sH2`;6)lOK5LVMf(HCCSMJ-hw1Wmng)KXu&W!(MU!|0ZouVOhryUZ^VY2 zTqoZ#io_C2*BME1piCZ`E3m~7ni9v9+{`?U;F6-uymW=k6w*8bj58czvQb<LIb#tM JTe?iOTmaY|&#eFe delta 51 zcmV-30L=fO*#U>70kC=+vkD+f7qhQD*AkNoAS#ofHy@L52?UdmS~8R0Hwu%mUIw!m JSlSE%eG30q63hSq diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index bd3433872affc4c0b037a8ad22de36e3480fc2c3..bf306ac10a0617c451855f287fa25538cddad012 100644 GIT binary patch delta 197 zcmbPoj&a5%#tkl-ypCmwC5c5rsl^5PdBu|rwM5w<B9psZHMk+n(1Mi25|9)Mf1<16 zW*5z3zR5;fB9qUV^G%-QsxbM#Ioss*=9{5nY?JFP*kIzDV=N9Ras(8mrll68=1tDG lQ{oFPPA!57B&SYZD6hkhDpWj~QC1d1EKyo>vqaP%9soVGNf7`5 delta 32 qcmV+*0N?+Z(gBpt0kA|Vv*0On4wLye9h1N~j<cFMx*@Y5T=)$3$PTsu diff --git a/mobile/openapi/lib/model/avatar_response.dart b/mobile/openapi/lib/model/avatar_response.dart new file mode 100644 index 0000000000000000000000000000000000000000..edd242df4e3be1a6861db070ef1cd17963868507 GIT binary patch literal 2752 zcmbVOU2oeq6n*!vxB-S(0aSV0Q<2PGgT@)!wK0&U4~1a}j6_@PWKkoj7)I*<zI!hv zS#sPaoq@y_c|XrNm(*x97>(fa^WE&#U(=iE?d9F{3a;OMn1*mQgPYkMe4JgqyZ&;5 zW@Py$6~>K!kA8bSphvYTw2|>rn|P^mc@9-kS((N%mup$Luz6PNQX6;FL*yH=wQ*Uw z*vNk?WzfA4Yy4jdgWpaXi@}XO?w;tvI#IY-rI=7Hl{D;bk0#4iA#I%NVufZd6Gi;` z*Eq?9v4a7obD)=?OIe6gMfi6y7$mu{7A_xz6XsUh64Oa|8~}{4{afiOQ&<pC$PLW< zRtr#ES|TGf@9zZ^0ibCZT&8T4=v<o+H+E;37~r#&#=E_x9pHW5d7~esG1xI$-D{mw z%|<x18IOb6{a2Y_CoYrb48{}i$Rr{UC{5VxeEa5qfqK&4=_!}TA$(aF_&fPk;b0{l zWWr`|bt*9<JGbf}Q4}(f)(W#FQjw=jT4mWR;I5*^+jrjW?~^mCSb@|2N5Cx&`*6-V z$oLojS_~PZkD~m;yM&N#QBf+HV3hY(7Z}6izf^_D;VUk|7@k-^?k>1QV2imKa`@o_ zZTEb66T?DaJ6hNgeLo=(5Jd`pMl2|2;X&k;w6HXKT}PVp5^8FR21AnE96MZuP*gec zPh@YDWvO+LFO6KH(s@Wsm7qdwOxlTYW}Hf59Is5L#Drfskm;XX0Yg?p=qqdR!(sxi zi!_AgH0uC8WZ5K2gmq6a4m{z07-*OdDhRMN|6%aY4~HTmh`2yvanI4&2@Mi`Fadq- z_<zk4Vb=0gcm6?Pd3{+;Ajie)CPM=OWeVYu2XAHcM%?G9dRU6w%5c|U;1Mv=R=Fc} z9fY1Qd|d<~1tbKi1UNlq8AZz8Xg6J#ZremeRB^(CI<Vg0`NF3A{RVf>$@l=vJyuwB z4GTfDXYpjra_R=8LyB66M4Ec8JDwiY2HYORwU^V>RM5b54$Uu)!gEsN-<{V3tY9t5 zqbxLqdPvZ!JH`h=nrZF=jyZpbHxo1Ve0)#7IOp{>cn<B272~CWcO%__{+R1=hpT4W zuouyjlD6rE81el)$0CP`*s8<HGi)MAbMN?%+|p6T{$7ie`UjMvYdbX9G(OTX#29IR z=WLYAaEYddZqhyjue=fZ)aM(X#%N406ch{R^N12iJ%^(+A#>U|s)?6QCdTNh<!GY+ zqnQ`V;U&_lPq5C^KdT(<s!wm9gNUeUVe1GeYzK2cqG78lFN)rLW1@~2MsSzOmiP9x z1|??kt`tacg_5|JfESX*v?P83??26M=-sI1oo}i3o#=1!ZExktBZE<2!TgeRF~H;F EKgd;lPyhe` literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/avatar_update.dart b/mobile/openapi/lib/model/avatar_update.dart new file mode 100644 index 0000000000000000000000000000000000000000..b92eb8dcbdb2d0ecc1f50f96efbcc0c083672e2d GIT binary patch literal 3144 zcmbVOU2ob*6n*Dc+*GTENW^sasiF&Ml#f-rO;|O_!wN;#U=J{@nPF$fK`7<F@3}KJ zm<>szjT8*_{XFN~>*;n|-7cNp-42icxVXOfdU|_tPM4p)Ty*GsNY}$#x*DE;y8Qb9 zW^DN}7uNTGOMZRd;-6}&jg{%d*mR-_*`rdIPUfjB<Xmdsseh~a!dSo8LljG~a_OS< zsg?hf%EDcXIX)M{;_9V!2yWee_snSLh4!h+kx)&Pw87o&O;#u^U0N7Dg_)lToxXpU zW;0=3tA%u)%!JHBYOzoW{y(%@Ss|RG(+A;&y<6nMOL}Y(b<zA+=}W5}3DR;&qo&P0 zsZdrX6Bsw2wAq`;&y-6tQyAO9@AVNfg`n}1{wkz!lC<%_^cJ?Xu&B%W6}g!yi=1ZS zLDE=C4I_Q5%c2nDLLShK(wRhfNp2xC;E@Thj3%b13Z5J3I5cX^iZYjIAeR$S7M>nN zQA%o;PU&d~@}_#yd$XzFs6#Weln>H&g2_~BX@N^#g?Fa3C?PY5JTIN6i6{yhi|jjj z!*z3PU{;k(ezgcFF{Ec;6GU1Pc^=L76o9&)X)q*!KOO@zMvvh~V0_*=X(JJ54EqTR z#3kVV(V!U%KbVb;DOdvN$XM$4+r#lUnL!wU)E-fPKv8I!DB7H*Q$P9fBYq=fZW2y! z-y$r<hmmrGeI~ooo~F_RB{w@`0Q-aBycso~FfB9boUkjN5Qu&zopQmf9eU#Mj4qwf zJL<(u2T2_=O0C)Qjkker6Th@Wmxs+BMj>whmvFg144_?!#qZIfL-`65tFi&`|LlxL zgsAd~(xRYmxJ7+>V*R-O5UN%a3Q$(y9K`DD7!>?R_An$ryho(%@WcRXk_DzE<^)tv zJs6J4Hl(y+bb}q=URTrix{)TKbBY_yk@7B1jiZS*a|B}@kHR2f3rt{e$SYz5yzV=Z zm!PGka+Nx(E!x9`$qDvSSZTJ*AV8P0G*fIiud)=&aAtC*I^NjP*-6MB;IHV2b=5dB z%5l6QP!v<&C#;4BOTi?`g!51MYDej?m@E968fgb$`C%Zc_uqj_7@klb>tW015idag zLv+5Z!)KMC<}DCZHT^}Qwr1u56}WX-hp)TmG#z>j13k57$@>xF!8UTTv##5Z&0Fbs z{XwKVfk*eTCdQ7#@oaN?%yiIBqTIFd7xRT*)$sr)_C~u3YnHsM*me8720W6{-yv}e z5(=&WVNkZ@J(&)E9LrCzG$X|g&j@D?>khb;mp9x}yN0Mo^|uhEiF|Y9cW|P)SnS2W z?mEH&tL7fq3DH~zHPp!WBGSRGADMVgu^7kgeANev6z_B#UHFI+7v~OZxNBE6ZwdcW z($$?X+_9UN`3pgkVAEjZCp-e6QFQz+SG?(hf7Bo+et}H5HZ~a(!doXGWa1lasr(GL zcvkqB(?tI`n!ux4X8a<>sen&Jh=hr(X78%8^jS5#<1%1lzP(oquboB2XsTpSn*XDf zSIXculB)JqW6po781%GDY!m&2DcL~Q5Kd<^irb;=Y}6FRurtv}(-1)yCr7#AyFOeK zq~-7m6`+<+XJg6l9f!0mc|o6VhSz**su&M1BP^JBGXIl(H?Q)+Bkb@J$By_Hl4bVv literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/memory_response.dart b/mobile/openapi/lib/model/memory_response.dart new file mode 100644 index 0000000000000000000000000000000000000000..fb34bc1518876a70c3b39584e94c733993e8d147 GIT binary patch literal 2761 zcmbVOZExE)5dQ98aRG)z0aSV0ry{An7K<~qYhobv1`LKFFcM|4lSz%FY8a{i`|e0l zksP<lX27;Y9q;9Np5tje9*xKF?vKU%)o-)g+1<Ov><X^mf1Jf|HHX{z0&eD4@2~$l zK{K*^n=@@EKPNxE9?`8>OQm^QD4iCf;up}!#_&Al72j}a<8UuFwbJ(J4pwd1&ZKo? zQ_cTrgvRZXZSc2d8viZV2Aykje0!#(u}s=j<QPyCf@`<k91T_p$xT`*xkfWvGMT>q zG0jS*&1i(-ET{rh%_XZvg5S&0D65z;aKkrB@9wy%F&u}75x^MBzvH&i(f|X=x3C;q ztw6ZcGM=FM@L5D207AiFOJNcWdtdQ9#z*^e3=MFvR0Zv_TX?I!aILY_@DP*L%qk6& zNi@Iz%rh*(mRz61WC|XVj8zf&kHeF%U;Pz`CxxA!y1bl=kPo{4W)4DHSaS;{Gv%); z=NQ#F=%hi4kUZnYFufy2k))CvVVqgi)|Q0*zU+RLpR5*g4OaP!$Q5=rM044?nf%aR ztj-)-H>`f+x5bd}P*ftDqNfi=N%Z3>D1>Ace8wf1z!U3-{iRD3oW)X~wE@mnXx%5x zn`x&9XG;rv;_yG{1enahw~7H_4SZo$!wnQ#ZCX!Dw}jFv(BO=ex5O$}G03K>Jgr+{ zoZ@;M)S4G4c-Jes$xtsQB_*ZUI#y&Uc3G)%;>0f;sPz|@frG7u(PtJg#AG6ums1q0 z!#cnKU1ySH%-AP5N1k}!6Vy`&8AO<x|Fi^Lz=qX-5<K%_v8(wTYL#h3JjU=y)A<Q? z6MZlM{q5av%M)SN@zhrSQDAyQc}=0h#cL#C0D+J7;Cghe)s~tURZ+0Y@OU3E@Cayb zn#z*G4qPu+J}-ih91?<*0-T;Y870EpDm&ZMb{9M%s@U&Q>sW2^m~p0$&jaqBlgR;= z3CYP-f=Rcq5Hyc0o=lva`VQ%=q7o98=br1{P7i7TcK~s_%PJ}=3NT$jxN=@xp9JOg zdiDS-qWmZeAyE$rTJ>A`z)9%nKH%6v-^JSzGx2<UOTO6W?KHfAZg8bIJ8(qODD(&3 zfID2(yTBfvws_KU6KWwwd_B*x=-edEs>jJQY$8Z_YGVB4Z@#0$%<cDDq|o1?6t{Lw zgGJ*l9YYQy<tNZq*b<khYiOVj8F=N5a8G-_;fajK^j<-}us@F|fz)%@I#V*I2S+s# z!^=7oee~6GG|>Oi%nRl45^1$3Sg-4!RSs4)q<6?cLezAy^#l}mjrrK3ai=OTia~#4 zpq?1Uuqb)Qd-ryO5;Hh084}!}B<?xjKr$GX#LwWvr}-_tBDK7`Db>9i{Z4zk$vk~< PV02^Hy(?Xg@BsN2j}w2k literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/memory_update.dart b/mobile/openapi/lib/model/memory_update.dart new file mode 100644 index 0000000000000000000000000000000000000000..f2529186c0432db7c348cb2f75849bc1e196b924 GIT binary patch literal 3157 zcmbVO?@t>!5dEINVyI3fNK5G5r;0YARe@7`g`5gK>4ebA*>#B9jlH$KL=nyZes64V z*oZ*UA+bs9@%(u2&8!a&dItye{_Ev<|Id^2lP~WtPmbyI<L8r%j>mL9zNEA9@yFAD zcVNbr?@D3)@Q=aouY3Gc-DqQFJ~cL<s!ASGqZ=p7Tvl=}wa?n$YF-=bx1JEyLM&Zg zH$J!WN24r`Yca>O78ajgS_kLWZ9iTZ?Yz)FS0w_fsggDvcUyy1N=uhlM&H29&xFoj zzsif5u&&obI8SCurj}aNYJmSYy<SlX=jcq%ja`1NOW`Hm^@t8o{1@pPs~ri_azR&J zn`=@btwIi9+}-K(X(B&UZh$zGN|qT`R(psQ+{P#UT}k02Y2$(FC9J8o$jtgBxtVFI zl4jyo(nLxPBfYPis$y?DbfI)15kQiwWdTGA;g!+E1SRLCk&ay>%c5#Zi4saV6;0*o zR#c6oe&dwB2~J+5ox>OF4E8dbnT5QSHVY;<QcDYD%H-ae#v+Bn!1KIuo~ELzXd;U5 z<PGP|zJXdwnf$U2I5DUnz$d7*B+4?HZAk!mKT==_fb-Y}WQ-mY0LOSvjH!+&fga#~ zXHbKMdD>;}QHZfL9QMbPZ?b?QfTZ1{;fSKb3Q_ghdDiay{29Ly0(P0EmoMR#;%3V= z96y?Y(w=Ul2S9H2fw3Eof`fIRaeHZ5NauuIa(h7WGwGBIX8lwdhhS*wh+ffQ8*OLM zx{XTfmVV)FVB5qm{ZQ?0x0b8qd-z-Wa=jY>JQMZXcqpTCiMCb62o!vBM#D>#`c!FA z(Kjs7kRDjyt=@#})szCPYoK;w6|ENv9<J<eN?`XI-nv~B-K<DfXqy-rz->Fl;3Vr1 z+J-(3mb|=5jrhF&CZS7;<IR!sE;f*(sWo#rV@Y3yp2A3&!eE!@#T9V7<@eUkl}u zP}4{~M6<~$hE$kuwkaS=m$O`Rj61K29FuWoN~S(8Y-sNAwiT4G);nv8brbQZ0s z+jYW}=&%(`28D3`0dM^%BNlkVudA1S0G2lsU57Lm^*=mY#?wo*`&(|tQJ+<j(LJeq zJPY{`-uYt{o+&`fTOcSE{aGQmZsZYFSULsz8`Z}^cN~>&tXc4=gn%%ZoXl2j`LTE_ z9Zy1tb;I%1b<ByeV|U!|93Fihv@=ld-1w7u?U$`Tz=>JXPj1bE=N2Em-){krWDGY* z9I%E$6-YP%!l2xf_n=33JElLt{EQTLPDVIeSU12;r@Y~k`Yl9<Yp{ZsF7n;h-@u8P z-im))b%X;}-FdL#qB|5?s1ff;q=VfsGVz#V(T?~1)!r_0yx?(<;rmJ)q#LYZ)h=7! zFq|+drE4o;xMMX>^B0^9f=!2wU+@TkFm@4r@Leu>+J*B`gB<t?GI6vs$(RtHIsqXQ z-)alxXISE1;X6+k{rzZy6KR<7I~5lMz8=97I<g%&o#D@_*=&~)8}ss3EoAh(UWAXX zO16agKU#UF44xyYG_N{+{!7K6rcGkI=nt5ZHDn#(Wb0PEZ^~?~rYMGujz*Y{2o7+G zlxx1*n>ImO4zE!GYWbozk^J6q2+NWe^yy-J&KIb}cz79Mz{G3%Uwn4`N*~?94li@e Gi2nfU^82y? literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/user_admin_create_dto.dart b/mobile/openapi/lib/model/user_admin_create_dto.dart index daf8854e019fa2766a2430f1a75cdecfdaf7c2b2..db514a1d571b6477b41db49d958d450d60329c0f 100644 GIT binary patch delta 54 zcmV-60LlO9G2$z*qXCoV0S=SP0iLtq1LXs=^#@7;vwsO>0kZ`Q4gs^S3`GP4ZeeX@ Mld%yYvtbd42XRysZ~y=R delta 342 zcmaE){nBv5Vn&gYjLc%a+|=CsqRiA{*Sy4}oYa)ba~U^n7Gg<e<VTa(?8WqoSqV*0 z!PZs*O=z<kTLdGzsUUTEr8zkY_6i0HR_Iz3H|MaIF`|pws-T<vfJ1;$A~Q`v1FORo zxFj{ubg0KF*eYOE?9U}9k4-Vik=9%aKv28+HK!K~dMHdT<dYXf*QmK!n{PWi0FP99 ARR910 diff --git a/mobile/openapi/lib/model/user_admin_response_dto.dart b/mobile/openapi/lib/model/user_admin_response_dto.dart index 3fc8c2e274bff3eff66c0a86b53e271869c39fc7..8060fa7cfcf0532d34b93034568898847b449ecf 100644 GIT binary patch delta 42 zcmV+_0M-BNKIAyCGXk?s0;2)5X9W!cv!n-W0<%vF3<9%#48j7l+7HhKv+@;r2~n~S AkN^Mx delta 717 zcmZ`%&q^CX7{?W<4Yi`B*Yuar5(8=qUP@{zMZ62?p+$O_?0oBvoy>%p*_7bHLmwgZ z)Jq?su$Ml7_y*p*2;O`NXEv$OsoldI_V@SuW^UfxzWcS4Jaf6Q+d~>^Bgh^oHjp%3 zOP-hSlkW>>old6)_!Ebcm?bFfsEZF?gOSlAGHwE!>q2tmY)lvsDNj;cs*o~xHP9C- zCAgemN0f?8q|6DekO@h1bFRq-Yj~<GIDvsv#)>oPFlMqKY!p_g<I1I3LDF@b_Z8Zk z$n{qmli92&$E3(GM||#YbzyvmRD0`TVI49iB?c`00;faq;LY%-Gu&iE+g=;y$G%op za57+=&v*|s@Xh}-VwK?a9`;KM`wPyq#SXNVd-!|#{B6zSeP?`DdA0Z`X+}7Dy;xFS zE=`i?5_#Z-iLi&)=*E&pxw~9X;=jF@QCRu0cAk6^88#kxCmfAgL>s5RbNEiIR!624 rxwJl`SIr-Hoc?%PUyaDqpHJ%LtCfqTe}LcXFV-S*t6g5Ne|vlfrn31( diff --git a/mobile/openapi/lib/model/user_admin_update_dto.dart b/mobile/openapi/lib/model/user_admin_update_dto.dart index ecd145248f1feb1e2a907402837176e655905344..dd0db767fe6b08cc7f0c4ce10e479f6ab68e89e2 100644 GIT binary patch delta 83 zcmX?R^Uz>JKjY-3i~^g_GhSfaEX$hDI9ZmJV>36$Cg#acxn(vR@`y4{uH)6&{E9b( mkta1bF*C<j1x!tD<9{~!yFl?~Rv|-{$<<=!n}3UWvjYI$&K+a` delta 648 zcmaE8aLi^yKci`4Sz<|Ik#l}devuBB0uYpBWEShC<|bz5An|ikbMuQbQ;S{m5|eUL zQ#Mawyv-;RTAW(sh|q4YfMnKYUZ#soli#zm@uO?yWy)gQ{ETH2vlNnY1zTHW-IKdG zw3N^^f|Q^MZC=Kq%xHvWOkQbDj)J{{fr1sXiHdp|iNzVt`6;QI3fhwoa~kQQnFTT) zNh_M{=0}|Aj2=if*{UFMbQHkhWvc=SHgsp&s-T%RIh6YuH<JFz4m^^`j#ZCUu!Y0} zvLZu%ITTG3c;qHq^Aw{8Fvu_sGz$?1qDf2!8j5bf<_SFhEH+3sTPdK$v|d_Key&$> lex61akcci)M^daFtEn^jnt%fzs!fxh3MybdNc4gim+>4X3P diff --git a/mobile/openapi/lib/model/user_preferences_response_dto.dart b/mobile/openapi/lib/model/user_preferences_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..673f5bfaf856489e712dbc89386dbf35688d8ffe GIT binary patch literal 3234 zcmbVOZExE)5dQ98aRG{2!BlzMr^1=M21_#Z#qp4)9|pq^7@4-%$)ZM5F^tszeRrfN z%eAd!HDFsJ?~V67cf7%%HyFU>m;1?^Kd*1E?=J7Jui)nWr|S@|CU85shw<d<{mtKJ zC`OX+=1iIR*XWnGJ$e<(LMomtq)HYd=R>H9O7nTbbH3t*3H`fRl~S3b8Z2M4jZVtS zB#QrA35DtfTj6iX6#g5oG#Xd>xOyrJZCGIvF-M1DA-J;Y`lz#96kI2{ES4x{GFBvS ze@@bjDc$R#I|Fh7vg8FTMTFmrUN6m=*6>Ah^_Sucu6U7heaCf)e)*M=@YDksU@Gpo zsZ^nXLBZEBJ*_bVVM9)NgsP|I0<sL?lL96aI%1E^FcluzS@D%rf@|8_o@3+ymsZ#Z z3r^SWj7fOY?08+f_f9@?r7)dn;X%rr5~7%q3gS4JJbdLTrpaVnokKhVm-dw90mT>k zop0aV6Nt{_y?$*^V$IcOLoaotu(#`Dq<#N<5U(f<EV+S<>Ff_V=NO@N&`^~aT5yC= zGqoXxA!#zMg|=dWOHt(2&*3Mx?j7!S4qSgWN)$^l(mez=JN?t}PMxp#mHQ8yL&dMW zw^`qT#me8D(GcbvtPPQl(9Z`g3-sbxvJeH!;VUjd3<uIr+Y5URuo6?%cRu?w6mHS# zJ0Ty&PRy@3L1h_lP13@aDEEVv0IZmUYZ4lS(eTLfifdRXxvH&B?GhHx0tNb1XVWn& zXCV|-jvOUIu7$QW)k3?pMVr_zp{f*FtP|pQf=$DSG{OFo$vL6#7CP$jcN>k(dX0jM z(nElO1vW<FJcj1i0rtSLB2mh;Ie;l}W$UV*x=BYg0fy{84$I+p)gDpmY_LR%7Ubt= zu6vNO$8!kdy@qh0>;?O?ojL|fHn#S+<^o~SaG}oVCxJ&jT?Zq`QQ*c$>r+cnhwx+@ z&{D}Yd&sexVZn0E!>!M)%dFzM$_){}!_9Q&;w6~KIH5$LVVAKKj)cCI=6Y3{jkk`- zVb=?4CvuIA+lub?J+=<_xC5_UO7Spt>L`6X)||zb%5CG+{pn~Co6jA$TAD8L9)LX< zYAqw_fPqin5d4$1V(YcW2<h6eu_K4DV&ze!e9$gcDAn#l9R_`4YJ-BYbYfz`iydaC z$_V>VJ(~=n86y(B$#Gzz!Nj%uJ$!LhZ9Kvez2Rl*&qYYNv;7~Wt;xu0v~YU{aZKLS zDgMJZ)S#`t<1U5K0XwQT#fJ&NS89_sU((&%Yhf~6qKczY=QJ{JoC50Cy%;YXRHW&T z?C$#IxWyb|TUQB=NR@<+a_FVijyBrtde-TWaO{Qjc?lKkKHcgh&oT{9xKDx8I7I|y zlg<|5!lu}FWgj*ybR^pA8R)b{o&nrvyx~bRgCZq0P7Dl5P$7jLIN$}V(Jd7YgAbo4 nw=~q&UB%wI%>et6berL1^kfs`ClPli^_zRfPO%p~>{0&#SAZ+o literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/user_preferences_update_dto.dart b/mobile/openapi/lib/model/user_preferences_update_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..887293931c282acb441fe7979fba57b6b0f4b731 GIT binary patch literal 4054 zcmeHK-*4MC5PtVxaVd(%!BnT)Q{m1@izXe~HA&H?4}%d1j6}z5Wzr?77)F}^eczFy zEjLz^4cOZrqR7<!;(g!Uk^B3-{e61%`TXqZAIGQ1A77mxAJLn)?~gM&I-}FGb2>RY zdi&<D9f+~y%TicB`fc#*^B(_HSK3&aPmIkcs**#hb?s!C%Sz6q_F4L^W^-fxRt-@t z#M0&S+UHh&tCfYi7Bf8O!s5S|*1@=S+tmxBofq2Ys)R!|QPKu=x7Astv~+o8^cBSX zROtNqv%HuJ>v}!7^JFGu=2DBf8sP6muUAyUIr{9R{b1!pTB!@^KF>?x<&igZ(<9nP zB0frATkS}YmJ1qp$zPBP;TCcL#V)BnTP5;S<p$zfcww{k!c5MLRnqa&?H(cw0mLVL zsHAX`wDG{=9Qri3*pKx~a#K@RB~8V(q)RC^g!EHgSCzP|<PLpO*ce<&a&uWAkA?8c zXku6!<E4>~O=Ay5RhJT*E#*Yim8WY_)sp(PQ~D|xdDMIvK3YYvm(kQL<h8U}5V?|C zTBNaT%sW$Cgish*p4HCNL{t@BisCCF(TJOEBfm|^<d^f1MNa=~umA+GB+4?1Z3zHz zcWf{?fIdDWWt=^R2Zr%{6#$5cA5w(l|Even|A$#WVU{mb))VJL-d`F}$O0{Zb7@S) z1q&V-OQTW$?DC5&P{ts#-J{U~#kNq0s?XSF>C5-;@r{6YR~gciCvZVguqj0NXgIL> zHATqZ)BBxhmG*QcJ-F<qubJkfgWzD*NV$J%i3a0@U2^|JOPflkToCI=^05mp0y?B; zG;E^T86=~7^h=aZVf-MUmRlNk0{!G|C}a~~`XQ6MU3nVExJHlT|7?|j(c|RoVmJ6W z5%b@p$&AV+y1Xh5P<!v3(eM$w-b86p(HAVyi0(+=+`b6AQxgh+CRlc2PLdG{2F7+b zhO~PDTdj5)ZQPQq&@7`{V6a7p^V|q;x=kCSF2_k=?K-Xe+<B4;dOy0iVakL!*~)Fx z{Qvp%A)!l(L)nq?E;evS6KiHL%q?{sdO8Ny1OmG}*~WnHB8%J$1-4MGX_U=DV2EbV zLoyFj+13TB(&b#QIfk@XMUHtsH6`;oE_4>(VTdI#qhU&nF(J$Zh*Jj@jKI=5VPdri z6hsDvaQ+UJ`f+%2g*SQaB6vSw&w`Oo!;PUZ;G~5>nY1m=?Zv6hdI{AbowOR;9nB=R zd$H0*kZj;KJexQZEdu^c9^Wf$v1<bdRN?cR6lY2($D7ejXrfovEchgVLc!d1GP_L~ zjrp|F@t{XZb~+hf#8?>`HqE`2!$PMFlpQE{YW(qR?w83n&_dttH!jSAM>MOhzb8B+ z)}v0otaFKtG)d#ct$A{%Qu%Y-)&R$b$flNNOnFNE1^|tgH@v-mO5KpsTd>kbr``BE z^G7o=--=PnMobf=+MTh}T)R~!I1tBA(Fk&*h*nq8pgXL~Jsr_=oRV=F;6r~LRvXA- z)h-jZ1O9~rY}ysW5O?-)jDx{JkZ&P&57tQBI4XXXOP&ZpKXR6X_zB=pZ8cm5iW_3c zmWd~hh4ND@ah>rcq6?L$Q5@P$&*h^h&e44LfYo(hJZX(eW?6UH9k3#cY~{{F>l-%O zth&eLzv0#cY4Z>YHvPEOJMLu|_O}Us7eWJuaFxmy(Xv%}uM0g}u`iNmqf5YLi!}SV zkI5B3(p8s0MTet_0Q`LCxs*IY9o%xc2zvMF?3C{!O_vXI6-_JNlmBGh)gkHN268wa Hq3`?+X#Fu< literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/user_update_me_dto.dart b/mobile/openapi/lib/model/user_update_me_dto.dart index 1b54d4a383320d8e4f8eda1f0c36939046ee8604..2d665fc7847b8bd3ca24e313ab9c5b34b335d9e4 100644 GIT binary patch delta 79 zcmeyQ*Q>mtjgd7qH!(A3^KQm2#?6OW@)<X;WdF!G`4FefWInF?$w#?#CL3~h^MDoE es(`7<EId_{IeCjWi}QIiO<pcwzFA$+n+*V^1RHVy delta 703 zcmeBG{-n2|jgcp@EU_f9$T>eJzes0t4Wkr)Zfb6RQD$nfYhGefPHM{LZpJP~nb6|Y zB1eQudj%wIn<ubjF>aP+PG=NN%FoZSS3oxbEZ@WOl2Hmtmx8S=vLTbx*|e0<G=h|% z2~BQh>or0%C9gCmN5NjfK*0*xKt;Wb#NrI+{FGEp1?|aP>_)n1MuAL6(uyWKS(AO9 z2a-j$Do7k1h1A@{%p6-4Fr|a;NLv+j!!EN|xghCRP=r{msZh<O00vpb`FYVr>PTwT zV-;)_N-{Ew^^gQ7*K^2mBl870<tF=b6bGPLh|>ZzbqGt)BtRCSr-aEs%Qhe2@Mf|> za)FfsTGG%<E6UIH0(wUSo1ZjwCNpt6*rM5HrI4Fg5SExzn(ChxQdy8{14>7BU;}g% Oiqz3mZ#Lr&X9ED|KKVER diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 558823e62b..d875994865 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -432,6 +432,98 @@ ] } }, + "/admin/users/{id}/preferences": { + "get": { + "operationId": "getUserPreferencesAdmin", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPreferencesResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "User" + ] + }, + "put": { + "operationId": "updateUserPreferencesAdmin", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPreferencesUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPreferencesResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "User" + ] + } + }, "/admin/users/{id}/restore": { "post": { "operationId": "restoreUserAdmin", @@ -6403,6 +6495,78 @@ ] } }, + "/users/me/preferences": { + "get": { + "operationId": "getMyPreferences", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPreferencesResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "User" + ] + }, + "put": { + "operationId": "updateMyPreferences", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPreferencesUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPreferencesResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "User" + ] + } + }, "/users/profile-image": { "delete": { "operationId": "deleteProfileImage", @@ -7621,6 +7785,25 @@ ], "type": "object" }, + "AvatarResponse": { + "properties": { + "color": { + "$ref": "#/components/schemas/UserAvatarColor" + } + }, + "required": [ + "color" + ], + "type": "object" + }, + "AvatarUpdate": { + "properties": { + "color": { + "$ref": "#/components/schemas/UserAvatarColor" + } + }, + "type": "object" + }, "BulkIdResponseDto": { "properties": { "error": { @@ -8584,6 +8767,17 @@ ], "type": "object" }, + "MemoryResponse": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "MemoryResponseDto": { "properties": { "assets": { @@ -8650,6 +8844,14 @@ ], "type": "string" }, + "MemoryUpdate": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "type": "object" + }, "MemoryUpdateDto": { "properties": { "isSaved": { @@ -10878,9 +11080,6 @@ "email": { "type": "string" }, - "memoriesEnabled": { - "type": "boolean" - }, "name": { "type": "string" }, @@ -10942,9 +11141,6 @@ "isAdmin": { "type": "boolean" }, - "memoriesEnabled": { - "type": "boolean" - }, "name": { "type": "string" }, @@ -11000,15 +11196,9 @@ }, "UserAdminUpdateDto": { "properties": { - "avatarColor": { - "$ref": "#/components/schemas/UserAvatarColor" - }, "email": { "type": "string" }, - "memoriesEnabled": { - "type": "boolean" - }, "name": { "type": "string" }, @@ -11046,6 +11236,32 @@ ], "type": "string" }, + "UserPreferencesResponseDto": { + "properties": { + "avatar": { + "$ref": "#/components/schemas/AvatarResponse" + }, + "memories": { + "$ref": "#/components/schemas/MemoryResponse" + } + }, + "required": [ + "avatar", + "memories" + ], + "type": "object" + }, + "UserPreferencesUpdateDto": { + "properties": { + "avatar": { + "$ref": "#/components/schemas/AvatarUpdate" + }, + "memories": { + "$ref": "#/components/schemas/MemoryUpdate" + } + }, + "type": "object" + }, "UserResponseDto": { "properties": { "avatarColor": { @@ -11083,15 +11299,9 @@ }, "UserUpdateMeDto": { "properties": { - "avatarColor": { - "$ref": "#/components/schemas/UserAvatarColor" - }, "email": { "type": "string" }, - "memoriesEnabled": { - "type": "boolean" - }, "name": { "type": "string" }, diff --git a/open-api/typescript-sdk/README.md b/open-api/typescript-sdk/README.md index 53a83a4237..046cea7695 100644 --- a/open-api/typescript-sdk/README.md +++ b/open-api/typescript-sdk/README.md @@ -13,22 +13,13 @@ npm i --save @immich/sdk For a more detailed example, check out the [`@immich/cli`](https://github.com/immich-app/immich/tree/main/cli). ```typescript -<<<<<<< HEAD -import { getAllAlbums, getAllAssets, getMyUser, init } from "@immich/sdk"; -======= -import { getAllAlbums, getMyUserInfo, init } from "@immich/sdk"; ->>>>>>> e7c8501930a988dfb6c23ce1c48b0beb076a58c2 +import { getAllAlbums, getMyUser, init } from "@immich/sdk"; const API_KEY = "<API_KEY>"; // process.env.IMMICH_API_KEY init({ baseUrl: "https://demo.immich.app/api", apiKey: API_KEY }); -<<<<<<< HEAD const user = await getMyUser(); -const assets = await getAllAssets({ take: 1000 }); -======= -const user = await getMyUserInfo(); ->>>>>>> e7c8501930a988dfb6c23ce1c48b0beb076a58c2 const albums = await getAllAlbums({}); console.log({ user, albums }); diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 2c07072f68..8030c92d44 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -45,7 +45,6 @@ export type UserAdminResponseDto = { email: string; id: string; isAdmin: boolean; - memoriesEnabled?: boolean; name: string; oauthId: string; profileImagePath: string; @@ -58,7 +57,6 @@ export type UserAdminResponseDto = { }; export type UserAdminCreateDto = { email: string; - memoriesEnabled?: boolean; name: string; notify?: boolean; password: string; @@ -70,15 +68,33 @@ export type UserAdminDeleteDto = { force?: boolean; }; export type UserAdminUpdateDto = { - avatarColor?: UserAvatarColor; email?: string; - memoriesEnabled?: boolean; name?: string; password?: string; quotaSizeInBytes?: number | null; shouldChangePassword?: boolean; storageLabel?: string | null; }; +export type AvatarResponse = { + color: UserAvatarColor; +}; +export type MemoryResponse = { + enabled: boolean; +}; +export type UserPreferencesResponseDto = { + avatar: AvatarResponse; + memories: MemoryResponse; +}; +export type AvatarUpdate = { + color?: UserAvatarColor; +}; +export type MemoryUpdate = { + enabled?: boolean; +}; +export type UserPreferencesUpdateDto = { + avatar?: AvatarUpdate; + memories?: MemoryUpdate; +}; export type AlbumUserResponseDto = { role: AlbumUserRole; user: UserResponseDto; @@ -1073,9 +1089,7 @@ export type TimeBucketResponseDto = { timeBucket: string; }; export type UserUpdateMeDto = { - avatarColor?: UserAvatarColor; email?: string; - memoriesEnabled?: boolean; name?: string; password?: string; }; @@ -1200,6 +1214,29 @@ export function updateUserAdmin({ id, userAdminUpdateDto }: { body: userAdminUpdateDto }))); } +export function getUserPreferencesAdmin({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: UserPreferencesResponseDto; + }>(`/admin/users/${encodeURIComponent(id)}/preferences`, { + ...opts + })); +} +export function updateUserPreferencesAdmin({ id, userPreferencesUpdateDto }: { + id: string; + userPreferencesUpdateDto: UserPreferencesUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: UserPreferencesResponseDto; + }>(`/admin/users/${encodeURIComponent(id)}/preferences`, oazapfts.json({ + ...opts, + method: "PUT", + body: userPreferencesUpdateDto + }))); +} export function restoreUserAdmin({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { @@ -2780,6 +2817,26 @@ export function updateMyUser({ userUpdateMeDto }: { body: userUpdateMeDto }))); } +export function getMyPreferences(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: UserPreferencesResponseDto; + }>("/users/me/preferences", { + ...opts + })); +} +export function updateMyPreferences({ userPreferencesUpdateDto }: { + userPreferencesUpdateDto: UserPreferencesUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: UserPreferencesResponseDto; + }>("/users/me/preferences", oazapfts.json({ + ...opts, + method: "PUT", + body: userPreferencesUpdateDto + }))); +} export function deleteProfileImage(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText("/users/profile-image", { ...opts, diff --git a/server/src/controllers/user-admin.controller.ts b/server/src/controllers/user-admin.controller.ts index 4d0b781e81..83b5156eda 100644 --- a/server/src/controllers/user-admin.controller.ts +++ b/server/src/controllers/user-admin.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; +import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; import { UserAdminCreateDto, UserAdminDeleteDto, @@ -55,6 +56,22 @@ export class UserAdminController { return this.service.delete(auth, id, dto); } + @Get(':id/preferences') + @Authenticated() + getUserPreferencesAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserPreferencesResponseDto> { + return this.service.getPreferences(auth, id); + } + + @Put(':id/preferences') + @Authenticated() + updateUserPreferencesAdmin( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: UserPreferencesUpdateDto, + ): Promise<UserPreferencesResponseDto> { + return this.service.updatePreferences(auth, id, dto); + } + @Post(':id/restore') @Authenticated({ admin: true }) restoreUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserAdminResponseDto> { diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index f66807b92c..66a92e1a3f 100644 --- a/server/src/controllers/user.controller.ts +++ b/server/src/controllers/user.controller.ts @@ -17,6 +17,7 @@ import { import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { NextFunction, Response } from 'express'; import { AuthDto } from 'src/dtos/auth.dto'; +import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; @@ -52,6 +53,21 @@ export class UserController { return this.service.updateMe(auth, dto); } + @Get('me/preferences') + @Authenticated() + getMyPreferences(@Auth() auth: AuthDto): UserPreferencesResponseDto { + return this.service.getMyPreferences(auth); + } + + @Put('me/preferences') + @Authenticated() + updateMyPreferences( + @Auth() auth: AuthDto, + @Body() dto: UserPreferencesUpdateDto, + ): Promise<UserPreferencesResponseDto> { + return this.service.updateMyPreferences(auth, dto); + } + @Get(':id') @Authenticated() getUser(@Param() { id }: UUIDParamDto): Promise<UserResponseDto> { diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts new file mode 100644 index 0000000000..2dd9492d07 --- /dev/null +++ b/server/src/dtos/user-preferences.dto.ts @@ -0,0 +1,47 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsEnum, ValidateNested } from 'class-validator'; +import { UserAvatarColor, UserPreferences } from 'src/entities/user-metadata.entity'; +import { Optional, ValidateBoolean } from 'src/validation'; + +class AvatarUpdate { + @Optional() + @IsEnum(UserAvatarColor) + @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) + color?: UserAvatarColor; +} + +class MemoryUpdate { + @ValidateBoolean({ optional: true }) + enabled?: boolean; +} + +export class UserPreferencesUpdateDto { + @Optional() + @ValidateNested() + @Type(() => AvatarUpdate) + avatar?: AvatarUpdate; + + @Optional() + @ValidateNested() + @Type(() => MemoryUpdate) + memories?: MemoryUpdate; +} + +class AvatarResponse { + @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) + color!: UserAvatarColor; +} + +class MemoryResponse { + enabled!: boolean; +} + +export class UserPreferencesResponseDto implements UserPreferences { + memories!: MemoryResponse; + avatar!: AvatarResponse; +} + +export const mapPreferences = (preferences: UserPreferences): UserPreferencesResponseDto => { + return preferences; +}; diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 8290df6adb..63bac60d06 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsPositive, IsString } from 'class-validator'; +import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsPositive, IsString } from 'class-validator'; import { UserAvatarColor } from 'src/entities/user-metadata.entity'; import { UserEntity, UserStatus } from 'src/entities/user.entity'; import { getPreferences } from 'src/utils/preferences'; @@ -22,14 +22,6 @@ export class UserUpdateMeDto { @IsString() @IsNotEmpty() name?: string; - - @ValidateBoolean({ optional: true }) - memoriesEnabled?: boolean; - - @Optional() - @IsEnum(UserAvatarColor) - @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) - avatarColor?: UserAvatarColor; } export class UserResponseDto { @@ -37,7 +29,6 @@ export class UserResponseDto { name!: string; email!: string; profileImagePath!: string; - @IsEnum(UserAvatarColor) @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) avatarColor!: UserAvatarColor; } @@ -75,9 +66,6 @@ export class UserAdminCreateDto { @Transform(toSanitized) storageLabel?: string | null; - @ValidateBoolean({ optional: true }) - memoriesEnabled?: boolean; - @Optional({ nullable: true }) @IsNumber() @IsPositive() @@ -116,14 +104,6 @@ export class UserAdminUpdateDto { @ValidateBoolean({ optional: true }) shouldChangePassword?: boolean; - @ValidateBoolean({ optional: true }) - memoriesEnabled?: boolean; - - @Optional() - @IsEnum(UserAvatarColor) - @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) - avatarColor?: UserAvatarColor; - @Optional({ nullable: true }) @IsNumber() @IsPositive() @@ -144,7 +124,6 @@ export class UserAdminResponseDto extends UserResponseDto { deletedAt!: Date | null; updatedAt!: Date; oauthId!: string; - memoriesEnabled?: boolean; @ApiProperty({ type: 'integer', format: 'int64' }) quotaSizeInBytes!: number | null; @ApiProperty({ type: 'integer', format: 'int64' }) @@ -163,7 +142,6 @@ export function mapUserAdmin(entity: UserEntity): UserAdminResponseDto { deletedAt: entity.deletedAt, updatedAt: entity.updatedAt, oauthId: entity.oauthId, - memoriesEnabled: getPreferences(entity).memories.enabled, quotaSizeInBytes: entity.quotaSizeInBytes, quotaUsageInBytes: entity.quotaUsageInBytes, status: entity.status, diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 1b93f96e71..72330ac9b7 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, ForbiddenException, Inject, Injectable } from '@ne import { SALT_ROUNDS } from 'src/constants'; import { UserCore } from 'src/cores/user.core'; import { AuthDto } from 'src/dtos/auth.dto'; +import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; import { UserAdminCreateDto, UserAdminDeleteDto, @@ -17,7 +18,7 @@ import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface'; -import { getPreferences, getPreferencesPartial } from 'src/utils/preferences'; +import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; @Injectable() export class UserAdminService { @@ -40,18 +41,8 @@ export class UserAdminService { } async create(dto: UserAdminCreateDto): Promise<UserAdminResponseDto> { - const { memoriesEnabled, notify, ...rest } = dto; - let user = await this.userCore.createUser(rest); - - // TODO remove and replace with entire dto.preferences config - if (memoriesEnabled === false) { - await this.userRepository.upsertMetadata(user.id, { - key: UserMetadataKey.PREFERENCES, - value: { memories: { enabled: false } }, - }); - - user = await this.findOrFail(user.id, {}); - } + const { notify, ...rest } = dto; + const user = await this.userCore.createUser(rest); const tempPassword = user.shouldChangePassword ? rest.password : undefined; if (notify) { @@ -72,25 +63,6 @@ export class UserAdminService { await this.userRepository.syncUsage(id); } - // TODO replace with entire preferences object - if (dto.memoriesEnabled !== undefined || dto.avatarColor) { - const newPreferences = getPreferences(user); - if (dto.memoriesEnabled !== undefined) { - newPreferences.memories.enabled = dto.memoriesEnabled; - delete dto.memoriesEnabled; - } - - if (dto.avatarColor) { - newPreferences.avatar.color = dto.avatarColor; - delete dto.avatarColor; - } - - await this.userRepository.upsertMetadata(id, { - key: UserMetadataKey.PREFERENCES, - value: getPreferencesPartial(user, newPreferences), - }); - } - if (dto.email) { const duplicate = await this.userRepository.getByEmail(dto.email); if (duplicate && duplicate.id !== id) { @@ -144,6 +116,24 @@ export class UserAdminService { return mapUserAdmin(user); } + async getPreferences(auth: AuthDto, id: string): Promise<UserPreferencesResponseDto> { + const user = await this.findOrFail(id, { withDeleted: false }); + const preferences = getPreferences(user); + return mapPreferences(preferences); + } + + async updatePreferences(auth: AuthDto, id: string, dto: UserPreferencesUpdateDto) { + const user = await this.findOrFail(id, { withDeleted: false }); + const preferences = mergePreferences(user, dto); + + await this.userRepository.upsertMetadata(user.id, { + key: UserMetadataKey.PREFERENCES, + value: getPreferencesPartial(user, preferences), + }); + + return mapPreferences(preferences); + } + private async findOrFail(id: string, options: UserFindOptions) { const user = await this.userRepository.get(id, options); if (!user) { diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 1f36501051..3920dbeaac 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -4,6 +4,7 @@ import { SALT_ROUNDS } from 'src/constants'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { AuthDto } from 'src/dtos/auth.dto'; +import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; import { CreateProfileImageResponseDto, mapCreateProfileImageResponse } from 'src/dtos/user-profile.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto'; import { UserMetadataKey } from 'src/entities/user-metadata.entity'; @@ -16,7 +17,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface'; import { CacheControl, ImmichFileResponse } from 'src/utils/file'; -import { getPreferences, getPreferencesPartial } from 'src/utils/preferences'; +import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; @Injectable() export class UserService { @@ -45,25 +46,6 @@ export class UserService { } async updateMe({ user }: AuthDto, dto: UserUpdateMeDto): Promise<UserAdminResponseDto> { - // TODO replace with entire preferences object - if (dto.memoriesEnabled !== undefined || dto.avatarColor) { - const newPreferences = getPreferences(user); - if (dto.memoriesEnabled !== undefined) { - newPreferences.memories.enabled = dto.memoriesEnabled; - delete dto.memoriesEnabled; - } - - if (dto.avatarColor) { - newPreferences.avatar.color = dto.avatarColor; - delete dto.avatarColor; - } - - await this.userRepository.upsertMetadata(user.id, { - key: UserMetadataKey.PREFERENCES, - value: getPreferencesPartial(user, newPreferences), - }); - } - if (dto.email) { const duplicate = await this.userRepository.getByEmail(dto.email); if (duplicate && duplicate.id !== user.id) { @@ -87,6 +69,22 @@ export class UserService { return mapUserAdmin(updatedUser); } + getMyPreferences({ user }: AuthDto): UserPreferencesResponseDto { + const preferences = getPreferences(user); + return mapPreferences(preferences); + } + + async updateMyPreferences({ user }: AuthDto, dto: UserPreferencesUpdateDto) { + const preferences = mergePreferences(user, dto); + + await this.userRepository.upsertMetadata(user.id, { + key: UserMetadataKey.PREFERENCES, + value: getPreferencesPartial(user, preferences), + }); + + return mapPreferences(preferences); + } + async get(id: string): Promise<UserResponseDto> { const user = await this.findOrFail(id, { withDeleted: false }); return mapUser(user); diff --git a/server/src/utils/preferences.ts b/server/src/utils/preferences.ts index ae10c24fc9..f3561fa7b6 100644 --- a/server/src/utils/preferences.ts +++ b/server/src/utils/preferences.ts @@ -1,4 +1,5 @@ import _ from 'lodash'; +import { UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; import { UserMetadataKey, UserPreferences, getDefaultPreferences } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; import { getKeysDeep } from 'src/utils/misc'; @@ -37,3 +38,12 @@ export const getPreferencesPartial = (user: { email: string }, newPreferences: U return partial; }; + +export const mergePreferences = (user: UserEntity, dto: UserPreferencesUpdateDto) => { + const preferences = getPreferences(user); + for (const key of getKeysDeep(dto)) { + _.set(preferences, key, _.get(dto, key)); + } + + return preferences; +}; diff --git a/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte b/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte index 8c73ed4d84..5d5a351de2 100644 --- a/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte @@ -1,18 +1,18 @@ <script lang="ts"> import Button from '$lib/components/elements/buttons/button.svelte'; + import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import Icon from '$lib/components/elements/icon.svelte'; + import FocusTrap from '$lib/components/shared-components/focus-trap.svelte'; import { AppRoute } from '$lib/constants'; - import { user } from '$lib/stores/user.store'; + import { preferences, user } from '$lib/stores/user.store'; import { handleError } from '$lib/utils/handle-error'; - import { deleteProfileImage, updateMyUser, type UserAvatarColor } from '@immich/sdk'; + import { deleteProfileImage, updateMyPreferences, type UserAvatarColor } from '@immich/sdk'; import { mdiCog, mdiLogout, mdiPencil } from '@mdi/js'; import { createEventDispatcher } from 'svelte'; import { fade } from 'svelte/transition'; - import { notificationController, NotificationType } from '../notification/notification'; + import { NotificationType, notificationController } from '../notification/notification'; import UserAvatar from '../user-avatar.svelte'; import AvatarSelector from './avatar-selector.svelte'; - import FocusTrap from '$lib/components/shared-components/focus-trap.svelte'; - import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; let isShowSelectAvatar = false; @@ -27,14 +27,7 @@ await deleteProfileImage(); } - $user = await updateMyUser({ - userUpdateMeDto: { - email: $user.email, - name: $user.name, - avatarColor: color, - }, - }); - + $preferences = await updateMyPreferences({ userPreferencesUpdateDto: { avatar: { color } } }); isShowSelectAvatar = false; notificationController.show({ diff --git a/web/src/lib/components/user-settings-page/memories-settings.svelte b/web/src/lib/components/user-settings-page/memories-settings.svelte index dcd7033aa4..4d103ade13 100644 --- a/web/src/lib/components/user-settings-page/memories-settings.svelte +++ b/web/src/lib/components/user-settings-page/memories-settings.svelte @@ -3,20 +3,20 @@ notificationController, NotificationType, } from '$lib/components/shared-components/notification/notification'; - import { updateMyUser, type UserAdminResponseDto } from '@immich/sdk'; + import { updateMyPreferences } from '@immich/sdk'; import { fade } from 'svelte/transition'; import { handleError } from '../../utils/handle-error'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; + import { preferences } from '$lib/stores/user.store'; import Button from '../elements/buttons/button.svelte'; - export let user: UserAdminResponseDto; + let memoriesEnabled = $preferences?.memories?.enabled ?? false; const handleSave = async () => { try { - const data = await updateMyUser({ userUpdateMeDto: { memoriesEnabled: user.memoriesEnabled } }); - - Object.assign(user, data); + const data = await updateMyPreferences({ userPreferencesUpdateDto: { memories: { enabled: memoriesEnabled } } }); + $preferences.memories.enabled = data.memories.enabled; notificationController.show({ message: 'Saved settings', type: NotificationType.Info }); } catch (error) { @@ -34,7 +34,7 @@ id="time-based-memories" title="Time-based memories" subtitle="Photos from previous years" - bind:checked={user.memoriesEnabled} + bind:checked={memoriesEnabled} /> </div> <div class="flex justify-end"> diff --git a/web/src/lib/components/user-settings-page/user-settings-list.svelte b/web/src/lib/components/user-settings-page/user-settings-list.svelte index d239886ed9..f88ee58872 100644 --- a/web/src/lib/components/user-settings-page/user-settings-list.svelte +++ b/web/src/lib/components/user-settings-page/user-settings-list.svelte @@ -42,7 +42,7 @@ </SettingAccordion> <SettingAccordion key="memories" title="Memories" subtitle="Manage what you see in your memories"> - <MemoriesSettings user={$user} /> + <MemoriesSettings /> </SettingAccordion> {#if $featureFlags.loaded && $featureFlags.oauth} diff --git a/web/src/lib/stores/user.store.ts b/web/src/lib/stores/user.store.ts index 506782f48a..8d422d3704 100644 --- a/web/src/lib/stores/user.store.ts +++ b/web/src/lib/stores/user.store.ts @@ -1,7 +1,8 @@ -import type { UserAdminResponseDto } from '@immich/sdk'; +import { type UserAdminResponseDto, type UserPreferencesResponseDto } from '@immich/sdk'; import { writable } from 'svelte/store'; export const user = writable<UserAdminResponseDto>(); +export const preferences = writable<UserPreferencesResponseDto>(); /** * Reset the store to its initial undefined value. Make sure to @@ -9,4 +10,5 @@ export const user = writable<UserAdminResponseDto>(); */ export const resetSavedUser = () => { user.set(undefined as unknown as UserAdminResponseDto); + preferences.set(undefined as unknown as UserPreferencesResponseDto); }; diff --git a/web/src/lib/utils/auth.ts b/web/src/lib/utils/auth.ts index 91beb0293f..df5c9bc46a 100644 --- a/web/src/lib/utils/auth.ts +++ b/web/src/lib/utils/auth.ts @@ -1,7 +1,7 @@ import { browser } from '$app/environment'; import { serverInfo } from '$lib/stores/server-info.store'; -import { user } from '$lib/stores/user.store'; -import { getMyUser, getStorage } from '@immich/sdk'; +import { preferences as preferences$, user as user$ } from '$lib/stores/user.store'; +import { getMyPreferences, getMyUser, getStorage } from '@immich/sdk'; import { redirect } from '@sveltejs/kit'; import { get } from 'svelte/store'; import { AppRoute } from '../constants'; @@ -13,12 +13,14 @@ export interface AuthOptions { export const loadUser = async () => { try { - let loaded = get(user); - if (!loaded && hasAuthCookie()) { - loaded = await getMyUser(); - user.set(loaded); + let user = get(user$); + let preferences = get(preferences$); + if ((!user || !preferences) && hasAuthCookie()) { + [user, preferences] = await Promise.all([getMyUser(), getMyPreferences()]); + user$.set(user); + preferences$.set(preferences); } - return loaded; + return user; } catch { return null; } @@ -57,7 +59,7 @@ export const authenticate = async (options?: AuthOptions) => { }; export const requestServerInfo = async () => { - if (get(user)) { + if (get(user$)) { const data = await getStorage(); serverInfo.set(data); } diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index f711b081d6..d510edd25d 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -22,7 +22,7 @@ import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { mdiDotsVertical, mdiPlus } from '@mdi/js'; - import { user } from '$lib/stores/user.store'; + import { preferences, user } from '$lib/stores/user.store'; let { isViewing: showAssetViewer } = assetViewingStore; let handleEscapeKey = false; @@ -98,7 +98,7 @@ on:escape={handleEscape} withStacked > - {#if $user.memoriesEnabled} + {#if $preferences.memories.enabled} <MemoryLane /> {/if} <EmptyPlaceholder text="CLICK TO UPLOAD YOUR FIRST PHOTO" onClick={() => openFileUploadDialog()} slot="empty" />