mirror of
https://github.com/immich-app/immich.git
synced 2025-01-23 20:22:45 +01:00
refactor: migrate user repository to kysely (#15296)
* 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:
parent
a6c8eb57f1
commit
3da750117f
18 changed files with 447 additions and 312 deletions
server
web
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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');
|
||||
};
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -65,7 +65,7 @@ export class AuthService extends BaseService {
|
|||
if (user) {
|
||||
const isAuthenticated = this.validatePassword(dto.password, user);
|
||||
if (!isAuthenticated) {
|
||||
user = null;
|
||||
user = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -48,4 +48,8 @@ export class CliService extends BaseService {
|
|||
config.oauth.enabled = true;
|
||||
await this.updateConfig(config);
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
return this.databaseRepository.shutdown();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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
2
web/package-lock.json
generated
|
@ -80,7 +80,7 @@
|
|||
"@oazapfts/runtime": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/node": "^22.10.5",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue