Skip to content

Commit 39f9b44

Browse files
fix(server): store null instead of empty string for session deviceType and deviceOS
Aligns session.deviceType and session.deviceOS with the pattern established by #30223 (user.password), #30123 (album.description) and #31560 (user.oauthId): the database stores NULL instead of '' when the field is unknown, while API responses continue to return '' for backwards compatibility until v4. Changes: - schema: DB columns nullable with default NULL, migration converts existing '' rows. - database.ts / session.table.ts: Session.deviceType and .deviceOS are now string | null. - session.dto.ts: - SessionCreateSchema input accepts null/undefined/'' and coerces to null (TODO(v4): drop the transform, clients should send null). - SessionResponseSchema stays z.string() and mapSession coerces null -> '' with a TODO(v4) note. - utils/request.getUserAgentDetails: returns null instead of '' when the header can't be parsed, so new sessions written by the login flow store null directly. - LoginDetails type + affected test fixtures updated to match. Refs #28832
1 parent 47f349c commit 39f9b44

9 files changed

Lines changed: 49 additions & 17 deletions

File tree

server/src/database.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,8 +231,8 @@ export type Session = {
231231
createdAt: Date;
232232
updatedAt: Date;
233233
expiresAt: Date | null;
234-
deviceOS: string;
235-
deviceType: string;
234+
deviceOS: string | null;
235+
deviceType: string | null;
236236
appVersion: string | null;
237237
pinExpiresAt: Date | null;
238238
isPendingSyncReset: boolean;

server/src/dtos/session.dto.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,18 @@ import { Session } from 'src/database.js';
55
const SessionCreateSchema = z
66
.object({
77
duration: z.int().min(1).optional().describe('Session duration in seconds'),
8-
deviceType: z.string().optional().describe('Device type'),
9-
deviceOS: z.string().optional().describe('Device OS'),
8+
// TODO(v4): drop the empty-string-to-null transform (clients should send null)
9+
deviceType: z
10+
.string()
11+
.nullish()
12+
.transform((value) => (value === '' ? null : value))
13+
.describe('Device type'),
14+
// TODO(v4): drop the empty-string-to-null transform (clients should send null)
15+
deviceOS: z
16+
.string()
17+
.nullish()
18+
.transform((value) => (value === '' ? null : value))
19+
.describe('Device OS'),
1020
})
1121
.meta({ id: 'SessionCreateDto' });
1222

@@ -46,7 +56,9 @@ export const mapSession = (entity: Session, currentId?: string): SessionResponse
4656
expiresAt: entity.expiresAt?.toISOString(),
4757
current: currentId === entity.id,
4858
appVersion: entity.appVersion,
49-
deviceOS: entity.deviceOS,
50-
deviceType: entity.deviceType,
59+
// TODO(v4): remove the null coercion and make `deviceOS` nullable on the response
60+
deviceOS: entity.deviceOS ?? '',
61+
// TODO(v4): remove the null coercion and make `deviceType` nullable on the response
62+
deviceType: entity.deviceType ?? '',
5163
isPendingSyncReset: entity.isPendingSyncReset,
5264
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Kysely, sql } from 'kysely';
2+
3+
export async function up(db: Kysely<any>): Promise<void> {
4+
await sql`ALTER TABLE "session" ALTER COLUMN "deviceType" DROP NOT NULL;`.execute(db);
5+
await sql`ALTER TABLE "session" ALTER COLUMN "deviceType" SET DEFAULT NULL;`.execute(db);
6+
await sql`UPDATE "session" SET "deviceType" = NULL WHERE "deviceType" = '';`.execute(db);
7+
await sql`ALTER TABLE "session" ALTER COLUMN "deviceOS" DROP NOT NULL;`.execute(db);
8+
await sql`ALTER TABLE "session" ALTER COLUMN "deviceOS" SET DEFAULT NULL;`.execute(db);
9+
await sql`UPDATE "session" SET "deviceOS" = NULL WHERE "deviceOS" = '';`.execute(db);
10+
}
11+
12+
export async function down(db: Kysely<any>): Promise<void> {
13+
await sql`UPDATE "session" SET "deviceOS" = '' WHERE "deviceOS" IS NULL;`.execute(db);
14+
await sql`ALTER TABLE "session" ALTER COLUMN "deviceOS" SET DEFAULT '';`.execute(db);
15+
await sql`ALTER TABLE "session" ALTER COLUMN "deviceOS" SET NOT NULL;`.execute(db);
16+
await sql`UPDATE "session" SET "deviceType" = '' WHERE "deviceType" IS NULL;`.execute(db);
17+
await sql`ALTER TABLE "session" ALTER COLUMN "deviceType" SET DEFAULT '';`.execute(db);
18+
await sql`ALTER TABLE "session" ALTER COLUMN "deviceType" SET NOT NULL;`.execute(db);
19+
}

server/src/schema/migrations/ORDER

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,4 @@
9595
1787148183729-ClusterGroups
9696
1787148183730-DeleteMismatchedMemoryAssets
9797
1789419229196-ConvertUserOAuthIdEmptyStringToNull
98+
1789763328290-ConvertSessionDeviceTypeAndOSEmptyStringToNull

server/src/schema/tables/session.table.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,11 @@ export class SessionTable {
3535
@ForeignKeyColumn(() => SessionTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', nullable: true })
3636
parentId!: string | null;
3737

38-
@Column({ default: '' })
39-
deviceType!: Generated<string>;
38+
@Column({ nullable: true })
39+
deviceType!: string | null;
4040

41-
@Column({ default: '' })
42-
deviceOS!: Generated<string>;
41+
@Column({ nullable: true })
42+
deviceOS!: string | null;
4343

4444
@Column({ nullable: true })
4545
appVersion!: string | null;

server/src/services/auth.service.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ const email = 'test@immich.com';
2121
const loginDetails = {
2222
isSecure: true,
2323
clientIp: '127.0.0.1',
24-
deviceOS: '',
25-
deviceType: '',
24+
deviceOS: null,
25+
deviceType: null,
2626
appVersion: null,
2727
};
2828

server/src/services/auth.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ import { getUserAgentDetails } from 'src/utils/request.js';
3232
export interface LoginDetails {
3333
isSecure: boolean;
3434
clientIp: string;
35-
deviceType: string;
36-
deviceOS: string;
35+
deviceType: string | null;
36+
deviceOS: string | null;
3737
appVersion: string | null;
3838
}
3939

server/src/utils/request.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ export const getUserAgentDetails = (headers: IncomingHttpHeaders) => {
1818
const appVersion = getAppVersionFromUA(headers['user-agent'] ?? '');
1919

2020
return {
21-
deviceType: userAgent.browser.name || userAgent.device.type || (headers['devicemodel'] as string) || '',
22-
deviceOS: userAgent.os.name || (headers['devicetype'] as string) || '',
21+
deviceType: userAgent.browser.name || userAgent.device.type || (headers['devicemodel'] as string) || null,
22+
deviceOS: userAgent.os.name || (headers['devicetype'] as string) || null,
2323
appVersion,
2424
};
2525
};

server/test/medium.factory.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -832,7 +832,7 @@ const syncStream = () => {
832832
};
833833

834834
const loginDetails = () => {
835-
return { isSecure: false, clientIp: '', deviceType: '', deviceOS: '', appVersion: null };
835+
return { isSecure: false, clientIp: '', deviceType: null, deviceOS: null, appVersion: null };
836836
};
837837

838838
const loginResponse = (): LoginResponseDto => {

0 commit comments

Comments
 (0)