안녕하세요, 님!
+TaskFlow 회원가입 초대 메일입니다.
+-
+
- 초대 링크: 회원가입 링크 +
- 초기 비밀번호: +
diff --git a/src/main/java/clap/server/adapter/inbound/web/admin/SendInvitationController.java b/src/main/java/clap/server/adapter/inbound/web/admin/SendInvitationController.java new file mode 100644 index 00000000..c82d59b4 --- /dev/null +++ b/src/main/java/clap/server/adapter/inbound/web/admin/SendInvitationController.java @@ -0,0 +1,26 @@ +package clap.server.adapter.inbound.web.admin; + +import clap.server.adapter.inbound.web.dto.admin.SendInvitationRequest; +import clap.server.application.port.inbound.admin.SendInvitationUsecase; +import clap.server.common.annotation.architecture.WebAdapter; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.annotation.Secured; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "05. Admin") +@WebAdapter +@RequiredArgsConstructor +@RequestMapping("/api/managements") +public class SendInvitationController { + private final SendInvitationUsecase sendInvitationUsecase; + + @Operation(summary = "회원 초대 이메일 발송 API") + @Secured("ROLE_ADMIN") + @PostMapping("/members/invite") + public void sendInvitation(@RequestBody @Valid SendInvitationRequest request) { + sendInvitationUsecase.sendInvitation(request); + } +} diff --git a/src/main/java/clap/server/adapter/inbound/web/dto/admin/SendInvitationRequest.java b/src/main/java/clap/server/adapter/inbound/web/dto/admin/SendInvitationRequest.java new file mode 100644 index 00000000..8456c26a --- /dev/null +++ b/src/main/java/clap/server/adapter/inbound/web/dto/admin/SendInvitationRequest.java @@ -0,0 +1,9 @@ +package clap.server.adapter.inbound.web.dto.admin; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record SendInvitationRequest( + @Schema(description = "회원 ID", required = true) + @NotNull Long memberId +) {} diff --git a/src/main/java/clap/server/adapter/outbound/api/EmailClient.java b/src/main/java/clap/server/adapter/outbound/api/EmailClient.java index 979233b0..36d4f7cb 100644 --- a/src/main/java/clap/server/adapter/outbound/api/EmailClient.java +++ b/src/main/java/clap/server/adapter/outbound/api/EmailClient.java @@ -36,8 +36,7 @@ public void sendEmail(SendWebhookRequest request) { context.setVariable("title", request.taskName()); body = templateEngine.process("task-request", context); - } - else if (request.notificationType() == NotificationType.STATUS_SWITCHED) { + } else if (request.notificationType() == NotificationType.STATUS_SWITCHED) { helper.setTo(request.email()); helper.setSubject("[TaskFlow 알림] 작업 상태가 변경되었습니다."); @@ -45,9 +44,7 @@ else if (request.notificationType() == NotificationType.STATUS_SWITCHED) { context.setVariable("title", request.taskName()); body = templateEngine.process("status-switch", context); - } - - else if (request.notificationType() == NotificationType.PROCESSOR_CHANGED) { + } else if (request.notificationType() == NotificationType.PROCESSOR_CHANGED) { helper.setTo(request.email()); helper.setSubject("[TaskFlow 알림] 작업 담당자가 변경되었습니다."); @@ -55,9 +52,7 @@ else if (request.notificationType() == NotificationType.PROCESSOR_CHANGED) { context.setVariable("title", request.taskName()); body = templateEngine.process("processor-change", context); - } - - else if (request.notificationType() == NotificationType.PROCESSOR_ASSIGNED) { + } else if (request.notificationType() == NotificationType.PROCESSOR_ASSIGNED) { helper.setTo(request.email()); helper.setSubject("[TaskFlow 알림] 작업 담당자가 지정되었습니다."); @@ -65,9 +60,16 @@ else if (request.notificationType() == NotificationType.PROCESSOR_ASSIGNED) { context.setVariable("title", request.taskName()); body = templateEngine.process("processor-assign", context); - } + } else if (request.notificationType() == NotificationType.INVITATION) { + helper.setTo(request.email()); + helper.setSubject("[TaskFlow 초대] 회원가입을 환영합니다."); + + context.setVariable("invitationLink", "https://example.com/reset-password"); //TODO:비밀번호 설정 링크로 변경 예정 + context.setVariable("initialPassword", request.message()); + context.setVariable("receiverName", request.senderName()); - else { + body = templateEngine.process("invitation", context); + } else { helper.setTo(request.email()); helper.setSubject("[TaskFlow 알림] 댓글이 작성되었습니다."); @@ -83,4 +85,26 @@ else if (request.notificationType() == NotificationType.PROCESSOR_ASSIGNED) { throw new ApplicationException(NotificationErrorCode.EMAIL_SEND_FAILED); } } + + public void sendInvitationEmail(String memberEmail, String receiverName, String initialPassword) { + try { + MimeMessage mimeMessage = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); + + helper.setTo(memberEmail); + helper.setSubject("[TaskFlow 초대] 회원가입을 환영합니다."); + + Context context = new Context(); + context.setVariable("invitationLink", "https://example.com/reset-password"); // TODO: 비밀번호 재설정 링크로 변경 + context.setVariable("initialPassword", initialPassword); + context.setVariable("receiverName", receiverName); + + String body = templateEngine.process("invitation", context); + helper.setText(body, true); + + mailSender.send(mimeMessage); + } catch (Exception e) { + throw new ApplicationException(NotificationErrorCode.EMAIL_SEND_FAILED); + } + } } diff --git a/src/main/java/clap/server/adapter/outbound/persistense/entity/notification/constant/NotificationType.java b/src/main/java/clap/server/adapter/outbound/persistense/entity/notification/constant/NotificationType.java index de07356c..955db493 100644 --- a/src/main/java/clap/server/adapter/outbound/persistense/entity/notification/constant/NotificationType.java +++ b/src/main/java/clap/server/adapter/outbound/persistense/entity/notification/constant/NotificationType.java @@ -10,7 +10,8 @@ public enum NotificationType { TASK_REQUESTED("작업 요청"), STATUS_SWITCHED("상태 전환"), PROCESSOR_ASSIGNED("처리자 할당"), - PROCESSOR_CHANGED("처리자 변경"); + PROCESSOR_CHANGED("처리자 변경"), + INVITATION("회원가입 초대"); private final String description; } diff --git a/src/main/java/clap/server/application/port/inbound/admin/SendInvitationUsecase.java b/src/main/java/clap/server/application/port/inbound/admin/SendInvitationUsecase.java new file mode 100644 index 00000000..64f394df --- /dev/null +++ b/src/main/java/clap/server/application/port/inbound/admin/SendInvitationUsecase.java @@ -0,0 +1,7 @@ +package clap.server.application.port.inbound.admin; + +import clap.server.adapter.inbound.web.dto.admin.SendInvitationRequest; + +public interface SendInvitationUsecase { + void sendInvitation(SendInvitationRequest request); +} diff --git a/src/main/java/clap/server/application/port/outbound/webhook/SendEmailPort.java b/src/main/java/clap/server/application/port/outbound/webhook/SendEmailPort.java index e69f7094..0a3b7b6c 100644 --- a/src/main/java/clap/server/application/port/outbound/webhook/SendEmailPort.java +++ b/src/main/java/clap/server/application/port/outbound/webhook/SendEmailPort.java @@ -5,4 +5,7 @@ public interface SendEmailPort { void sendEmail(SendWebhookRequest request); + + void sendInvitationEmail(String memberEmail, String receiverName, String initialPassword); + } \ No newline at end of file diff --git a/src/main/java/clap/server/application/service/admin/SendInvitationService.java b/src/main/java/clap/server/application/service/admin/SendInvitationService.java new file mode 100644 index 00000000..d58c1b8b --- /dev/null +++ b/src/main/java/clap/server/application/service/admin/SendInvitationService.java @@ -0,0 +1,50 @@ +package clap.server.application.service.admin; + +import clap.server.adapter.inbound.web.dto.admin.SendInvitationRequest; +import clap.server.application.port.inbound.admin.SendInvitationUsecase; +import clap.server.application.port.outbound.member.CommandMemberPort; +import clap.server.application.port.outbound.member.LoadMemberPort; +import clap.server.application.port.outbound.webhook.SendEmailPort; +import clap.server.common.annotation.architecture.ApplicationService; +import clap.server.common.utils.InitialPasswordGenerator; +import clap.server.domain.model.member.Member; +import clap.server.exception.ApplicationException; +import clap.server.exception.code.MemberErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; + +@ApplicationService +@RequiredArgsConstructor +public class SendInvitationService implements SendInvitationUsecase { + private final LoadMemberPort loadMemberPort; + private final CommandMemberPort commandMemberPort; + private final SendEmailPort sendEmailPort; + private final InitialPasswordGenerator passwordGenerator; + private final PasswordEncoder passwordEncoder; + + @Override + @Transactional + public void sendInvitation(SendInvitationRequest request) { + // 회원 조회 + Member member = loadMemberPort.findById(request.memberId()) + .orElseThrow(() -> new ApplicationException(MemberErrorCode.MEMBER_NOT_FOUND)); + + // 초기 비밀번호 생성 + String initialPassword = passwordGenerator.generateRandomPassword(8); + String encodedPassword = passwordEncoder.encode(initialPassword); + + // 회원 비밀번호 업데이트 + member.resetPassword(encodedPassword); + commandMemberPort.save(member); + + // 회원 상태를 APPROVAL_REQUEST으로 변경 + member.changeStatusToAPPROVAL_REQUEST(); + + sendEmailPort.sendInvitationEmail( + member.getMemberInfo().getEmail(), + member.getMemberInfo().getName(), + initialPassword + ); + } +} diff --git a/src/main/java/clap/server/common/utils/InitialPasswordGenerator.java b/src/main/java/clap/server/common/utils/InitialPasswordGenerator.java index 175e5984..3e5ed23f 100644 --- a/src/main/java/clap/server/common/utils/InitialPasswordGenerator.java +++ b/src/main/java/clap/server/common/utils/InitialPasswordGenerator.java @@ -1,20 +1,14 @@ package clap.server.common.utils; import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; import java.security.SecureRandom; - +@Component public class InitialPasswordGenerator { - - @Value("${password.policy.characters}") + @Value("${password.policy.characters:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()}") private String characters; - private static final int PASSWORD_LENGTH = 8; - - private InitialPasswordGenerator() { - throw new IllegalStateException("Utility class"); - } - public String generateRandomPassword(int length) { if (length <= 0) { throw new IllegalArgumentException("Password length must be greater than 0"); @@ -24,10 +18,11 @@ public String generateRandomPassword(int length) { StringBuilder password = new StringBuilder(length); for (int i = 0; i < length; i++) { - int randomIndex = secureRandom.nextInt(PASSWORD_LENGTH); + int randomIndex = secureRandom.nextInt(characters.length()); password.append(characters.charAt(randomIndex)); } return password.toString(); } } + diff --git a/src/main/java/clap/server/domain/model/member/Member.java b/src/main/java/clap/server/domain/model/member/Member.java index 852758a2..8b11add8 100644 --- a/src/main/java/clap/server/domain/model/member/Member.java +++ b/src/main/java/clap/server/domain/model/member/Member.java @@ -70,6 +70,9 @@ public boolean isReviewer() { return this.memberInfo != null && this.memberInfo.isReviewer(); } + public void changeStatusToAPPROVAL_REQUEST() { + this.status = MemberStatus.APPROVAL_REQUEST; + } public void updateMemberInfo(String name, Boolean agitNotificationEnabled, Boolean emailNotificationEnabled, Boolean kakaoWorkNotificationEnabled, String imageUrl) { this.memberInfo.updateName(name); this.agitNotificationEnabled = agitNotificationEnabled; diff --git a/src/main/resources/templates/invitation.html b/src/main/resources/templates/invitation.html new file mode 100644 index 00000000..89090b4d --- /dev/null +++ b/src/main/resources/templates/invitation.html @@ -0,0 +1,89 @@ + + +
+ +