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
3 changes: 3 additions & 0 deletions .github/workflows/deploy-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ jobs:
AWS_TEST_RESOURCE_BUCKET=${{ secrets.AWS_TEST_RESOURCE_BUCKET }}
GMAIL=${{ secrets.GMAIL }}
GMAIL_PASSWORD=${{ secrets.GMAIL_PASSWORD }}
ADMIN_LOGIN_ID=${{ secrets.ADMIN_LOGIN_ID }}
ADMIN_LOGIN_PASSWORD=${{ secrets.ADMIN_LOGIN_PASSWORD }}
APP_MAIL_RECRUIT_FROM=${{ secrets.APP_MAIL_RECRUIT_FROM }}
DOZZLE_USERNAME=${{ secrets.DOZZLE_USERNAME }}
DOZZLE_PASSWORD=${{ secrets.DOZZLE_PASSWORD }}
EOF
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/deploy-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ jobs:
AWS_TEST_RESOURCE_BUCKET=${{ secrets.AWS_TEST_RESOURCE_BUCKET }}
GMAIL=${{ secrets.GMAIL }}
GMAIL_PASSWORD=${{ secrets.GMAIL_PASSWORD }}
ADMIN_LOGIN_ID=${{ secrets.ADMIN_LOGIN_ID }}
ADMIN_LOGIN_PASSWORD=${{ secrets.ADMIN_LOGIN_PASSWORD }}
APP_MAIL_RECRUIT_FROM=${{ secrets.APP_MAIL_RECRUIT_FROM }}
DOZZLE_USERNAME=${{ secrets.DOZZLE_USERNAME }}
DOZZLE_PASSWORD=${{ secrets.DOZZLE_PASSWORD }}
EOF
Expand Down
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
Loading
Loading