diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 9d96a0499b..d0422756b6 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -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(); + } +} diff --git a/server/src/decorators.ts b/server/src/decorators.ts index c2bbe19b28..047b9ec4a7 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -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'; diff --git a/server/src/entities/user.entity.ts b/server/src/entities/user.entity.ts index ea446be390..3f5b470ce4 100644 --- a/server/src/entities/user.entity.ts +++ b/server/src/entities/user.entity.ts @@ -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) => { + return jsonArrayFrom( + eb.selectFrom('user_metadata').selectAll('user_metadata').whereRef('users.id', '=', 'user_metadata.userId'), + ).as('metadata'); +}; diff --git a/server/src/interfaces/database.interface.ts b/server/src/interfaces/database.interface.ts index 5ad37efa71..8cfc040271 100644 --- a/server/src/interfaces/database.interface.ts +++ b/server/src/interfaces/database.interface.ts @@ -61,6 +61,7 @@ export const IDatabaseRepository = 'IDatabaseRepository'; export interface IDatabaseRepository { init(): void; reconnect(): Promise; + shutdown(): Promise; getExtensionVersion(extension: DatabaseExtension): Promise; getExtensionVersionRange(extension: VectorExtension): string; getPostgresVersion(): Promise; diff --git a/server/src/interfaces/user.interface.ts b/server/src/interfaces/user.interface.ts index 385a4d3d50..6ff3fc824a 100644 --- a/server/src/interfaces/user.interface.ts +++ b/server/src/interfaces/user.interface.ts @@ -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; - getAdmin(): Promise; + get(id: string, options: UserFindOptions): Promise; + getAdmin(): Promise; hasAdmin(): Promise; - getByEmail(email: string, withPassword?: boolean): Promise; - getByStorageLabel(storageLabel: string): Promise; - getByOAuthId(oauthId: string): Promise; + getByEmail(email: string, withPassword?: boolean): Promise; + getByStorageLabel(storageLabel: string): Promise; + getByOAuthId(oauthId: string): Promise; getDeletedUsers(): Promise; getList(filter?: UserListFilter): Promise; getUserStats(): Promise; - create(user: Partial): Promise; - update(id: string, user: Partial): Promise; + create(user: Insertable): Promise; + update(id: string, user: Updateable): Promise; upsertMetadata(id: string, item: { key: T; value: UserMetadata[T] }): Promise; deleteMetadata(id: string, key: T): Promise; delete(user: UserEntity, hard?: boolean): Promise; diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index c35dc540ce..7ae8003a09 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -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 diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 0eefce0cd2..7188678212 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -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, @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; diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index a2e4375701..e7c65b3f01 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -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; @Injectable() export class UserRepository implements IUserRepository { - constructor( - @InjectRepository(AssetEntity) private assetRepository: Repository, - @InjectRepository(UserEntity) private userRepository: Repository, - @InjectRepository(UserMetadataEntity) private metadataRepository: Repository, - ) {} + constructor(@InjectKysely() private db: Kysely) {} - async get(userId: string, options: UserFindOptions): Promise { + @GenerateSql({ params: [DummyValue.UUID, DummyValue.BOOLEAN] }) + get(userId: string, options: UserFindOptions): Promise { 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; } @GenerateSql() - async getAdmin(): Promise { - return this.userRepository.findOne({ where: { isAdmin: true } }); + getAdmin(): Promise { + return this.db + .selectFrom('users') + .select(columns) + .where('users.isAdmin', '=', true) + .where('users.deletedAt', 'is', null) + .executeTakeFirst() as Promise; } @GenerateSql() async hasAdmin(): Promise { - 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 { - const builder = this.userRepository.createQueryBuilder('user').where({ email }); - - if (withPassword) { - builder.addSelect('user.password'); - } - - return builder.getOne(); + getByEmail(email: string, withPassword?: boolean): Promise { + return this.db + .selectFrom('users') + .select(columns) + .$if(!!withPassword, (eb) => eb.select('password')) + .where('email', '=', email) + .where('users.deletedAt', 'is', null) + .executeTakeFirst() as Promise; } @GenerateSql({ params: [DummyValue.STRING] }) - async getByStorageLabel(storageLabel: string): Promise { - return this.userRepository.findOne({ where: { storageLabel } }); + getByStorageLabel(storageLabel: string): Promise { + return this.db + .selectFrom('users') + .select(columns) + .where('users.storageLabel', '=', storageLabel) + .where('users.deletedAt', 'is', null) + .executeTakeFirst() as Promise; } @GenerateSql({ params: [DummyValue.STRING] }) - async getByOAuthId(oauthId: string): Promise { - return this.userRepository.findOne({ where: { oauthId } }); + getByOAuthId(oauthId: string): Promise { + return this.db + .selectFrom('users') + .select(columns) + .where('users.oauthId', '=', oauthId) + .where('users.deletedAt', 'is', null) + .executeTakeFirst() as Promise; } - async getDeletedUsers(): Promise { - return this.userRepository.find({ withDeleted: true, where: { deletedAt: Not(IsNull()) } }); + getDeletedUsers(): Promise { + return this.db + .selectFrom('users') + .select(columns) + .where('users.deletedAt', 'is not', null) + .execute() as unknown as Promise; } - async getList({ withDeleted }: UserListFilter = {}): Promise { - return this.userRepository.find({ - withDeleted, - order: { - createdAt: 'DESC', - }, - relations: { - metadata: true, - }, - }); + getList({ withDeleted }: UserListFilter = {}): Promise { + 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; } - create(user: Partial): Promise { - return this.save(user); + async create(dto: Insertable): Promise { + return this.db + .insertInto('users') + .values(dto) + .returning(columns) + .executeTakeFirst() as unknown as Promise; } - // TODO change to (user: Partial) - update(id: string, user: Partial): Promise { - return this.save({ ...user, id }); + update(id: string, dto: Updateable): Promise { + 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; } async upsertMetadata(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(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 { - return hard ? this.userRepository.remove(user) : this.userRepository.softRemove(user); + delete(user: UserEntity, hard?: boolean): Promise { + return hard + ? (this.db.deleteFrom('users').where('id', '=', user.id).execute() as unknown as Promise) + : (this.db + .updateTable('users') + .set({ deletedAt: new Date() }) + .where('id', '=', user.id) + .execute() as unknown as Promise); } @GenerateSql() async getUserStats(): Promise { - 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 { - 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) { - const { id } = await this.userRepository.save(user); - return this.userRepository.findOneOrFail({ - where: { id }, - withDeleted: true, - relations: { - metadata: true, - }, - }); - } } diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 12c93ee127..bb1aac8e6e 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -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); diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 06035b03a2..6494a735b1 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -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 }); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index d6154976f9..29b7395465 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -65,7 +65,7 @@ export class AuthService extends BaseService { if (user) { const isAuthenticated = this.validatePassword(dto.password, user); if (!isAuthenticated) { - user = null; + user = undefined; } } diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 3630d69c18..82852c27e2 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -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 & { email: string }): Promise { + async createUser(dto: Insertable & { email: string }): Promise { 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 = { ...dto }; + const payload: Insertable = { ...dto }; if (payload.password) { payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS); } diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts index ef520070ea..149b030e50 100644 --- a/server/src/services/cli.service.spec.ts +++ b/server/src/services/cli.service.spec.ts @@ -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'); diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index 18a79108c4..87e004845d 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -48,4 +48,8 @@ export class CliService extends BaseService { config.oauth.enabled = true; await this.updateConfig(config); } + + cleanup() { + return this.databaseRepository.shutdown(); + } } diff --git a/server/src/services/user-admin.service.spec.ts b/server/src/services/user-admin.service.spec.ts index 70999332dc..6d2bc31cb7 100644 --- a/server/src/services/user-admin.service.spec.ts +++ b/server/src/services/user-admin.service.spec.ts @@ -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(); }); diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index 08b663046b..cb7c2f08ad 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -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); diff --git a/server/test/repositories/database.repository.mock.ts b/server/test/repositories/database.repository.mock.ts index bfb931105a..c135772518 100644 --- a/server/test/repositories/database.repository.mock.ts +++ b/server/test/repositories/database.repository.mock.ts @@ -4,6 +4,7 @@ import { Mocked, vitest } from 'vitest'; export const newDatabaseRepositoryMock = (): Mocked => { return { init: vitest.fn(), + shutdown: vitest.fn(), reconnect: vitest.fn(), getExtensionVersion: vitest.fn(), getExtensionVersionRange: vitest.fn(), diff --git a/web/package-lock.json b/web/package-lock.json index b25947dd3d..9450b76834 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -80,7 +80,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.10.2", + "@types/node": "^22.10.5", "typescript": "^5.3.3" } },