diff --git a/i18n/en.json b/i18n/en.json index 30c8949aefd10..160b05e08f685 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", @@ -1233,6 +1234,7 @@ "large_files": "Large Files", "last": "Last", "last_seen": "Last seen", + "last_upload": "Last uploaded ", "latest_version": "Latest Version", "latitude": "Latitude", "leave": "Leave", @@ -1398,6 +1400,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", @@ -2037,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/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..62ebabafe12bc 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,6 @@ export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto { quotaUsageInBytes: entity.quotaUsageInBytes, status: entity.status, license: license ? { ...license, activatedAt: new Date(license?.activatedAt) } : null, + lastAssetUploadedAt: null, }; } diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 23fd3caf3ca78..991210346ad7e 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -7,6 +7,19 @@ set where "assetId" in ($2) +-- AssetRepository.getLatestCreatedAtForUser +select + "createdAt" +from + "asset" +where + "ownerId" = $1::uuid + and "deletedAt" is null +order by + "createdAt" desc +limit + $2 + -- AssetRepository.updateDateTimeOriginal update "asset_exif" set diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 8e793f9603129..3318db3403c6a 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -172,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( diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 58b4221cc9edf..21d57a052a7a5 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -47,7 +47,12 @@ 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); + + const last = await this.assetRepository.getLatestCreatedAtForUser(id); + dto.lastAssetUploadedAt = last ?? null; + + return dto; } async update(auth: AuthDto, id: string, dto: UserAdminUpdateDto): Promise { 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(''); + 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)); let updatedAtDate: Date = $derived(new Date(user.updatedAt)); @@ -345,6 +355,11 @@ {/if} +
+ + {$t('last_upload')} + {lastUploadText} +
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); } diff --git a/web/src/test-data/factories/user-factory.ts b/web/src/test-data/factories/user-factory.ts index 92d1510d40fa6..39d8a2a33452b 100644 --- a/web/src/test-data/factories/user-factory.ts +++ b/web/src/test-data/factories/user-factory.ts @@ -32,5 +32,6 @@ export const userAdminFactory = Sync.makeFactory({ activationKey: 'activation-key', activatedAt: new Date().toISOString(), }, + lastAssetUploadedAt: Sync.each(() => faker.date.recent().toISOString()), profileChangedAt: Sync.each(() => faker.date.recent().toISOString()), });