From 171b6bb0a6571262420c0bd3789d66267bc097e1 Mon Sep 17 00:00:00 2001
From: Jason Rasmussen <jrasm91@gmail.com>
Date: Fri, 19 Apr 2024 20:36:15 -0400
Subject: [PATCH] refactor: system metadata (#8923)

refactor(server): system metadata
---
 e2e/src/api/specs/server-info.e2e-spec.ts     |  17 +-
 e2e/src/api/specs/system-metadata.e2e-spec.ts |  76 +++++++++
 e2e/src/utils.ts                              |   7 +-
 mobile/openapi/.openapi-generator/FILES       |   9 ++
 mobile/openapi/README.md                      | Bin 26365 -> 26826 bytes
 .../openapi/doc/AdminOnboardingUpdateDto.md   | Bin 0 -> 423 bytes
 .../doc/ReverseGeocodingStateResponseDto.md   | Bin 0 -> 474 bytes
 mobile/openapi/doc/ServerInfoApi.md           | Bin 11069 -> 9228 bytes
 mobile/openapi/doc/SystemMetadataApi.md       | Bin 0 -> 6367 bytes
 mobile/openapi/lib/api.dart                   | Bin 9114 -> 9254 bytes
 mobile/openapi/lib/api/server_info_api.dart   | Bin 12548 -> 11619 bytes
 .../openapi/lib/api/system_metadata_api.dart  | Bin 0 -> 4697 bytes
 mobile/openapi/lib/api_client.dart            | Bin 25021 -> 25237 bytes
 .../model/admin_onboarding_update_dto.dart    | Bin 0 -> 2989 bytes
 .../reverse_geocoding_state_response_dto.dart | Bin 0 -> 3748 bytes
 .../admin_onboarding_update_dto_test.dart     | Bin 0 -> 600 bytes
 ...rse_geocoding_state_response_dto_test.dart | Bin 0 -> 745 bytes
 mobile/openapi/test/server_info_api_test.dart | Bin 1583 -> 1473 bytes
 .../test/system_metadata_api_test.dart        | Bin 0 -> 936 bytes
 open-api/immich-openapi-specs.json            | 150 +++++++++++++++---
 open-api/typescript-sdk/src/fetch-client.ts   |  38 ++++-
 server/src/controllers/index.ts               |   2 +
 .../src/controllers/server-info.controller.ts |   9 +-
 .../controllers/system-metadata.controller.ts |  28 ++++
 server/src/dtos/system-metadata.dto.ts        |  15 ++
 .../src/repositories/metadata.repository.ts   |   2 +-
 server/src/services/index.ts                  |   2 +
 .../src/services/server-info.service.spec.ts  |   8 -
 server/src/services/server-info.service.ts    |   8 +-
 .../services/system-metadata.service.spec.ts  |  31 ++++
 .../src/services/system-metadata.service.ts   |  29 ++++
 web/src/routes/auth/onboarding/+page.svelte   |   4 +-
 32 files changed, 362 insertions(+), 73 deletions(-)
 create mode 100644 e2e/src/api/specs/system-metadata.e2e-spec.ts
 create mode 100644 mobile/openapi/doc/AdminOnboardingUpdateDto.md
 create mode 100644 mobile/openapi/doc/ReverseGeocodingStateResponseDto.md
 create mode 100644 mobile/openapi/doc/SystemMetadataApi.md
 create mode 100644 mobile/openapi/lib/api/system_metadata_api.dart
 create mode 100644 mobile/openapi/lib/model/admin_onboarding_update_dto.dart
 create mode 100644 mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart
 create mode 100644 mobile/openapi/test/admin_onboarding_update_dto_test.dart
 create mode 100644 mobile/openapi/test/reverse_geocoding_state_response_dto_test.dart
 create mode 100644 mobile/openapi/test/system_metadata_api_test.dart
 create mode 100644 server/src/controllers/system-metadata.controller.ts
 create mode 100644 server/src/dtos/system-metadata.dto.ts
 create mode 100644 server/src/services/system-metadata.service.spec.ts
 create mode 100644 server/src/services/system-metadata.service.ts

diff --git a/e2e/src/api/specs/server-info.e2e-spec.ts b/e2e/src/api/specs/server-info.e2e-spec.ts
index 5cfd6a8b98..690bfae744 100644
--- a/e2e/src/api/specs/server-info.e2e-spec.ts
+++ b/e2e/src/api/specs/server-info.e2e-spec.ts
@@ -1,4 +1,4 @@
-import { LoginResponseDto, getServerConfig } from '@immich/sdk';
+import { LoginResponseDto } from '@immich/sdk';
 import { createUserDto } from 'src/fixtures';
 import { errorDto } from 'src/responses';
 import { app, utils } from 'src/utils';
@@ -162,19 +162,4 @@ describe('/server-info', () => {
       });
     });
   });
-
-  describe('POST /server-info/admin-onboarding', () => {
-    it('should set admin onboarding', async () => {
-      const config = await getServerConfig({});
-      expect(config.isOnboarded).toBe(false);
-
-      const { status } = await request(app)
-        .post('/server-info/admin-onboarding')
-        .set('Authorization', `Bearer ${admin.accessToken}`);
-      expect(status).toBe(204);
-
-      const newConfig = await getServerConfig({});
-      expect(newConfig.isOnboarded).toBe(true);
-    });
-  });
 });
diff --git a/e2e/src/api/specs/system-metadata.e2e-spec.ts b/e2e/src/api/specs/system-metadata.e2e-spec.ts
new file mode 100644
index 0000000000..bd17bf2524
--- /dev/null
+++ b/e2e/src/api/specs/system-metadata.e2e-spec.ts
@@ -0,0 +1,76 @@
+import { LoginResponseDto, getServerConfig } from '@immich/sdk';
+import { createUserDto } from 'src/fixtures';
+import { errorDto } from 'src/responses';
+import { app, utils } from 'src/utils';
+import request from 'supertest';
+import { beforeAll, describe, expect, it } from 'vitest';
+
+describe('/server-info', () => {
+  let admin: LoginResponseDto;
+  let nonAdmin: LoginResponseDto;
+
+  beforeAll(async () => {
+    await utils.resetDatabase();
+    admin = await utils.adminSetup({ onboarding: false });
+    nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
+  });
+
+  describe('POST /system-metadata/admin-onboarding', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(app).post('/system-metadata/admin-onboarding').send({ isOnboarded: true });
+      expect(status).toBe(401);
+      expect(body).toEqual(errorDto.unauthorized);
+    });
+
+    it('should only work for admins', async () => {
+      const { status, body } = await request(app)
+        .post('/system-metadata/admin-onboarding')
+        .set('Authorization', `Bearer ${nonAdmin.accessToken}`)
+        .send({ isOnboarded: true });
+      expect(status).toBe(403);
+      expect(body).toEqual(errorDto.forbidden);
+    });
+
+    it('should set admin onboarding', async () => {
+      const config = await getServerConfig({});
+      expect(config.isOnboarded).toBe(false);
+
+      const { status } = await request(app)
+        .post('/system-metadata/admin-onboarding')
+        .set('Authorization', `Bearer ${admin.accessToken}`)
+        .send({ isOnboarded: true });
+      expect(status).toBe(204);
+
+      const newConfig = await getServerConfig({});
+      expect(newConfig.isOnboarded).toBe(true);
+    });
+  });
+
+  describe('GET /system-metadata/reverse-geocoding-state', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(app).get('/system-metadata/reverse-geocoding-state');
+      expect(status).toBe(401);
+      expect(body).toEqual(errorDto.unauthorized);
+    });
+
+    it('should only work for admins', async () => {
+      const { status, body } = await request(app)
+        .get('/system-metadata/reverse-geocoding-state')
+        .set('Authorization', `Bearer ${nonAdmin.accessToken}`);
+      expect(status).toBe(403);
+      expect(body).toEqual(errorDto.forbidden);
+    });
+
+    it('should get the reverse geocoding state', async () => {
+      const { status, body } = await request(app)
+        .get('/system-metadata/reverse-geocoding-state')
+        .set('Authorization', `Bearer ${admin.accessToken}`);
+
+      expect(status).toBe(200);
+      expect(body).toEqual({
+        lastUpdate: expect.any(String),
+        lastImportFileName: 'cities500.txt',
+      });
+    });
+  });
+});
diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts
index 0047502023..96994c7f0a 100644
--- a/e2e/src/utils.ts
+++ b/e2e/src/utils.ts
@@ -24,8 +24,8 @@ import {
   getConfigDefaults,
   login,
   searchMetadata,
-  setAdminOnboarding,
   signUpAdmin,
+  updateAdminOnboarding,
   updateConfig,
   validate,
 } from '@immich/sdk';
@@ -264,7 +264,10 @@ export const utils = {
     await signUpAdmin({ signUpDto: signupDto.admin });
     const response = await login({ loginCredentialDto: loginDto.admin });
     if (options.onboarding) {
-      await setAdminOnboarding({ headers: asBearerAuth(response.accessToken) });
+      await updateAdminOnboarding(
+        { adminOnboardingUpdateDto: { isOnboarded: true } },
+        { headers: asBearerAuth(response.accessToken) },
+      );
     }
     return response;
   },
diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES
index 42f1034dce..64229329aa 100644
--- a/mobile/openapi/.openapi-generator/FILES
+++ b/mobile/openapi/.openapi-generator/FILES
@@ -13,6 +13,7 @@ doc/ActivityCreateDto.md
 doc/ActivityResponseDto.md
 doc/ActivityStatisticsResponseDto.md
 doc/AddUsersDto.md
+doc/AdminOnboardingUpdateDto.md
 doc/AlbumApi.md
 doc/AlbumCountResponseDto.md
 doc/AlbumResponseDto.md
@@ -123,6 +124,7 @@ doc/QueueStatusDto.md
 doc/ReactionLevel.md
 doc/ReactionType.md
 doc/RecognitionConfig.md
+doc/ReverseGeocodingStateResponseDto.md
 doc/ScanLibraryDto.md
 doc/SearchAlbumResponseDto.md
 doc/SearchApi.md
@@ -174,6 +176,7 @@ doc/SystemConfigTemplateStorageOptionDto.md
 doc/SystemConfigThemeDto.md
 doc/SystemConfigTrashDto.md
 doc/SystemConfigUserDto.md
+doc/SystemMetadataApi.md
 doc/TagApi.md
 doc/TagResponseDto.md
 doc/TagTypeEnum.md
@@ -226,6 +229,7 @@ lib/api/sessions_api.dart
 lib/api/shared_link_api.dart
 lib/api/sync_api.dart
 lib/api/system_config_api.dart
+lib/api/system_metadata_api.dart
 lib/api/tag_api.dart
 lib/api/timeline_api.dart
 lib/api/trash_api.dart
@@ -242,6 +246,7 @@ lib/model/activity_create_dto.dart
 lib/model/activity_response_dto.dart
 lib/model/activity_statistics_response_dto.dart
 lib/model/add_users_dto.dart
+lib/model/admin_onboarding_update_dto.dart
 lib/model/album_count_response_dto.dart
 lib/model/album_response_dto.dart
 lib/model/all_job_status_response_dto.dart
@@ -343,6 +348,7 @@ lib/model/queue_status_dto.dart
 lib/model/reaction_level.dart
 lib/model/reaction_type.dart
 lib/model/recognition_config.dart
+lib/model/reverse_geocoding_state_response_dto.dart
 lib/model/scan_library_dto.dart
 lib/model/search_album_response_dto.dart
 lib/model/search_asset_response_dto.dart
@@ -419,6 +425,7 @@ test/activity_create_dto_test.dart
 test/activity_response_dto_test.dart
 test/activity_statistics_response_dto_test.dart
 test/add_users_dto_test.dart
+test/admin_onboarding_update_dto_test.dart
 test/album_api_test.dart
 test/album_count_response_dto_test.dart
 test/album_response_dto_test.dart
@@ -534,6 +541,7 @@ test/queue_status_dto_test.dart
 test/reaction_level_test.dart
 test/reaction_type_test.dart
 test/recognition_config_test.dart
+test/reverse_geocoding_state_response_dto_test.dart
 test/scan_library_dto_test.dart
 test/search_album_response_dto_test.dart
 test/search_api_test.dart
@@ -585,6 +593,7 @@ test/system_config_template_storage_option_dto_test.dart
 test/system_config_theme_dto_test.dart
 test/system_config_trash_dto_test.dart
 test/system_config_user_dto_test.dart
+test/system_metadata_api_test.dart
 test/tag_api_test.dart
 test/tag_response_dto_test.dart
 test/tag_type_enum_test.dart
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index 3ebd65025b606cd030899cfffaad5099acb6832b..27d631e4fd03f971074a7eb7a3764485ec64ad20 100644
GIT binary patch
delta 491
zcmex+mhsd@#tocCo4bsjvr7h77MG;v`lgm7rX-dmIu>MVDby%LYiXrVmNAl6LzmRc
zO;Jt<sm;yI%g;;7Pb^Bw%uAnq!BV6?7ixq)SW*`u3p9^Q3#T=yB|)iWsYS)9?y337
z`5@DROA<>`wX|Y2Qu34a^)WO;olpePl@8OzS`5+#a)~qCB~UfGa7DUcMR*-lS^x}(
z)X8#2l2X__50&;Zl9K~V>%uKV^Y-R!s~w!ipm6eshf^rnY?qRJsNWp1NpBW&nImL_
cHBf?5iwpAeic=9LU{yA`AyRbn<}fb-0JILcP5=M^

delta 76
zcmV-S0JHzf(E<I<0k8=&lZP%60dtcHIvbPAF%ScDWptBlJ02Kia&~2MEop9MZ!ckF
iZE0>TZ*F35VRB@%|1t3gv#vX`3A3O@j}o(gSW6IS@g4^N

diff --git a/mobile/openapi/doc/AdminOnboardingUpdateDto.md b/mobile/openapi/doc/AdminOnboardingUpdateDto.md
new file mode 100644
index 0000000000000000000000000000000000000000..b25084301943eb5495f27b1e781338f4732efdd4
GIT binary patch
literal 423
zcma)2!A`?4488j+EcGxJDcv1TMco03jS0bNn$)cEMnn@wsW~9=@g$`ZgA3&1Jp1|C
zeid@0V4|x%TN>)ddKVtTkxjBLoS&mLLai`BRpC7wi}FG^aWR_y)tm{suCpcyI3GC)
zF6R98th_4fg|N6O)JbWVaxsac5w^w?e&Ex4ETN^bPcDk%kkOVUGxWvF$qS_QUl^2f
z<9rWUr7~ZsBl9qQGXmJ}Z}TB2jGJNs4sMjg;i|4zkL#x0tZQ#8%l(_DAf_y)Pn`5*
e!MMcl?q+xQ&)#f^reGtxAU`C&2L2kp1b|PEh=TtB

literal 0
HcmV?d00001

diff --git a/mobile/openapi/doc/ReverseGeocodingStateResponseDto.md b/mobile/openapi/doc/ReverseGeocodingStateResponseDto.md
new file mode 100644
index 0000000000000000000000000000000000000000..87f8aa8ab7cfc8c3ee7b938638b9538caa64edfc
GIT binary patch
literal 474
zcma)2!Ab)$5WVLs2KG?9knLShm90{+rEK+73L7?K4en+_G75qp?_^!DilDiKH+gSf
z-Yb9tdJ|kVkj1`g@D*diTcnPx^QS!<#s*U$pRi_9swm)&j0Ju|7&Q<~_iRq6+P1YO
zvT{S9$S|Acmy^RHyJW)bDpSWzyC{#59VWFk1HwDpzlD6&n7Zf!JMx0I#KO>LM}}ue
z>%Xun%ibhjk6*iSJ!ZSI6j@pBIZDzI2%eY^!AkPq8R~GjmdwBeo7@`oF7ferQB{j(
ywX8R*YOsZ+?qxtP{YeJ>NKub%r8#`5m%H^pwbGqNZv#2c-&TAY{xF{_r9J=#W|SBJ

literal 0
HcmV?d00001

diff --git a/mobile/openapi/doc/ServerInfoApi.md b/mobile/openapi/doc/ServerInfoApi.md
index cb5cf0fd3ec98fc4cd06b4afb039859add422bee..e8121a8001e11a58ed44ca847a54b7d8ff49eff2 100644
GIT binary patch
delta 12
TcmdlR*5k2Z5%XpqmMjGTBS{2`

delta 261
zcmeD2*c-NC5wmEtmR50UiDOD`W}bguQhs7lN@iaA<OPhPmO$Y|kZ?X+SW}@!K}##Z
zKR857OF_RlwWut$NH;StEngq3P#3Cl^K9lq1zlyJQcRPzw7BdPFoh>8O4x3;5&X!k
zkXVozpP5%&l9-pAs)wmiV{(D0lMtr(<b`~(l4bdsDGC~?xdkPa3Pq{K1^Ic!sR~K?
fDV3AsS;Z#jiLK=J%P&z#NlnYlOHG-~D6j(nRP|o4

diff --git a/mobile/openapi/doc/SystemMetadataApi.md b/mobile/openapi/doc/SystemMetadataApi.md
new file mode 100644
index 0000000000000000000000000000000000000000..f8c2347afed6701d0a62ff44945123b26b3fe1f9
GIT binary patch
literal 6367
zcmeHL?{C{S5dE&d;s6h@EL1YKkJ%Q$O}w<o8V9!Xp~!-Sk<K<3nbagYjo0LV-=nBF
z{@FTf1GI$=1dho&-tl;RPfs<{87q;=$o}=Fh2il#HX;#5v@+>=wHp1YMM7r8)H;1j
znTT)1kiDy`t3>3+ljBV1hU(S$UbS4)E@utuzgknzYo#fjb>9{wa;BV9VdO27(G*zI
z!1EAwq!YTQH~s!8EdH5rVes>ii#V5=ky?4-{0DRMKf~qU-gyuV*|d_eR2_A#MV?4C
z41x=PWt-7BslilOQ#~_nV8tLfYWIVHnnhWfa9q`<S=xqA#;~mdcKMd`f{$3o+D7ad
zVfa&FbLTL8bSSLDCxcfW;mjp0eX1NL8BUM4-M6z|yY<sfZ|l9IQ&6mR_4f8@DVMV{
z!9yJWs=eRv@TsBg+IM1{rO)cLW|R7z!wwy2HITyzI?cB14Npl-&|SIw7j3829>rSU
zNcNh|ga=}h8npUBDp{GxW&~UOE927mH_RRL)DG_Zb>~=b*z1B(`km_UJ7?WX#JN0f
zzel$thlz^yc#I|Jnp6G3c6q^Ol2MlPK;Dth(J-RdjH{40XrS}QG+&V%kYY|a`SRRO
zT`*+mBDvhQs}FW}iL(8VBr4BuvqH<sZB?QYk6a0t+vvJHLVM%~qG0+tqBDH;Opa?$
zeSO1fy9&NBCUX(0HggN3i0GSE9CJ~WtNB&49UQcKy~}>*xP4M@lr2}r|HZ}s1N~>)
zoIZ?XJi;AQoC6ThhDa!~*;W}&^&~IXJ#r>Ge{}U5^pT<{qKB`%fz2VY4{soqNXed(
zR6sNpGcMQ)TKm@2<Wu_S5kAf-=K}W_=JVd=ZC=EG>ukL;oRRUJ7ls}Pw;jdu0laQm
zXHak7#axmPs>upgX=)F5C8=<K70dSMC;m&>6MAqYXkqPC<N_=Litzd)SrDrv)5wJq
z%>^lKN&q0Crr_GjG;)a81&HK|%rs-qlFcSqwPdqt4paw(wTakPn?WMhbI^9-M^Ure
zZXLdBTUK;~jaPJD-JPZRYSbFIo?V)EgH^-@W*@OgY!N&PK^#DA1HcUX*jo_n(Fy7R
zY?ca0SQ^+{kQqBAvMjBzx%qd2rvmT%{576REX*36U0nFJ20aZ~21sRLFut&}M8^}0
z(Iv=-0O1gO2_H|Rm|x<ZPB`6ik+HR1@OH&q3U4do_;SPJ0$6kHOqYvHtAZ=`Tx+xA
z&>e@q*i%Mz<7WiF<Io?ULpPwz3o_~2h%e2jYlF@KuI1Iw+3fhWdUE(C_;rI62Dz@C
z@k<%}^s-<kRCl6uCrV#Rw(kh_M-io!d|ecrGYo+?-6YB&N(r)j)3klBHz#;)!#wKa
z=$Sb7Rg~`9n4cqO?*y_JJFyB6UvsZ@2|uCtxOm?CU3e|L|Mj4!HsLqk;NH_0DCT>c
n;~Eiut7QUQ7-wdRSJqCM(X~#d--E_p5z<RSdtQToryBb|U>aw-

literal 0
HcmV?d00001

diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index 8520bab3056ca6340d4b5fabdb3bdfadb00de704..44bd35a6838a99692a4c97328b6ed3feef902bc6 100644
GIT binary patch
delta 86
zcmbQ`zRY7oIV*p0WpPPrZhUTPNn%Q3N#f>2)^m*fDY=<>@%ed4`H4j-nR)4x4LB7x
o>$7j<6E8|FOD!r+jZaU_PtFHP#TS<(mZVND<W=1KKy()`0IAR+M*si-

delta 26
icmZ4HG0S~JIqT**tUDMduV-i7+`+zvZ*z{=E?xkWs|rs5

diff --git a/mobile/openapi/lib/api/server_info_api.dart b/mobile/openapi/lib/api/server_info_api.dart
index 77840acd19247702f1fb83175465b4e491d35b9f..b67045add13f3825be1657f7bd05c6d52f845159 100644
GIT binary patch
delta 10
RcmZokdK|SOQJ1Nf3ji7Q1M2_)

delta 156
zcmaDH)snO!QFn4TkEDEne{hI`esOA1S!$7PW?ovpequ^)W}a?-UQ&KyQOaaJ4LhOY
z)DlOKpg%+`GcSGeMD_#$6oo*+&3A<)8JU3wPHvE#C0>@FnPR7ap>=Wsi#j#~6%-b-
J)N<8w0RXDpIMo0E

diff --git a/mobile/openapi/lib/api/system_metadata_api.dart b/mobile/openapi/lib/api/system_metadata_api.dart
new file mode 100644
index 0000000000000000000000000000000000000000..f3952fda8a565f4974b78534c216d7bf97c17544
GIT binary patch
literal 4697
zcmeHKU2oeq6n)pPxa~u253=0_dl=lto|`ynfHn3K2gMKsDq~&S>||0UskmN||GrC7
zlI>Jpx@G8EAP@c!$@{@`&pjgB?V#O;{>6CMJRY15&idoQAsn6jItbx#2&cm_ycr&z
z9R2=;mf4lR#7vp}z1EL=fqmpNAr(ebsiG-QumhRMG{zAoxIkgT;+Zc}sZ6B>OO|Y<
zqck&-!Vei&WG>i(zEh^?*PznmT<L1_i4@u}VIm$=KtAQDoVl(HmT-YON~D<4GBal)
z`uTY@nKPw>fWjG&Q;;bNmhu*TUIalfNto7fzS0I4Z_u!q8P-oZd<x+6(fTD#9VDDm
z=>a+Lf3F(QSwF%4#{XUqVw|!pF{L2|1(<Td5-7*k7H+mp(2r0N><bM{!0YjN1hwPA
z7~0w;+9cdurfn84xM)goEt!hBnAPn56f;~3vW!8Y$&}Dw<_MQ(s8hlduUeGCQARWP
zvKZ^a3{Bs98<x)Aax;HzOnM@wvfcouS7PFT0Vv7QfmGp?;C7XYVkAI)d`&@$ou<sp
z2^dwoe*+GIssIi%D(-|O-u3YMJx-_)snq6`j90m($M!>pYBjPH(-ipSZ{=h6^6JG#
zW^-gQs`bKiqX@<j{BQTu?dD=zk-uH)96Ka{z76qYyh_n!kUO-|EdwAg&ARk0E^g!>
z*dLwL-8_IrEzJMh^4E%ojm;H91qj9S=Dv!tb^67~W|A$g2aD9Kpa&vL65{mMtm{#4
z*RYt}+Fj%%y2G1`l)4BHjqFvi>JF29xl~R~+dWIh&An{&W#Ln(7p5%|3X|yr86$M}
z!4mh}jkK;=d`8-X+B9<d0yTt^OWT2ij}uI7zf?I2^K2}y$**PEtCc}ru5CChEE=|?
z)D>^%NS#N;T}XqVQQe>_yHH5DCKs@z#-c?&VJk{|&wmPGN$cl~kQdUBY}kOnjqO$E
zxo=z-8@@nmvQ*p<Q)!Ir5Z+ng?oh!foOwq9$c^73>BrLxfge-q6^sE%6v&zjQsk$F
z>m%YdGbIwRw3{$$<^0Pc7xILTAlmhaPi(6Kc5_6dQ)xVto;{<jd(GLb&XcX`2zlUJ
zWnCPvS~>Qm&bDGCJUL7FpV+rrRQFWbU7QeVyQo<Ut)RiKX-_YQk1uVWlI@I={r^Cx
z6;fd|HfJcQu7qpT79Srb*Js}8apBX_9lwTX@7m3yc7K1`T}h(iR8VSo<*F}M@=EJ%
zqCV>Rqn>|>o>x+7gRZ%*-?!`m`aT+-|EC9W_ZBJdLLGYN(1`zrAK-G=8|n(!_G!I5
zb=f14ziBnBx6cmxnLD1{@w?THcjnffcZGB>({KBBU{97i0Q){J4mFmh37@#TUHiS3
zqV{jE5|+W=-d*A=^}&tgaTh9kp#D(a@3_lU2HR@5UCa#g>&4b7+g}_0zkAMYa2xyu
Dpg1Ye

literal 0
HcmV?d00001

diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart
index 0a0cd80088bbdd96df0fef2da6db126d89dea032..a92f1df7a7bed8c052cc85c84d8993ab98f23266 100644
GIT binary patch
delta 108
zcmdmcm~rY+#tolk_)>B+^ZfIY@)L_vGV{_WD=NyN3nfZxZT>71uP7RnT9#T=oa&yM
fpPUa;99)uEk~(>!gS-efnaLmQbvFmwWhnyy6;vu)

delta 19
bcmbPwlyUE2#tolkHiyf`D{j`Z&sGKiS`i2B

diff --git a/mobile/openapi/lib/model/admin_onboarding_update_dto.dart b/mobile/openapi/lib/model/admin_onboarding_update_dto.dart
new file mode 100644
index 0000000000000000000000000000000000000000..50c4ae090ede8409ad0d2003b51e6b03e8eaf215
GIT binary patch
literal 2989
zcmbVOZExE)5dQ98aRG)}0aSVGry;4m28%PaYh$2w2Mk6a&=O^_lSz%FY8a{i`|e0d
zp;s#jY9O&i-5a0hxg#f&(PRQwpO=f5f6Q;^AFr14Yq)v)ejda10&W*e_^`Nsd-L}h
znvvz3oM}6KmHhH*M2})4mF8)obXtgtUqB-p!}F9^e9NVcyJxYjm9{56ShZt&lh%z*
zHUGB}8r>z^;{Tdy{I*;h46e;-_e@D+nY5|MF`*~~*UsIXOjZfWO<F0rK{H!2ng057
znw3nO(FoI7Pz9)(OIC{n|1L+PtYXH%RlXH+A=irOT*%GmI%k$&TLt$cfC&Qek=sT~
z0}LeJ!D`6<4umT=;|V$rSw$2Tpo0T!DNG_vo1f<~zCN5|aR6(jDhOV=#W(6J*BSxo
z3~)=DS*2k*jTY-KJj0FJlIwGr&cFkgu_~fGarfriH~$Nx4;Bh9UbsqKu&_c*^k`K=
zTG((4B{SvkD(6^-3)D-Blq7k^jbVCEN<eZ;ZiI1WQLy8ri{SwY_x{OhBnzzaKO(o?
z@et~(An5d`;NfoUQ2oH_*FI1T`5q-IvKhvHXOzUqp0YwnR>2osf+>7weSf%g<%6?W
z>2Z)`e1{(YC)%59r!D703kTx;hcpLF=HOe=fUpL>vZ~<*3az$n%#~Y0nHFd;COxlk
z-*+*{rmDOkcEULI23*ye7uZm)7j=`N(o9MkOtCes$WrX$Qsu;*Ul<hBZ>|;xX$!Hh
zvBMY3id==HN-V5f2RK6BnIsuA_B#$DFB9Ki)U$)NL|Cr>1T<a1meqd|Y>Q&KulZ|g
zz*$6$$8b#O`5E;oeJ~|`9o+wxO~SKhvqj~-z=DV5IfDvc-yl^-@c7L4ZVqg;+Oc(o
z-2?@z437^TJ{}#-O;cG?=3tW5ov)RErqF~lm4%(|1c*(3tL%JR+kF=svB$|0wJFpN
zN1QW#c;B((nVSwGo{$jT?r<Y5Xaw8?>t|CZ#4t3?N!nJz^4x19@br*(&>s=ddRax=
zi#q5op!?;bxL#}73HyH#Ka$5uj=GXPOlTDjh{15(#0crYIv!=i(KhvL4YdHykM``o
zfc}_G@ub58oepk4W{;S~RlV=HP3TE*)9s2_@;iGz$1Y&v>;uN0;2MEjdB=bFo<^qg
z_nM_59w8iE`-a2q<0FkXhnDhZ;!fBSm#EL^A~3|tOK*<e?F7YB0Nv<jL{YKJPbh{|
zHr#eIawL<J+KJ(L{f#j~RXvgNN3``!**r&r?Q9HY$CC=f{T-7(#85&M_D}{wi~II|
zX!N*OpBK;3l)#jMXeO{Md9RQDeuh#scy=?Szd`A&IpBe4FfFwUgLj`6w{+cWRpCy_
a{&M#x`Sy2(*}a41=wje*dzT{|MgIXv!PQ^@

literal 0
HcmV?d00001

diff --git a/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart b/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart
new file mode 100644
index 0000000000000000000000000000000000000000..71e1d3ad99b5b3584620c8b322926fda965ddadd
GIT binary patch
literal 3748
zcmbtXU2oeq6n*!vxB-U7!Bn~H(~!(gizO-2HZhR60|vto7>SD6$)ZNmHH_5%efRQ8
zwp=NZw*^RIQ}_EE9#VrrZ!m!OUnb+%A1-e%@7_-?FW~C>^JM@RW4Ikp;AVVref8%N
znvvzZL>M=G8~*aPM_<)ru8oXRZK70V@)VZ&(#j-~nOw-+1=V-8D70~{9wJ+bwT+6U
zi;VnxsSLX3Vu61PVer3`#$s?|+udWGTPJcCsRR?MR7vCAZELbj<<dr(&L7dt%|#yl
z`X-9!!q{F9(>c&7=tAbAP$B-F^?Gq8tc5%I4a<-pq>go>^2d88oV=5^!0htEX?W@Z
z3~(TK(k)GHK|n57Fzxc2f%4_YGQ<E~)_uwXP>l)PT-gxobJu>nSBStZPAquF>PwMe
z)%0|8f}H`}JA=KPLo-C<G5h-EcN%A5aNHQ-LF<eLW`xrQhQt2&;j4^sjBYN?2@FTT
zu*4$kQ?)_$<okF21nPj{zIx?5@<Chr#k?&oK*NiBsH~sqNaYS5rGvS!^WSwMu?L@g
zOLpRTE@Nq}Fl*u{(rzxTvfixUoFyg9mstkq@CHs>Dm@CTWGC?RF3PUlAH4ZN&8q<m
z0}D>`hrUPRxQkxXX6?hDnrne?fs5eh{CRfl>$nlcZ=5!OWQ{vn#Urfbqt!Xq!3<7S
zE;9IvOE83n_0#6ePeG;NNz(@T5zn*=2tL@<95>57o?(t>b_(a&fXu8cOGBJHVi6jp
zW3$W>w3o_bll`)It-G9>?~i2|uROkXinE~&{=XCnM4kW(w*}=ad=uGHT1bsvl(DCN
z2}LYLgJV*?DZ)PsnD2hSu9WrKR@*;?ktxcrS8lV6Q8{cxni8RqI~7N$`E#Ane&U5r
zT|D<Sd4`pY<60|r5RrVPq&8S!wGOaHL~jztsHY7|_E|7leM#XvYwBb9{L`f5DJ(?s
z2TgOFPS%Ai(~bJHBLZk;@d>Lsg`|K?go6T=oY@AzRw&BG|1DWlk;${Bocd>lgXq$X
z5oBm~g_PJEKR5E^kB3L2SK=W<C52SrMhiC8{CWCD+GXZQ2s)EZXIwi0N+}3WDigKd
z6F*e;R=dkZ;nr0S#28Peewj+I@WAz^yXO@Pm?6VXWJB5jzFTap93~BP$Fj!mr^no8
zD3XM^So35kDoFN#DZQMgHv82mPoetcqtsEW%WvmAiHEJ&Rny_Ip;cX{JHyrmyn+eq
zdJ!dWH)Ir_+k&WP<&E<cwwJ>Q?`L=orRydi!F!0~s##Z<hxAQJTXjdUaEJdMdmlLT
zel;XJIDmtkvg2QJP3MyLXHHWwFAxD;YqCzjk=**JX})u<l$+xc?MJ%CcA@l|-7re2
z?086{JKgUo`c8dsY6m*8uzQ=1C;*wZaw~wtbOB@3S$AOS|H8ckX>|xiOVzJwPA3Dg
z&wWyKAr}(F+tJpD7;I%>ClJAwolLGhb-~m%QVn1-ms|d9-!hP@7B5f&X|Y6VJxIU<
y++teVRsufWk8kPar93tKslI(h`9!|kE8ghIkEiXD#;2QqQl3}-J>{&2+V&4Yf7_Y>

literal 0
HcmV?d00001

diff --git a/mobile/openapi/test/admin_onboarding_update_dto_test.dart b/mobile/openapi/test/admin_onboarding_update_dto_test.dart
new file mode 100644
index 0000000000000000000000000000000000000000..09cc73e977c0748ac9d4babb2828f7e65a246e9d
GIT binary patch
literal 600
zcmZ`#&1=Fi6u<Yccso_W+0D~XgbA%f*es0huv3ifYYl6YEJ-(H_`mNZDh#HFybt(&
zy(CGLB(QiZ^4q6uoxLuKEQRIjIU7Nm!#XeEB~Mq&_ZxzF<YUE+n@`5~lgO{4k;)*e
zm0`7LaSFZcEmjO$>`=PVc^92l#$9Ca_M7i5)4gK`KYL+_E_p{)b3@&su_SKnWqPTk
zbzC|oDl!zcKojWpqFE~>TGlGr5X|kkWDj?&+;L;0=$H{=&C3to;8bapT#JO>*k2vb
z{y`jCm-uo9cvHVypf7<%)d`u)t>UH<vU$@LcbGaAeJN2v$AugW;U@whRxKoNL4;#U
zg!xtSU^t72(iqk2LF~=0LgNvf41&d0DRQu<Kv<CqgtR21!T@fEpbcGa+#WW6u$!Y6
Yw|iNJ$ev5Fi#*MP&cVl`DfC113;U7BF#rGn

literal 0
HcmV?d00001

diff --git a/mobile/openapi/test/reverse_geocoding_state_response_dto_test.dart b/mobile/openapi/test/reverse_geocoding_state_response_dto_test.dart
new file mode 100644
index 0000000000000000000000000000000000000000..91fdfcfea4030dafc64cd9cb685a3d2efbaad4ad
GIT binary patch
literal 745
zcma)3&1=Fi6u<Yccsr@!?B;1G!i1^_n*~1(JH^mGYgkRP<kca=|9!7<UZ$Xjrg`xD
ze2L>Ij$yc3rjL`+Y&0J(M+uClZ=*IODa_I(yr;=@{PDoB8u^+_M}uzXxf|7^DztGZ
zHpYpKDzOKZt~}-fOWdKR_IXx2YaCr=$nskrys#AshhLR)OxJS9r<IQXgwC_L^VjK_
z(VnCxp>j4<8-*^={Z+G4Y4oBrx?mV>r4}zwBHK#mqv(_o8dheXvcR6PsHIggf1SE@
zqkjP*^c2^}8Bm+nvj<)Y%yG}*ae^i@xzfdgB;g#r<;<9niS|b2u#-wRTksPBFngo4
zEJ1}*8N%SENVC<Cm|8ef*=D0Qze#Dd;m<HwF352>D5a-qc&9Nx<&0O)vJmR9t_c^Q
zErNBt8b=3MUsBd5ZRrm>3zcdKuC+{)G{_xKasL~yY|gv6%kTuf&xi$s;W7FJA!qh$

literal 0
HcmV?d00001

diff --git a/mobile/openapi/test/server_info_api_test.dart b/mobile/openapi/test/server_info_api_test.dart
index 68cd1c348bcf9c43aacbf924cc90ca0391480ca8..dac465116eb42c185c525ec1eee5ca4a399c6078 100644
GIT binary patch
delta 14
VcmZ3_bC7$(ZdSHhO>3@NE&wM_1aJTV

delta 65
zcmX@ey`E>oZq~^T%v{0>#i=EZDY=<>{&`9HiA5=ydFhk?GD`?y5njlw&Q_~w%~i_<
E01$~64gdfE

diff --git a/mobile/openapi/test/system_metadata_api_test.dart b/mobile/openapi/test/system_metadata_api_test.dart
new file mode 100644
index 0000000000000000000000000000000000000000..bc1ce6f6f33849d10723585ed976cf7f29255adc
GIT binary patch
literal 936
zcmb7>PfNo<5XJBL6yvECs!>nkKP;pbK@HS;@U)D{G%hB)>+VD;;&*p8RgoG+57{uu
z@6Eh<o2E&c!uV#MZ=Fr9CbRK;lEL)+Vlsj(hpT)Jmw9$Rz1<KjlBXqlKHl5j-AiJr
zD`P#Wh4pHoYdV0|w1G-RH8o^-^ik`^S<k%+te<filxtaedTg~9xxq$GhhE-HK1g#P
z`sIZ+fzhzir3};yO}>+d-eI*i6jW_ZB^ckMQTscpxJMt7WaSYC4vPn@=)gKM=yWR2
zcHDY!_zhtSoW|n=h@qqqzXXs_;CdODnoD9Vk#X#F@?>=h4QeyogqH+B@P#&5gYNby
z2#&w2hMR|pu$8y18xDSHKN!J#-`IKDa_i}6TsGR|=FXxowW)5Lc%h8#2~@;uKShmU
zX$qMJ-5#rPJ>+Us2X~7^fh;r6v%C|(t~`J7F{3BBOFAQ4*l6UM#m9_-lPmo6(+mCL
hs+a$_H)y+1*27q<Ka&IeA<`ex>+SwWlQGBF<PA}THTnPm

literal 0
HcmV?d00001

diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index f49df7baea..4f666b303c 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -4908,31 +4908,6 @@
         ]
       }
     },
-    "/server-info/admin-onboarding": {
-      "post": {
-        "operationId": "setAdminOnboarding",
-        "parameters": [],
-        "responses": {
-          "204": {
-            "description": ""
-          }
-        },
-        "security": [
-          {
-            "bearer": []
-          },
-          {
-            "cookie": []
-          },
-          {
-            "api_key": []
-          }
-        ],
-        "tags": [
-          "Server Info"
-        ]
-      }
-    },
     "/server-info/config": {
       "get": {
         "operationId": "getServerConfig",
@@ -5885,6 +5860,103 @@
         ]
       }
     },
+    "/system-metadata/admin-onboarding": {
+      "get": {
+        "operationId": "getAdminOnboarding",
+        "parameters": [],
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/AdminOnboardingUpdateDto"
+                }
+              }
+            },
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "System Metadata"
+        ]
+      },
+      "post": {
+        "operationId": "updateAdminOnboarding",
+        "parameters": [],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/AdminOnboardingUpdateDto"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "204": {
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "System Metadata"
+        ]
+      }
+    },
+    "/system-metadata/reverse-geocoding-state": {
+      "get": {
+        "operationId": "getReverseGeocodingState",
+        "parameters": [],
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/ReverseGeocodingStateResponseDto"
+                }
+              }
+            },
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "System Metadata"
+        ]
+      }
+    },
     "/tag": {
       "get": {
         "operationId": "getAllTags",
@@ -7180,6 +7252,17 @@
         ],
         "type": "object"
       },
+      "AdminOnboardingUpdateDto": {
+        "properties": {
+          "isOnboarded": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "isOnboarded"
+        ],
+        "type": "object"
+      },
       "AlbumCountResponseDto": {
         "properties": {
           "notShared": {
@@ -9618,6 +9701,23 @@
         ],
         "type": "object"
       },
+      "ReverseGeocodingStateResponseDto": {
+        "properties": {
+          "lastImportFileName": {
+            "nullable": true,
+            "type": "string"
+          },
+          "lastUpdate": {
+            "nullable": true,
+            "type": "string"
+          }
+        },
+        "required": [
+          "lastImportFileName",
+          "lastUpdate"
+        ],
+        "type": "object"
+      },
       "ScanLibraryDto": {
         "properties": {
           "refreshAllFiles": {
diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts
index 9148b4d3b1..1bf219162d 100644
--- a/open-api/typescript-sdk/src/fetch-client.ts
+++ b/open-api/typescript-sdk/src/fetch-client.ts
@@ -998,6 +998,13 @@ export type SystemConfigTemplateStorageOptionDto = {
     weekOptions: string[];
     yearOptions: string[];
 };
+export type AdminOnboardingUpdateDto = {
+    isOnboarded: boolean;
+};
+export type ReverseGeocodingStateResponseDto = {
+    lastImportFileName: string | null;
+    lastUpdate: string | null;
+};
 export type CreateTagDto = {
     name: string;
     "type": TagTypeEnum;
@@ -2330,12 +2337,6 @@ export function getServerInfo(opts?: Oazapfts.RequestOpts) {
         ...opts
     }));
 }
-export function setAdminOnboarding(opts?: Oazapfts.RequestOpts) {
-    return oazapfts.ok(oazapfts.fetchText("/server-info/admin-onboarding", {
-        ...opts,
-        method: "POST"
-    }));
-}
 export function getServerConfig(opts?: Oazapfts.RequestOpts) {
     return oazapfts.ok(oazapfts.fetchJson<{
         status: 200;
@@ -2597,6 +2598,31 @@ export function getStorageTemplateOptions(opts?: Oazapfts.RequestOpts) {
         ...opts
     }));
 }
+export function getAdminOnboarding(opts?: Oazapfts.RequestOpts) {
+    return oazapfts.ok(oazapfts.fetchJson<{
+        status: 200;
+        data: AdminOnboardingUpdateDto;
+    }>("/system-metadata/admin-onboarding", {
+        ...opts
+    }));
+}
+export function updateAdminOnboarding({ adminOnboardingUpdateDto }: {
+    adminOnboardingUpdateDto: AdminOnboardingUpdateDto;
+}, opts?: Oazapfts.RequestOpts) {
+    return oazapfts.ok(oazapfts.fetchText("/system-metadata/admin-onboarding", oazapfts.json({
+        ...opts,
+        method: "POST",
+        body: adminOnboardingUpdateDto
+    })));
+}
+export function getReverseGeocodingState(opts?: Oazapfts.RequestOpts) {
+    return oazapfts.ok(oazapfts.fetchJson<{
+        status: 200;
+        data: ReverseGeocodingStateResponseDto;
+    }>("/system-metadata/reverse-geocoding-state", {
+        ...opts
+    }));
+}
 export function getAllTags(opts?: Oazapfts.RequestOpts) {
     return oazapfts.ok(oazapfts.fetchJson<{
         status: 200;
diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts
index ad2f6e8de1..bd10c41a43 100644
--- a/server/src/controllers/index.ts
+++ b/server/src/controllers/index.ts
@@ -21,6 +21,7 @@ import { SessionController } from 'src/controllers/session.controller';
 import { SharedLinkController } from 'src/controllers/shared-link.controller';
 import { SyncController } from 'src/controllers/sync.controller';
 import { SystemConfigController } from 'src/controllers/system-config.controller';
+import { SystemMetadataController } from 'src/controllers/system-metadata.controller';
 import { TagController } from 'src/controllers/tag.controller';
 import { TimelineController } from 'src/controllers/timeline.controller';
 import { TrashController } from 'src/controllers/trash.controller';
@@ -51,6 +52,7 @@ export const controllers = [
   SharedLinkController,
   SyncController,
   SystemConfigController,
+  SystemMetadataController,
   TagController,
   TimelineController,
   TrashController,
diff --git a/server/src/controllers/server-info.controller.ts b/server/src/controllers/server-info.controller.ts
index e32b0d191c..35e5e17594 100644
--- a/server/src/controllers/server-info.controller.ts
+++ b/server/src/controllers/server-info.controller.ts
@@ -1,4 +1,4 @@
-import { Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common';
+import { Controller, Get } from '@nestjs/common';
 import { ApiTags } from '@nestjs/swagger';
 import {
   ServerConfigDto,
@@ -65,11 +65,4 @@ export class ServerInfoController {
   getSupportedMediaTypes(): ServerMediaTypesResponseDto {
     return this.service.getSupportedMediaTypes();
   }
-
-  @AdminRoute()
-  @Post('admin-onboarding')
-  @HttpCode(HttpStatus.NO_CONTENT)
-  setAdminOnboarding(): Promise<void> {
-    return this.service.setAdminOnboarding();
-  }
 }
diff --git a/server/src/controllers/system-metadata.controller.ts b/server/src/controllers/system-metadata.controller.ts
new file mode 100644
index 0000000000..7f186fec03
--- /dev/null
+++ b/server/src/controllers/system-metadata.controller.ts
@@ -0,0 +1,28 @@
+import { Body, Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common';
+import { ApiTags } from '@nestjs/swagger';
+import { AdminOnboardingUpdateDto, ReverseGeocodingStateResponseDto } from 'src/dtos/system-metadata.dto';
+import { Authenticated } from 'src/middleware/auth.guard';
+import { SystemMetadataService } from 'src/services/system-metadata.service';
+
+@ApiTags('System Metadata')
+@Controller('system-metadata')
+@Authenticated({ admin: true })
+export class SystemMetadataController {
+  constructor(private service: SystemMetadataService) {}
+
+  @Get('admin-onboarding')
+  getAdminOnboarding(): Promise<AdminOnboardingUpdateDto> {
+    return this.service.getAdminOnboarding();
+  }
+
+  @Post('admin-onboarding')
+  @HttpCode(HttpStatus.NO_CONTENT)
+  updateAdminOnboarding(@Body() dto: AdminOnboardingUpdateDto): Promise<void> {
+    return this.service.updateAdminOnboarding(dto);
+  }
+
+  @Get('reverse-geocoding-state')
+  getReverseGeocodingState(): Promise<ReverseGeocodingStateResponseDto> {
+    return this.service.getReverseGeocodingState();
+  }
+}
diff --git a/server/src/dtos/system-metadata.dto.ts b/server/src/dtos/system-metadata.dto.ts
new file mode 100644
index 0000000000..1c04435341
--- /dev/null
+++ b/server/src/dtos/system-metadata.dto.ts
@@ -0,0 +1,15 @@
+import { IsBoolean } from 'class-validator';
+
+export class AdminOnboardingUpdateDto {
+  @IsBoolean()
+  isOnboarded!: boolean;
+}
+
+export class AdminOnboardingResponseDto {
+  isOnboarded!: boolean;
+}
+
+export class ReverseGeocodingStateResponseDto {
+  lastUpdate!: string | null;
+  lastImportFileName!: string | null;
+}
diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts
index 8eeb0064ac..e7d37407d9 100644
--- a/server/src/repositories/metadata.repository.ts
+++ b/server/src/repositories/metadata.repository.ts
@@ -36,8 +36,8 @@ export class MetadataRepository implements IMetadataRepository {
     this.logger.log('Initializing metadata repository');
     const geodataDate = await readFile(geodataDatePath, 'utf8');
 
+    // TODO move to metadata service init
     const geocodingMetadata = await this.systemMetadataRepository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE);
-
     if (geocodingMetadata?.lastUpdate === geodataDate) {
       return;
     }
diff --git a/server/src/services/index.ts b/server/src/services/index.ts
index db3d6083e9..2305708caa 100644
--- a/server/src/services/index.ts
+++ b/server/src/services/index.ts
@@ -25,6 +25,7 @@ import { StorageTemplateService } from 'src/services/storage-template.service';
 import { StorageService } from 'src/services/storage.service';
 import { SyncService } from 'src/services/sync.service';
 import { SystemConfigService } from 'src/services/system-config.service';
+import { SystemMetadataService } from 'src/services/system-metadata.service';
 import { TagService } from 'src/services/tag.service';
 import { TimelineService } from 'src/services/timeline.service';
 import { TrashService } from 'src/services/trash.service';
@@ -58,6 +59,7 @@ export const services = [
   StorageTemplateService,
   SyncService,
   SystemConfigService,
+  SystemMetadataService,
   TagService,
   TimelineService,
   TrashService,
diff --git a/server/src/services/server-info.service.spec.ts b/server/src/services/server-info.service.spec.ts
index 836909b74f..115ab4b6a1 100644
--- a/server/src/services/server-info.service.spec.ts
+++ b/server/src/services/server-info.service.spec.ts
@@ -1,5 +1,4 @@
 import { serverVersion } from 'src/constants';
-import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
 import { IEventRepository } from 'src/interfaces/event.interface';
 import { ILoggerRepository } from 'src/interfaces/logger.interface';
 import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
@@ -207,13 +206,6 @@ describe(ServerInfoService.name, () => {
     });
   });
 
-  describe('setAdminOnboarding', () => {
-    it('should set admin onboarding to true', async () => {
-      await sut.setAdminOnboarding();
-      expect(systemMetadataMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true });
-    });
-  });
-
   describe('getStats', () => {
     it('should total up usage by user', async () => {
       userMock.getUserStats.mockResolvedValue([
diff --git a/server/src/services/server-info.service.ts b/server/src/services/server-info.service.ts
index bb092896bf..52bf8bd1d3 100644
--- a/server/src/services/server-info.service.ts
+++ b/server/src/services/server-info.service.ts
@@ -51,7 +51,9 @@ export class ServerInfoService {
 
     const featureFlags = await this.getFeatures();
     if (featureFlags.configFile) {
-      await this.setAdminOnboarding();
+      await this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, {
+        isOnboarded: true,
+      });
     }
   }
 
@@ -105,10 +107,6 @@ export class ServerInfoService {
     };
   }
 
-  setAdminOnboarding(): Promise<void> {
-    return this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true });
-  }
-
   async getStatistics(): Promise<ServerStatsResponseDto> {
     const userStats: UserStatsQueryResponse[] = await this.userRepository.getUserStats();
     const serverStats = new ServerStatsResponseDto();
diff --git a/server/src/services/system-metadata.service.spec.ts b/server/src/services/system-metadata.service.spec.ts
new file mode 100644
index 0000000000..9d11c1c72a
--- /dev/null
+++ b/server/src/services/system-metadata.service.spec.ts
@@ -0,0 +1,31 @@
+import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
+import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
+import { SystemMetadataService } from 'src/services/system-metadata.service';
+import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
+import { Mocked } from 'vitest';
+
+describe(SystemMetadataService.name, () => {
+  let sut: SystemMetadataService;
+  let metadataMock: Mocked<ISystemMetadataRepository>;
+
+  beforeEach(() => {
+    metadataMock = newSystemMetadataRepositoryMock();
+    sut = new SystemMetadataService(metadataMock);
+  });
+
+  it('should work', () => {
+    expect(sut).toBeDefined();
+  });
+
+  describe('updateAdminOnboarding', () => {
+    it('should update isOnboarded to true', async () => {
+      await expect(sut.updateAdminOnboarding({ isOnboarded: true })).resolves.toBeUndefined();
+      expect(metadataMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true });
+    });
+
+    it('should update isOnboarded to false', async () => {
+      await expect(sut.updateAdminOnboarding({ isOnboarded: false })).resolves.toBeUndefined();
+      expect(metadataMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: false });
+    });
+  });
+});
diff --git a/server/src/services/system-metadata.service.ts b/server/src/services/system-metadata.service.ts
new file mode 100644
index 0000000000..e8fddfc13c
--- /dev/null
+++ b/server/src/services/system-metadata.service.ts
@@ -0,0 +1,29 @@
+import { Inject, Injectable } from '@nestjs/common';
+import {
+  AdminOnboardingResponseDto,
+  AdminOnboardingUpdateDto,
+  ReverseGeocodingStateResponseDto,
+} from 'src/dtos/system-metadata.dto';
+import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
+import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
+
+@Injectable()
+export class SystemMetadataService {
+  constructor(@Inject(ISystemMetadataRepository) private repository: ISystemMetadataRepository) {}
+
+  async getAdminOnboarding(): Promise<AdminOnboardingResponseDto> {
+    const value = await this.repository.get(SystemMetadataKey.ADMIN_ONBOARDING);
+    return { isOnboarded: false, ...value };
+  }
+
+  async updateAdminOnboarding(dto: AdminOnboardingUpdateDto): Promise<void> {
+    await this.repository.set(SystemMetadataKey.ADMIN_ONBOARDING, {
+      isOnboarded: dto.isOnboarded,
+    });
+  }
+
+  async getReverseGeocodingState(): Promise<ReverseGeocodingStateResponseDto> {
+    const value = await this.repository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE);
+    return { lastUpdate: null, lastImportFileName: null, ...value };
+  }
+}
diff --git a/web/src/routes/auth/onboarding/+page.svelte b/web/src/routes/auth/onboarding/+page.svelte
index 09139a7f7e..4647ad8bde 100644
--- a/web/src/routes/auth/onboarding/+page.svelte
+++ b/web/src/routes/auth/onboarding/+page.svelte
@@ -5,7 +5,7 @@
   import OnboadingStorageTemplate from '$lib/components/onboarding-page/onboarding-storage-template.svelte';
   import OnboardingTheme from '$lib/components/onboarding-page/onboarding-theme.svelte';
   import { AppRoute, QueryParameter } from '$lib/constants';
-  import { setAdminOnboarding } from '@immich/sdk';
+  import { updateAdminOnboarding } from '@immich/sdk';
 
   let index = 0;
 
@@ -28,7 +28,7 @@
 
   const handleDoneClicked = async () => {
     if (index >= onboardingSteps.length - 1) {
-      await setAdminOnboarding();
+      await updateAdminOnboarding({ adminOnboardingUpdateDto: { isOnboarded: true } });
       await goto(AppRoute.PHOTOS);
     } else {
       index++;