1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-23 20:22:45 +01:00

refactor: migrate user repository to kysely ()

* refactor: migrate user repository to kysely

* refactor: migrate user repository to kysely

* refactor: migrate user repository to kysely

* refactor: migrate user repository to kysely

* fix: test

* clean up

* fix: metadata retrieval bug

* use correct typeing for upsert metadata

* pr feedback

* pr feedback

* fix: add deletedAt check

* fix: get non deleted user by default

* remove console.log

* fix: stop kysely after command finishes

* final clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
Alex 2025-01-13 19:30:34 -06:00 committed by GitHub
parent a6c8eb57f1
commit 3da750117f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 447 additions and 312 deletions

View file

@ -24,6 +24,7 @@ import { repositories } from 'src/repositories';
import { ConfigRepository } from 'src/repositories/config.repository';
import { teardownTelemetry } from 'src/repositories/telemetry.repository';
import { services } from 'src/services';
import { CliService } from 'src/services/cli.service';
import { DatabaseService } from 'src/services/database.service';
const common = [...services, ...repositories];
@ -106,4 +107,10 @@ export class MicroservicesModule extends BaseModule {}
imports: [...imports],
providers: [...common, ...commands, SchedulerRegistry],
})
export class ImmichAdminModule {}
export class ImmichAdminModule implements OnModuleDestroy {
constructor(private service: CliService) {}
async onModuleDestroy() {
await this.service.cleanup();
}
}

View file

@ -99,6 +99,7 @@ export const DummyValue = {
BUFFER: Buffer.from('abcdefghi'),
DATE: new Date(),
TIME_BUCKET: '2024-01-01T00:00:00.000Z',
BOOLEAN: true,
};
export const GENERATE_SQL_KEY = 'generate-sql-key';

View file

@ -1,3 +1,6 @@
import { ExpressionBuilder } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { DB } from 'src/db';
import { AssetEntity } from 'src/entities/asset.entity';
import { TagEntity } from 'src/entities/tag.entity';
import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
@ -71,3 +74,9 @@ export class UserEntity {
@Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
profileChangedAt!: Date;
}
export const withMetadata = (eb: ExpressionBuilder<DB, 'users'>) => {
return jsonArrayFrom(
eb.selectFrom('user_metadata').selectAll('user_metadata').whereRef('users.id', '=', 'user_metadata.userId'),
).as('metadata');
};

View file

@ -61,6 +61,7 @@ export const IDatabaseRepository = 'IDatabaseRepository';
export interface IDatabaseRepository {
init(): void;
reconnect(): Promise<boolean>;
shutdown(): Promise<void>;
getExtensionVersion(extension: DatabaseExtension): Promise<ExtensionVersion>;
getExtensionVersionRange(extension: VectorExtension): string;
getPostgresVersion(): Promise<string>;

View file

@ -1,3 +1,5 @@
import { Insertable, Updateable } from 'kysely';
import { Users } from 'src/db';
import { UserMetadata } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';
@ -23,17 +25,17 @@ export interface UserFindOptions {
export const IUserRepository = 'IUserRepository';
export interface IUserRepository {
get(id: string, options: UserFindOptions): Promise<UserEntity | null>;
getAdmin(): Promise<UserEntity | null>;
get(id: string, options: UserFindOptions): Promise<UserEntity | undefined>;
getAdmin(): Promise<UserEntity | undefined>;
hasAdmin(): Promise<boolean>;
getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null>;
getByStorageLabel(storageLabel: string): Promise<UserEntity | null>;
getByOAuthId(oauthId: string): Promise<UserEntity | null>;
getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | undefined>;
getByStorageLabel(storageLabel: string): Promise<UserEntity | undefined>;
getByOAuthId(oauthId: string): Promise<UserEntity | undefined>;
getDeletedUsers(): Promise<UserEntity[]>;
getList(filter?: UserListFilter): Promise<UserEntity[]>;
getUserStats(): Promise<UserStatsQueryResponse[]>;
create(user: Partial<UserEntity>): Promise<UserEntity>;
update(id: string, user: Partial<UserEntity>): Promise<UserEntity>;
create(user: Insertable<Users>): Promise<UserEntity>;
update(id: string, user: Updateable<Users>): Promise<UserEntity>;
upsertMetadata<T extends keyof UserMetadata>(id: string, item: { key: T; value: UserMetadata[T] }): Promise<void>;
deleteMetadata<T extends keyof UserMetadata>(id: string, key: T): Promise<void>;
delete(user: UserEntity, hard?: boolean): Promise<UserEntity>;

View file

@ -1,195 +1,222 @@
-- NOTE: This file is auto generated by ./sql-generator
-- UserRepository.get
select
"id",
"email",
"createdAt",
"profileImagePath",
"isAdmin",
"shouldChangePassword",
"deletedAt",
"oauthId",
"updatedAt",
"storageLabel",
"name",
"quotaSizeInBytes",
"quotaUsageInBytes",
"status",
"profileChangedAt",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"user_metadata".*
from
"user_metadata"
where
"users"."id" = "user_metadata"."userId"
) as agg
) as "metadata"
from
"users"
where
"users"."id" = $1
and "users"."deletedAt" is null
-- UserRepository.getAdmin
SELECT
"UserEntity"."id" AS "UserEntity_id",
"UserEntity"."name" AS "UserEntity_name",
"UserEntity"."isAdmin" AS "UserEntity_isAdmin",
"UserEntity"."email" AS "UserEntity_email",
"UserEntity"."storageLabel" AS "UserEntity_storageLabel",
"UserEntity"."oauthId" AS "UserEntity_oauthId",
"UserEntity"."profileImagePath" AS "UserEntity_profileImagePath",
"UserEntity"."shouldChangePassword" AS "UserEntity_shouldChangePassword",
"UserEntity"."createdAt" AS "UserEntity_createdAt",
"UserEntity"."deletedAt" AS "UserEntity_deletedAt",
"UserEntity"."status" AS "UserEntity_status",
"UserEntity"."updatedAt" AS "UserEntity_updatedAt",
"UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes",
"UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes",
"UserEntity"."profileChangedAt" AS "UserEntity_profileChangedAt"
FROM
"users" "UserEntity"
WHERE
((("UserEntity"."isAdmin" = $1)))
AND ("UserEntity"."deletedAt" IS NULL)
LIMIT
1
select
"id",
"email",
"createdAt",
"profileImagePath",
"isAdmin",
"shouldChangePassword",
"deletedAt",
"oauthId",
"updatedAt",
"storageLabel",
"name",
"quotaSizeInBytes",
"quotaUsageInBytes",
"status",
"profileChangedAt"
from
"users"
where
"users"."isAdmin" = $1
and "users"."deletedAt" is null
-- UserRepository.hasAdmin
SELECT
1 AS "row_exists"
FROM
(
SELECT
1 AS dummy_column
) "dummy_table"
WHERE
EXISTS (
SELECT
1
FROM
"users" "UserEntity"
WHERE
((("UserEntity"."isAdmin" = $1)))
AND ("UserEntity"."deletedAt" IS NULL)
)
LIMIT
1
select
"users"."id"
from
"users"
where
"users"."isAdmin" = $1
and "users"."deletedAt" is null
-- UserRepository.getByEmail
SELECT
"user"."id" AS "user_id",
"user"."name" AS "user_name",
"user"."isAdmin" AS "user_isAdmin",
"user"."email" AS "user_email",
"user"."storageLabel" AS "user_storageLabel",
"user"."oauthId" AS "user_oauthId",
"user"."profileImagePath" AS "user_profileImagePath",
"user"."shouldChangePassword" AS "user_shouldChangePassword",
"user"."createdAt" AS "user_createdAt",
"user"."deletedAt" AS "user_deletedAt",
"user"."status" AS "user_status",
"user"."updatedAt" AS "user_updatedAt",
"user"."quotaSizeInBytes" AS "user_quotaSizeInBytes",
"user"."quotaUsageInBytes" AS "user_quotaUsageInBytes",
"user"."profileChangedAt" AS "user_profileChangedAt"
FROM
"users" "user"
WHERE
("user"."email" = $1)
AND ("user"."deletedAt" IS NULL)
select
"id",
"email",
"createdAt",
"profileImagePath",
"isAdmin",
"shouldChangePassword",
"deletedAt",
"oauthId",
"updatedAt",
"storageLabel",
"name",
"quotaSizeInBytes",
"quotaUsageInBytes",
"status",
"profileChangedAt"
from
"users"
where
"email" = $1
and "users"."deletedAt" is null
-- UserRepository.getByStorageLabel
SELECT
"UserEntity"."id" AS "UserEntity_id",
"UserEntity"."name" AS "UserEntity_name",
"UserEntity"."isAdmin" AS "UserEntity_isAdmin",
"UserEntity"."email" AS "UserEntity_email",
"UserEntity"."storageLabel" AS "UserEntity_storageLabel",
"UserEntity"."oauthId" AS "UserEntity_oauthId",
"UserEntity"."profileImagePath" AS "UserEntity_profileImagePath",
"UserEntity"."shouldChangePassword" AS "UserEntity_shouldChangePassword",
"UserEntity"."createdAt" AS "UserEntity_createdAt",
"UserEntity"."deletedAt" AS "UserEntity_deletedAt",
"UserEntity"."status" AS "UserEntity_status",
"UserEntity"."updatedAt" AS "UserEntity_updatedAt",
"UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes",
"UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes",
"UserEntity"."profileChangedAt" AS "UserEntity_profileChangedAt"
FROM
"users" "UserEntity"
WHERE
((("UserEntity"."storageLabel" = $1)))
AND ("UserEntity"."deletedAt" IS NULL)
LIMIT
1
select
"id",
"email",
"createdAt",
"profileImagePath",
"isAdmin",
"shouldChangePassword",
"deletedAt",
"oauthId",
"updatedAt",
"storageLabel",
"name",
"quotaSizeInBytes",
"quotaUsageInBytes",
"status",
"profileChangedAt"
from
"users"
where
"users"."storageLabel" = $1
and "users"."deletedAt" is null
-- UserRepository.getByOAuthId
SELECT
"UserEntity"."id" AS "UserEntity_id",
"UserEntity"."name" AS "UserEntity_name",
"UserEntity"."isAdmin" AS "UserEntity_isAdmin",
"UserEntity"."email" AS "UserEntity_email",
"UserEntity"."storageLabel" AS "UserEntity_storageLabel",
"UserEntity"."oauthId" AS "UserEntity_oauthId",
"UserEntity"."profileImagePath" AS "UserEntity_profileImagePath",
"UserEntity"."shouldChangePassword" AS "UserEntity_shouldChangePassword",
"UserEntity"."createdAt" AS "UserEntity_createdAt",
"UserEntity"."deletedAt" AS "UserEntity_deletedAt",
"UserEntity"."status" AS "UserEntity_status",
"UserEntity"."updatedAt" AS "UserEntity_updatedAt",
"UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes",
"UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes",
"UserEntity"."profileChangedAt" AS "UserEntity_profileChangedAt"
FROM
"users" "UserEntity"
WHERE
((("UserEntity"."oauthId" = $1)))
AND ("UserEntity"."deletedAt" IS NULL)
LIMIT
1
select
"id",
"email",
"createdAt",
"profileImagePath",
"isAdmin",
"shouldChangePassword",
"deletedAt",
"oauthId",
"updatedAt",
"storageLabel",
"name",
"quotaSizeInBytes",
"quotaUsageInBytes",
"status",
"profileChangedAt"
from
"users"
where
"users"."oauthId" = $1
and "users"."deletedAt" is null
-- UserRepository.getUserStats
SELECT
"users"."id" AS "userId",
"users"."name" AS "userName",
"users"."quotaSizeInBytes" AS "quotaSizeInBytes",
COUNT("assets"."id") FILTER (
WHERE
"assets"."type" = 'IMAGE'
AND "assets"."isVisible"
) AS "photos",
COUNT("assets"."id") FILTER (
WHERE
"assets"."type" = 'VIDEO'
AND "assets"."isVisible"
) AS "videos",
COALESCE(
SUM("exif"."fileSizeInByte") FILTER (
WHERE
"assets"."libraryId" IS NULL
select
"users"."id" as "userId",
"users"."name" as "userName",
"users"."quotaSizeInBytes" as "quotaSizeInBytes",
count(*) filter (
where
(
"assets"."type" = $1
and "assets"."isVisible" = $2
)
) as "photos",
count(*) filter (
where
(
"assets"."type" = $3
and "assets"."isVisible" = $4
)
) as "videos",
coalesce(
sum("exif"."fileSizeInByte") filter (
where
"assets"."libraryId" is null
),
0
) AS "usage",
COALESCE(
SUM("exif"."fileSizeInByte") FILTER (
WHERE
"assets"."libraryId" IS NULL
AND "assets"."type" = 'IMAGE'
) as "usage",
coalesce(
sum("exif"."fileSizeInByte") filter (
where
(
"assets"."libraryId" is null
and "assets"."type" = $5
)
),
0
) AS "usagePhotos",
COALESCE(
SUM("exif"."fileSizeInByte") FILTER (
WHERE
"assets"."libraryId" IS NULL
AND "assets"."type" = 'VIDEO'
) as "usagePhotos",
coalesce(
sum("exif"."fileSizeInByte") filter (
where
(
"assets"."libraryId" is null
and "assets"."type" = $6
)
),
0
) AS "usageVideos"
FROM
"users" "users"
LEFT JOIN "assets" "assets" ON "assets"."ownerId" = "users"."id"
AND ("assets"."deletedAt" IS NULL)
LEFT JOIN "exif" "exif" ON "exif"."assetId" = "assets"."id"
WHERE
"users"."deletedAt" IS NULL
GROUP BY
) as "usageVideos"
from
"users"
left join "assets" on "assets"."ownerId" = "users"."id"
left join "exif" on "exif"."assetId" = "assets"."id"
where
"assets"."deletedAt" is null
group by
"users"."id"
ORDER BY
"users"."createdAt" ASC
order by
"users"."createdAt" asc
-- UserRepository.updateUsage
UPDATE "users"
SET
"quotaUsageInBytes" = "quotaUsageInBytes" + 50,
"updatedAt" = CURRENT_TIMESTAMP
WHERE
"id" = $1
update "users"
set
"quotaUsageInBytes" = "quotaUsageInBytes" + $1,
"updatedAt" = $2
where
"id" = $3::uuid
and "users"."deletedAt" is null
-- UserRepository.syncUsage
UPDATE "users"
SET
update "users"
set
"quotaUsageInBytes" = (
SELECT
COALESCE(SUM(exif."fileSizeInByte"), 0)
FROM
"assets" "assets"
LEFT JOIN "exif" "exif" ON "exif"."assetId" = "assets"."id"
WHERE
"assets"."ownerId" = users.id
AND "assets"."libraryId" IS NULL
select
coalesce(sum("exif"."fileSizeInByte"), 0) as "usage"
from
"assets"
left join "exif" on "exif"."assetId" = "assets"."id"
where
"assets"."libraryId" is null
and "assets"."ownerId" = "users"."id"
),
"updatedAt" = CURRENT_TIMESTAMP
WHERE
users.id = $1
"updatedAt" = $1
where
"users"."deletedAt" is null
and "users"."id" = $2::uuid

View file

@ -1,7 +1,8 @@
import { Inject, Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import AsyncLock from 'async-lock';
import { sql } from 'kysely';
import { Kysely, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import semver from 'semver';
import { POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants';
import { DB } from 'src/db';
@ -27,6 +28,7 @@ export class DatabaseRepository implements IDatabaseRepository {
private readonly asyncLock = new AsyncLock();
constructor(
@InjectKysely() private db: Kysely<DB>,
@InjectDataSource() private dataSource: DataSource,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@ -35,6 +37,10 @@ export class DatabaseRepository implements IDatabaseRepository {
this.logger.setContext(DatabaseRepository.name);
}
async shutdown() {
await this.db.destroy();
}
init() {
for (const metadata of this.dataSource.entityMetadatas) {
const table = metadata.tableName as keyof DB;

View file

@ -1,127 +1,212 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Insertable, Kysely, sql, Updateable } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DB, UserMetadata as DbUserMetadata, Users } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetEntity } from 'src/entities/asset.entity';
import { UserMetadata, UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';
import { UserMetadata } from 'src/entities/user-metadata.entity';
import { UserEntity, withMetadata } from 'src/entities/user.entity';
import {
IUserRepository,
UserFindOptions,
UserListFilter,
UserStatsQueryResponse,
} from 'src/interfaces/user.interface';
import { IsNull, Not, Repository } from 'typeorm';
import { asUuid } from 'src/utils/database';
const columns = [
'id',
'email',
'createdAt',
'profileImagePath',
'isAdmin',
'shouldChangePassword',
'deletedAt',
'oauthId',
'updatedAt',
'storageLabel',
'name',
'quotaSizeInBytes',
'quotaUsageInBytes',
'status',
'profileChangedAt',
] as const;
type Upsert = Insertable<DbUserMetadata>;
@Injectable()
export class UserRepository implements IUserRepository {
constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(UserEntity) private userRepository: Repository<UserEntity>,
@InjectRepository(UserMetadataEntity) private metadataRepository: Repository<UserMetadataEntity>,
) {}
constructor(@InjectKysely() private db: Kysely<DB>) {}
async get(userId: string, options: UserFindOptions): Promise<UserEntity | null> {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.BOOLEAN] })
get(userId: string, options: UserFindOptions): Promise<UserEntity | undefined> {
options = options || {};
return this.userRepository.findOne({
where: { id: userId },
withDeleted: options.withDeleted,
relations: {
metadata: true,
},
});
return this.db
.selectFrom('users')
.select(columns)
.select(withMetadata)
.where('users.id', '=', userId)
.$if(!options.withDeleted, (eb) => eb.where('users.deletedAt', 'is', null))
.executeTakeFirst() as Promise<UserEntity | undefined>;
}
@GenerateSql()
async getAdmin(): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { isAdmin: true } });
getAdmin(): Promise<UserEntity | undefined> {
return this.db
.selectFrom('users')
.select(columns)
.where('users.isAdmin', '=', true)
.where('users.deletedAt', 'is', null)
.executeTakeFirst() as Promise<UserEntity | undefined>;
}
@GenerateSql()
async hasAdmin(): Promise<boolean> {
return this.userRepository.exists({ where: { isAdmin: true } });
const admin = await this.db
.selectFrom('users')
.select('users.id')
.where('users.isAdmin', '=', true)
.where('users.deletedAt', 'is', null)
.executeTakeFirst();
return !!admin;
}
@GenerateSql({ params: [DummyValue.EMAIL] })
async getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null> {
const builder = this.userRepository.createQueryBuilder('user').where({ email });
if (withPassword) {
builder.addSelect('user.password');
}
return builder.getOne();
getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | undefined> {
return this.db
.selectFrom('users')
.select(columns)
.$if(!!withPassword, (eb) => eb.select('password'))
.where('email', '=', email)
.where('users.deletedAt', 'is', null)
.executeTakeFirst() as Promise<UserEntity | undefined>;
}
@GenerateSql({ params: [DummyValue.STRING] })
async getByStorageLabel(storageLabel: string): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { storageLabel } });
getByStorageLabel(storageLabel: string): Promise<UserEntity | undefined> {
return this.db
.selectFrom('users')
.select(columns)
.where('users.storageLabel', '=', storageLabel)
.where('users.deletedAt', 'is', null)
.executeTakeFirst() as Promise<UserEntity | undefined>;
}
@GenerateSql({ params: [DummyValue.STRING] })
async getByOAuthId(oauthId: string): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { oauthId } });
getByOAuthId(oauthId: string): Promise<UserEntity | undefined> {
return this.db
.selectFrom('users')
.select(columns)
.where('users.oauthId', '=', oauthId)
.where('users.deletedAt', 'is', null)
.executeTakeFirst() as Promise<UserEntity | undefined>;
}
async getDeletedUsers(): Promise<UserEntity[]> {
return this.userRepository.find({ withDeleted: true, where: { deletedAt: Not(IsNull()) } });
getDeletedUsers(): Promise<UserEntity[]> {
return this.db
.selectFrom('users')
.select(columns)
.where('users.deletedAt', 'is not', null)
.execute() as unknown as Promise<UserEntity[]>;
}
async getList({ withDeleted }: UserListFilter = {}): Promise<UserEntity[]> {
return this.userRepository.find({
withDeleted,
order: {
createdAt: 'DESC',
},
relations: {
metadata: true,
},
});
getList({ withDeleted }: UserListFilter = {}): Promise<UserEntity[]> {
return this.db
.selectFrom('users')
.select(columns)
.select(withMetadata)
.$if(!withDeleted, (eb) => eb.where('users.deletedAt', 'is', null))
.orderBy('createdAt', 'desc')
.execute() as unknown as Promise<UserEntity[]>;
}
create(user: Partial<UserEntity>): Promise<UserEntity> {
return this.save(user);
async create(dto: Insertable<Users>): Promise<UserEntity> {
return this.db
.insertInto('users')
.values(dto)
.returning(columns)
.executeTakeFirst() as unknown as Promise<UserEntity>;
}
// TODO change to (user: Partial<UserEntity>)
update(id: string, user: Partial<UserEntity>): Promise<UserEntity> {
return this.save({ ...user, id });
update(id: string, dto: Updateable<Users>): Promise<UserEntity> {
return this.db
.updateTable('users')
.set(dto)
.where('users.id', '=', asUuid(id))
.where('users.deletedAt', 'is', null)
.returning(columns)
.returning(withMetadata)
.executeTakeFirst() as unknown as Promise<UserEntity>;
}
async upsertMetadata<T extends keyof UserMetadata>(id: string, { key, value }: { key: T; value: UserMetadata[T] }) {
await this.metadataRepository.upsert({ userId: id, key, value }, { conflictPaths: { userId: true, key: true } });
await this.db
.insertInto('user_metadata')
.values({ userId: id, key, value } as Upsert)
.onConflict((oc) =>
oc.columns(['userId', 'key']).doUpdateSet({
key,
value,
} as Upsert),
)
.execute();
}
async deleteMetadata<T extends keyof UserMetadata>(id: string, key: T) {
await this.metadataRepository.delete({ userId: id, key });
await this.db.deleteFrom('user_metadata').where('userId', '=', id).where('key', '=', key).execute();
}
async delete(user: UserEntity, hard?: boolean): Promise<UserEntity> {
return hard ? this.userRepository.remove(user) : this.userRepository.softRemove(user);
delete(user: UserEntity, hard?: boolean): Promise<UserEntity> {
return hard
? (this.db.deleteFrom('users').where('id', '=', user.id).execute() as unknown as Promise<UserEntity>)
: (this.db
.updateTable('users')
.set({ deletedAt: new Date() })
.where('id', '=', user.id)
.execute() as unknown as Promise<UserEntity>);
}
@GenerateSql()
async getUserStats(): Promise<UserStatsQueryResponse[]> {
const stats = await this.userRepository
.createQueryBuilder('users')
.select('users.id', 'userId')
.addSelect('users.name', 'userName')
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos')
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos')
.addSelect('COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL), 0)', 'usage')
.addSelect(
`COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL AND assets.type = 'IMAGE'), 0)`,
'usagePhotos',
)
.addSelect(
`COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL AND assets.type = 'VIDEO'), 0)`,
'usageVideos',
)
.addSelect('users.quotaSizeInBytes', 'quotaSizeInBytes')
.leftJoin('users.assets', 'assets')
.leftJoin('assets.exifInfo', 'exif')
const stats = (await this.db
.selectFrom('users')
.leftJoin('assets', 'assets.ownerId', 'users.id')
.leftJoin('exif', 'exif.assetId', 'assets.id')
.select(['users.id as userId', 'users.name as userName', 'users.quotaSizeInBytes as quotaSizeInBytes'])
.select((eb) => [
eb.fn
.countAll()
.filterWhere((eb) => eb.and([eb('assets.type', '=', 'IMAGE'), eb('assets.isVisible', '=', true)]))
.as('photos'),
eb.fn
.countAll()
.filterWhere((eb) => eb.and([eb('assets.type', '=', 'VIDEO'), eb('assets.isVisible', '=', true)]))
.as('videos'),
eb.fn
.coalesce(eb.fn.sum('exif.fileSizeInByte').filterWhere('assets.libraryId', 'is', null), eb.lit(0))
.as('usage'),
eb.fn
.coalesce(
eb.fn
.sum('exif.fileSizeInByte')
.filterWhere((eb) => eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', 'IMAGE')])),
eb.lit(0),
)
.as('usagePhotos'),
eb.fn
.coalesce(
eb.fn
.sum('exif.fileSizeInByte')
.filterWhere((eb) => eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', 'VIDEO')])),
eb.lit(0),
)
.as('usageVideos'),
])
.where('assets.deletedAt', 'is', null)
.groupBy('users.id')
.orderBy('users.createdAt', 'ASC')
.getRawMany();
.orderBy('users.createdAt', 'asc')
.execute()) as UserStatsQueryResponse[];
for (const stat of stats) {
stat.photos = Number(stat.photos);
@ -137,41 +222,31 @@ export class UserRepository implements IUserRepository {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.NUMBER] })
async updateUsage(id: string, delta: number): Promise<void> {
await this.userRepository.increment({ id }, 'quotaUsageInBytes', delta);
await this.db
.updateTable('users')
.set({ quotaUsageInBytes: sql`"quotaUsageInBytes" + ${delta}`, updatedAt: new Date() })
.where('id', '=', asUuid(id))
.where('users.deletedAt', 'is', null)
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
async syncUsage(id?: string) {
// we can't use parameters with getQuery, hence the template string
const subQuery = this.assetRepository
.createQueryBuilder('assets')
.select('COALESCE(SUM(exif."fileSizeInByte"), 0)')
.leftJoin('assets.exifInfo', 'exif')
.where('assets.ownerId = users.id')
.andWhere(`assets.libraryId IS NULL`)
.withDeleted();
const query = this.userRepository
.createQueryBuilder('users')
.leftJoin('users.assets', 'assets')
.update()
.set({ quotaUsageInBytes: () => `(${subQuery.getQuery()})` });
if (id) {
query.where('users.id = :id', { id });
}
const query = this.db
.updateTable('users')
.set({
quotaUsageInBytes: (eb) =>
eb
.selectFrom('assets')
.leftJoin('exif', 'exif.assetId', 'assets.id')
.select((eb) => eb.fn.coalesce(eb.fn.sum('exif.fileSizeInByte'), eb.lit(0)).as('usage'))
.where('assets.libraryId', 'is', null)
.where('assets.ownerId', '=', eb.ref('users.id')),
updatedAt: new Date(),
})
.where('users.deletedAt', 'is', null)
.$if(id != undefined, (eb) => eb.where('users.id', '=', asUuid(id!)));
await query.execute();
}
private async save(user: Partial<UserEntity>) {
const { id } = await this.userRepository.save(user);
return this.userRepository.findOneOrFail({
where: { id },
withDeleted: true,
relations: {
metadata: true,
},
});
}
}

View file

@ -153,7 +153,7 @@ describe(AlbumService.name, () => {
});
it('should require valid userIds', async () => {
userMock.get.mockResolvedValue(null);
userMock.get.mockResolvedValue(void 0);
await expect(
sut.create(authStub.admin, {
albumName: 'Empty album',
@ -299,7 +299,7 @@ describe(AlbumService.name, () => {
it('should throw an error if the userId does not exist', async () => {
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
userMock.get.mockResolvedValue(null);
userMock.get.mockResolvedValue(void 0);
await expect(
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: 'user-3' }] }),
).rejects.toBeInstanceOf(BadRequestException);

View file

@ -96,7 +96,7 @@ describe('AuthService', () => {
});
it('should check the user exists', async () => {
userMock.getByEmail.mockResolvedValue(null);
userMock.getByEmail.mockResolvedValue(void 0);
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
});
@ -144,7 +144,7 @@ describe('AuthService', () => {
const auth = { user: { email: 'test@imimch.com' } } as AuthDto;
const dto = { password: 'old-password', newPassword: 'new-password' };
userMock.getByEmail.mockResolvedValue(null);
userMock.getByEmail.mockResolvedValue(void 0);
await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(UnauthorizedException);
});
@ -227,7 +227,7 @@ describe('AuthService', () => {
});
it('should sign up the admin', async () => {
userMock.getAdmin.mockResolvedValue(null);
userMock.getAdmin.mockResolvedValue(void 0);
userMock.create.mockResolvedValue({
...dto,
id: 'admin',
@ -309,7 +309,7 @@ describe('AuthService', () => {
it('should not accept a key without a user', async () => {
sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
userMock.get.mockResolvedValue(null);
userMock.get.mockResolvedValue(void 0);
await expect(
sut.authenticate({
headers: { 'x-immich-share-key': 'key' },
@ -473,7 +473,7 @@ describe('AuthService', () => {
it('should not allow auto registering', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.oauthEnabled);
userMock.getByEmail.mockResolvedValue(null);
userMock.getByEmail.mockResolvedValue(void 0);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
BadRequestException,
);
@ -510,7 +510,7 @@ describe('AuthService', () => {
it('should allow auto registering by default', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.enabled);
userMock.getByEmail.mockResolvedValue(null);
userMock.getByEmail.mockResolvedValue(void 0);
userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1);
sessionMock.create.mockResolvedValue(sessionStub.valid);
@ -525,7 +525,7 @@ describe('AuthService', () => {
it('should throw an error if user should be auto registered but the email claim does not exist', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.enabled);
userMock.getByEmail.mockResolvedValue(null);
userMock.getByEmail.mockResolvedValue(void 0);
userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1);
sessionMock.create.mockResolvedValue(sessionStub.valid);
@ -559,7 +559,7 @@ describe('AuthService', () => {
it('should use the default quota', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
userMock.getByEmail.mockResolvedValue(null);
userMock.getByEmail.mockResolvedValue(void 0);
userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1);
@ -572,7 +572,7 @@ describe('AuthService', () => {
it('should ignore an invalid storage quota', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
userMock.getByEmail.mockResolvedValue(null);
userMock.getByEmail.mockResolvedValue(void 0);
userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1);
oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 'abc' });
@ -586,7 +586,7 @@ describe('AuthService', () => {
it('should ignore a negative quota', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
userMock.getByEmail.mockResolvedValue(null);
userMock.getByEmail.mockResolvedValue(void 0);
userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1);
oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: -5 });
@ -600,7 +600,7 @@ describe('AuthService', () => {
it('should not set quota for 0 quota', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
userMock.getByEmail.mockResolvedValue(null);
userMock.getByEmail.mockResolvedValue(void 0);
userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1);
oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 0 });
@ -620,7 +620,7 @@ describe('AuthService', () => {
it('should use a valid storage quota', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
userMock.getByEmail.mockResolvedValue(null);
userMock.getByEmail.mockResolvedValue(void 0);
userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1);
oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 5 });

View file

@ -65,7 +65,7 @@ export class AuthService extends BaseService {
if (user) {
const isAuthenticated = this.validatePassword(dto.password, user);
if (!isAuthenticated) {
user = null;
user = undefined;
}
}

View file

@ -1,8 +1,10 @@
import { BadRequestException, Inject } from '@nestjs/common';
import { Insertable } from 'kysely';
import sanitize from 'sanitize-filename';
import { SystemConfig } from 'src/config';
import { SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { Users } from 'src/db';
import { UserEntity } from 'src/entities/user.entity';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IActivityRepository } from 'src/interfaces/activity.interface';
@ -131,7 +133,7 @@ export class BaseService {
return checkAccess(this.accessRepository, request);
}
async createUser(dto: Partial<UserEntity> & { email: string }): Promise<UserEntity> {
async createUser(dto: Insertable<Users> & { email: string }): Promise<UserEntity> {
const user = await this.userRepository.getByEmail(dto.email);
if (user) {
throw new BadRequestException('User exists');
@ -144,7 +146,7 @@ export class BaseService {
}
}
const payload: Partial<UserEntity> = { ...dto };
const payload: Insertable<Users> = { ...dto };
if (payload.password) {
payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS);
}

View file

@ -25,7 +25,7 @@ describe(CliService.name, () => {
describe('resetAdminPassword', () => {
it('should only work when there is an admin account', async () => {
userMock.getAdmin.mockResolvedValue(null);
userMock.getAdmin.mockResolvedValue(void 0);
const ask = vitest.fn().mockResolvedValue('new-password');
await expect(sut.resetAdminPassword(ask)).rejects.toThrowError('Admin account does not exist');

View file

@ -48,4 +48,8 @@ export class CliService extends BaseService {
config.oauth.enabled = true;
await this.updateConfig(config);
}
cleanup() {
return this.databaseRepository.shutdown();
}
}

View file

@ -19,13 +19,13 @@ describe(UserAdminService.name, () => {
({ sut, jobMock, userMock } = newTestService(UserAdminService));
userMock.get.mockImplementation((userId) =>
Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? null),
Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? undefined),
);
});
describe('create', () => {
it('should not create a user if there is no local admin account', async () => {
userMock.getAdmin.mockResolvedValueOnce(null);
userMock.getAdmin.mockResolvedValueOnce(void 0);
await expect(
sut.create({
@ -66,8 +66,8 @@ describe(UserAdminService.name, () => {
email: 'immich@test.com',
storageLabel: 'storage_label',
};
userMock.getByEmail.mockResolvedValue(null);
userMock.getByStorageLabel.mockResolvedValue(null);
userMock.getByEmail.mockResolvedValue(void 0);
userMock.getByStorageLabel.mockResolvedValue(void 0);
userMock.update.mockResolvedValue(userStub.user1);
await sut.update(authStub.user1, userStub.user1.id, update);
@ -108,7 +108,7 @@ describe(UserAdminService.name, () => {
});
it('update user information should throw error if user not found', async () => {
userMock.get.mockResolvedValueOnce(null);
userMock.get.mockResolvedValueOnce(void 0);
await expect(
sut.update(authStub.admin, userStub.user1.id, { shouldChangePassword: true }),
@ -118,7 +118,7 @@ describe(UserAdminService.name, () => {
describe('delete', () => {
it('should throw error if user could not be found', async () => {
userMock.get.mockResolvedValue(null);
userMock.get.mockResolvedValue(void 0);
await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toThrowError(BadRequestException);
expect(userMock.delete).not.toHaveBeenCalled();
@ -166,7 +166,7 @@ describe(UserAdminService.name, () => {
describe('restore', () => {
it('should throw error if user could not be found', async () => {
userMock.get.mockResolvedValue(null);
userMock.get.mockResolvedValue(void 0);
await expect(sut.restore(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException);
expect(userMock.update).not.toHaveBeenCalled();
});

View file

@ -33,7 +33,7 @@ describe(UserService.name, () => {
({ sut, albumMock, jobMock, storageMock, systemMock, userMock } = newTestService(UserService));
userMock.get.mockImplementation((userId) =>
Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? null),
Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? undefined),
);
});
@ -81,7 +81,7 @@ describe(UserService.name, () => {
});
it('should throw an error if a user is not found', async () => {
userMock.get.mockResolvedValue(null);
userMock.get.mockResolvedValue(void 0);
await expect(sut.get(authStub.admin.user.id)).rejects.toBeInstanceOf(BadRequestException);
expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, { withDeleted: false });
});
@ -100,7 +100,7 @@ describe(UserService.name, () => {
describe('createProfileImage', () => {
it('should throw an error if the user does not exist', async () => {
const file = { path: '/profile/path' } as Express.Multer.File;
userMock.get.mockResolvedValue(null);
userMock.get.mockResolvedValue(void 0);
userMock.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path });
await expect(sut.createProfileImage(authStub.admin, file)).rejects.toThrowError(BadRequestException);
@ -155,7 +155,7 @@ describe(UserService.name, () => {
describe('getUserProfileImage', () => {
it('should throw an error if the user does not exist', async () => {
userMock.get.mockResolvedValue(null);
userMock.get.mockResolvedValue(void 0);
await expect(sut.getProfileImage(userStub.admin.id)).rejects.toBeInstanceOf(BadRequestException);

View file

@ -4,6 +4,7 @@ import { Mocked, vitest } from 'vitest';
export const newDatabaseRepositoryMock = (): Mocked<IDatabaseRepository> => {
return {
init: vitest.fn(),
shutdown: vitest.fn(),
reconnect: vitest.fn(),
getExtensionVersion: vitest.fn(),
getExtensionVersionRange: vitest.fn(),

2
web/package-lock.json generated
View file

@ -80,7 +80,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/node": "^22.10.5",
"typescript": "^5.3.3"
}
},