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 }; + } }