From 805af60c583d625252ae5384deb678b3462c2b92 Mon Sep 17 00:00:00 2001 From: daechan_jo <103374153+daechan-jo@users.noreply.github.com> Date: Mon, 28 Oct 2024 02:16:30 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20(#459)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: update cron task names and add key-based filtering in getCronLogs - getCronLogs 메소드에 key 파라미터를 추가하여 taskName을 기준으로 필터링 가능하도록 수정 - Cron 작업의 로그 태스크 이름을 간결하게 변경 * refactor: update cron task names and add key-based filtering in getCronLogs - getCronLogs 메소드에 key 파라미터를 추가하여 taskName을 기준으로 필터링 가능하도록 수정 - Cron 작업의 로그 태스크 이름을 간결하게 변경 * feat: store database implementation * feat: theme crud * feat: item crud * feat: view and purchase themes * feat: view and purchase items * feat: activate themes and items * feat: Add layout information to user basic information and modify caching * doc: swagger update * doc: swagger update * doc: swagger update * fix: problem with published posts not being viewed properly due to paging issues * test: Added console log to resolve issues that occur when using rt * test: Added console log to resolve issues that occur when using rt * feat: middleware is added to add request request identifier for testing. * feat: middleware is added to add request request identifier for testing. * fix: replace app.enableCors with express cors middleware for custom headers * fix: replace app.enableCors with express cors middleware for custom headers * fix: add X-Access-Token to Access-Control-Expose-Headers * feat: Add basic theme creation logic * refactor: guard refactoring and improving duplicate handling logic * fix: remove tag limit * refactor: item and theme purchase logic by applying Redis distributed locks and transactions * refactor: Add retry logic to method * refactor: Added name duplication check logic when saving and updating stories * fix: 키워드 타입 검사 추가 * fix: 키워드 타입 검사 추가 * chore: Update dependency package versions to latest * chore: 의존성 패키지 업데이트 * chore: 의존성 패키지 업데이트 * chore: 의존성 패키지 업데이트 * chore: eslint updated * fix: redis connection settings in redlock * fix: 에세이 조회 분산락에 지수 백오프 적용 * doc: edit readme * chore: edit eslint config * fix: 땅에 묻기 기능 추가로 인한 기존 로직 변경 * fix: 땅에묻기 필수 좌표 분기 추가 * refactor: geometry 타입 변경 밑작업 * fix: 에세이 작성시 위도,경도 데이터 geometry로 변환 * feat: 사용자 위치 기반 주변 에세이 알림 추가 * feat: 사용자 위치 기반 주변 에세이 알림 추가 * fix: 장치 설정 검색을 일괄 처리하여 성능 최적화 * fix: 관리자 검색 API에 대한 페이지네이션 추가 * feat: 유저 및 에세이 유사도 검색 추가 --- src/config/typeorm.config.ts | 2 + src/entities/user.entity.ts | 6 + .../2024/10/1730047331380-addTrigramUser.ts | 52 ++++++++ .../2024/10/1730048056148-addVectorUser.ts | 30 +++++ src/modules/admin/admin.controller.ts | 76 ++++++++++-- src/modules/admin/admin.repository.ts | 13 +- src/modules/admin/admin.service.ts | 35 +++--- .../admin/dto/response/adminsRes.dto.ts | 18 +++ .../admin/dto/response/essayInfoRes.dto.ts | 116 +----------------- src/modules/essay/dto/essay.dto.ts | 19 +-- src/modules/essay/essay.repository.ts | 11 +- src/modules/user/user.repository.ts | 29 +++++ 12 files changed, 241 insertions(+), 166 deletions(-) create mode 100644 src/migrations/2024/10/1730047331380-addTrigramUser.ts create mode 100644 src/migrations/2024/10/1730048056148-addVectorUser.ts diff --git a/src/config/typeorm.config.ts b/src/config/typeorm.config.ts index 6d9948b2..3d19dc2f 100644 --- a/src/config/typeorm.config.ts +++ b/src/config/typeorm.config.ts @@ -25,6 +25,8 @@ export const TypeormConfig: TypeOrmModuleAsyncOptions = { }, extra: { max: 9, + keepAlive: true, + idleTimeoutMillis: 30000, connectionTimeoutMillis: 5000, }, ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : undefined, diff --git a/src/entities/user.entity.ts b/src/entities/user.entity.ts index 914d073c..c3733040 100644 --- a/src/entities/user.entity.ts +++ b/src/entities/user.entity.ts @@ -181,4 +181,10 @@ export class User { @OneToMany(() => SeenRelease, (seenRelease) => seenRelease.user, { onDelete: 'CASCADE' }) seenReleases: SeenRelease[]; + + @Column({ type: 'tsvector', select: false, nullable: true }) + search_vector?: any; + + @Column({ type: 'text', nullable: true, select: false }) + unaccented_email?: string; } diff --git a/src/migrations/2024/10/1730047331380-addTrigramUser.ts b/src/migrations/2024/10/1730047331380-addTrigramUser.ts new file mode 100644 index 00000000..3d4af7d7 --- /dev/null +++ b/src/migrations/2024/10/1730047331380-addTrigramUser.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTrigramUser1730047331380 implements MigrationInterface { + name = 'AddTrigramUser1730047331380'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS unaccent`); + + // Add unaccented_email column (no generated expression) + await queryRunner.query(`ALTER TABLE "user" ADD COLUMN "unaccented_email" TEXT`); + + // Populate unaccented_email initially + await queryRunner.query(`UPDATE "user" SET "unaccented_email" = unaccent(email)`); + + // Create trigger function for unaccented_email updates + await queryRunner.query(` + CREATE OR REPLACE FUNCTION update_unaccented_email() + RETURNS TRIGGER AS $$ + BEGIN + NEW.unaccented_email = unaccent(NEW.email); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `); + + // Apply trigger to update unaccented_email on email change + await queryRunner.query(` + CREATE TRIGGER trigger_update_unaccented_email + BEFORE INSERT OR UPDATE OF email ON "user" + FOR EACH ROW EXECUTE FUNCTION update_unaccented_email(); + `); + + // Create index for trigram search on unaccented_email + await queryRunner.query( + `CREATE INDEX "IDX_USER_UNACCENTED_EMAIL" ON "user" USING gin (unaccented_email gin_trgm_ops)`, + ); + + // Optional: Create index for text search if required + await queryRunner.query( + `CREATE INDEX "IDX_USER_SEARCH_VECTOR" ON "user" USING gin (to_tsvector('simple', coalesce(email, '')))`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_USER_SEARCH_VECTOR"`); + await queryRunner.query(`DROP INDEX "IDX_USER_UNACCENTED_EMAIL"`); + await queryRunner.query(`DROP TRIGGER trigger_update_unaccented_email ON "user"`); + await queryRunner.query(`DROP FUNCTION update_unaccented_email`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "unaccented_email"`); + } +} diff --git a/src/migrations/2024/10/1730048056148-addVectorUser.ts b/src/migrations/2024/10/1730048056148-addVectorUser.ts new file mode 100644 index 00000000..e26c7689 --- /dev/null +++ b/src/migrations/2024/10/1730048056148-addVectorUser.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddVectorUser1730048056148 implements MigrationInterface { + name = 'AddVectorUser1730048056148'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS unaccent`); + + // Add search_vector column for full-text search on email + await queryRunner.query( + `ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "search_vector" TSVECTOR GENERATED ALWAYS AS (to_tsvector('simple', coalesce(email, ''))) STORED`, + ); + + // Check and create index if not exists + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'IDX_USER_SEARCH_VECTOR') THEN + CREATE INDEX "IDX_USER_SEARCH_VECTOR" ON "user" USING gin (search_vector); + END IF; + END + $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_USER_SEARCH_VECTOR"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "search_vector"`); + } +} diff --git a/src/modules/admin/admin.controller.ts b/src/modules/admin/admin.controller.ts index dc056904..30dd1dc8 100644 --- a/src/modules/admin/admin.controller.ts +++ b/src/modules/admin/admin.controller.ts @@ -67,6 +67,7 @@ import { CreateThemeReqDto } from './dto/request/createThemeReq.dto'; import { CreateItemReqDto } from './dto/request/createItemReq.dto'; import { ItemsResDto } from '../home/dto/response/itemsRes.dto'; import { ThemesResDto } from '../home/dto/response/themesRes.dto'; +import { SummaryEssaysResDto } from '../essay/dto/response/SummaryEssaysRes.dto'; @ApiTags('Admin-auth') @Controller('admin-auth') @@ -436,6 +437,8 @@ export class AdminInfoController { **쿼리 파라미터:** - \`activated\`: 어드민의 활성 상태 (true 또는 false, 선택적) + - \`page\`: 페이지 번호 (기본값: 1) + - \`limit\`: 한 페이지에 표시할 신고 수 (기본값: 10) **동작 과정:** 1. 선택적 쿼리 파라미터 \`activated\`를 기반으로 어드민을 조회합니다. @@ -447,8 +450,14 @@ export class AdminInfoController { }) @ApiResponse({ status: 200, type: AdminsResDto }) @ApiQuery({ name: 'activated', required: false }) - async getAdmins(@Query('activated', OptionalBoolPipe) activated?: boolean) { - return this.adminService.getAdmins(activated); + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'limit', required: false }) + async getAdmins( + @Query('page', new PagingParseIntPipe(1)) page: number, + @Query('limit', new PagingParseIntPipe(10)) limit: number, + @Query('activated', OptionalBoolPipe) activated?: boolean, + ) { + return this.adminService.getAdmins(page, limit, activated); } @Get('inactive') @@ -470,6 +479,18 @@ export class AdminInfoController { return this.adminService.getInactiveAdmins(); } + @Get('my') + @ApiOperation({ + summary: '어드민 본인조회', + description: ` + 어드민 자신의 상세 정보를 조회합니다. + `, + }) + @ApiResponse({ status: 200, type: AdminResDto }) + async getMyAdmin(@Req() req: ExpressRequest) { + return this.adminService.getAdmin(req.user.id); + } + @Get(':adminId') @ApiOperation({ summary: '어드민 상세조회', @@ -1056,25 +1077,26 @@ export class AdminManagementController { return this.adminService.getUser(userId); } - @Get('users/search/:email') + @Get('users/search/:keyword') @ApiOperation({ summary: '유저 검색 (이메일)', description: ` 관리자 권한으로 이메일로 유저를 검색합니다.. **경로 파라미터:** - - \`email\`: 조회할 유저의 고유 이메일 - - **동작 과정:** - 1. 해당 유저의 상세 정보를 조회합니다. + - \`keyword\`: 검색할 이메일 **주의 사항:** - 관리자 권한이 필요합니다. `, }) @ApiResponse({ status: 200, type: UserDetailResDto }) - async searchUser(@Param('email') email: string) { - return this.adminService.searchUser(email); + async searchUsers( + @Param('keyword') email: string, + @Query('page', new PagingParseIntPipe(1)) page: number, + @Query('limit', new PagingParseIntPipe(10)) limit: number, + ) { + return this.adminService.searchUsers(email, page, limit); } @Put('users/:userId') @@ -1105,6 +1127,42 @@ export class AdminManagementController { return this.adminService.updateUser(req.user.id, userId, data); } + @Get('essays/search/:keyword') + @ApiOperation({ + summary: '에세이 검색', + description: ` + 키워드를 기반으로 에세이를 검색합니다. + + **쿼리 파라미터:** + - \`keyword\`: 검색할 키워드(필수) + - \`page\`: 페이지 번호 (기본값: 1) + - \`limit\`: 한 페이지에 보여줄 에세이 수 (기본값: 10) + + **동작 과정:** + 1. 주어진 키워드를 제목 또는 내용에서 검색합니다. + 2. 검색된 결과에서 페이징 처리를 합니다. + 3. 결과는 제목 또는 내용에 키워드가 포함된 에세이의 슬라이스된 내용을 반환합니다. + + **주의 사항:** + - 검색 키워드는 URL 인코딩된 문자열이어야 합니다. + - 응답에는 제목 또는 본문에 키워드가 포함된 에세이만 포함됩니다. + `, + }) + @ApiQuery({ name: 'keyword', required: true }) + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'limit', required: false }) + @ApiResponse({ + status: 200, + type: SummaryEssaysResDto, + }) + async searchEssays( + @Query('keyword') keyword: string, + @Query('page', new PagingParseIntPipe(1)) page: number, + @Query('limit', new PagingParseIntPipe(10)) limit: number, + ) { + return this.adminService.searchEssays(keyword, page, limit); + } + @Get('essays') @ApiOperation({ summary: '에세이 리스트 조회', diff --git a/src/modules/admin/admin.repository.ts b/src/modules/admin/admin.repository.ts index 44ed7c62..1429998e 100644 --- a/src/modules/admin/admin.repository.ts +++ b/src/modules/admin/admin.repository.ts @@ -195,11 +195,14 @@ export class AdminRepository { return this.adminRepository.findOne({ where: { name: name } }); } - async findAdmins(activated: boolean) { - if (activated !== undefined) { - return this.adminRepository.find({ where: { activated: activated } }); - } - return this.adminRepository.find(); + async findAdmins(activated: boolean, page?: number, limit?: number) { + const [admins, total] = await this.adminRepository.findAndCount({ + where: activated !== undefined ? { activated } : {}, + skip: (page - 1) * limit, + take: limit, + order: { createdDate: 'DESC' }, + }); + return { admins, total }; } async findAdmin(adminId: number) { diff --git a/src/modules/admin/admin.service.ts b/src/modules/admin/admin.service.ts index 65887fa0..d1a35b91 100644 --- a/src/modules/admin/admin.service.ts +++ b/src/modules/admin/admin.service.ts @@ -67,6 +67,8 @@ import { CreateItemReqDto } from './dto/request/createItemReq.dto'; import { Item } from '../../entities/item.entity'; import { ItemResDto } from '../home/dto/response/itemRes.dto'; import { ThemeResDto } from '../home/dto/response/themeRes.dto'; +import { UserResDto } from '../user/dto/response/userRes.dto'; +import { EssayService } from '../essay/essay.service'; @Injectable() export class AdminService { @@ -80,6 +82,7 @@ export class AdminService { private readonly alertService: AlertService, private readonly geulroquisService: GeulroquisService, private readonly geulroquisRepository: GeulroquisRepository, + private readonly essayService: EssayService, private readonly nicknameService: NicknameService, private readonly mailService: MailService, private readonly awsService: AwsService, @@ -506,10 +509,17 @@ export class AdminService { return { users: userDtos, totalPage, page, total }; } - async searchUser(email: string) { - const user = await this.userRepository.findUserByEmail(email); + async searchUsers(keyword: string, page: number, limit: number) { + const { users, total } = await this.userRepository.searchUsers(keyword, page, limit); - return this.utilsService.transformToDto(UserDetailResDto, user); + const totalPage: number = Math.ceil(total / limit); + + const usersDto = this.utilsService.transformToDto(UserResDto, users); + return { usersDto, total, page, totalPage }; + } + + async searchEssays(keyword: string, page: number, limit: number) { + return await this.essayService.searchEssays(keyword, page, limit); } async getUser(userId: number) { @@ -552,15 +562,7 @@ export class AdminService { const { essays, total } = await this.essayRepository.findFullEssays(page, limit); const totalPage: number = Math.ceil(total / limit); - const data = essays.map((essay) => ({ - ...essay, - authorId: essay.author.id, - storyId: essay.story?.id ?? null, - reportCount: essay?.reports ? essay.reports.length : null, - reviewCount: essay?.createdDate ? essay.reviews.length : null, - })); - - const essaysDto = this.utilsService.transformToDto(EssayInfoResDto, data); + const essaysDto = this.utilsService.transformToDto(EssayInfoResDto, essays); return { essays: essaysDto, total, page, totalPage }; } @@ -649,11 +651,14 @@ export class AdminService { return !admin ? null : admin; } - async getAdmins(activated?: boolean) { - const admins = await this.adminRepository.findAdmins(activated); + async getAdmins(page: number, limit: number, activated?: boolean) { + const { admins, total } = await this.adminRepository.findAdmins(activated, page, limit); + + const totalPage: number = Math.ceil(total / limit); + const adminsDto = this.utilsService.transformToDto(AdminResDto, admins); - return { admins: adminsDto }; + return { admins: adminsDto, total, page, totalPage }; } async getAdmin(adminId: number) { diff --git a/src/modules/admin/dto/response/adminsRes.dto.ts b/src/modules/admin/dto/response/adminsRes.dto.ts index 4713a98f..6f7cb34f 100644 --- a/src/modules/admin/dto/response/adminsRes.dto.ts +++ b/src/modules/admin/dto/response/adminsRes.dto.ts @@ -1,7 +1,25 @@ import { ApiProperty } from '@nestjs/swagger'; import { AdminResDto } from './adminRes.dto'; +import { Expose } from 'class-transformer'; +import { IsNumber } from 'class-validator'; export class AdminsResDto { @ApiProperty({ type: [AdminResDto] }) + @Expose() admins: AdminResDto[]; + + @ApiProperty() + @IsNumber() + @Expose() + page: number; + + @ApiProperty() + @IsNumber() + @Expose() + total: number; + + @ApiProperty() + @IsNumber() + @Expose() + totalPage: number; } diff --git a/src/modules/admin/dto/response/essayInfoRes.dto.ts b/src/modules/admin/dto/response/essayInfoRes.dto.ts index 0e08c8cc..d85c95aa 100644 --- a/src/modules/admin/dto/response/essayInfoRes.dto.ts +++ b/src/modules/admin/dto/response/essayInfoRes.dto.ts @@ -1,115 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Expose } from 'class-transformer'; -import { - IsBoolean, - IsDate, - IsEnum, - IsLatitude, - IsLongitude, - IsNotEmpty, - IsNumber, - IsString, -} from 'class-validator'; -import { EssayStatus } from '../../../../common/types/enum.types'; +import { Expose, Type } from 'class-transformer'; +import { EssayDto } from '../../../essay/dto/essay.dto'; +import { UserDto } from '../../../user/dto/user.dto'; -export class EssayInfoResDto { +export class EssayInfoResDto extends EssayDto { @ApiProperty() @Expose() - @IsNumber() - @IsNotEmpty() - id: number; - - @ApiProperty() - @Expose() - @IsString() - @IsNotEmpty() - title: string; - - @ApiProperty() - @Expose() - @IsString() - @IsNotEmpty() - content: string; - - @ApiProperty() - @Expose() - @IsNumber() - linkedOutGauge: number; - - // @IsLatitude() - // @Expose() - // latitude: number; - // - // @IsLongitude() - // @Expose() - // longitude: number; - - @IsString() - @Expose() - location: string; - - @ApiProperty() - @Expose() - @IsDate() - createdDate: Date; - - @ApiProperty() - @Expose() - @IsDate() - updatedDate: Date; - - @ApiProperty() - @Expose() - @IsString() - thumbnail: string; - - @ApiProperty() - @Expose() - @IsBoolean() - @IsNotEmpty() - bookmarks: boolean; - - @ApiProperty() - @Expose() - @IsNumber() - @IsNotEmpty() - views: number; - - @ApiProperty({ type: 'enum' }) - @IsNotEmpty() - @IsEnum(EssayStatus) - @Expose() - status: EssayStatus; - - @ApiProperty() - @Expose() - @IsString() - @IsNotEmpty() - device: string; - - @ApiProperty() - @Expose() - @IsNumber() - @IsNotEmpty() - authorId: number; - - @ApiProperty() - @Expose() - @IsNumber() - storyId: number; - - @ApiProperty() - @Expose() - @IsNumber() - reportCount: number; - - @ApiProperty() - @Expose() - @IsNumber() - reviewCount: number; - - @ApiProperty() - @Expose() - @IsNumber() - trandScore: number; + @Type(() => UserDto) + author: UserDto; } diff --git a/src/modules/essay/dto/essay.dto.ts b/src/modules/essay/dto/essay.dto.ts index bb25c22f..cd6c826e 100644 --- a/src/modules/essay/dto/essay.dto.ts +++ b/src/modules/essay/dto/essay.dto.ts @@ -1,14 +1,5 @@ import { Expose } from 'class-transformer'; -import { - IsBoolean, - IsDate, - IsEnum, - IsLatitude, - IsLongitude, - IsNumber, - IsOptional, - IsString, -} from 'class-validator'; +import { IsBoolean, IsDate, IsEnum, IsNumber, IsOptional, IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; import { EssayStatus } from '../../../common/types/enum.types'; @@ -55,14 +46,6 @@ export class EssayDto { @IsOptional() status?: EssayStatus; - // @IsLatitude() - // @Expose() - // latitude: number; - // - // @IsLongitude() - // @Expose() - // longitude: number; - @IsString() @Expose() location: string; diff --git a/src/modules/essay/essay.repository.ts b/src/modules/essay/essay.repository.ts index 79bd5207..dea76a5c 100644 --- a/src/modules/essay/essay.repository.ts +++ b/src/modules/essay/essay.repository.ts @@ -441,20 +441,13 @@ export class EssayRepository { async findFullEssays(page: number, limit: number) { const queryBuilder = this.essayRepository .createQueryBuilder('essay') - .leftJoinAndSelect('essay.story', 'story') - .leftJoinAndSelect('essay.reports', 'reports') - .leftJoinAndSelect('essay.reviews', 'reviews') - .leftJoinAndSelect( - 'essay.author', - 'author', - 'author.deletedDate IS NOT NULL OR author.deletedDate IS NULL', - ) + .leftJoinAndSelect('essay.author', 'author', 'author.deletedDate IS NULL') .withDeleted() .orderBy('essay.createdDate', 'DESC') .skip((page - 1) * limit) .take(limit); - const [essays, total] = await queryBuilder.withDeleted().getManyAndCount(); + const [essays, total] = await queryBuilder.getManyAndCount(); return { essays, total }; } diff --git a/src/modules/user/user.repository.ts b/src/modules/user/user.repository.ts index 304232e6..79b7db8c 100644 --- a/src/modules/user/user.repository.ts +++ b/src/modules/user/user.repository.ts @@ -145,4 +145,33 @@ export class UserRepository { { reputation: () => `reputationScore + ${reputation}` }, ); } + + async searchUsers(keyword: string, page: number, limit: number) { + const offset = (page - 1) * limit; + const useTrigramSearch = keyword.length >= 3; + + const query = this.userRepository.createQueryBuilder('user'); + + if (useTrigramSearch) { + query + .addSelect(`similarity(unaccented_email, :keyword)`, 'relevance') + .where('user.deletedDate IS NULL AND unaccented_email ILIKE :wildcardKeyword', { + keyword, + wildcardKeyword: `%${keyword}%`, + }); + } else { + query + .addSelect(`ts_rank_cd(search_vector, plainto_tsquery('simple', :keyword))`, 'relevance') + .where( + 'user.deletedDate IS NULL AND (search_vector @@ plainto_tsquery(:keyword) OR unaccented_email ILIKE :wildcardKeyword)', + { keyword, wildcardKeyword: `%${keyword}%` }, + ); + } + + query.orderBy('relevance', 'DESC').offset(offset).limit(limit); + + const [users, total] = await query.getManyAndCount(); + + return { users, total }; + } }