import { Injectable } from '@nestjs/common'; import { ExpressionBuilder, Insertable, Kysely, sql, Updateable } from 'kysely'; import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { DateTime } from 'luxon'; import { InjectKysely } from 'nestjs-kysely'; import { columns } from 'src/database'; import { DummyValue, GenerateSql } from 'src/decorators'; import { AssetType, AssetVisibility, UserStatus } from 'src/enum'; import { DB } from 'src/schema'; import { UserMetadataTable } from 'src/schema/tables/user-metadata.table'; import { UserTable } from 'src/schema/tables/user.table'; import { UserMetadata, UserMetadataItem } from 'src/types'; import { asUuid } from 'src/utils/database'; type Upsert = Insertable; export interface UserListFilter { id?: string; withDeleted?: boolean; } export interface UserStatsQueryResponse { userId: string; userName: string; photos: number; videos: number; usage: number; usagePhotos: number; usageVideos: number; quotaSizeInBytes: number | null; } export interface UserFindOptions { withDeleted?: boolean; } const withMetadata = (eb: ExpressionBuilder) => { return jsonArrayFrom( eb .selectFrom('user_metadata') .select(['user_metadata.key', 'user_metadata.value']) .whereRef('user.id', '=', 'user_metadata.userId'), ).as('metadata'); }; @Injectable() export class UserRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID, DummyValue.BOOLEAN] }) get(userId: string, options: UserFindOptions) { options = options || {}; return this.db .selectFrom('user') .select(columns.userAdmin) .select(withMetadata) .where('user.id', '=', userId) .$if(!options.withDeleted, (eb) => eb.where('user.deletedAt', 'is', null)) .executeTakeFirst(); } getMetadata(userId: string) { return this.db .selectFrom('user_metadata') .select(['key', 'value']) .where('user_metadata.userId', '=', userId) .execute() as Promise; } @GenerateSql() getAdmin() { return this.db .selectFrom('user') .select(columns.userAdmin) .select(withMetadata) .where('user.isAdmin', '=', true) .where('user.deletedAt', 'is', null) .executeTakeFirst(); } @GenerateSql() async hasAdmin(): Promise { const admin = await this.db .selectFrom('user') .select('user.id') .where('user.isAdmin', '=', true) .where('user.deletedAt', 'is', null) .executeTakeFirst(); return !!admin; } @GenerateSql({ params: [DummyValue.UUID] }) getForPinCode(id: string) { return this.db .selectFrom('user') .select(['user.pinCode', 'user.password']) .where('user.id', '=', id) .where('user.deletedAt', 'is', null) .executeTakeFirstOrThrow(); } @GenerateSql({ params: [DummyValue.UUID] }) getForChangePassword(id: string) { return this.db .selectFrom('user') .select(['user.id', 'user.password']) .where('user.id', '=', id) .where('user.deletedAt', 'is', null) .executeTakeFirstOrThrow(); } @GenerateSql({ params: [DummyValue.EMAIL] }) getByEmail(email: string, options?: { withPassword?: boolean }) { return this.db .selectFrom('user') .select(columns.userAdmin) .select(withMetadata) .$if(!!options?.withPassword, (eb) => eb.select('password')) .where('email', '=', email) .where('user.deletedAt', 'is', null) .executeTakeFirst(); } @GenerateSql({ params: [DummyValue.STRING] }) getByStorageLabel(storageLabel: string) { return this.db .selectFrom('user') .select(columns.userAdmin) .where('user.storageLabel', '=', storageLabel) .where('user.deletedAt', 'is', null) .executeTakeFirst(); } @GenerateSql({ params: [DummyValue.STRING] }) getByOAuthId(oauthId: string) { return this.db .selectFrom('user') .select(columns.userAdmin) .select(withMetadata) .where('user.oauthId', '=', oauthId) .where('user.deletedAt', 'is', null) .executeTakeFirst(); } @GenerateSql({ params: [DateTime.now().minus({ years: 1 })] }) getDeletedAfter(target: DateTime) { return this.db.selectFrom('user').select(['id']).where('user.deletedAt', '<', target.toJSDate()).execute(); } @GenerateSql( { name: 'with deleted', params: [{ withDeleted: true }] }, { name: 'without deleted', params: [{ withDeleted: false }] }, ) getList({ id, withDeleted }: UserListFilter = {}) { return this.db .selectFrom('user') .select(columns.userAdmin) .select(withMetadata) .$if(!withDeleted, (eb) => eb.where('user.deletedAt', 'is', null)) .$if(!!id, (eb) => eb.where('user.id', '=', id!)) .orderBy('createdAt', 'desc') .execute(); } async create(dto: Insertable) { return this.db .insertInto('user') .values(dto) .returning(columns.userAdmin) .returning(withMetadata) .executeTakeFirstOrThrow(); } update(id: string, dto: Updateable) { return this.db .updateTable('user') .set(dto) .where('user.id', '=', asUuid(id)) .where('user.deletedAt', 'is', null) .returning(columns.userAdmin) .returning(withMetadata) .executeTakeFirstOrThrow(); } restore(id: string) { return this.db .updateTable('user') .set({ status: UserStatus.ACTIVE, deletedAt: null }) .where('user.id', '=', asUuid(id)) .returning(columns.userAdmin) .returning(withMetadata) .executeTakeFirstOrThrow(); } async upsertMetadata(id: string, { key, value }: { key: T; value: UserMetadata[T] }) { 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.db.deleteFrom('user_metadata').where('userId', '=', id).where('key', '=', key).execute(); } delete(user: { id: string }, hard?: boolean) { return hard ? this.db.deleteFrom('user').where('id', '=', user.id).execute() : this.db.updateTable('user').set({ deletedAt: new Date() }).where('id', '=', user.id).execute(); } @GenerateSql() getUserStats() { return this.db .selectFrom('user') .leftJoin('asset', (join) => join.onRef('asset.ownerId', '=', 'user.id').on('asset.deletedAt', 'is', null)) .leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id') .select(['user.id as userId', 'user.name as userName', 'user.quotaSizeInBytes']) .select((eb) => [ eb.fn .countAll() .filterWhere((eb) => eb.and([ eb('asset.type', '=', sql.lit(AssetType.IMAGE)), eb('asset.visibility', '!=', sql.lit(AssetVisibility.HIDDEN)), ]), ) .as('photos'), eb.fn .countAll() .filterWhere((eb) => eb.and([ eb('asset.type', '=', sql.lit(AssetType.VIDEO)), eb('asset.visibility', '!=', sql.lit(AssetVisibility.HIDDEN)), ]), ) .as('videos'), eb.fn .coalesce( eb.fn.sum('asset_exif.fileSizeInByte').filterWhere('asset.libraryId', 'is', null), eb.lit(0), ) .as('usage'), eb.fn .coalesce( eb.fn .sum('asset_exif.fileSizeInByte') .filterWhere((eb) => eb.and([eb('asset.libraryId', 'is', null), eb('asset.type', '=', sql.lit(AssetType.IMAGE))]), ), eb.lit(0), ) .as('usagePhotos'), eb.fn .coalesce( eb.fn .sum('asset_exif.fileSizeInByte') .filterWhere((eb) => eb.and([eb('asset.libraryId', 'is', null), eb('asset.type', '=', sql.lit(AssetType.VIDEO))]), ), eb.lit(0), ) .as('usageVideos'), ]) .groupBy('user.id') .orderBy('user.createdAt', 'asc') .execute(); } @GenerateSql({ params: [DummyValue.UUID, DummyValue.NUMBER] }) async updateUsage(id: string, delta: number): Promise { await this.db .updateTable('user') .set({ quotaUsageInBytes: sql`"quotaUsageInBytes" + ${delta}`, updatedAt: new Date() }) .where('id', '=', asUuid(id)) .where('user.deletedAt', 'is', null) .execute(); } @GenerateSql({ params: [DummyValue.UUID] }) async syncUsage(id?: string) { const query = this.db .updateTable('user') .set({ quotaUsageInBytes: (eb) => eb .selectFrom('asset') .leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id') .select((eb) => eb.fn.coalesce(eb.fn.sum('asset_exif.fileSizeInByte'), eb.lit(0)).as('usage')) .where('asset.libraryId', 'is', null) .where('asset.ownerId', '=', eb.ref('user.id')), updatedAt: new Date(), }) .where('user.deletedAt', 'is', null) .$if(id != undefined, (eb) => eb.where('user.id', '=', asUuid(id!))); await query.execute(); } }