Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.example.solidconnection.admin.controller;

import com.example.solidconnection.admin.dto.MentorApplicationSearchCondition;
import com.example.solidconnection.admin.dto.MentorApplicationSearchResponse;
import com.example.solidconnection.admin.service.AdminMentorApplicationService;
import com.example.solidconnection.common.response.PageResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RequestMapping("/admin/mentor-applications")
@RestController
@Slf4j
public class AdminMentorApplicationController {
private final AdminMentorApplicationService adminMentorApplicationService;

@GetMapping
public ResponseEntity<PageResponse<MentorApplicationSearchResponse>> searchMentorApplications(
@Valid @ModelAttribute MentorApplicationSearchCondition mentorApplicationSearchCondition,
Pageable pageable
) {
Page<MentorApplicationSearchResponse> page = adminMentorApplicationService.searchMentorApplications(
mentorApplicationSearchCondition,
pageable
);

return ResponseEntity.ok(PageResponse.of(page));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.example.solidconnection.admin.dto;

import com.example.solidconnection.mentor.domain.MentorApplicationStatus;
import java.time.ZonedDateTime;

public record MentorApplicationResponse(
long id,
String region,
String country,
String university,
String mentorProofUrl,
MentorApplicationStatus mentorApplicationStatus,
String rejectedReason,
ZonedDateTime createdAt,
ZonedDateTime approvedAt
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.example.solidconnection.admin.dto;

import com.example.solidconnection.mentor.domain.MentorApplicationStatus;
import java.time.LocalDate;

public record MentorApplicationSearchCondition(
MentorApplicationStatus mentorApplicationStatus,
String keyword,
LocalDate createdAt
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.example.solidconnection.admin.dto;

public record MentorApplicationSearchResponse(
SiteUserResponse siteUserResponse,
MentorApplicationResponse mentorApplicationResponse
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.example.solidconnection.admin.service;

import com.example.solidconnection.admin.dto.MentorApplicationSearchCondition;
import com.example.solidconnection.admin.dto.MentorApplicationSearchResponse;
import com.example.solidconnection.mentor.repository.MentorApplicationRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Service
public class AdminMentorApplicationService {

private final MentorApplicationRepository mentorApplicationRepository;

@Transactional(readOnly = true)
public Page<MentorApplicationSearchResponse> searchMentorApplications(
MentorApplicationSearchCondition mentorApplicationSearchCondition,
Pageable pageable
) {
return mentorApplicationRepository.searchMentorApplications(mentorApplicationSearchCondition, pageable);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.time.ZonedDateTime;
import java.util.Collections;
import java.util.EnumSet;
import java.util.Set;
Expand Down Expand Up @@ -66,6 +67,9 @@ public class MentorApplication extends BaseEntity {
@Enumerated(EnumType.STRING)
private MentorApplicationStatus mentorApplicationStatus;

@Column
private ZonedDateTime approvedAt;

private static final Set<ExchangeStatus> ALLOWED =
Collections.unmodifiableSet(EnumSet.of(ExchangeStatus.STUDYING_ABROAD, ExchangeStatus.AFTER_EXCHANGE));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

import com.example.solidconnection.mentor.domain.MentorApplication;
import com.example.solidconnection.mentor.domain.MentorApplicationStatus;
import com.example.solidconnection.mentor.repository.custom.MentorApplicationFilterRepository;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MentorApplicationRepository extends JpaRepository<MentorApplication, Long> {
public interface MentorApplicationRepository extends JpaRepository<MentorApplication, Long> , MentorApplicationFilterRepository {

boolean existsBySiteUserIdAndMentorApplicationStatusIn(long siteUserId, List<MentorApplicationStatus> mentorApplicationStatuses);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.example.solidconnection.mentor.repository.custom;

import com.example.solidconnection.admin.dto.MentorApplicationSearchCondition;
import com.example.solidconnection.admin.dto.MentorApplicationSearchResponse;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public interface MentorApplicationFilterRepository {

Page<MentorApplicationSearchResponse> searchMentorApplications(MentorApplicationSearchCondition mentorApplicationSearchCondition, Pageable pageable);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package com.example.solidconnection.mentor.repository.custom;

import static com.example.solidconnection.location.country.domain.QCountry.country;
import static com.example.solidconnection.location.region.domain.QRegion.region;
import static com.example.solidconnection.mentor.domain.QMentorApplication.mentorApplication;
import static com.example.solidconnection.siteuser.domain.QSiteUser.siteUser;
import static com.example.solidconnection.university.domain.QUniversity.university;
import static org.springframework.util.StringUtils.hasText;

import com.example.solidconnection.admin.dto.MentorApplicationResponse;
import com.example.solidconnection.admin.dto.MentorApplicationSearchCondition;
import com.example.solidconnection.admin.dto.MentorApplicationSearchResponse;
import com.example.solidconnection.admin.dto.SiteUserResponse;
import com.example.solidconnection.mentor.domain.MentorApplicationStatus;
import com.querydsl.core.types.ConstructorExpression;
import com.querydsl.core.types.Projections;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;

@Repository
public class MentorApplicationFilterRepositoryImpl implements MentorApplicationFilterRepository {

private static final ZoneId SYSTEM_ZONE_ID = ZoneId.systemDefault();

private static final ConstructorExpression<SiteUserResponse> SITE_USER_RESPONSE_PROJECTION =
Projections.constructor(
SiteUserResponse.class,
siteUser.id,
siteUser.nickname,
siteUser.profileImageUrl
);

private static final ConstructorExpression<MentorApplicationResponse> MENTOR_APPLICATION_RESPONSE_PROJECTION =
Projections.constructor(
MentorApplicationResponse.class,
mentorApplication.id,
region.koreanName,
country.koreanName,
university.koreanName,
mentorApplication.mentorProofUrl,
mentorApplication.mentorApplicationStatus,
mentorApplication.rejectedReason,
mentorApplication.createdAt,
mentorApplication.approvedAt
);

private static final ConstructorExpression<MentorApplicationSearchResponse> MENTOR_APPLICATION_SEARCH_RESPONSE_PROJECTION =
Projections.constructor(
MentorApplicationSearchResponse.class,
SITE_USER_RESPONSE_PROJECTION,
MENTOR_APPLICATION_RESPONSE_PROJECTION
);

private final JPAQueryFactory queryFactory;

@Autowired
public MentorApplicationFilterRepositoryImpl(EntityManager em) {
this.queryFactory = new JPAQueryFactory(em);
}

@Override
public Page<MentorApplicationSearchResponse> searchMentorApplications(MentorApplicationSearchCondition condition, Pageable pageable) {
List<MentorApplicationSearchResponse> content = queryFactory
.select(MENTOR_APPLICATION_SEARCH_RESPONSE_PROJECTION)
.from(mentorApplication)
.join(siteUser).on(mentorApplication.siteUserId.eq(siteUser.id))
.leftJoin(university).on(mentorApplication.universityId.eq(university.id))
.leftJoin(region).on(university.region.eq(region))
.leftJoin(country).on(university.country.eq(country))
.where(
verifyMentorStatusEq(condition.mentorApplicationStatus()),
keywordContains(condition.keyword()),
createdAtEq(condition.createdAt())
)
.orderBy(mentorApplication.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();

Long totalCount = createCountQuery(condition).fetchOne();

return new PageImpl<>(content, pageable, totalCount != null ? totalCount : 0L);
}

private JPAQuery<Long> createCountQuery(MentorApplicationSearchCondition condition) {
JPAQuery<Long> query = queryFactory
.select(mentorApplication.count())
.from(mentorApplication);

String keyword = condition.keyword();

if (hasText(keyword)) {
query.join(siteUser).on(mentorApplication.siteUserId.eq(siteUser.id))
.leftJoin(university).on(mentorApplication.universityId.eq(university.id))
.leftJoin(region).on(university.region.eq(region))
.leftJoin(country).on(university.country.eq(country));
}

return query.where(
verifyMentorStatusEq(condition.mentorApplicationStatus()),
keywordContains(condition.keyword()),
createdAtEq(condition.createdAt())
);
Comment on lines +110 to +114

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

함수로 나눠져 있어서 깔끔하네요!
조건이 더 복잡해지면 BooleanBuilder를 사용하는 것도 좋을 것 같습니다

}

private BooleanExpression verifyMentorStatusEq(MentorApplicationStatus status) {
return status != null ? mentorApplication.mentorApplicationStatus.eq(status) : null;
}

private BooleanExpression keywordContains(String keyword) {
if (!hasText(keyword)) {
return null;
}

return siteUser.nickname.containsIgnoreCase(keyword)
.or(university.koreanName.containsIgnoreCase(keyword))
.or(region.koreanName.containsIgnoreCase(keyword))
.or(country.koreanName.containsIgnoreCase(keyword));
Comment on lines +126 to +129

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

개인적인 생각으로는 일단 컬럼별 우선순위 없이 현재 방식을 유지해도 될 것 같습니다!

}

private BooleanExpression createdAtEq(LocalDate createdAt) {
if (createdAt == null) {
return null;
}

LocalDateTime startOfDay = createdAt.atStartOfDay();
LocalDateTime endOfDay = createdAt.plusDays(1).atStartOfDay().minusNanos(1);

return mentorApplication.createdAt.between(
startOfDay.atZone(SYSTEM_ZONE_ID),
endOfDay.atZone(SYSTEM_ZONE_ID)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
ALTER TABLE mentor_application
ADD COLUMN approved_at DATETIME(6);

UPDATE mentor_application
SET approved_at = NOW()
WHERE mentor_application_status = 'APPROVED'
AND approved_at IS NULL;
Comment on lines +1 to +7
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

마이그레이션 파일명에 오타가 있습니다.

파일명이 V39__add_approved_at_mentro_application.sql인데, mentromentor의 오타로 보입니다.

다음과 같이 파일명을 수정해주세요:

-V39__add_approved_at_mentro_application.sql
+V39__add_approved_at_mentor_application.sql

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/main/resources/db/migration/V39__add_approved_at_mentro_application.sql
lines 1-7 the filename contains a typo ("mentro" → "mentor"); rename the file to
V39__add_approved_at_mentor_application.sql (use git mv to preserve history),
keep the file contents unchanged, and update any references or build/migration
config that refer to the old filename so the migration tool recognizes the
corrected migration.

Comment on lines +4 to +7
Copy link

@coderabbitai coderabbitai bot Nov 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

기존 승인 데이터의 실제 승인 시점이 손실됩니다.

4-7번 라인의 UPDATE 구문이 모든 기존 APPROVED 레코드에 대해 NOW()를 사용하여 동일한 타임스탬프를 설정합니다. 이는 각 멘토 지원서가 실제로 승인된 시점 정보를 잃게 되어 히스토리 추적이 불가능해집니다.

더 나은 접근 방법:

  1. 선호하는 방식: created_at을 대체 값으로 사용 - 승인 시점의 근사치로 활용
  2. 대안: 기존 데이터는 NULL로 두고, 향후 승인부터만 정확한 시점 기록

방식 1을 적용한 diff:

 UPDATE mentor_application
-SET approved_at = NOW()
+SET approved_at = created_at
 WHERE mentor_application_status = 'APPROVED'
   AND approved_at IS NULL;

참고: Line 7의 AND approved_at IS NULL 체크는 방금 추가된 컬럼이므로 모든 값이 NULL이어서 실제로는 불필요합니다만, 재실행 방어를 위해 유지하는 것도 합리적입니다.

🤖 Prompt for AI Agents
In src/main/resources/db/migration/V39__add_approved_at_mentro_application.sql
around lines 4-7, the UPDATE uses NOW() which overwrites historical approval
times; instead set approved_at to the row's created_at as an approximate
approval timestamp: run UPDATE mentor_application SET approved_at = created_at
WHERE mentor_application_status = 'APPROVED' AND approved_at IS NULL; keep the
approved_at IS NULL guard for idempotency (or, if preferred, leave existing rows
NULL and only populate future approvals).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai 현재 서비스에서 승인 된 멘토 지원서는 존재할 수 없다는 가정이면?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sukangpunch 좋은 지적입니다!

만약 현재 프로덕션/스테이징 환경에 APPROVED 상태의 멘토 지원서가 존재하지 않는다면, 제가 제기한 우려는 해당되지 않습니다. UPDATE 구문이 0개의 행에 영향을 미치므로 데이터 손실 문제가 발생하지 않습니다.

해당 가정이 맞다면 현재 마이그레이션 스크립트는 안전합니다. 다만 확실성을 위해:

1. 이 가정을 확인해주실 수 있나요?

  • 현재 운영/스테이징 DB에 mentor_application_status = 'APPROVED'인 레코드가 0건인지 확인

2. (선택사항) 명확성을 위해 주석 추가를 고려해보시겠어요?

-- Note: As of this migration, no APPROVED mentor applications exist in production/staging
-- This UPDATE serves as a defensive measure for any edge cases
UPDATE mentor_application
SET approved_at = NOW()
WHERE mentor_application_status = 'APPROVED'
  AND approved_at IS NULL;

가정이 맞다면 현재 코드 그대로 진행하셔도 괜찮습니다! 👍


🧠 Learnings used
Learnt from: Gyuhyeok99
Repo: solid-connection/solid-connect-server PR: 443
File: src/main/resources/db/migration/V28__alter_mentor_introduction_pass_tip_not_null.sql:1-5
Timestamp: 2025-08-11T14:39:49.462Z
Learning: The mentor table currently has no data in production/staging environment, so NULL backfill operations are not needed when adding NOT NULL constraints to the introduction and pass_tip columns in the mentor table.

Learnt from: sukangpunch
Repo: solid-connection/solid-connect-server PR: 562
File: src/main/java/com/example/solidconnection/mentor/service/MentorMyPageService.java:76-93
Timestamp: 2025-11-20T14:03:56.450Z
Learning: MentorApplication의 universityId는 PENDING 상태에서는 null일 수 있지만, admin이 승인(APPROVED)할 때 반드시 대학 데이터를 생성하고 universityId를 채운 후 승인하므로, APPROVED 상태의 MentorApplication은 항상 non-null universityId를 가진다는 것이 비즈니스 규칙이다.

Loading
Loading