Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/java/inha/gdgoc/GdgocApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
public class GdgocApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package inha.gdgoc.domain.admin.recruit.member.controller;

import static inha.gdgoc.domain.admin.recruit.member.controller.message.RecruitMemberMemoAdminMessage.MEMBER_MEMO_NOTIFICATION_ENQUEUED;
import static inha.gdgoc.domain.admin.recruit.member.controller.message.RecruitMemberMemoAdminMessage.MEMBER_MEMO_NOTIFICATION_FAILED_RETRIED;
import static inha.gdgoc.domain.admin.recruit.member.controller.message.RecruitMemberMemoAdminMessage.MEMBER_MEMO_NOTIFICATION_TEMPLATE_RETRIEVED;

import inha.gdgoc.domain.admin.recruit.member.dto.request.RecruitMemberMemoOpeningNotificationRequest;
import inha.gdgoc.domain.admin.recruit.member.dto.response.RecruitMemberMemoFailedRetryResponse;
import inha.gdgoc.domain.admin.recruit.member.dto.response.RecruitMemberMemoOpeningNotificationEnqueueResponse;
import inha.gdgoc.domain.admin.recruit.member.dto.response.RecruitMemberMemoNotificationTemplateResponse;
import inha.gdgoc.domain.admin.recruit.member.service.RecruitMemberMemoAdminService;
import inha.gdgoc.global.dto.response.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v1/admin/recruit/member/memo/notifications")
public class RecruitMemberMemoAdminController {

private static final String LEAD_OR_HR_RULE =
"@accessGuard.check(authentication,"
+ " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast("
+ "T(inha.gdgoc.domain.user.enums.UserRole).LEAD),"
+ " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).of("
+ "T(inha.gdgoc.domain.user.enums.UserRole).CORE,"
+ " T(inha.gdgoc.domain.user.enums.TeamType).HR))";

private final RecruitMemberMemoAdminService adminService;

@Operation(summary = "신입생 지원 오픈 알림 메일 기본 문구 조회", security = {@SecurityRequirement(name = "BearerAuth")})
@PreAuthorize(LEAD_OR_HR_RULE)
@GetMapping("/template")
public ResponseEntity<ApiResponse<RecruitMemberMemoNotificationTemplateResponse, Void>> getTemplate() {
RecruitMemberMemoNotificationTemplateResponse response = adminService.getTemplate();
return ResponseEntity.ok(ApiResponse.ok(MEMBER_MEMO_NOTIFICATION_TEMPLATE_RETRIEVED, response));
}

@Operation(summary = "신입생 지원 오픈 알림 메일 큐 적재", security = {@SecurityRequirement(name = "BearerAuth")})
@PreAuthorize(LEAD_OR_HR_RULE)
@PostMapping("/opening")
public ResponseEntity<ApiResponse<RecruitMemberMemoOpeningNotificationEnqueueResponse, Void>> enqueueOpening(
@Valid @RequestBody RecruitMemberMemoOpeningNotificationRequest request
) {
RecruitMemberMemoOpeningNotificationEnqueueResponse response = adminService.enqueueOpeningNotifications(request);
return ResponseEntity.ok(ApiResponse.ok(MEMBER_MEMO_NOTIFICATION_ENQUEUED, response));
}

@Operation(summary = "신입생 지원 오픈 알림 메일 실패 건 재시도", security = {@SecurityRequirement(name = "BearerAuth")})
@PreAuthorize(LEAD_OR_HR_RULE)
@PostMapping("/retry-failed")
public ResponseEntity<ApiResponse<RecruitMemberMemoFailedRetryResponse, Void>> retryFailed() {
RecruitMemberMemoFailedRetryResponse response = adminService.retryFailedNotifications();
return ResponseEntity.ok(ApiResponse.ok(MEMBER_MEMO_NOTIFICATION_FAILED_RETRIED, response));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package inha.gdgoc.domain.admin.recruit.member.controller.message;

public final class RecruitMemberMemoAdminMessage {

public static final String MEMBER_MEMO_NOTIFICATION_ENQUEUED = "신입생 지원 알림 메일 발송 작업을 큐잉했습니다.";
public static final String MEMBER_MEMO_NOTIFICATION_TEMPLATE_RETRIEVED = "신입생 지원 알림 기본 문구를 조회했습니다.";
public static final String MEMBER_MEMO_NOTIFICATION_FAILED_RETRIED = "신입생 지원 알림 실패 건을 재시도 큐에 반영했습니다.";

private RecruitMemberMemoAdminMessage() {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package inha.gdgoc.domain.admin.recruit.member.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record RecruitMemberMemoOpeningNotificationRequest(
@NotBlank(message = "메일 제목은 필수입니다.")
@Size(max = 200, message = "메일 제목은 200자 이하여야 합니다.")
String subject,

@NotBlank(message = "메일 본문은 필수입니다.")
@Size(max = 5000, message = "메일 본문은 5000자 이하여야 합니다.")
String body
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package inha.gdgoc.domain.admin.recruit.member.dto.response;

public record RecruitMemberMemoFailedRetryResponse(
String semester,
int retriedCount
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package inha.gdgoc.domain.admin.recruit.member.dto.response;

public record RecruitMemberMemoNotificationTemplateResponse(
String semester,
String defaultSubject,
String defaultBody,
String lastSubject,
String lastBody
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package inha.gdgoc.domain.admin.recruit.member.dto.response;

import inha.gdgoc.domain.recruit.member.notification.service.RecruitMemberMemoNotificationEnqueueResult;

public record RecruitMemberMemoOpeningNotificationEnqueueResponse(
String semester,
int distinctTargetCount,
int enqueuedCount,
int alreadyProcessedCount
) {
public static RecruitMemberMemoOpeningNotificationEnqueueResponse from(
RecruitMemberMemoNotificationEnqueueResult result
) {
return new RecruitMemberMemoOpeningNotificationEnqueueResponse(
result.semester(),
result.distinctTargetCount(),
result.enqueuedCount(),
result.alreadyProcessedCount()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package inha.gdgoc.domain.admin.recruit.member.service;

import inha.gdgoc.domain.admin.recruit.member.dto.request.RecruitMemberMemoOpeningNotificationRequest;
import inha.gdgoc.domain.admin.recruit.member.dto.response.RecruitMemberMemoFailedRetryResponse;
import inha.gdgoc.domain.admin.recruit.member.dto.response.RecruitMemberMemoOpeningNotificationEnqueueResponse;
import inha.gdgoc.domain.admin.recruit.member.dto.response.RecruitMemberMemoNotificationTemplateResponse;
import inha.gdgoc.domain.recruit.member.notification.service.RecruitMemberMemoNotificationEnqueueResult;
import inha.gdgoc.domain.recruit.member.notification.service.RecruitMemberMemoNotificationRetryResult;
import inha.gdgoc.domain.recruit.member.notification.service.RecruitMemberMemoNotificationService;
import inha.gdgoc.domain.recruit.member.notification.service.RecruitMemberMemoNotificationTemplateInfo;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class RecruitMemberMemoAdminService {

private final RecruitMemberMemoNotificationService notificationService;

@Transactional(readOnly = true)
public RecruitMemberMemoNotificationTemplateResponse getTemplate() {
RecruitMemberMemoNotificationTemplateInfo info = notificationService.getTemplateInfoForCurrentSemester();
return new RecruitMemberMemoNotificationTemplateResponse(
info.semester(),
info.defaultSubject(),
info.defaultBody(),
info.lastSubject(),
info.lastBody()
);
}

@Transactional
public RecruitMemberMemoOpeningNotificationEnqueueResponse enqueueOpeningNotifications(
RecruitMemberMemoOpeningNotificationRequest request
) {
RecruitMemberMemoNotificationEnqueueResult result =
notificationService.enqueueOpeningNotificationsForCurrentSemester(
request.subject(),
request.body()
);
return RecruitMemberMemoOpeningNotificationEnqueueResponse.from(result);
}

@Transactional
public RecruitMemberMemoFailedRetryResponse retryFailedNotifications() {
RecruitMemberMemoNotificationRetryResult result = notificationService.retryFailedForCurrentSemester();
return new RecruitMemberMemoFailedRetryResponse(result.semester(), result.retriedCount());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ public class UserAdminController {
"@accessGuard.check(authentication,"
+ " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast("
+ "T(inha.gdgoc.domain.user.enums.UserRole).LEAD))";
private static final String CORE_OR_HIGHER_RULE =
"@accessGuard.check(authentication,"
+ " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast("
+ "T(inha.gdgoc.domain.user.enums.UserRole).CORE))";

private final UserAdminService userAdminService;

Expand All @@ -63,7 +67,7 @@ public ResponseEntity<ApiResponse<Page<UserSummaryResponse>, PageMeta>> list(
}

@Operation(summary = "사용자 역할/팀 수정", security = {@SecurityRequirement(name = "BearerAuth")})
@PreAuthorize(LEAD_OR_HIGHER_RULE)
@PreAuthorize(CORE_OR_HIGHER_RULE)
@PatchMapping("/{userId}/role-team")
public ResponseEntity<ApiResponse<Void, Void>> updateRoleTeam(
@AuthenticationPrincipal CustomUserDetails me,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,28 @@ private Pageable rewriteSort(Pageable pageable) {

@Transactional
public void updateRoleAndTeam(CustomUserDetails editor, Long targetUserId, UpdateUserRoleTeamRequest req) {
User editorUser = getEditor(editor);
Long editorUserId = editor.getUserId();
UserRole editorRole;
TeamType editorTeam;

if (editorUserId == null) {
editorRole = editor.getRole();
editorTeam = editor.getTeam();
if (editorRole != UserRole.ADMIN) {
throw new BusinessException(GlobalErrorCode.UNAUTHORIZED_USER);
}
} else {
User editorUser = getEditor(editor);
editorRole = editorUser.getUserRole();
editorTeam = editorUser.getTeam();
}

User target = userRepository.findById(targetUserId)
.orElseThrow(() -> new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND));

UserRole editorRole = editorUser.getUserRole();
if (editorUserId != null && Objects.equals(editorUserId, target.getId())) {
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "자기 자신의 정보는 수정할 수 없습니다.");
}
UserRole targetCurrentRole = target.getUserRole();

UserRole newRole = (req.role() != null ? req.role() : targetCurrentRole);
Expand All @@ -101,41 +118,13 @@ public void updateRoleAndTeam(CustomUserDetails editor, Long targetUserId, Updat

switch (editorRole) {
case ADMIN -> {
if (editorUser.getId().equals(target.getId()) && newRole.rank() < UserRole.ADMIN.rank()) {
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "자기 자신을 강등할 수 없습니다.");
}
}
case ORGANIZER -> {
if (targetCurrentRole == UserRole.ADMIN) {
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "ADMIN 사용자는 수정할 수 없습니다.");
}
}
case LEAD -> {
if (editor.getTeam() == null) {
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD 토큰에 팀 정보가 없습니다.");
}
if (!(targetCurrentRole == UserRole.MEMBER || targetCurrentRole == UserRole.CORE)) {
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD는 MEMBER/CORE만 수정할 수 있습니다.");
}
if (!(newRole == UserRole.MEMBER || newRole == UserRole.CORE)) {
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD는 MEMBER/CORE로만 변경할 수 있습니다.");
}

if (editor.getTeam() == TeamType.HR) {
if (editorUser.getId().equals(target.getId())) {
if (req.team() != null && !Objects.equals(req.team(), target.getTeam())) {
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "HR-LEAD도 자기 자신의 팀은 변경할 수 없습니다.");
}
}
} else {
if (target.getTeam() != editor.getTeam()) {
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "다른 팀 사용자는 수정할 수 없습니다.");
}
if (req.team() != null && !Objects.equals(req.team(), editor.getTeam())) {
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD는 팀을 변경할 수 없습니다.");
}
}
}
case LEAD, CORE -> validateLeadAndCorePolicy(editorRole, editorTeam, target, req, targetCurrentRole, newRole);
default -> throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER);
}

Expand Down Expand Up @@ -234,6 +223,48 @@ private User getEditor(CustomUserDetails editor) {
.orElseThrow(() -> new BusinessException(GlobalErrorCode.UNAUTHORIZED_USER));
}

private void validateLeadAndCorePolicy(
UserRole editorRole,
TeamType editorTeam,
User target,
UpdateUserRoleTeamRequest req,
UserRole targetCurrentRole,
UserRole newRole
) {
if (editorTeam == null) {
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, editorRole + " 토큰에 팀 정보가 없습니다.");
}

if (editorRole == UserRole.LEAD) {
if (!(targetCurrentRole == UserRole.MEMBER || targetCurrentRole == UserRole.CORE)) {
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD는 MEMBER/CORE만 수정할 수 있습니다.");
}
if (!(newRole == UserRole.MEMBER || newRole == UserRole.CORE)) {
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD는 MEMBER/CORE로만 변경할 수 있습니다.");
}
}

if (editorRole == UserRole.CORE) {
if (!(targetCurrentRole == UserRole.GUEST || targetCurrentRole == UserRole.MEMBER)) {
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "CORE는 GUEST/MEMBER만 수정할 수 있습니다.");
}
if (!(newRole == UserRole.GUEST || newRole == UserRole.MEMBER)) {
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "CORE는 GUEST/MEMBER로만 변경할 수 있습니다.");
}
}

if (editorTeam == TeamType.HR) {
return;
}

if (!Objects.equals(target.getTeam(), editorTeam)) {
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "다른 팀 사용자는 수정할 수 없습니다.");
}
if (req.team() != null && !Objects.equals(req.team(), editorTeam)) {
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, editorRole + "는 팀을 변경할 수 없습니다.");
}
}

private void targetChange(User target, UserRole newRole, TeamType newTeam) {
target.changeRole(newRole);
if (!isTeamAssignableRole(newRole)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import inha.gdgoc.domain.auth.dto.request.SignupRequest;
import inha.gdgoc.domain.auth.dto.request.TokenRefreshRequest;
import inha.gdgoc.domain.auth.dto.response.AccessTokenResponse;
import inha.gdgoc.domain.auth.dto.response.AuthUserResponse;
import inha.gdgoc.domain.auth.dto.response.CheckPhoneNumberResponse;
import inha.gdgoc.domain.auth.dto.response.CheckStudentIdResponse;
import inha.gdgoc.domain.auth.exception.AuthErrorCode;
Expand Down Expand Up @@ -48,6 +47,17 @@ public ResponseEntity<?> login(@RequestBody LoginRequest request) {
}
}

@PostMapping("/admin/login")
public ResponseEntity<?> adminLogin(@RequestBody LoginRequest request) {
try {
Object response = authService.adminLogin(request.getAdminId(), request.getPassword());
return ResponseEntity.ok().body(ApiResponse.ok(LOGIN_SUCCESS, response));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.error(AuthErrorCode.INVALID_TOKEN.getStatus().value(), e.getMessage(), null));
}
}

// 2. 회원가입 (추가 정보 입력)
@PostMapping("/signup")
public ResponseEntity<?> signup(@Valid @RequestBody SignupRequest request) {
Expand Down Expand Up @@ -84,7 +94,7 @@ public ResponseEntity<?> refreshAccessToken(@Valid @RequestBody TokenRefreshRequ
return ResponseEntity.ok()
.body(ApiResponse.ok(
ACCESS_TOKEN_REFRESH_SUCCESS,
new AccessTokenResponse(result.accessToken(), AuthUserResponse.from(result.user()))
new AccessTokenResponse(result.accessToken(), result.user())
));
} catch (Exception e) {
log.error("Token refresh failed", e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@
@NoArgsConstructor
public class LoginRequest {
private String idToken;
}
private String adminId;
private String password;
}
Loading
Loading