refactor: more database types (#17490)

This commit is contained in:
Jason Rasmussen 2025-04-09 10:24:38 -04:00 committed by GitHub
parent 04b03f2924
commit 8943ec23ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 123 additions and 148 deletions

View File

@ -163,6 +163,28 @@ export type Partner = {
inTimeline: boolean; inTimeline: boolean;
}; };
export type Place = {
admin1Code: string | null;
admin1Name: string | null;
admin2Code: string | null;
admin2Name: string | null;
alternateNames: string | null;
countryCode: string;
id: number;
latitude: number;
longitude: number;
modificationDate: Date;
name: string;
};
export type Session = {
id: string;
createdAt: Date;
updatedAt: Date;
deviceOS: string;
deviceType: string;
};
const userColumns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const; const userColumns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const;
export const columns = { export const columns = {

View File

@ -1,11 +1,11 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
import { Place } from 'src/database';
import { PropertyLifecycle } from 'src/decorators'; import { PropertyLifecycle } from 'src/decorators';
import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AlbumResponseDto } from 'src/dtos/album.dto';
import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AssetOrder, AssetType } from 'src/enum'; import { AssetOrder, AssetType } from 'src/enum';
import { SearchPlacesItem } from 'src/types';
import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
class BaseSearchDto { class BaseSearchDto {
@ -226,7 +226,7 @@ export class PlacesResponseDto {
admin2name?: string; admin2name?: string;
} }
export function mapPlaces(place: SearchPlacesItem): PlacesResponseDto { export function mapPlaces(place: Place): PlacesResponseDto {
return { return {
name: place.name, name: place.name,
latitude: place.latitude, latitude: place.latitude,

View File

@ -1,4 +1,4 @@
import { SessionItem } from 'src/types'; import { Session } from 'src/database';
export class SessionResponseDto { export class SessionResponseDto {
id!: string; id!: string;
@ -9,7 +9,7 @@ export class SessionResponseDto {
deviceOS!: string; deviceOS!: string;
} }
export const mapSession = (entity: SessionItem, currentId?: string): SessionResponseDto => ({ export const mapSession = (entity: Session, currentId?: string): SessionResponseDto => ({
id: entity.id, id: entity.id,
createdAt: entity.createdAt.toISOString(), createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(), updatedAt: entity.updatedAt.toISOString(),

View File

@ -38,42 +38,11 @@ where
-- SessionRepository.getByUserId -- SessionRepository.getByUserId
select select
"sessions".*, "sessions".*
to_json("user") as "user"
from from
"sessions" "sessions"
inner join lateral ( inner join "users" on "users"."id" = "sessions"."userId"
select
"id",
"name",
"email",
"profileImagePath",
"profileChangedAt",
"createdAt",
"updatedAt",
"deletedAt",
"isAdmin",
"status",
"oauthId",
"profileImagePath",
"shouldChangePassword",
"storageLabel",
"quotaSizeInBytes",
"quotaUsageInBytes",
(
select
array_agg("user_metadata") as "metadata"
from
"user_metadata"
where
"users"."id" = "user_metadata"."userId"
) as "metadata"
from
"users"
where
"users"."id" = "sessions"."userId"
and "users"."deletedAt" is null and "users"."deletedAt" is null
) as "user" on true
where where
"sessions"."userId" = $1 "sessions"."userId" = $1
order by order by

View File

@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Insertable, Kysely, Updateable } from 'kysely'; import { Insertable, Kysely, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database'; import { columns } from 'src/database';
@ -9,22 +9,6 @@ import { asUuid } from 'src/utils/database';
export type SessionSearchOptions = { updatedBefore: Date }; export type SessionSearchOptions = { updatedBefore: Date };
const withUser = (eb: ExpressionBuilder<DB, 'sessions'>) => {
return eb
.selectFrom('users')
.select(columns.userAdmin)
.select((eb) =>
eb
.selectFrom('user_metadata')
.whereRef('users.id', '=', 'user_metadata.userId')
.select((eb) => eb.fn('array_agg', [eb.table('user_metadata')]).as('metadata'))
.as('metadata'),
)
.whereRef('users.id', '=', 'sessions.userId')
.where('users.deletedAt', 'is', null)
.as('user');
};
@Injectable() @Injectable()
export class SessionRepository { export class SessionRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@ -60,9 +44,8 @@ export class SessionRepository {
getByUserId(userId: string) { getByUserId(userId: string) {
return this.db return this.db
.selectFrom('sessions') .selectFrom('sessions')
.innerJoinLateral(withUser, (join) => join.onTrue()) .innerJoin('users', (join) => join.onRef('users.id', '=', 'sessions.userId').on('users.deletedAt', 'is', null))
.selectAll('sessions') .selectAll('sessions')
.select((eb) => eb.fn.toJson('user').as('user'))
.where('sessions.userId', '=', userId) .where('sessions.userId', '=', userId)
.orderBy('sessions.updatedAt', 'desc') .orderBy('sessions.updatedAt', 'desc')
.orderBy('sessions.createdAt', 'desc') .orderBy('sessions.createdAt', 'desc')

View File

@ -1,10 +1,10 @@
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { DateTime } from 'luxon';
import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { AuthType, Permission } from 'src/enum'; import { AuthType, Permission } from 'src/enum';
import { AuthService } from 'src/services/auth.service'; import { AuthService } from 'src/services/auth.service';
import { sessionStub } from 'test/fixtures/session.stub';
import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
@ -97,17 +97,19 @@ describe('AuthService', () => {
}); });
it('should successfully log the user in', async () => { it('should successfully log the user in', async () => {
mocks.user.getByEmail.mockResolvedValue(userStub.user1); const user = { ...factory.user(), password: 'immich_password' } as UserEntity;
mocks.session.create.mockResolvedValue(sessionStub.valid); const session = factory.session();
mocks.user.getByEmail.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(session);
await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual({ await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual({
accessToken: 'cmFuZG9tLWJ5dGVz', accessToken: 'cmFuZG9tLWJ5dGVz',
userId: 'user-id', userId: user.id,
userEmail: 'immich@test.com', userEmail: user.email,
name: 'immich_name', name: user.name,
profileImagePath: '', profileImagePath: user.profileImagePath,
isAdmin: false, isAdmin: user.isAdmin,
shouldChangePassword: false, shouldChangePassword: user.shouldChangePassword,
}); });
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
@ -256,8 +258,14 @@ describe('AuthService', () => {
}); });
it('should validate using authorization header', async () => { it('should validate using authorization header', async () => {
mocks.user.get.mockResolvedValue(userStub.user1); const session = factory.session();
mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any); const sessionWithToken = {
id: session.id,
updatedAt: session.updatedAt,
user: factory.authUser(),
};
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
await expect( await expect(
sut.authenticate({ sut.authenticate({
@ -266,8 +274,8 @@ describe('AuthService', () => {
metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' },
}), }),
).resolves.toEqual({ ).resolves.toEqual({
user: userStub.user1, user: sessionWithToken.user,
session: sessionStub.valid, session: { id: session.id },
}); });
}); });
}); });
@ -371,7 +379,14 @@ describe('AuthService', () => {
}); });
it('should return an auth dto', async () => { it('should return an auth dto', async () => {
mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any); const session = factory.session();
const sessionWithToken = {
id: session.id,
updatedAt: session.updatedAt,
user: factory.authUser(),
};
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
await expect( await expect(
sut.authenticate({ sut.authenticate({
@ -380,13 +395,20 @@ describe('AuthService', () => {
metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' },
}), }),
).resolves.toEqual({ ).resolves.toEqual({
user: userStub.user1, user: sessionWithToken.user,
session: sessionStub.valid, session: { id: session.id },
}); });
}); });
it('should throw if admin route and not an admin', async () => { it('should throw if admin route and not an admin', async () => {
mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any); const session = factory.session();
const sessionWithToken = {
id: session.id,
updatedAt: session.updatedAt,
user: factory.authUser(),
};
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
await expect( await expect(
sut.authenticate({ sut.authenticate({
@ -398,8 +420,15 @@ describe('AuthService', () => {
}); });
it('should update when access time exceeds an hour', async () => { it('should update when access time exceeds an hour', async () => {
mocks.session.getByToken.mockResolvedValue(sessionStub.inactive as any); const session = factory.session({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() });
mocks.session.update.mockResolvedValue(sessionStub.valid); const sessionWithToken = {
id: session.id,
updatedAt: session.updatedAt,
user: factory.authUser(),
};
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
mocks.session.update.mockResolvedValue(session);
await expect( await expect(
sut.authenticate({ sut.authenticate({
@ -408,7 +437,8 @@ describe('AuthService', () => {
metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' },
}), }),
).resolves.toBeDefined(); ).resolves.toBeDefined();
expect(mocks.session.update.mock.calls[0][1]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) });
expect(mocks.session.update).toHaveBeenCalled();
}); });
}); });
@ -506,7 +536,7 @@ describe('AuthService', () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.user.getByEmail.mockResolvedValue(userStub.user1); mocks.user.getByEmail.mockResolvedValue(userStub.user1);
mocks.user.update.mockResolvedValue(userStub.user1); mocks.user.update.mockResolvedValue(userStub.user1);
mocks.session.create.mockResolvedValue(sessionStub.valid); mocks.session.create.mockResolvedValue(factory.session());
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
oauthResponse, oauthResponse,
@ -535,7 +565,7 @@ describe('AuthService', () => {
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(userStub.user1); mocks.user.getAdmin.mockResolvedValue(userStub.user1);
mocks.user.create.mockResolvedValue(userStub.user1); mocks.user.create.mockResolvedValue(userStub.user1);
mocks.session.create.mockResolvedValue(sessionStub.valid); mocks.session.create.mockResolvedValue(factory.session());
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
oauthResponse, oauthResponse,
@ -550,7 +580,7 @@ describe('AuthService', () => {
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(userStub.user1); mocks.user.getAdmin.mockResolvedValue(userStub.user1);
mocks.user.create.mockResolvedValue(userStub.user1); mocks.user.create.mockResolvedValue(userStub.user1);
mocks.session.create.mockResolvedValue(sessionStub.valid); mocks.session.create.mockResolvedValue(factory.session());
mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined }); mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined });
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
@ -572,7 +602,7 @@ describe('AuthService', () => {
it(`should use the mobile redirect override for a url of ${url}`, async () => { it(`should use the mobile redirect override for a url of ${url}`, async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
mocks.user.getByOAuthId.mockResolvedValue(userStub.user1); mocks.user.getByOAuthId.mockResolvedValue(userStub.user1);
mocks.session.create.mockResolvedValue(sessionStub.valid); mocks.session.create.mockResolvedValue(factory.session());
await sut.callback({ url }, loginDetails); await sut.callback({ url }, loginDetails);

View File

@ -338,7 +338,9 @@ export class AuthService extends BaseService {
return { return {
user: session.user, user: session.user,
session, session: {
id: session.id,
},
}; };
} }

View File

@ -1,7 +1,7 @@
import { JobStatus } from 'src/enum'; import { JobStatus } from 'src/enum';
import { SessionService } from 'src/services/session.service'; import { SessionService } from 'src/services/session.service';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { sessionStub } from 'test/fixtures/session.stub'; import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService, ServiceMocks } from 'test/utils';
describe('SessionService', () => { describe('SessionService', () => {
@ -45,40 +45,35 @@ describe('SessionService', () => {
describe('getAll', () => { describe('getAll', () => {
it('should get the devices', async () => { it('should get the devices', async () => {
mocks.session.getByUserId.mockResolvedValue([sessionStub.valid as any, sessionStub.inactive]); const currentSession = factory.session();
await expect(sut.getAll(authStub.user1)).resolves.toEqual([ const otherSession = factory.session();
{ const auth = factory.auth({ session: currentSession });
createdAt: '2021-01-01T00:00:00.000Z',
current: true, mocks.session.getByUserId.mockResolvedValue([currentSession, otherSession]);
deviceOS: '',
deviceType: '', await expect(sut.getAll(auth)).resolves.toEqual([
id: 'token-id', expect.objectContaining({ current: true, id: currentSession.id }),
updatedAt: expect.any(String), expect.objectContaining({ current: false, id: otherSession.id }),
},
{
createdAt: '2021-01-01T00:00:00.000Z',
current: false,
deviceOS: 'Android',
deviceType: 'Mobile',
id: 'not_active',
updatedAt: expect.any(String),
},
]); ]);
expect(mocks.session.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id); expect(mocks.session.getByUserId).toHaveBeenCalledWith(auth.user.id);
}); });
}); });
describe('logoutDevices', () => { describe('logoutDevices', () => {
it('should logout all devices', async () => { it('should logout all devices', async () => {
mocks.session.getByUserId.mockResolvedValue([sessionStub.inactive, sessionStub.valid] as any[]); const currentSession = factory.session();
const otherSession = factory.session();
const auth = factory.auth({ session: currentSession });
mocks.session.getByUserId.mockResolvedValue([currentSession, otherSession]);
mocks.session.delete.mockResolvedValue(); mocks.session.delete.mockResolvedValue();
await sut.deleteAll(authStub.user1); await sut.deleteAll(auth);
expect(mocks.session.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id); expect(mocks.session.getByUserId).toHaveBeenCalledWith(auth.user.id);
expect(mocks.session.delete).toHaveBeenCalledWith('not_active'); expect(mocks.session.delete).toHaveBeenCalledWith(otherSession.id);
expect(mocks.session.delete).not.toHaveBeenCalledWith('token-id'); expect(mocks.session.delete).not.toHaveBeenCalledWith(currentSession.id);
}); });
}); });

View File

@ -13,20 +13,11 @@ import {
TranscodeTarget, TranscodeTarget,
VideoCodec, VideoCodec,
} from 'src/enum'; } from 'src/enum';
import { SearchRepository } from 'src/repositories/search.repository';
import { SessionRepository } from 'src/repositories/session.repository';
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T; export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
export type RepositoryInterface<T extends object> = Pick<T, keyof T>; export type RepositoryInterface<T extends object> = Pick<T, keyof T>;
type ISearchRepository = RepositoryInterface<SearchRepository>;
type ISessionRepository = RepositoryInterface<SessionRepository>;
export type SearchPlacesItem = Awaited<ReturnType<ISearchRepository['searchPlaces']>>[0];
export type SessionItem = Awaited<ReturnType<ISessionRepository['getByUserId']>>[0];
export interface CropOptions { export interface CropOptions {
top: number; top: number;
left: number; left: number;

View File

@ -1,6 +1,6 @@
import { Session } from 'src/database';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { SessionItem } from 'src/types';
const authUser = { const authUser = {
admin: { admin: {
@ -27,7 +27,7 @@ export const authStub = {
user: authUser.user1, user: authUser.user1,
session: { session: {
id: 'token-id', id: 'token-id',
} as SessionItem, } as Session,
}), }),
user2: Object.freeze<AuthDto>({ user2: Object.freeze<AuthDto>({
user: { user: {
@ -40,7 +40,7 @@ export const authStub = {
}, },
session: { session: {
id: 'token-id', id: 'token-id',
} as SessionItem, } as Session,
}), }),
adminSharedLink: Object.freeze<AuthDto>({ adminSharedLink: Object.freeze<AuthDto>({
user: authUser.admin, user: authUser.admin,

View File

@ -1,27 +0,0 @@
import { SessionItem } from 'src/types';
import { userStub } from 'test/fixtures/user.stub';
export const sessionStub = {
valid: Object.freeze<SessionItem>({
id: 'token-id',
token: 'auth_token',
userId: userStub.user1.id,
user: userStub.user1,
createdAt: new Date('2021-01-01'),
updatedAt: new Date(),
deviceType: '',
deviceOS: '',
updateId: 'uuid-v7',
}),
inactive: Object.freeze<SessionItem>({
id: 'not_active',
token: 'auth_token',
userId: userStub.user1.id,
user: userStub.user1,
createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'),
deviceType: 'Mobile',
deviceOS: 'Android',
updateId: 'uuid-v7',
}),
};

View File

@ -8,6 +8,7 @@ import {
Library, Library,
Memory, Memory,
Partner, Partner,
Session,
SidecarWriteAsset, SidecarWriteAsset,
User, User,
UserAdmin, UserAdmin,
@ -31,7 +32,11 @@ export const newEmbedding = () => {
return '[' + embedding + ']'; return '[' + embedding + ']';
}; };
const authFactory = ({ apiKey, ...user }: Partial<AuthUser> & { apiKey?: Partial<AuthApiKey> } = {}) => { const authFactory = ({
apiKey,
session,
...user
}: Partial<AuthUser> & { apiKey?: Partial<AuthApiKey>; session?: { id: string } } = {}) => {
const auth: AuthDto = { const auth: AuthDto = {
user: authUserFactory(user), user: authUserFactory(user),
}; };
@ -40,6 +45,10 @@ const authFactory = ({ apiKey, ...user }: Partial<AuthUser> & { apiKey?: Partial
auth.apiKey = authApiKeyFactory(apiKey); auth.apiKey = authApiKeyFactory(apiKey);
} }
if (session) {
auth.session = { id: session.id };
}
return auth; return auth;
}; };
@ -76,7 +85,7 @@ const partnerFactory = (partner: Partial<Partner> = {}) => {
}; };
}; };
const sessionFactory = () => ({ const sessionFactory = (session: Partial<Session> = {}) => ({
id: newUuid(), id: newUuid(),
createdAt: newDate(), createdAt: newDate(),
updatedAt: newDate(), updatedAt: newDate(),
@ -85,6 +94,7 @@ const sessionFactory = () => ({
deviceType: 'mobile', deviceType: 'mobile',
token: 'abc123', token: 'abc123',
userId: newUuid(), userId: newUuid(),
...session,
}); });
const stackFactory = () => ({ const stackFactory = () => ({