Skip to content

Commit

Permalink
feat: 관리자 기능 개선 및 추가 (#459)
Browse files Browse the repository at this point in the history
* 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: 유저 및 에세이 유사도 검색 추가
  • Loading branch information
daechan-jo authored Oct 27, 2024
1 parent 77ab555 commit 805af60
Show file tree
Hide file tree
Showing 12 changed files with 241 additions and 166 deletions.
2 changes: 2 additions & 0 deletions src/config/typeorm.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/entities/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
52 changes: 52 additions & 0 deletions src/migrations/2024/10/1730047331380-addTrigramUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddTrigramUser1730047331380 implements MigrationInterface {
name = 'AddTrigramUser1730047331380';

public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}
30 changes: 30 additions & 0 deletions src/migrations/2024/10/1730048056148-addVectorUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddVectorUser1730048056148 implements MigrationInterface {
name = 'AddVectorUser1730048056148';

public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`DROP INDEX "IDX_USER_SEARCH_VECTOR"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "search_vector"`);
}
}
76 changes: 67 additions & 9 deletions src/modules/admin/admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -436,6 +437,8 @@ export class AdminInfoController {
**쿼리 파라미터:**
- \`activated\`: 어드민의 활성 상태 (true 또는 false, 선택적)
- \`page\`: 페이지 번호 (기본값: 1)
- \`limit\`: 한 페이지에 표시할 신고 수 (기본값: 10)
**동작 과정:**
1. 선택적 쿼리 파라미터 \`activated\`를 기반으로 어드민을 조회합니다.
Expand All @@ -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')
Expand All @@ -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: '어드민 상세조회',
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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: '에세이 리스트 조회',
Expand Down
13 changes: 8 additions & 5 deletions src/modules/admin/admin.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
35 changes: 20 additions & 15 deletions src/modules/admin/admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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) {
Expand Down
18 changes: 18 additions & 0 deletions src/modules/admin/dto/response/adminsRes.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading

0 comments on commit 805af60

Please sign in to comment.