From 2a31812c5c5b154ec29ddf57b609c9223f819bb1 Mon Sep 17 00:00:00 2001 From: Aviv <51673860+aviv926@users.noreply.github.com> Date: Wed, 5 Nov 2025 02:17:04 +0200 Subject: [PATCH 1/9] feat: add last upload date to user admin details --- i18n/en.json | 4 +++- .../openapi/lib/model/user_admin_response_dto.dart | 14 +++++++++++++- open-api/immich-openapi-specs.json | 6 ++++++ open-api/typescript-sdk/src/fetch-client.ts | 1 + server/src/dtos/user.dto.ts | 4 ++++ server/src/repositories/asset.repository.ts | 14 ++++++++++++++ server/src/services/user-admin.service.ts | 9 ++++++++- web/src/routes/admin/users/[id]/+page.svelte | 10 ++++++++++ web/src/routes/admin/users/[id]/+page.ts | 4 ++-- 9 files changed, 61 insertions(+), 5 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 30c8949aefd10..529fab83a63b6 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2174,5 +2174,7 @@ "you_dont_have_any_shared_links": "You don't have any shared links", "your_wifi_name": "Your Wi-Fi name", "zoom_image": "Zoom Image", - "zoom_to_bounds": "Zoom to bounds" + "zoom_to_bounds": "Zoom to bounds", + "last_upload": "Last upload", + "never_uploaded": "Never uploaded" } diff --git a/mobile/openapi/lib/model/user_admin_response_dto.dart b/mobile/openapi/lib/model/user_admin_response_dto.dart index e5ae8e1d4ef27..34cc512e9e5fe 100644 --- a/mobile/openapi/lib/model/user_admin_response_dto.dart +++ b/mobile/openapi/lib/model/user_admin_response_dto.dart @@ -19,6 +19,7 @@ class UserAdminResponseDto { required this.email, required this.id, required this.isAdmin, + required this.lastAssetUploadedAt, required this.license, required this.name, required this.oauthId, @@ -44,6 +45,8 @@ class UserAdminResponseDto { bool isAdmin; + DateTime? lastAssetUploadedAt; + UserLicense? license; String name; @@ -74,6 +77,7 @@ class UserAdminResponseDto { other.email == email && other.id == id && other.isAdmin == isAdmin && + other.lastAssetUploadedAt == lastAssetUploadedAt && other.license == license && other.name == name && other.oauthId == oauthId && @@ -95,6 +99,7 @@ class UserAdminResponseDto { (email.hashCode) + (id.hashCode) + (isAdmin.hashCode) + + (lastAssetUploadedAt == null ? 0 : lastAssetUploadedAt!.hashCode) + (license == null ? 0 : license!.hashCode) + (name.hashCode) + (oauthId.hashCode) + @@ -108,7 +113,7 @@ class UserAdminResponseDto { (updatedAt.hashCode); @override - String toString() => 'UserAdminResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, isAdmin=$isAdmin, license=$license, name=$name, oauthId=$oauthId, profileChangedAt=$profileChangedAt, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]'; + String toString() => 'UserAdminResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, isAdmin=$isAdmin, lastAssetUploadedAt=$lastAssetUploadedAt, license=$license, name=$name, oauthId=$oauthId, profileChangedAt=$profileChangedAt, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -122,6 +127,11 @@ class UserAdminResponseDto { json[r'email'] = this.email; json[r'id'] = this.id; json[r'isAdmin'] = this.isAdmin; + if (this.lastAssetUploadedAt != null) { + json[r'lastAssetUploadedAt'] = this.lastAssetUploadedAt!.toUtc().toIso8601String(); + } else { + // json[r'lastAssetUploadedAt'] = null; + } if (this.license != null) { json[r'license'] = this.license; } else { @@ -167,6 +177,7 @@ class UserAdminResponseDto { email: mapValueOfType(json, r'email')!, id: mapValueOfType(json, r'id')!, isAdmin: mapValueOfType(json, r'isAdmin')!, + lastAssetUploadedAt: mapDateTime(json, r'lastAssetUploadedAt', r''), license: UserLicense.fromJson(json[r'license']), name: mapValueOfType(json, r'name')!, oauthId: mapValueOfType(json, r'oauthId')!, @@ -231,6 +242,7 @@ class UserAdminResponseDto { 'email', 'id', 'isAdmin', + 'lastAssetUploadedAt', 'license', 'name', 'oauthId', diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d16e4c4e10f5f..af092bb9daa36 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -17866,6 +17866,11 @@ "isAdmin": { "type": "boolean" }, + "lastAssetUploadedAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, "license": { "allOf": [ { @@ -17923,6 +17928,7 @@ "email", "id", "isAdmin", + "lastAssetUploadedAt", "license", "name", "oauthId", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 435e10046a039..a20a5a7a59f28 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -95,6 +95,7 @@ export type UserAdminResponseDto = { email: string; id: string; isAdmin: boolean; + lastAssetUploadedAt: string | null; license: (UserLicense) | null; name: string; oauthId: string; diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index c5067f3e8d351..8231b4c7e9c7c 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -166,6 +166,8 @@ export class UserAdminResponseDto extends UserResponseDto { @ValidateEnum({ enum: UserStatus, name: 'UserStatus' }) status!: string; license!: UserLicense | null; + @ApiProperty({ type: 'string', format: 'date-time', nullable: true }) + lastAssetUploadedAt!: Date | null; } export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto { @@ -187,5 +189,7 @@ export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto { quotaUsageInBytes: entity.quotaUsageInBytes, status: entity.status, license: license ? { ...license, activatedAt: new Date(license?.activatedAt) } : null, + // default value; service may populate a real value + lastAssetUploadedAt: null, }; } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 8e793f9603129..94b5b4c823ee5 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -117,6 +117,20 @@ interface GetByIdsRelations { export class AssetRepository { constructor(@InjectKysely() private db: Kysely) {} + @GenerateSql({ params: [DummyValue.UUID] }) + async getLatestCreatedAtForUser(ownerId: string): Promise { + const row = await this.db + .selectFrom('asset') + .select(['createdAt']) + .where('ownerId', '=', asUuid(ownerId)) + .where('deletedAt', 'is', null) + .orderBy('createdAt', 'desc') + .limit(1) + .executeTakeFirst(); + + return row?.createdAt ?? null; + } + async upsertExif(exif: Insertable): Promise { const value = { ...exif, assetId: asUuid(exif.assetId) }; await this.db diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 58b4221cc9edf..911ca1b654d0d 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -47,7 +47,14 @@ export class UserAdminService extends BaseService { async get(auth: AuthDto, id: string): Promise { const user = await this.findOrFail(id, { withDeleted: true }); - return mapUserAdmin(user); + const dto = mapUserAdmin(user); + + // populate lastAssetUploadedAt using AssetRepository + const last = await this.assetRepository.getLatestCreatedAtForUser(id); + dto.lastAssetUploadedAt = last ?? null; + this.logger.debug(`UserAdminService.get: lastAssetUploadedAt for user ${id} = ${dto.lastAssetUploadedAt}`); + + return dto; } async update(auth: AuthDto, id: string, dto: UserAdminUpdateDto): Promise { diff --git a/web/src/routes/admin/users/[id]/+page.svelte b/web/src/routes/admin/users/[id]/+page.svelte index 1414bfbf89818..448e65c539eb7 100644 --- a/web/src/routes/admin/users/[id]/+page.svelte +++ b/web/src/routes/admin/users/[id]/+page.svelte @@ -39,6 +39,7 @@ mdiChartPie, mdiChartPieOutline, mdiCheckCircle, + mdiCloudUpload, mdiDeleteRestore, mdiFeatureSearchOutline, mdiLockSmart, @@ -345,6 +346,15 @@ {/if} +
+ + {$t('last_upload')} + {#if user.lastAssetUploadedAt} + {createDateFormatter(editedLocale).formatDateTime(new Date(user.lastAssetUploadedAt))} + {:else} + {$t('never_uploaded')} + {/if} +
diff --git a/web/src/routes/admin/users/[id]/+page.ts b/web/src/routes/admin/users/[id]/+page.ts index bfc5bcefa9aae..7f1d902754f80 100644 --- a/web/src/routes/admin/users/[id]/+page.ts +++ b/web/src/routes/admin/users/[id]/+page.ts @@ -1,14 +1,14 @@ import { AppRoute } from '$lib/constants'; import { authenticate, requestServerInfo } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; -import { getUserPreferencesAdmin, getUserSessionsAdmin, getUserStatisticsAdmin, searchUsersAdmin } from '@immich/sdk'; +import { getUserAdmin, getUserPreferencesAdmin, getUserSessionsAdmin, getUserStatisticsAdmin } from '@immich/sdk'; import { redirect } from '@sveltejs/kit'; import type { PageLoad } from './$types'; export const load = (async ({ params, url }) => { await authenticate(url, { admin: true }); await requestServerInfo(); - const [user] = await searchUsersAdmin({ id: params.id, withDeleted: true }).catch(() => []); + const user = await getUserAdmin({ id: params.id }).catch(() => undefined); if (!user) { redirect(302, AppRoute.ADMIN_USERS); } From 9388faf8033e155638c7c13692d5564ad5407a50 Mon Sep 17 00:00:00 2001 From: Aviv <51673860+aviv926@users.noreply.github.com> Date: Wed, 5 Nov 2025 02:26:27 +0200 Subject: [PATCH 2/9] some fixes --- i18n/en.json | 6 +++--- server/src/queries/asset.repository.sql | 13 +++++++++++++ server/test/repositories/asset.repository.mock.ts | 1 + web/src/test-data/factories/user-factory.ts | 1 + 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 529fab83a63b6..0e3716b8f94ae 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1233,6 +1233,7 @@ "large_files": "Large Files", "last": "Last", "last_seen": "Last seen", + "last_upload": "Last upload", "latest_version": "Latest Version", "latitude": "Latitude", "leave": "Leave", @@ -1398,6 +1399,7 @@ "networking_settings": "Networking", "networking_subtitle": "Manage the server endpoint settings", "never": "Never", + "never_uploaded": "Never uploaded", "new_album": "New Album", "new_api_key": "New API Key", "new_date_range": "New date range", @@ -2174,7 +2176,5 @@ "you_dont_have_any_shared_links": "You don't have any shared links", "your_wifi_name": "Your Wi-Fi name", "zoom_image": "Zoom Image", - "zoom_to_bounds": "Zoom to bounds", - "last_upload": "Last upload", - "never_uploaded": "Never uploaded" + "zoom_to_bounds": "Zoom to bounds" } diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 23fd3caf3ca78..ce6f06221b6d8 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -1,5 +1,18 @@ -- NOTE: This file is auto generated by ./sql-generator +-- AssetRepository.getLatestCreatedAtForUser +select + "createdAt" +from + "asset" +where + "ownerId" = $1::uuid + and "deletedAt" is null +order by + "createdAt" desc +limit + $2 + -- AssetRepository.updateAllExif update "asset_exif" set diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index e735b37564f97..c869556e95911 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -45,5 +45,6 @@ export const newAssetRepositoryMock = (): Mocked({ activationKey: 'activation-key', activatedAt: new Date().toISOString(), }, + lastAssetUploadedAt: Sync.each(() => faker.date.recent().toISOString()), profileChangedAt: Sync.each(() => faker.date.recent().toISOString()), }); From 8fc380b36345a9909df7cf9f9b2cffdab91544e2 Mon Sep 17 00:00:00 2001 From: Aviv <51673860+aviv926@users.noreply.github.com> Date: Wed, 5 Nov 2025 03:12:06 +0200 Subject: [PATCH 3/9] use luxon for days ago insted of full date --- i18n/en.json | 2 ++ web/src/routes/admin/users/[id]/+page.svelte | 24 +++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 0e3716b8f94ae..b0d022ceb9172 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -800,6 +800,7 @@ "date_range": "Date range", "day": "Day", "days": "Days", + "days_ago": "{days, plural, one {# day} other {# days}} ago", "deduplicate_all": "Deduplicate All", "deduplication_criteria_1": "Image size in bytes", "deduplication_criteria_2": "Count of EXIF data", @@ -2039,6 +2040,7 @@ "to_parent": "Go to parent", "to_select": "to select", "to_trash": "Trash", + "today": "Today", "toggle_settings": "Toggle settings", "total": "Total", "total_usage": "Total usage", diff --git a/web/src/routes/admin/users/[id]/+page.svelte b/web/src/routes/admin/users/[id]/+page.svelte index 448e65c539eb7..bf72476c67aa3 100644 --- a/web/src/routes/admin/users/[id]/+page.svelte +++ b/web/src/routes/admin/users/[id]/+page.svelte @@ -48,6 +48,7 @@ mdiPlayCircle, mdiTrashCanOutline, } from '@mdi/js'; + import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -70,6 +71,21 @@ let canResetPassword = $derived($authUser.id !== user.id); let newPassword = $state(''); + let lastUploadText = $derived( + user.lastAssetUploadedAt + ? (() => { + const dt = DateTime.fromJSDate(new Date(user.lastAssetUploadedAt)); + const now = DateTime.now(); + if (dt.hasSame(now, 'day')) { + return $t('today'); + } else { + const days = Math.floor(now.diff(dt, 'days').days); + return $t('days_ago', { values: { days } }); + } + })() + : $t('never_uploaded'), + ); + let editedLocale = $derived(findLocale($locale).code); let createAtDate: Date = $derived(new Date(user.createdAt)); let updatedAtDate: Date = $derived(new Date(user.updatedAt)); @@ -346,14 +362,10 @@ {/if} -
+
{$t('last_upload')} - {#if user.lastAssetUploadedAt} - {createDateFormatter(editedLocale).formatDateTime(new Date(user.lastAssetUploadedAt))} - {:else} - {$t('never_uploaded')} - {/if} + {lastUploadText}
From 05d6d9e658927f85c0a58b3760bc05543751e0cb Mon Sep 17 00:00:00 2001 From: Aviv <51673860+aviv926@users.noreply.github.com> Date: Wed, 5 Nov 2025 03:16:42 +0200 Subject: [PATCH 4/9] upload -> uploaded --- i18n/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i18n/en.json b/i18n/en.json index b0d022ceb9172..160b05e08f685 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1234,7 +1234,7 @@ "large_files": "Large Files", "last": "Last", "last_seen": "Last seen", - "last_upload": "Last upload", + "last_upload": "Last uploaded ", "latest_version": "Latest Version", "latitude": "Latitude", "leave": "Leave", From 4ceafa0ca2122c4186003136b36f620724bda604 Mon Sep 17 00:00:00 2001 From: Aviv <51673860+aviv926@users.noreply.github.com> Date: Wed, 5 Nov 2025 03:33:36 +0200 Subject: [PATCH 5/9] remvoe note --- server/src/dtos/user.dto.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 8231b4c7e9c7c..62ebabafe12bc 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -189,7 +189,6 @@ export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto { quotaUsageInBytes: entity.quotaUsageInBytes, status: entity.status, license: license ? { ...license, activatedAt: new Date(license?.activatedAt) } : null, - // default value; service may populate a real value lastAssetUploadedAt: null, }; } From fce6095c8b9593135c0364be52f8d95780620cc7 Mon Sep 17 00:00:00 2001 From: Aviv <51673860+aviv926@users.noreply.github.com> Date: Wed, 5 Nov 2025 03:54:55 +0200 Subject: [PATCH 6/9] GenerateSql Consistent --- server/src/repositories/asset.repository.ts | 27 ++++++++++----------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 94b5b4c823ee5..3318db3403c6a 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -117,20 +117,6 @@ interface GetByIdsRelations { export class AssetRepository { constructor(@InjectKysely() private db: Kysely) {} - @GenerateSql({ params: [DummyValue.UUID] }) - async getLatestCreatedAtForUser(ownerId: string): Promise { - const row = await this.db - .selectFrom('asset') - .select(['createdAt']) - .where('ownerId', '=', asUuid(ownerId)) - .where('deletedAt', 'is', null) - .orderBy('createdAt', 'desc') - .limit(1) - .executeTakeFirst(); - - return row?.createdAt ?? null; - } - async upsertExif(exif: Insertable): Promise { const value = { ...exif, assetId: asUuid(exif.assetId) }; await this.db @@ -186,6 +172,19 @@ export class AssetRepository { await this.db.updateTable('asset_exif').set(options).where('assetId', 'in', ids).execute(); } + @GenerateSql({ params: [DummyValue.UUID] }) + getLatestCreatedAtForUser(ownerId: string) { + return this.db + .selectFrom('asset') + .select('createdAt') + .where('ownerId', '=', asUuid(ownerId)) + .where('deletedAt', 'is', null) + .orderBy('createdAt', 'desc') + .limit(1) + .executeTakeFirst() + .then((row) => row?.createdAt ?? null); + } + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.NUMBER, DummyValue.STRING] }) @Chunked() async updateDateTimeOriginal( From 8f63fec2a802921aa80f2ab9542a4ac8f22d75ca Mon Sep 17 00:00:00 2001 From: Aviv <51673860+aviv926@users.noreply.github.com> Date: Wed, 5 Nov 2025 03:55:19 +0200 Subject: [PATCH 7/9] make new sql --- server/src/queries/asset.repository.sql | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index ce6f06221b6d8..991210346ad7e 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -1,5 +1,12 @@ -- NOTE: This file is auto generated by ./sql-generator +-- AssetRepository.updateAllExif +update "asset_exif" +set + "model" = $1 +where + "assetId" in ($2) + -- AssetRepository.getLatestCreatedAtForUser select "createdAt" @@ -13,13 +20,6 @@ order by limit $2 --- AssetRepository.updateAllExif -update "asset_exif" -set - "model" = $1 -where - "assetId" in ($2) - -- AssetRepository.updateDateTimeOriginal update "asset_exif" set From a67f5e5149791cbaeb40ca187509b802c9cfa275 Mon Sep 17 00:00:00 2001 From: Aviv <51673860+aviv926@users.noreply.github.com> Date: Wed, 5 Nov 2025 04:00:11 +0200 Subject: [PATCH 8/9] remove log --- server/src/services/user-admin.service.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 911ca1b654d0d..21d57a052a7a5 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -49,10 +49,8 @@ export class UserAdminService extends BaseService { const user = await this.findOrFail(id, { withDeleted: true }); const dto = mapUserAdmin(user); - // populate lastAssetUploadedAt using AssetRepository const last = await this.assetRepository.getLatestCreatedAtForUser(id); dto.lastAssetUploadedAt = last ?? null; - this.logger.debug(`UserAdminService.get: lastAssetUploadedAt for user ${id} = ${dto.lastAssetUploadedAt}`); return dto; } From 76d42869bf6cacfdc0c042c34e919eef59b823bb Mon Sep 17 00:00:00 2001 From: Aviv <51673860+aviv926@users.noreply.github.com> Date: Sat, 29 Nov 2025 18:26:19 +0200 Subject: [PATCH 9/9] PR feedback --- web/src/routes/admin/users/[id]/+page.svelte | 21 +++++++------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/web/src/routes/admin/users/[id]/+page.svelte b/web/src/routes/admin/users/[id]/+page.svelte index bf72476c67aa3..3f7949019d9af 100644 --- a/web/src/routes/admin/users/[id]/+page.svelte +++ b/web/src/routes/admin/users/[id]/+page.svelte @@ -71,20 +71,13 @@ let canResetPassword = $derived($authUser.id !== user.id); let newPassword = $state(''); - let lastUploadText = $derived( - user.lastAssetUploadedAt - ? (() => { - const dt = DateTime.fromJSDate(new Date(user.lastAssetUploadedAt)); - const now = DateTime.now(); - if (dt.hasSame(now, 'day')) { - return $t('today'); - } else { - const days = Math.floor(now.diff(dt, 'days').days); - return $t('days_ago', { values: { days } }); - } - })() - : $t('never_uploaded'), - ); + let lastUploadText = $derived.by(() => { + if (!user.lastAssetUploadedAt) { + return $t('never_uploaded'); + } + + return DateTime.fromISO(user.lastAssetUploadedAt).toRelative({ locale: $locale }) ?? $t('unknown_time'); + }); let editedLocale = $derived(findLocale($locale).code); let createAtDate: Date = $derived(new Date(user.createdAt));