From 6a359f0372ea7925448bc29d4a7c90803edf2792 Mon Sep 17 00:00:00 2001 From: aviv <51673860+aviv926@users.noreply.github.com> Date: Wed, 27 Aug 2025 18:55:00 +0300 Subject: [PATCH 01/18] feat: add mobile device version tracking --- .../lib/model/user_admin_response_dto.dart | 19 +++++++ open-api/immich-openapi-specs.json | 31 +++++++++++ server/src/database.ts | 2 + server/src/dtos/user.dto.ts | 26 +++++++++ server/src/middleware/auth.guard.ts | 29 +++++++++- server/src/repositories/session.repository.ts | 14 +++++ server/src/repositories/user.repository.ts | 24 +++++++-- .../20250827_add_app_version_to_session.ts | 15 ++++++ server/src/schema/tables/session.table.ts | 3 ++ server/src/services/auth.service.ts | 19 +++++++ server/src/services/cli.service.ts | 2 +- server/src/services/memory.service.ts | 4 +- .../src/services/storage-template.service.ts | 2 +- server/src/services/user-admin.service.ts | 2 +- server/src/services/user.service.ts | 3 +- web/src/routes/admin/users/[id]/+page.svelte | 54 ++++++++++++++++++- 16 files changed, 236 insertions(+), 13 deletions(-) create mode 100644 server/src/schema/migrations/20250827_add_app_version_to_session.ts diff --git a/mobile/openapi/lib/model/user_admin_response_dto.dart b/mobile/openapi/lib/model/user_admin_response_dto.dart index e5ae8e1d4e..6bb1f9b6a2 100644 --- a/mobile/openapi/lib/model/user_admin_response_dto.dart +++ b/mobile/openapi/lib/model/user_admin_response_dto.dart @@ -30,8 +30,27 @@ class UserAdminResponseDto { required this.status, required this.storageLabel, required this.updatedAt, + required this.mobileDevices, }); + List mobileDevices; + +} + +class MobileDeviceDto { + MobileDeviceDto({ + required this.deviceType, + required this.deviceOS, + required this.appVersion, + required this.lastSeen, + }); + + String deviceType; + String deviceOS; + String appVersion; + DateTime lastSeen; +} + UserAvatarColor avatarColor; DateTime createdAt; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index eb9b6ac5a9..8663342e72 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -12348,6 +12348,30 @@ }, "type": "object" }, + "MobileDeviceDto": { + "properties": { + "appVersion": { + "type": "string" + }, + "deviceOS": { + "type": "string" + }, + "deviceType": { + "type": "string" + }, + "lastSeen": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "appVersion", + "deviceOS", + "deviceType", + "lastSeen" + ], + "type": "object" + }, "NotificationCreateDto": { "properties": { "data": { @@ -17067,6 +17091,12 @@ ], "nullable": true }, + "mobileDevices": { + "items": { + "$ref": "#/components/schemas/MobileDeviceDto" + }, + "type": "array" + }, "name": { "type": "string" }, @@ -17117,6 +17147,7 @@ "id", "isAdmin", "license", + "mobileDevices", "name", "oauthId", "profileChangedAt", diff --git a/server/src/database.ts b/server/src/database.ts index f472c643ee..684aeec916 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -142,6 +142,7 @@ export type UserAdmin = User & { quotaUsageInBytes: number; status: UserStatus; metadata: UserMetadataItem[]; + sessions?: Session[]; }; export type StorageAsset = { @@ -238,6 +239,7 @@ export type Session = { expiresAt: Date | null; deviceOS: string; deviceType: string; + appVersion: string; pinExpiresAt: Date | null; isPendingSyncReset: boolean; }; diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 443178aa10..b360fd252c 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -6,6 +6,20 @@ import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; import { UserMetadataItem } from 'src/types'; import { Optional, PinCode, ValidateBoolean, ValidateEnum, ValidateUUID, toEmail, toSanitized } from 'src/validation'; +export class MobileDeviceDto { + @ApiProperty() + deviceType!: string; + + @ApiProperty() + deviceOS!: string; + + @ApiProperty() + appVersion!: string; + + @ApiProperty() + lastSeen!: Date; +} + export class UserUpdateMeDto { @Optional() @IsEmail({ require_tld: false }) @@ -152,6 +166,8 @@ export class UserAdminDeleteDto { } export class UserAdminResponseDto extends UserResponseDto { + @ApiProperty({ type: () => [MobileDeviceDto] }) + mobileDevices!: MobileDeviceDto[]; storageLabel!: string | null; shouldChangePassword!: boolean; isAdmin!: boolean; @@ -173,6 +189,15 @@ export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto { const license = metadata.find( (item): item is UserMetadataItem => item.key === UserMetadataKey.License, )?.value; + const mobileDevices = (entity.sessions || []) + .filter((s) => s.appVersion && s.appVersion !== '') + .map((s) => ({ + deviceType: s.deviceType, + deviceOS: s.deviceOS, + appVersion: s.appVersion, + lastSeen: s.updatedAt, + })); + return { ...mapUser(entity), storageLabel: entity.storageLabel, @@ -186,5 +211,6 @@ export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto { quotaUsageInBytes: entity.quotaUsageInBytes, status: entity.status, license: license ? { ...license, activatedAt: new Date(license?.activatedAt) } : null, + mobileDevices, }; } diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index 8af7bf7fb3..4a7a95d6e5 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -56,13 +56,21 @@ export const FileResponse = () => export const GetLoginDetails = createParamDecorator((data, context: ExecutionContext): LoginDetails => { const request = context.switchToHttp().getRequest(); - const userAgent = UAParser(request.headers['user-agent']); + const userAgentString = request.headers['user-agent'] as string || ''; + const userAgent = UAParser(userAgentString); + + let appVersion = ''; + const immichMatch = userAgentString.match(/^Immich_(Android|iOS)_(.+)$/); + if (immichMatch) { + appVersion = immichMatch[2]; + } return { clientIp: request.ip ?? '', isSecure: request.secure, deviceType: userAgent.browser.name || userAgent.device.type || (request.headers.devicemodel as string) || '', deviceOS: userAgent.os.name || (request.headers.devicetype as string) || '', + appVersion, }; }); @@ -86,7 +94,6 @@ export class AuthGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const targets = [context.getHandler()]; - const options = this.reflector.getAllAndOverride(MetadataKey.AuthRoute, targets); if (!options) { return true; @@ -105,6 +112,24 @@ export class AuthGuard implements CanActivate { metadata: { adminRoute, sharedLinkRoute, permission, uri: request.path }, }); + if (request.user?.session) { + let appVersion = ''; + const userAgent = request.headers['user-agent'] as string || ''; + const immichMatch = userAgent.match(/^Immich_(Android|iOS)_(.+)$/); + if (immichMatch) { + appVersion = immichMatch[2]; + } + if (request.headers['x-app-version']) { + appVersion = request.headers['x-app-version'] as string; + } + if (appVersion) { + await this.authService.heartbeat(request.user, appVersion); + } else { + // Always update lastSeen even if appVersion is missing + await this.authService.heartbeat(request.user, ''); + } + } + return true; } } diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index cdc0ab12db..93979bf277 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -13,6 +13,20 @@ export type SessionSearchOptions = { updatedBefore: Date }; @Injectable() export class SessionRepository { + +async cleanupMobileStaleSessions() { + const deleted = await this.db + .deleteFrom('session') + .where((eb) => + eb.and([ + eb('updatedAt', '<=', DateTime.now().minus({ minutes: 1 }).toJSDate()), + eb('deviceOS', 'in', ['Android', 'iOS']), + ]) + ) + .returning(['id', 'deviceOS', 'deviceType']) + .execute(); + return deleted; +} constructor(@InjectKysely() private db: Kysely) {} cleanup() { diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index a63a4cc553..a0f5032484 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -69,14 +69,22 @@ export class UserRepository { } @GenerateSql() - getAdmin() { - return this.db + async getAdmin() { + const user = await this.db .selectFrom('user') .select(columns.userAdmin) .select(withMetadata) .where('user.isAdmin', '=', true) .where('user.deletedAt', 'is', null) .executeTakeFirst(); + if (user) { + (user as any).sessions = await this.db + .selectFrom('session') + .selectAll() + .where('session.userId', '=', user.id) + .execute(); + } + return user as any; } @GenerateSql() @@ -163,8 +171,8 @@ export class UserRepository { { name: 'with deleted', params: [{ withDeleted: true }] }, { name: 'without deleted', params: [{ withDeleted: false }] }, ) - getList({ id, withDeleted }: UserListFilter = {}) { - return this.db + async getList({ id, withDeleted }: UserListFilter = {}) { + const users = await this.db .selectFrom('user') .select(columns.userAdmin) .select(withMetadata) @@ -172,6 +180,14 @@ export class UserRepository { .$if(!!id, (eb) => eb.where('user.id', '=', id!)) .orderBy('createdAt', 'desc') .execute(); + for (const user of users) { + (user as any).sessions = await this.db + .selectFrom('session') + .selectAll() + .where('session.userId', '=', user.id) + .execute(); + } + return users as any; } async create(dto: Insertable) { diff --git a/server/src/schema/migrations/20250827_add_app_version_to_session.ts b/server/src/schema/migrations/20250827_add_app_version_to_session.ts new file mode 100644 index 0000000000..627ede66d5 --- /dev/null +++ b/server/src/schema/migrations/20250827_add_app_version_to_session.ts @@ -0,0 +1,15 @@ +import { Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('session') + .addColumn('appVersion', 'varchar(32)', (col) => col.defaultTo('').notNull()) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable('session') + .dropColumn('appVersion') + .execute(); +} diff --git a/server/src/schema/tables/session.table.ts b/server/src/schema/tables/session.table.ts index 706abdf887..518582527c 100644 --- a/server/src/schema/tables/session.table.ts +++ b/server/src/schema/tables/session.table.ts @@ -42,6 +42,9 @@ export class SessionTable { @Column({ default: '' }) deviceOS!: Generated; + @Column({ default: '' }) + appVersion!: Generated; + @UpdateIdColumn({ index: true }) updateId!: Generated; diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 69d872e8c9..4f86243510 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -34,6 +34,7 @@ export interface LoginDetails { clientIp: string; deviceType: string; deviceOS: string; + appVersion?: string; } interface ClaimOptions { @@ -56,6 +57,23 @@ export type ValidateRequest = { @Injectable() export class AuthService extends BaseService { + /** + * Cleanup mobile sessions not seen for over a week + */ + async cleanupStaleMobileSessions(): Promise { + const deleted = await this.sessionRepository.cleanupMobileStaleSessions(); + return deleted.length ?? 0; + } + + async heartbeat(auth: AuthDto, appVersion: string): Promise { + if (!auth.session) { + throw new BadRequestException('No active session'); + } + await this.sessionRepository.update(auth.session.id, { + appVersion, + updatedAt: new Date(), + }); + } async login(dto: LoginCredentialDto, details: LoginDetails) { const config = await this.getConfig({ withCache: false }); if (!config.passwordLogin.enabled) { @@ -529,6 +547,7 @@ export class AuthService extends BaseService { token: tokenHashed, deviceOS: loginDetails.deviceOS, deviceType: loginDetails.deviceType, + appVersion: loginDetails.appVersion || '', userId: user.id, }); diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index 38144e95b4..b744ea0208 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -8,7 +8,7 @@ import { BaseService } from 'src/services/base.service'; export class CliService extends BaseService { async listUsers(): Promise { const users = await this.userRepository.getList({ withDeleted: true }); - return users.map((user) => mapUserAdmin(user)); + return users.map((user:any) => mapUserAdmin(user)); } async resetAdminPassword(ask: (admin: UserAdminResponseDto) => Promise) { diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index 7bf9deab4b..c16d200f77 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -16,7 +16,7 @@ export class MemoryService extends BaseService { async onMemoriesCreate() { const users = await this.userRepository.getList({ withDeleted: false }); const usersIds = await Promise.all( - users.map((user) => + users.map((user:any) => getMyPartnerIds({ userId: user.id, repository: this.partnerRepository, @@ -38,7 +38,7 @@ export class MemoryService extends BaseService { } try { - await Promise.all(users.map((owner, i) => this.createOnThisDayMemories(owner.id, usersIds[i], target))); + await Promise.all(users.map((owner:any, i:any) => this.createOnThisDayMemories(owner.id, usersIds[i], target))); } catch (error) { this.logger.error(`Failed to create memories for ${target.toISO()}`, error); } diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 6086d62809..96d54d58a2 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -167,7 +167,7 @@ export class StorageTemplateService extends BaseService { const users = await this.userRepository.getList(); for await (const asset of assets) { - const user = users.find((user) => user.id === asset.ownerId); + const user = users.find((user:any) => user.id === asset.ownerId); const storageLabel = user?.storageLabel || null; const filename = asset.originalFileName || asset.id; await this.moveAsset(asset, { storageLabel, filename }); diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 3ae9d429eb..c389c09a0a 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -23,7 +23,7 @@ export class UserAdminService extends BaseService { id: dto.id, withDeleted: dto.withDeleted, }); - return users.map((user) => mapUserAdmin(user)); + return users.map((user:any) => mapUserAdmin(user)); } async create(dto: UserAdminCreateDto): Promise { diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 6849b17ac3..3359ee64a3 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -31,10 +31,11 @@ export class UserService extends BaseService { users = authUser ? [authUser] : []; } - return users.map((user) => mapUser(user)); + return users.map((user:any) => mapUser(user)); } async getMe(auth: AuthDto): Promise { + await this.sessionRepository.cleanupMobileStaleSessions(); const user = await this.userRepository.get(auth.user.id, {}); if (!user) { throw new BadRequestException('User not found'); diff --git a/web/src/routes/admin/users/[id]/+page.svelte b/web/src/routes/admin/users/[id]/+page.svelte index 27e0b6cfd2..f5d33fecdc 100644 --- a/web/src/routes/admin/users/[id]/+page.svelte +++ b/web/src/routes/admin/users/[id]/+page.svelte @@ -13,6 +13,7 @@ import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte'; import { locale } from '$lib/stores/preferences.store'; import { user as authUser } from '$lib/stores/user.store'; + import { websocketStore } from '$lib/stores/websocket'; import { createDateFormatter, findLocale } from '$lib/utils'; import { getBytesWithUnit } from '$lib/utils/byte-units'; import { handleError } from '$lib/utils/handle-error'; @@ -36,12 +37,16 @@ } from '@immich/ui'; import { mdiAccountOutline, + mdiAndroid, + mdiApple, + mdiAppsBox, mdiCameraIris, mdiChartPie, mdiChartPieOutline, mdiCheckCircle, mdiDeleteRestore, mdiFeatureSearchOutline, + mdiHelp, mdiLockSmart, mdiOnepassword, mdiPencilOutline, @@ -64,7 +69,10 @@ const TiB = 1024 ** 4; const usage = $derived(user.quotaUsageInBytes ?? 0); let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(usage, usage > TiB ? 2 : 0)); - + const { serverVersion } = websocketStore; + let formattedServerVersion = $derived( + $serverVersion ? `${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null, + ); const usedBytes = $derived(user.quotaUsageInBytes ?? 0); const availableBytes = $derived(user.quotaSizeInBytes ?? 1); let usedPercentage = $derived(Math.min(Math.round((usedBytes / availableBytes) * 100), 100)); @@ -355,6 +363,50 @@ {/if} + + +
+ + Mobile Devices +
+
+ +
+ + {#if user.mobileDevices && user.mobileDevices.length > 0} + {#each user.mobileDevices as device} +
+ + {#if device.deviceType === 'Android' || device.deviceOS === 'Android'} + + {:else if device.deviceType === 'iOS' || device.deviceType === 'Apple' || device.deviceType === 'iPhone' || device.deviceType === 'iPad' || device.deviceOS === 'iOS' || device.deviceOS === 'Apple' || device.deviceOS === 'iPhone' || device.deviceOS === 'iPad' || device.deviceOS === 'macOS'} + + {:else} + + {/if} + +
+ {device.deviceType} + + v{device.appVersion} + + + Last seen: {new Date(device.lastSeen).toLocaleString()} +
+
+ {/each} + {:else} + No mobile devices + {/if} +
+
+
+
From 8179478cbfe4c8b21d2e4a30bb8e31ce650766e6 Mon Sep 17 00:00:00 2001 From: aviv <51673860+aviv926@users.noreply.github.com> Date: Wed, 27 Aug 2025 20:01:17 +0300 Subject: [PATCH 02/18] fix: update stale session cleanup to target sessions older than 7 days --- server/src/repositories/session.repository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index 93979bf277..76c883fa3a 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -19,7 +19,7 @@ async cleanupMobileStaleSessions() { .deleteFrom('session') .where((eb) => eb.and([ - eb('updatedAt', '<=', DateTime.now().minus({ minutes: 1 }).toJSDate()), + eb('updatedAt', '<=', DateTime.now().minus({ days: 7 }).toJSDate()), eb('deviceOS', 'in', ['Android', 'iOS']), ]) ) From 6595a5843e80685d3aefb076ab00a73c0a50004e Mon Sep 17 00:00:00 2001 From: aviv <51673860+aviv926@users.noreply.github.com> Date: Wed, 27 Aug 2025 20:26:50 +0300 Subject: [PATCH 03/18] feat: add session queries and mock implementation for cleanup in user service tests --- server/src/queries/user.repository.sql | 30 ++++++++++++++++++++++++ server/src/services/user.service.spec.ts | 1 + server/test/small.factory.ts | 1 + 3 files changed, 32 insertions(+) diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index 6a02654781..222f98908b 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -77,6 +77,12 @@ from where "user"."isAdmin" = $1 and "user"."deletedAt" is null +select + * +from + "session" +where + "session"."userId" = $1 -- UserRepository.getFileSamples select @@ -266,6 +272,18 @@ from "user" order by "createdAt" desc +select + * +from + "session" +where + "session"."userId" = $1 +select + * +from + "session" +where + "session"."userId" = $1 -- UserRepository.getList (without deleted) select @@ -306,6 +324,18 @@ where "user"."deletedAt" is null order by "createdAt" desc +select + * +from + "session" +where + "session"."userId" = $1 +select + * +from + "session" +where + "session"."userId" = $1 -- UserRepository.getUserStats select diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index bd896ffc24..dc43f4cfad 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -24,6 +24,7 @@ describe(UserService.name, () => { mocks.user.get.mockImplementation((userId) => Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? undefined), ); + mocks.session.cleanupMobileStaleSessions.mockResolvedValue([]); }); describe('getAll', () => { diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 8b44b6eddc..1203526a2b 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -135,6 +135,7 @@ const sessionFactory = (session: Partial = {}) => ({ userId: newUuid(), pinExpiresAt: newDate(), isPendingSyncReset: false, + appVersion: typeof session.appVersion === 'string' ? session.appVersion : '', ...session, }); From 645d4ed154c90ccd5bcc831e60bea1f4811e7f06 Mon Sep 17 00:00:00 2001 From: aviv <51673860+aviv926@users.noreply.github.com> Date: Thu, 28 Aug 2025 03:09:12 +0300 Subject: [PATCH 04/18] format --- server/src/middleware/auth.guard.ts | 4 +-- server/src/repositories/session.repository.ts | 27 +++++++++---------- server/src/services/cli.service.ts | 2 +- server/src/services/memory.service.ts | 6 +++-- .../src/services/storage-template.service.ts | 2 +- server/src/services/user-admin.service.ts | 2 +- server/src/services/user.service.ts | 2 +- 7 files changed, 23 insertions(+), 22 deletions(-) diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index 4a7a95d6e5..b9775c2697 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -56,7 +56,7 @@ export const FileResponse = () => export const GetLoginDetails = createParamDecorator((data, context: ExecutionContext): LoginDetails => { const request = context.switchToHttp().getRequest(); - const userAgentString = request.headers['user-agent'] as string || ''; + const userAgentString = (request.headers['user-agent'] as string) || ''; const userAgent = UAParser(userAgentString); let appVersion = ''; @@ -114,7 +114,7 @@ export class AuthGuard implements CanActivate { if (request.user?.session) { let appVersion = ''; - const userAgent = request.headers['user-agent'] as string || ''; + const userAgent = (request.headers['user-agent'] as string) || ''; const immichMatch = userAgent.match(/^Immich_(Android|iOS)_(.+)$/); if (immichMatch) { appVersion = immichMatch[2]; diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index 76c883fa3a..3c5b07c6f4 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -13,20 +13,19 @@ export type SessionSearchOptions = { updatedBefore: Date }; @Injectable() export class SessionRepository { - -async cleanupMobileStaleSessions() { - const deleted = await this.db - .deleteFrom('session') - .where((eb) => - eb.and([ - eb('updatedAt', '<=', DateTime.now().minus({ days: 7 }).toJSDate()), - eb('deviceOS', 'in', ['Android', 'iOS']), - ]) - ) - .returning(['id', 'deviceOS', 'deviceType']) - .execute(); - return deleted; -} + async cleanupMobileStaleSessions() { + const deleted = await this.db + .deleteFrom('session') + .where((eb) => + eb.and([ + eb('updatedAt', '<=', DateTime.now().minus({ days: 7 }).toJSDate()), + eb('deviceOS', 'in', ['Android', 'iOS']), + ]), + ) + .returning(['id', 'deviceOS', 'deviceType']) + .execute(); + return deleted; + } constructor(@InjectKysely() private db: Kysely) {} cleanup() { diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index b744ea0208..f4b682c275 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -8,7 +8,7 @@ import { BaseService } from 'src/services/base.service'; export class CliService extends BaseService { async listUsers(): Promise { const users = await this.userRepository.getList({ withDeleted: true }); - return users.map((user:any) => mapUserAdmin(user)); + return users.map((user: any) => mapUserAdmin(user)); } async resetAdminPassword(ask: (admin: UserAdminResponseDto) => Promise) { diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index c16d200f77..341fe5680c 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -16,7 +16,7 @@ export class MemoryService extends BaseService { async onMemoriesCreate() { const users = await this.userRepository.getList({ withDeleted: false }); const usersIds = await Promise.all( - users.map((user:any) => + users.map((user: any) => getMyPartnerIds({ userId: user.id, repository: this.partnerRepository, @@ -38,7 +38,9 @@ export class MemoryService extends BaseService { } try { - await Promise.all(users.map((owner:any, i:any) => this.createOnThisDayMemories(owner.id, usersIds[i], target))); + await Promise.all( + users.map((owner: any, i: any) => this.createOnThisDayMemories(owner.id, usersIds[i], target)), + ); } catch (error) { this.logger.error(`Failed to create memories for ${target.toISO()}`, error); } diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 96d54d58a2..f153a6597b 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -167,7 +167,7 @@ export class StorageTemplateService extends BaseService { const users = await this.userRepository.getList(); for await (const asset of assets) { - const user = users.find((user:any) => user.id === asset.ownerId); + const user = users.find((user: any) => user.id === asset.ownerId); const storageLabel = user?.storageLabel || null; const filename = asset.originalFileName || asset.id; await this.moveAsset(asset, { storageLabel, filename }); diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index c389c09a0a..dd28d94cc6 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -23,7 +23,7 @@ export class UserAdminService extends BaseService { id: dto.id, withDeleted: dto.withDeleted, }); - return users.map((user:any) => mapUserAdmin(user)); + return users.map((user: any) => mapUserAdmin(user)); } async create(dto: UserAdminCreateDto): Promise { diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 3359ee64a3..5df1f61be9 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -31,7 +31,7 @@ export class UserService extends BaseService { users = authUser ? [authUser] : []; } - return users.map((user:any) => mapUser(user)); + return users.map((user: any) => mapUser(user)); } async getMe(auth: AuthDto): Promise { From 7a480f0167cfae192ba574c5d70141eef9c346a7 Mon Sep 17 00:00:00 2001 From: aviv <51673860+aviv926@users.noreply.github.com> Date: Thu, 28 Aug 2025 03:29:41 +0300 Subject: [PATCH 05/18] feat: add the right migration for appVersion column in session table --- .../1756340577214-AddAppVersionToSession.ts | 13 +++++++++++++ .../20250827_add_app_version_to_session.ts | 15 --------------- 2 files changed, 13 insertions(+), 15 deletions(-) create mode 100644 server/src/schema/migrations/1756340577214-AddAppVersionToSession.ts delete mode 100644 server/src/schema/migrations/20250827_add_app_version_to_session.ts diff --git a/server/src/schema/migrations/1756340577214-AddAppVersionToSession.ts b/server/src/schema/migrations/1756340577214-AddAppVersionToSession.ts new file mode 100644 index 0000000000..f8ecf09ae5 --- /dev/null +++ b/server/src/schema/migrations/1756340577214-AddAppVersionToSession.ts @@ -0,0 +1,13 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "session" ADD "appVersion" character varying NOT NULL DEFAULT '';`.execute(db); + await sql`ALTER TABLE "session" DROP COLUMN "appVersion";`.execute(db); + await sql`ALTER TABLE "session" DROP COLUMN "app_version";`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "session" ADD "appVersion" character varying(32) NOT NULL DEFAULT ''::character varying;`.execute(db); + await sql`ALTER TABLE "session" ADD "app_version" character varying NOT NULL DEFAULT ''::character varying;`.execute(db); + await sql`ALTER TABLE "session" DROP COLUMN "appVersion";`.execute(db); +} diff --git a/server/src/schema/migrations/20250827_add_app_version_to_session.ts b/server/src/schema/migrations/20250827_add_app_version_to_session.ts deleted file mode 100644 index 627ede66d5..0000000000 --- a/server/src/schema/migrations/20250827_add_app_version_to_session.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Kysely } from 'kysely'; - -export async function up(db: Kysely): Promise { - await db.schema - .alterTable('session') - .addColumn('appVersion', 'varchar(32)', (col) => col.defaultTo('').notNull()) - .execute(); -} - -export async function down(db: Kysely): Promise { - await db.schema - .alterTable('session') - .dropColumn('appVersion') - .execute(); -} From 758355542dfaf1e307ec3036ab0dddf3c61496f7 Mon Sep 17 00:00:00 2001 From: aviv <51673860+aviv926@users.noreply.github.com> Date: Thu, 28 Aug 2025 03:47:56 +0300 Subject: [PATCH 06/18] fix sql error --- .../schema/migrations/1756340577214-AddAppVersionToSession.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/server/src/schema/migrations/1756340577214-AddAppVersionToSession.ts b/server/src/schema/migrations/1756340577214-AddAppVersionToSession.ts index f8ecf09ae5..4ea4ec85ec 100644 --- a/server/src/schema/migrations/1756340577214-AddAppVersionToSession.ts +++ b/server/src/schema/migrations/1756340577214-AddAppVersionToSession.ts @@ -2,12 +2,8 @@ import { Kysely, sql } from 'kysely'; export async function up(db: Kysely): Promise { await sql`ALTER TABLE "session" ADD "appVersion" character varying NOT NULL DEFAULT '';`.execute(db); - await sql`ALTER TABLE "session" DROP COLUMN "appVersion";`.execute(db); - await sql`ALTER TABLE "session" DROP COLUMN "app_version";`.execute(db); } export async function down(db: Kysely): Promise { - await sql`ALTER TABLE "session" ADD "appVersion" character varying(32) NOT NULL DEFAULT ''::character varying;`.execute(db); - await sql`ALTER TABLE "session" ADD "app_version" character varying NOT NULL DEFAULT ''::character varying;`.execute(db); await sql`ALTER TABLE "session" DROP COLUMN "appVersion";`.execute(db); } From 61aea7cac7ab23f1f39eeb538c6b60122303ea54 Mon Sep 17 00:00:00 2001 From: aviv <51673860+aviv926@users.noreply.github.com> Date: Thu, 28 Aug 2025 03:54:49 +0300 Subject: [PATCH 07/18] try to fix SQL error (again) --- server/src/queries/user.repository.sql | 30 -------------------------- 1 file changed, 30 deletions(-) diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index 222f98908b..6a02654781 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -77,12 +77,6 @@ from where "user"."isAdmin" = $1 and "user"."deletedAt" is null -select - * -from - "session" -where - "session"."userId" = $1 -- UserRepository.getFileSamples select @@ -272,18 +266,6 @@ from "user" order by "createdAt" desc -select - * -from - "session" -where - "session"."userId" = $1 -select - * -from - "session" -where - "session"."userId" = $1 -- UserRepository.getList (without deleted) select @@ -324,18 +306,6 @@ where "user"."deletedAt" is null order by "createdAt" desc -select - * -from - "session" -where - "session"."userId" = $1 -select - * -from - "session" -where - "session"."userId" = $1 -- UserRepository.getUserStats select From eea4c43dd9c35370056869fab0a9b675c2c74d7d Mon Sep 17 00:00:00 2001 From: aviv <51673860+aviv926@users.noreply.github.com> Date: Thu, 28 Aug 2025 04:25:39 +0300 Subject: [PATCH 08/18] make open-api --- mobile/openapi/README.md | 1 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api_client.dart | 2 + .../openapi/lib/model/mobile_device_dto.dart | 123 ++++++++++++++++++ .../lib/model/user_admin_response_dto.dart | 29 ++--- open-api/typescript-sdk/src/fetch-client.ts | 7 + 6 files changed, 143 insertions(+), 20 deletions(-) create mode 100644 mobile/openapi/lib/model/mobile_device_dto.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 04600250b1..903679033f 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -394,6 +394,7 @@ Class | Method | HTTP request | Description - [MemoryUpdateDto](doc//MemoryUpdateDto.md) - [MergePersonDto](doc//MergePersonDto.md) - [MetadataSearchDto](doc//MetadataSearchDto.md) + - [MobileDeviceDto](doc//MobileDeviceDto.md) - [NotificationCreateDto](doc//NotificationCreateDto.md) - [NotificationDeleteAllDto](doc//NotificationDeleteAllDto.md) - [NotificationDto](doc//NotificationDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index f5f353c968..49f6cb30ba 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -172,6 +172,7 @@ part 'model/memory_type.dart'; part 'model/memory_update_dto.dart'; part 'model/merge_person_dto.dart'; part 'model/metadata_search_dto.dart'; +part 'model/mobile_device_dto.dart'; part 'model/notification_create_dto.dart'; part 'model/notification_delete_all_dto.dart'; part 'model/notification_dto.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 3f31d4ed90..d35c3b1cc4 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -398,6 +398,8 @@ class ApiClient { return MergePersonDto.fromJson(value); case 'MetadataSearchDto': return MetadataSearchDto.fromJson(value); + case 'MobileDeviceDto': + return MobileDeviceDto.fromJson(value); case 'NotificationCreateDto': return NotificationCreateDto.fromJson(value); case 'NotificationDeleteAllDto': diff --git a/mobile/openapi/lib/model/mobile_device_dto.dart b/mobile/openapi/lib/model/mobile_device_dto.dart new file mode 100644 index 0000000000..5c3c9fcd3b --- /dev/null +++ b/mobile/openapi/lib/model/mobile_device_dto.dart @@ -0,0 +1,123 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class MobileDeviceDto { + /// Returns a new [MobileDeviceDto] instance. + MobileDeviceDto({ + required this.appVersion, + required this.deviceOS, + required this.deviceType, + required this.lastSeen, + }); + + String appVersion; + + String deviceOS; + + String deviceType; + + DateTime lastSeen; + + @override + bool operator ==(Object other) => identical(this, other) || other is MobileDeviceDto && + other.appVersion == appVersion && + other.deviceOS == deviceOS && + other.deviceType == deviceType && + other.lastSeen == lastSeen; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (appVersion.hashCode) + + (deviceOS.hashCode) + + (deviceType.hashCode) + + (lastSeen.hashCode); + + @override + String toString() => 'MobileDeviceDto[appVersion=$appVersion, deviceOS=$deviceOS, deviceType=$deviceType, lastSeen=$lastSeen]'; + + Map toJson() { + final json = {}; + json[r'appVersion'] = this.appVersion; + json[r'deviceOS'] = this.deviceOS; + json[r'deviceType'] = this.deviceType; + json[r'lastSeen'] = this.lastSeen.toUtc().toIso8601String(); + return json; + } + + /// Returns a new [MobileDeviceDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static MobileDeviceDto? fromJson(dynamic value) { + upgradeDto(value, "MobileDeviceDto"); + if (value is Map) { + final json = value.cast(); + + return MobileDeviceDto( + appVersion: mapValueOfType(json, r'appVersion')!, + deviceOS: mapValueOfType(json, r'deviceOS')!, + deviceType: mapValueOfType(json, r'deviceType')!, + lastSeen: mapDateTime(json, r'lastSeen', r'')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = MobileDeviceDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = MobileDeviceDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of MobileDeviceDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = MobileDeviceDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'appVersion', + 'deviceOS', + 'deviceType', + 'lastSeen', + }; +} + diff --git a/mobile/openapi/lib/model/user_admin_response_dto.dart b/mobile/openapi/lib/model/user_admin_response_dto.dart index 6bb1f9b6a2..bbd777b230 100644 --- a/mobile/openapi/lib/model/user_admin_response_dto.dart +++ b/mobile/openapi/lib/model/user_admin_response_dto.dart @@ -20,6 +20,7 @@ class UserAdminResponseDto { required this.id, required this.isAdmin, required this.license, + this.mobileDevices = const [], required this.name, required this.oauthId, required this.profileChangedAt, @@ -30,27 +31,8 @@ class UserAdminResponseDto { required this.status, required this.storageLabel, required this.updatedAt, - required this.mobileDevices, }); - List mobileDevices; - -} - -class MobileDeviceDto { - MobileDeviceDto({ - required this.deviceType, - required this.deviceOS, - required this.appVersion, - required this.lastSeen, - }); - - String deviceType; - String deviceOS; - String appVersion; - DateTime lastSeen; -} - UserAvatarColor avatarColor; DateTime createdAt; @@ -65,6 +47,8 @@ class MobileDeviceDto { UserLicense? license; + List mobileDevices; + String name; String oauthId; @@ -94,6 +78,7 @@ class MobileDeviceDto { other.id == id && other.isAdmin == isAdmin && other.license == license && + _deepEquality.equals(other.mobileDevices, mobileDevices) && other.name == name && other.oauthId == oauthId && other.profileChangedAt == profileChangedAt && @@ -115,6 +100,7 @@ class MobileDeviceDto { (id.hashCode) + (isAdmin.hashCode) + (license == null ? 0 : license!.hashCode) + + (mobileDevices.hashCode) + (name.hashCode) + (oauthId.hashCode) + (profileChangedAt.hashCode) + @@ -127,7 +113,7 @@ class MobileDeviceDto { (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, license=$license, mobileDevices=$mobileDevices, name=$name, oauthId=$oauthId, profileChangedAt=$profileChangedAt, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -146,6 +132,7 @@ class MobileDeviceDto { } else { // json[r'license'] = null; } + json[r'mobileDevices'] = this.mobileDevices; json[r'name'] = this.name; json[r'oauthId'] = this.oauthId; json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String(); @@ -187,6 +174,7 @@ class MobileDeviceDto { id: mapValueOfType(json, r'id')!, isAdmin: mapValueOfType(json, r'isAdmin')!, license: UserLicense.fromJson(json[r'license']), + mobileDevices: MobileDeviceDto.listFromJson(json[r'mobileDevices']), name: mapValueOfType(json, r'name')!, oauthId: mapValueOfType(json, r'oauthId')!, profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!, @@ -251,6 +239,7 @@ class MobileDeviceDto { 'id', 'isAdmin', 'license', + 'mobileDevices', 'name', 'oauthId', 'profileChangedAt', diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 08fa714823..8c355d2128 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -87,6 +87,12 @@ export type UserLicense = { activationKey: string; licenseKey: string; }; +export type MobileDeviceDto = { + appVersion: string; + deviceOS: string; + deviceType: string; + lastSeen: string; +}; export type UserAdminResponseDto = { avatarColor: UserAvatarColor; createdAt: string; @@ -95,6 +101,7 @@ export type UserAdminResponseDto = { id: string; isAdmin: boolean; license: (UserLicense) | null; + mobileDevices: MobileDeviceDto[]; name: string; oauthId: string; profileChangedAt: string; From 470d4582de2a608861359750c54213a7d9feb49a Mon Sep 17 00:00:00 2001 From: aviv <51673860+aviv926@users.noreply.github.com> Date: Thu, 28 Aug 2025 04:46:42 +0300 Subject: [PATCH 09/18] refactor: simplify appVersion extraction in GetLoginDetails and canActivate methods --- server/src/middleware/auth.guard.ts | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index b9775c2697..63f4e942d4 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -59,11 +59,7 @@ export const GetLoginDetails = createParamDecorator((data, context: ExecutionCon const userAgentString = (request.headers['user-agent'] as string) || ''; const userAgent = UAParser(userAgentString); - let appVersion = ''; - const immichMatch = userAgentString.match(/^Immich_(Android|iOS)_(.+)$/); - if (immichMatch) { - appVersion = immichMatch[2]; - } + const appVersion = userAgentString.match(/^Immich_(Android|iOS)_(.+)$/)?.[2] ?? ''; return { clientIp: request.ip ?? '', @@ -113,23 +109,13 @@ export class AuthGuard implements CanActivate { }); if (request.user?.session) { - let appVersion = ''; const userAgent = (request.headers['user-agent'] as string) || ''; - const immichMatch = userAgent.match(/^Immich_(Android|iOS)_(.+)$/); - if (immichMatch) { - appVersion = immichMatch[2]; - } - if (request.headers['x-app-version']) { - appVersion = request.headers['x-app-version'] as string; - } - if (appVersion) { - await this.authService.heartbeat(request.user, appVersion); - } else { - // Always update lastSeen even if appVersion is missing - await this.authService.heartbeat(request.user, ''); - } + const appVersion = + (request.headers['x-app-version'] as string) || + (userAgent.match(/^Immich_(Android|iOS)_(.+)$/)?.[2] ?? ''); + await this.authService.heartbeat(request.user, appVersion); } return true; } -} +} \ No newline at end of file From 38c713fdf64826628dcf2b401bbeaa8a745da387 Mon Sep 17 00:00:00 2001 From: aviv <51673860+aviv926@users.noreply.github.com> Date: Thu, 28 Aug 2025 05:13:37 +0300 Subject: [PATCH 10/18] fix errors --- server/src/middleware/auth.guard.ts | 5 ++--- web/src/routes/admin/users/[id]/+page.svelte | 4 ++-- web/src/test-data/factories/user-factory.ts | 8 ++++++++ 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index 63f4e942d4..ec94049eb2 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -111,11 +111,10 @@ export class AuthGuard implements CanActivate { if (request.user?.session) { const userAgent = (request.headers['user-agent'] as string) || ''; const appVersion = - (request.headers['x-app-version'] as string) || - (userAgent.match(/^Immich_(Android|iOS)_(.+)$/)?.[2] ?? ''); + (request.headers['x-app-version'] as string) || (userAgent.match(/^Immich_(Android|iOS)_(.+)$/)?.[2] ?? ''); await this.authService.heartbeat(request.user, appVersion); } return true; } -} \ No newline at end of file +} diff --git a/web/src/routes/admin/users/[id]/+page.svelte b/web/src/routes/admin/users/[id]/+page.svelte index f5d33fecdc..ea1f7b090b 100644 --- a/web/src/routes/admin/users/[id]/+page.svelte +++ b/web/src/routes/admin/users/[id]/+page.svelte @@ -374,7 +374,7 @@
{#if user.mobileDevices && user.mobileDevices.length > 0} - {#each user.mobileDevices as device} + {#each user.mobileDevices as device (`${device.deviceType}-${device.deviceOS}-${device.lastSeen}`)}
@@ -390,7 +390,7 @@
{device.deviceType} - + v{device.appVersion} diff --git a/web/src/test-data/factories/user-factory.ts b/web/src/test-data/factories/user-factory.ts index 92d1510d40..536a7ff223 100644 --- a/web/src/test-data/factories/user-factory.ts +++ b/web/src/test-data/factories/user-factory.ts @@ -17,6 +17,14 @@ export const userAdminFactory = Sync.makeFactory({ name: Sync.each(() => faker.person.fullName()), profileImagePath: '', avatarColor: UserAvatarColor.Primary, + mobileDevices: [ + { + deviceType: 'SM-S938B', + deviceOS: 'Android', + appVersion: '1.139.4', + lastSeen: '2025-08-28T01:57:37.170Z', + }, + ], isAdmin: true, createdAt: Sync.each(() => faker.date.recent().toISOString()), updatedAt: Sync.each(() => faker.date.recent().toISOString()), From 74a5a56b9a7d66100b9e78958855e7f3c86d351f Mon Sep 17 00:00:00 2001 From: aviv <51673860+aviv926@users.noreply.github.com> Date: Thu, 28 Aug 2025 05:23:25 +0300 Subject: [PATCH 11/18] prettier --- web/src/routes/admin/users/[id]/+page.svelte | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/src/routes/admin/users/[id]/+page.svelte b/web/src/routes/admin/users/[id]/+page.svelte index ea1f7b090b..08bd74ed17 100644 --- a/web/src/routes/admin/users/[id]/+page.svelte +++ b/web/src/routes/admin/users/[id]/+page.svelte @@ -390,7 +390,11 @@
{device.deviceType} - + v{device.appVersion} From 93d1f9ef54fd3c172ba4af04247919f0fb82c626 Mon Sep 17 00:00:00 2001 From: aviv <51673860+aviv926@users.noreply.github.com> Date: Sun, 31 Aug 2025 20:20:49 +0300 Subject: [PATCH 12/18] refactor: streamline appVersion handling and improve user session retrieval --- server/src/dtos/user.dto.ts | 12 ++++----- server/src/middleware/auth.guard.ts | 16 +++++++----- server/src/repositories/session.repository.ts | 13 ---------- server/src/repositories/user.repository.ts | 26 ++++++------------- server/src/services/auth.service.ts | 8 ------ server/src/services/cli.service.ts | 2 +- server/src/services/memory.service.ts | 2 +- .../src/services/storage-template.service.ts | 2 +- server/src/services/user-admin.service.ts | 2 +- server/src/services/user.service.ts | 3 +-- server/test/small.factory.ts | 2 +- 11 files changed, 29 insertions(+), 59 deletions(-) mode change 100644 => 100755 server/test/small.factory.ts diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index b360fd252c..a9d9b81b17 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -190,12 +190,12 @@ export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto { (item): item is UserMetadataItem => item.key === UserMetadataKey.License, )?.value; const mobileDevices = (entity.sessions || []) - .filter((s) => s.appVersion && s.appVersion !== '') - .map((s) => ({ - deviceType: s.deviceType, - deviceOS: s.deviceOS, - appVersion: s.appVersion, - lastSeen: s.updatedAt, + .filter(({ appVersion }) => appVersion) + .map(({ deviceType, deviceOS, appVersion, updatedAt }) => ({ + deviceType, + deviceOS, + appVersion, + lastSeen: updatedAt, })); return { diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index ec94049eb2..e3b7e88ca8 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -54,18 +54,21 @@ export const FileResponse = () => content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } }, }); +const getAppVersionFromUA = (ua: string) => + ua.match(/^Immich_(?:Android|iOS)_(?.+)$/)?.groups?.appVersion ?? ''; + export const GetLoginDetails = createParamDecorator((data, context: ExecutionContext): LoginDetails => { const request = context.switchToHttp().getRequest(); - const userAgentString = (request.headers['user-agent'] as string) || ''; + const userAgentString = request.get('user-agent') || ''; const userAgent = UAParser(userAgentString); - const appVersion = userAgentString.match(/^Immich_(Android|iOS)_(.+)$/)?.[2] ?? ''; + const appVersion = getAppVersionFromUA(userAgentString); return { clientIp: request.ip ?? '', isSecure: request.secure, - deviceType: userAgent.browser.name || userAgent.device.type || (request.headers.devicemodel as string) || '', - deviceOS: userAgent.os.name || (request.headers.devicetype as string) || '', + deviceType: userAgent.browser.name || userAgent.device.type || request.get('devicemodel') || '', + deviceOS: userAgent.os.name || request.get('devicetype') || '', appVersion, }; }); @@ -109,9 +112,8 @@ export class AuthGuard implements CanActivate { }); if (request.user?.session) { - const userAgent = (request.headers['user-agent'] as string) || ''; - const appVersion = - (request.headers['x-app-version'] as string) || (userAgent.match(/^Immich_(Android|iOS)_(.+)$/)?.[2] ?? ''); + const userAgent = request.get('user-agent') || ''; + const appVersion = request.get('x-app-version') || getAppVersionFromUA(userAgent); await this.authService.heartbeat(request.user, appVersion); } diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index 3c5b07c6f4..cdc0ab12db 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -13,19 +13,6 @@ export type SessionSearchOptions = { updatedBefore: Date }; @Injectable() export class SessionRepository { - async cleanupMobileStaleSessions() { - const deleted = await this.db - .deleteFrom('session') - .where((eb) => - eb.and([ - eb('updatedAt', '<=', DateTime.now().minus({ days: 7 }).toJSDate()), - eb('deviceOS', 'in', ['Android', 'iOS']), - ]), - ) - .returning(['id', 'deviceOS', 'deviceType']) - .execute(); - return deleted; - } constructor(@InjectKysely() private db: Kysely) {} cleanup() { diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index a0f5032484..af72bb0867 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -43,6 +43,10 @@ const withMetadata = (eb: ExpressionBuilder) => { ).as('metadata'); }; +const withSessions = (eb: ExpressionBuilder) => { + return jsonArrayFrom(eb.selectFrom('session').selectAll().whereRef('session.userId', '=', 'user.id')).as('sessions'); +}; + @Injectable() export class UserRepository { constructor(@InjectKysely() private db: Kysely) {} @@ -70,21 +74,14 @@ export class UserRepository { @GenerateSql() async getAdmin() { - const user = await this.db + return this.db .selectFrom('user') .select(columns.userAdmin) .select(withMetadata) + .select(withSessions) .where('user.isAdmin', '=', true) .where('user.deletedAt', 'is', null) .executeTakeFirst(); - if (user) { - (user as any).sessions = await this.db - .selectFrom('session') - .selectAll() - .where('session.userId', '=', user.id) - .execute(); - } - return user as any; } @GenerateSql() @@ -172,22 +169,15 @@ export class UserRepository { { name: 'without deleted', params: [{ withDeleted: false }] }, ) async getList({ id, withDeleted }: UserListFilter = {}) { - const users = await this.db + return this.db .selectFrom('user') .select(columns.userAdmin) .select(withMetadata) + .select(withSessions) .$if(!withDeleted, (eb) => eb.where('user.deletedAt', 'is', null)) .$if(!!id, (eb) => eb.where('user.id', '=', id!)) .orderBy('createdAt', 'desc') .execute(); - for (const user of users) { - (user as any).sessions = await this.db - .selectFrom('session') - .selectAll() - .where('session.userId', '=', user.id) - .execute(); - } - return users as any; } async create(dto: Insertable) { diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 4f86243510..8f69bc6987 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -57,14 +57,6 @@ export type ValidateRequest = { @Injectable() export class AuthService extends BaseService { - /** - * Cleanup mobile sessions not seen for over a week - */ - async cleanupStaleMobileSessions(): Promise { - const deleted = await this.sessionRepository.cleanupMobileStaleSessions(); - return deleted.length ?? 0; - } - async heartbeat(auth: AuthDto, appVersion: string): Promise { if (!auth.session) { throw new BadRequestException('No active session'); diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index f4b682c275..38144e95b4 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -8,7 +8,7 @@ import { BaseService } from 'src/services/base.service'; export class CliService extends BaseService { async listUsers(): Promise { const users = await this.userRepository.getList({ withDeleted: true }); - return users.map((user: any) => mapUserAdmin(user)); + return users.map((user) => mapUserAdmin(user)); } async resetAdminPassword(ask: (admin: UserAdminResponseDto) => Promise) { diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index 341fe5680c..17c732340a 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -16,7 +16,7 @@ export class MemoryService extends BaseService { async onMemoriesCreate() { const users = await this.userRepository.getList({ withDeleted: false }); const usersIds = await Promise.all( - users.map((user: any) => + users.map((user) => getMyPartnerIds({ userId: user.id, repository: this.partnerRepository, diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index f153a6597b..6086d62809 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -167,7 +167,7 @@ export class StorageTemplateService extends BaseService { const users = await this.userRepository.getList(); for await (const asset of assets) { - const user = users.find((user: any) => user.id === asset.ownerId); + const user = users.find((user) => user.id === asset.ownerId); const storageLabel = user?.storageLabel || null; const filename = asset.originalFileName || asset.id; await this.moveAsset(asset, { storageLabel, filename }); diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index dd28d94cc6..3ae9d429eb 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -23,7 +23,7 @@ export class UserAdminService extends BaseService { id: dto.id, withDeleted: dto.withDeleted, }); - return users.map((user: any) => mapUserAdmin(user)); + return users.map((user) => mapUserAdmin(user)); } async create(dto: UserAdminCreateDto): Promise { diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 5df1f61be9..6849b17ac3 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -31,11 +31,10 @@ export class UserService extends BaseService { users = authUser ? [authUser] : []; } - return users.map((user: any) => mapUser(user)); + return users.map((user) => mapUser(user)); } async getMe(auth: AuthDto): Promise { - await this.sessionRepository.cleanupMobileStaleSessions(); const user = await this.userRepository.get(auth.user.id, {}); if (!user) { throw new BadRequestException('User not found'); diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts old mode 100644 new mode 100755 index 1203526a2b..c932317288 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -135,7 +135,7 @@ const sessionFactory = (session: Partial = {}) => ({ userId: newUuid(), pinExpiresAt: newDate(), isPendingSyncReset: false, - appVersion: typeof session.appVersion === 'string' ? session.appVersion : '', + appVersion: session.appVersion ?? '', ...session, }); From 3621078c5ce30bff28cf9b179ae0fa6f9366c53b Mon Sep 17 00:00:00 2001 From: aviv <51673860+aviv926@users.noreply.github.com> Date: Sun, 31 Aug 2025 20:24:35 +0300 Subject: [PATCH 13/18] pnpm --filter immich run sync:sql --- server/src/queries/user.repository.sql | 45 ++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index 6a02654781..f0592533a4 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -71,7 +71,20 @@ select where "user"."id" = "user_metadata"."userId" ) as agg - ) as "metadata" + ) as "metadata", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "session" + where + "session"."userId" = "user"."id" + ) as agg + ) as "sessions" from "user" where @@ -261,7 +274,20 @@ select where "user"."id" = "user_metadata"."userId" ) as agg - ) as "metadata" + ) as "metadata", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "session" + where + "session"."userId" = "user"."id" + ) as agg + ) as "sessions" from "user" order by @@ -299,7 +325,20 @@ select where "user"."id" = "user_metadata"."userId" ) as agg - ) as "metadata" + ) as "metadata", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "session" + where + "session"."userId" = "user"."id" + ) as agg + ) as "sessions" from "user" where From 95793f60c185200c265ed39733958b2fb42667ee Mon Sep 17 00:00:00 2001 From: aviv <51673860+aviv926@users.noreply.github.com> Date: Sun, 31 Aug 2025 20:27:49 +0300 Subject: [PATCH 14/18] remove one more cleanupMobileStaleSessions --- server/src/services/user.service.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index dc43f4cfad..bd896ffc24 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -24,7 +24,6 @@ describe(UserService.name, () => { mocks.user.get.mockImplementation((userId) => Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? undefined), ); - mocks.session.cleanupMobileStaleSessions.mockResolvedValue([]); }); describe('getAll', () => { From dee21af0e3e8a14106d0d42e6bd140956ff6d1e5 Mon Sep 17 00:00:00 2001 From: aviv <51673860+aviv926@users.noreply.github.com> Date: Sun, 31 Aug 2025 23:50:57 +0300 Subject: [PATCH 15/18] revert memory.service.ts --- server/src/services/memory.service.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index 17c732340a..7bf9deab4b 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -38,9 +38,7 @@ export class MemoryService extends BaseService { } try { - await Promise.all( - users.map((owner: any, i: any) => this.createOnThisDayMemories(owner.id, usersIds[i], target)), - ); + await Promise.all(users.map((owner, i) => this.createOnThisDayMemories(owner.id, usersIds[i], target))); } catch (error) { this.logger.error(`Failed to create memories for ${target.toISO()}`, error); } From 1942137bdca6c787df7bbc2a48ce54406287677d Mon Sep 17 00:00:00 2001 From: aviv <51673860+aviv926@users.noreply.github.com> Date: Mon, 1 Sep 2025 01:55:55 +0300 Subject: [PATCH 16/18] Attempt to fix errors in tests --- server/src/database.ts | 2 +- server/src/queries/user.repository.sql | 136 +++++++++++++++++++-- server/src/repositories/user.repository.ts | 25 +++- server/test/fixtures/user.stub.ts | 3 + server/test/small.factory.ts | 2 + 5 files changed, 159 insertions(+), 9 deletions(-) diff --git a/server/src/database.ts b/server/src/database.ts index 684aeec916..7c25187f56 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -142,7 +142,7 @@ export type UserAdmin = User & { quotaUsageInBytes: number; status: UserStatus; metadata: UserMetadataItem[]; - sessions?: Session[]; + sessions: Session[]; }; export type StorageAsset = { diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index f0592533a4..e72a261de3 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -32,7 +32,28 @@ select where "user"."id" = "user_metadata"."userId" ) as agg - ) as "metadata" + ) as "metadata", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "session"."id", + "session"."createdAt", + "session"."updatedAt", + "session"."expiresAt", + "session"."deviceOS", + "session"."deviceType", + "session"."appVersion", + "session"."pinExpiresAt", + "session"."isPendingSyncReset" + from + "session" + where + "session"."userId" = "user"."id" + ) as agg + ) as "sessions" from "user" where @@ -78,7 +99,15 @@ select from ( select - * + "session"."id", + "session"."createdAt", + "session"."updatedAt", + "session"."expiresAt", + "session"."deviceOS", + "session"."deviceType", + "session"."appVersion", + "session"."pinExpiresAt", + "session"."isPendingSyncReset" from "session" where @@ -163,7 +192,28 @@ select where "user"."id" = "user_metadata"."userId" ) as agg - ) as "metadata" + ) as "metadata", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "session"."id", + "session"."createdAt", + "session"."updatedAt", + "session"."expiresAt", + "session"."deviceOS", + "session"."deviceType", + "session"."appVersion", + "session"."pinExpiresAt", + "session"."isPendingSyncReset" + from + "session" + where + "session"."userId" = "user"."id" + ) as agg + ) as "sessions" from "user" where @@ -188,7 +238,42 @@ select "shouldChangePassword", "storageLabel", "quotaSizeInBytes", - "quotaUsageInBytes" + "quotaUsageInBytes", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "user_metadata"."key", + "user_metadata"."value" + from + "user_metadata" + where + "user"."id" = "user_metadata"."userId" + ) as agg + ) as "metadata", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "session"."id", + "session"."createdAt", + "session"."updatedAt", + "session"."expiresAt", + "session"."deviceOS", + "session"."deviceType", + "session"."appVersion", + "session"."pinExpiresAt", + "session"."isPendingSyncReset" + from + "session" + where + "session"."userId" = "user"."id" + ) as agg + ) as "sessions" from "user" where @@ -227,7 +312,28 @@ select where "user"."id" = "user_metadata"."userId" ) as agg - ) as "metadata" + ) as "metadata", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "session"."id", + "session"."createdAt", + "session"."updatedAt", + "session"."expiresAt", + "session"."deviceOS", + "session"."deviceType", + "session"."appVersion", + "session"."pinExpiresAt", + "session"."isPendingSyncReset" + from + "session" + where + "session"."userId" = "user"."id" + ) as agg + ) as "sessions" from "user" where @@ -281,7 +387,15 @@ select from ( select - * + "session"."id", + "session"."createdAt", + "session"."updatedAt", + "session"."expiresAt", + "session"."deviceOS", + "session"."deviceType", + "session"."appVersion", + "session"."pinExpiresAt", + "session"."isPendingSyncReset" from "session" where @@ -332,7 +446,15 @@ select from ( select - * + "session"."id", + "session"."createdAt", + "session"."updatedAt", + "session"."expiresAt", + "session"."deviceOS", + "session"."deviceType", + "session"."appVersion", + "session"."pinExpiresAt", + "session"."isPendingSyncReset" from "session" where diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index af72bb0867..96ba9e2f67 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -44,7 +44,22 @@ const withMetadata = (eb: ExpressionBuilder) => { }; const withSessions = (eb: ExpressionBuilder) => { - return jsonArrayFrom(eb.selectFrom('session').selectAll().whereRef('session.userId', '=', 'user.id')).as('sessions'); + return jsonArrayFrom( + eb + .selectFrom('session') + .select([ + 'session.id', + 'session.createdAt', + 'session.updatedAt', + 'session.expiresAt', + 'session.deviceOS', + 'session.deviceType', + 'session.appVersion', + 'session.pinExpiresAt', + 'session.isPendingSyncReset', + ]) + .whereRef('session.userId', '=', 'user.id'), + ).as('sessions'); }; @Injectable() @@ -59,6 +74,7 @@ export class UserRepository { .selectFrom('user') .select(columns.userAdmin) .select(withMetadata) + .select(withSessions) .where('user.id', '=', userId) .$if(!options.withDeleted, (eb) => eb.where('user.deletedAt', 'is', null)) .executeTakeFirst(); @@ -132,6 +148,7 @@ export class UserRepository { .selectFrom('user') .select(columns.userAdmin) .select(withMetadata) + .select(withSessions) .$if(!!options?.withPassword, (eb) => eb.select('password')) .where('email', '=', email) .where('user.deletedAt', 'is', null) @@ -143,6 +160,8 @@ export class UserRepository { return this.db .selectFrom('user') .select(columns.userAdmin) + .select(withMetadata) + .select(withSessions) .where('user.storageLabel', '=', storageLabel) .where('user.deletedAt', 'is', null) .executeTakeFirst(); @@ -154,6 +173,7 @@ export class UserRepository { .selectFrom('user') .select(columns.userAdmin) .select(withMetadata) + .select(withSessions) .where('user.oauthId', '=', oauthId) .where('user.deletedAt', 'is', null) .executeTakeFirst(); @@ -186,6 +206,7 @@ export class UserRepository { .values(dto) .returning(columns.userAdmin) .returning(withMetadata) + .returning(withSessions) .executeTakeFirstOrThrow(); } @@ -197,6 +218,7 @@ export class UserRepository { .where('user.deletedAt', 'is', null) .returning(columns.userAdmin) .returning(withMetadata) + .returning(withSessions) .executeTakeFirstOrThrow(); } @@ -211,6 +233,7 @@ export class UserRepository { .where('user.id', '=', asUuid(id)) .returning(columns.userAdmin) .returning(withMetadata) + .returning(withSessions) .executeTakeFirstOrThrow(); } diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts index 807da5197f..3e6c9eb4f3 100644 --- a/server/test/fixtures/user.stub.ts +++ b/server/test/fixtures/user.stub.ts @@ -20,6 +20,7 @@ export const userStub = { metadata: [], quotaSizeInBytes: null, quotaUsageInBytes: 0, + sessions: [], }, user1: { ...authStub.user1.user, @@ -37,6 +38,7 @@ export const userStub = { metadata: [], quotaSizeInBytes: null, quotaUsageInBytes: 0, + sessions: [], }, user2: { ...authStub.user2.user, @@ -54,5 +56,6 @@ export const userStub = { updatedAt: new Date('2021-01-01'), quotaSizeInBytes: null, quotaUsageInBytes: 0, + sessions: [], }, }; diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index c932317288..832fe89ab3 100755 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -180,6 +180,7 @@ const userAdminFactory = (user: Partial = {}) => { quotaUsageInBytes = 0, status = UserStatus.Active, metadata = [], + sessions = [], } = user; return { id, @@ -199,6 +200,7 @@ const userAdminFactory = (user: Partial = {}) => { quotaUsageInBytes, status, metadata, + sessions, }; }; From 8f3cc3b34df63685b38d4e702a9ce4206e329b3d Mon Sep 17 00:00:00 2001 From: aviv <51673860+aviv926@users.noreply.github.com> Date: Mon, 1 Sep 2025 02:55:45 +0300 Subject: [PATCH 17/18] Only update appVersion if a non-empty version is provided --- server/src/services/auth.service.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 8f69bc6987..983cc1e0cf 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -61,10 +61,13 @@ export class AuthService extends BaseService { if (!auth.session) { throw new BadRequestException('No active session'); } - await this.sessionRepository.update(auth.session.id, { - appVersion, + const updateData: { appVersion?: string; updatedAt: Date } = { updatedAt: new Date(), - }); + }; + if (appVersion) { + updateData.appVersion = appVersion; + } + await this.sessionRepository.update(auth.session.id, updateData); } async login(dto: LoginCredentialDto, details: LoginDetails) { const config = await this.getConfig({ withCache: false }); From 818dba507cf82b04e530daccdfe03de82b5391bb Mon Sep 17 00:00:00 2001 From: aviv <51673860+aviv926@users.noreply.github.com> Date: Mon, 1 Sep 2025 03:16:48 +0300 Subject: [PATCH 18/18] Adding an application version scenario greater than a server version --- web/src/routes/admin/users/[id]/+page.svelte | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/web/src/routes/admin/users/[id]/+page.svelte b/web/src/routes/admin/users/[id]/+page.svelte index 08bd74ed17..1c47bc105a 100644 --- a/web/src/routes/admin/users/[id]/+page.svelte +++ b/web/src/routes/admin/users/[id]/+page.svelte @@ -388,15 +388,17 @@ {/if}
- {device.deviceType} - - v{device.appVersion} - + + {device.deviceType} + {#if formattedServerVersion != null && device.appVersion < formattedServerVersion} + v{device.appVersion} + {:else if formattedServerVersion != null && device.appVersion > formattedServerVersion} + v{device.appVersion} +
+ (App version is newer than server) + {:else} + v{device.appVersion} + {/if}
Last seen: {new Date(device.lastSeen).toLocaleString()}