1. cursorPageNation Dto 정하기

import { IsInt, IsObject, IsOptional, IsString } from 'class-validator';

export enum OrderField {
  // string 맵핑 값에 무조건 소문자로 정의 postgreSQL에서 소문자만 인식함
  REVIEW_COUNT = 'review_count', // 리뷰 수
  AVERAGE_RATING = 'average_rating', // 평균 평점
  EXPERIENCE = 'experience', // 경력
  CONFIRMED_ESTIMATE_COUNT = 'confirmed_estimate_count', // 확정 견적 수
  CREATED_AT = 'created_at', // 생성일 DESC 최신순
}

export enum OrderDirection {
  ASC = 'ASC', // 오름차순
  DESC = 'DESC', // 내림차순
}

export interface OrderItemMap {
  field: OrderField; // 추가적인 순서가 있을 경우 여기에 정의
  direction: OrderDirection;
}

export class CursorPaginationDto {
  @IsString()
  @IsOptional()
  cursor?: string; // 처음 조회할 땐 안넣음

  @IsObject()
  order: OrderItemMap; // 무조건 order 순서 정해줘야 함

  @IsInt()
  @IsOptional()
  take?: number = 5; // 기본은 5이지만 2또는 3으로 하고 싶으면 body값에 넣어주면 된다!
}

// 처음 조회할 때 body값 예시
{
	// 최신순으로 정렬
  order: {
		field: "created_at",
		direction: "DESC"
	}
	// 3개만 가져오고 싶을 경우
	take: 3
}
// CursorPaginationDto extends 필수
export class GetMoverProfilesDto extends CursorPaginationDto {
	// 아래 예시는 필터링에 대한 추가 속성임.
  @IsObject()
  @HasAtLeastOneTrue({
    message: '서비스 유형은 최소 하나 이상 선택되어야 합니다.',
  })
  serviceType: Partial<ServiceTypeMap> = defaultServiceTypeMap;

  @IsObject()
  @HasAtLeastOneTrue({
    message: '서비스 지역은 최소 하나 이상 선택되어야 합니다.',
  })
  serviceRegion: Partial<ServiceRegionMap> = defaultServiceRegionMap;
}

// 필터링이 없는 경우 그냥 extends에서만 끝내고 아래 코드와 같이 만듬
export class Dto이름 extends CursorPaginationDto {}

// 그래서 이 dto를 어디서 사용하는가?

2. 서비스에서 사용하는 방법

// 기사님 목록 조회
// 여기서 사용한다! controller에서 받은 dto를 여기에 넣는다.
async findAll(user: UserInfo, dto: GetMoverProfilesDto) {
		// 필터링 관련 코드
		// 집계 뷰테이블 조인하는 코드 

		// 커서기반 페이지 네이션을 사용할려면 쿼리빌더를 사용해야 한다!
		// 이 코드는 예시
    // 우리가 늘 findOne where select하는 부분을 쿼리빌더로 사용해서 조회한다
		const qb = this.moverProfileRepository
      .createQueryBuilder(MOVER_PROFILE_TABLE)
      .select(MOVER_PROFILE_LIST_SELECT);

    // 커서 기반 페이징 적용
    const { nextCursor } =
      await this.commonService.applyCursorPaginationParamsToQb(qb, dto);

		// 이 예시에서는 집계 뷰테이블을 조인했기에 저 함수를 사용한 것이고
		// 뷰테이블이 없는 경우, .getMany() .getRawMany() 등의 함수를 사용하여 응답값을 가져옴
    const { entities, raw: aggregates } = await qb.getRawAndEntities();

    // 엔티티와 뷰 데이터를 병합하는 코드

    // 고객일 경우, 해당 기사에게 지정 견적 요청을 했는지 확인하는 코드

    const count = await qb.getCount(); // 필요시 개수를 가져오고 싶으면.. 
    //근데 .getManyAndCount() 이런 함수도 있음

		// 반환시 nextCursor 필수!
    return { movers: moversWithAggregates, count, nextCursor, targetMoverIds };
  }

3. applyCursorPaginationParamsToQb 이 함수는 어떻게 구성되어 있는가?

async applyCursorPaginationParamsToQb<T>(
    qb: SelectQueryBuilder<T>,
    dto: CursorPaginationDto,
  ) {
    const { cursor, take } = dto;
    let { order } = dto; // dto에서 order 추출

		// cursor가 있는 경우 즉 그 다음 조회인 경우
    if (cursor) {
      const decodedCursor = Buffer.from(cursor, 'base64').toString('utf-8');
      const cursorObj = JSON.parse(decodedCursor) as {
        values: Record<string, any>;
        order: OrderItemMap;
      };

      const { values } = cursorObj;
      order = cursorObj.order; // cursorObj에서 order 추출

      const { field, direction } = order;
      const orderAlias = this.getOrderFieldAlias(qb, field);
      const cursorId = values.id;
      const cursorValue = values[field];

      const operator = direction === OrderDirection.DESC ? '<' : '>';
      const equals = '=';

      qb.andWhere(
        `(${orderAlias} ${operator} :cursorValue OR (${orderAlias} ${equals} :cursorValue AND ${qb.alias}.id ${operator} :cursorId))`,
        {
          cursorValue,
          cursorId,
        },
      );
    }

    const { field, direction } = order;

    if (direction !== OrderDirection.ASC && direction !== OrderDirection.DESC) {
      throw new BadRequestException('정렬 방향은 ASC 또는 DESC 여야 합니다.');
    }

    const orderAlias = this.getOrderFieldAlias(qb, field);

    qb.addOrderBy(orderAlias, direction); // 정렬 기준 필드
    qb.addOrderBy(`${qb.alias}.id`, direction); // 항상 id도 정렬에 포함

    /**
     * Q) qb.addOrderBy(`${qb.alias}.id`, direction); 이거 왜 해용 ?.?
     * A) 정렬 기준 값이 동일할 때, 중복 제거 및 페이지네이션 정확도를 위한 보조 정렬로서,
     *    id를 추가로 정렬 기준에 포함시킴
     *    예: experience 기준 DESC 정렬 시 경험치가 동일한 mover가 여러 명일 수 있으므로,
     *        mover.id를 추가 정렬 기준으로 설정해 고유한 정렬 순서를 보장함
     */

    qb.take(take);

    const results = await qb.getMany();
    const nextCursor = this.generateNextCursor(results, order);

    return { qb, nextCursor };
  }

private generateNextCursor<T>(
    results: T[],
    order: OrderItemMap,
  ): string | null {
    if (results.length === 0) return null;

    /**
     * cursorObj =
     * {
     *    values: [{
     *      id: 27,
     *      field: value, // 정렬 필드값
     *    }, ...],
     *    order: {
     *      field: MoverOrderField,
     *      direction: OrderDirection,
     *    }
     * }
     */

    const lastItem = results.at(-1); // 마지막 요소 가져오기
    const { field } = order; // 정렬 기준이 되는 필드 가져오기
    const value = lastItem[field]; // 마지막 요소의 정렬 기준이 되는 필드 값 가져오기

    const cursorObj = {
			// 마지막 요소에 대한 값들 널기
      values: {
        id: lastItem['id'],
        [field]: value,
      },
      order,
    };

    const nextCursor = Buffer.from(JSON.stringify(cursorObj)).toString(
      'base64',
    ); // 커서 객체를 인코딩 함 그래서 문자열로 보냄 (보안성 때문에)

    return nextCursor; // 만들어진 커서 문자열 반환
  }

// order filed에 있던 필드를 진짜 db의 alias로 대체해주는 함수
private getOrderFieldAlias<T>(
    qb: SelectQueryBuilder<T>,
    field: OrderField,
  ): string {
    // 정렬 필드에 따라 쿼리 빌더에 조인 및 선택 추가
    // 추가적으로 필요한 경우, OrderField enum에 추가 정의 후 아래 switch 문에 추가

    // 뷰가 조인되어 있는지 확인 (join 정보에서 mover_profile_view가 있는지)
    const joinNames = qb.expressionMap.joinAttributes.map(
      (join) => join.alias?.name,
    );
    const isViewJoined = joinNames.includes(MOVER_PROFILE_VIEW_TABLE);

    switch (field) {
      // MoverProfile 기준 정렬 필드
      case OrderField.REVIEW_COUNT:
      case OrderField.AVERAGE_RATING:
      case OrderField.CONFIRMED_ESTIMATE_COUNT:
        if (!isViewJoined) {
          // 뷰가 join 되어있지 않으면 예외 처리
          throw new BadRequestException(
            `${field} 정렬 필드를 사용하려면 뷰(${MOVER_PROFILE_VIEW_TABLE})가 조인되어야 합니다.`,
          );
        }
        return `${MOVER_PROFILE_VIEW_TABLE}.${field}`;

      case OrderField.EXPERIENCE:
        return `${MOVER_PROFILE_TABLE}.${field}`; // experience는 mover스키마에서만 필요
      case OrderField.CREATED_AT:
        return `${qb.alias}.${field}`; // 기본 테이블의 created_at 필드

      default:
        throw new BadRequestException('올바른 정렬 필드를 선택해주세요.');
    }
  }