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
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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
) {}
44 changes: 34 additions & 10 deletions src/main/java/clap/server/adapter/outbound/api/EmailClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,38 +36,40 @@ 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 알림] 작업 상태가 변경되었습니다.");

context.setVariable("status", request.message());
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 알림] 작업 담당자가 변경되었습니다.");

context.setVariable("processorName", request.message());
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 알림] 작업 담당자가 지정되었습니다.");

context.setVariable("processorName", request.message());
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 알림] 댓글이 작성되었습니다.");

Expand All @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ public enum NotificationType {
TASK_REQUESTED("작업 요청"),
STATUS_SWITCHED("상태 전환"),
PROCESSOR_ASSIGNED("처리자 할당"),
PROCESSOR_CHANGED("처리자 변경");
PROCESSOR_CHANGED("처리자 변경"),
INVITATION("회원가입 초대");

private final String description;
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,7 @@
public interface SendEmailPort {

void sendEmail(SendWebhookRequest request);

void sendInvitationEmail(String memberEmail, String receiverName, String initialPassword);

}
Original file line number Diff line number Diff line change
@@ -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
);
}
}
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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();
}
}

3 changes: 3 additions & 0 deletions src/main/java/clap/server/domain/model/member/Member.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
89 changes: 89 additions & 0 deletions src/main/resources/templates/invitation.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>TaskFlow 초대 이메일</title>
<style>
/* CSS 스타일 */
body {
font-family: Arial, sans-serif;
line-height: 1.6;
background-color: #f9f9f9;
margin: 0;
padding: 0;
}
.email-container {
max-width: 500px;
margin: 20px auto;
background: #ffffff;
border: 1px solid #eaeaea;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.header {
background-color: #0052cc;
color: #ffffff;
padding: 15px;
text-align: center;
}
.content {
padding: 20px;
color: #333333;
}
.content p {
margin: 10px 0;
}
.cta-button {
text-align: center;
margin: 20px 0;
}
.cta-button a {
background-color: #0052cc;
color: #ffffff;
text-decoration: none;
padding: 10px 20px;
border-radius: 5px;
font-weight: bold;
}
.cta-button a:hover {
background-color: #0041a7;
}
.footer {
text-align: center;
padding: 10px;
font-size: 0.9em;
color: #777777;
background-color: #f4f4f4;
border-top: 1px solid #eaeaea;
}
.footer .taskflow {
font-size: 1.2em; /* 글자 크기 조정 */
font-weight: bold; /* 글자 굵게 */
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
TaskFlow 초대 서비스
</div>
<div class="content">
<p>안녕하세요, <strong th:text="${receiverName}"></strong>님!</p>
<p>TaskFlow 회원가입 초대 메일입니다.</p>
<ul>
<li>초대 링크: <a href="https://example.com/register" target="_blank" th:href="${invitationLink}">회원가입 링크</a></li>
<li>초기 비밀번호: <strong th:text="${initialPassword}"></strong></li>
</ul>
<div class="cta-button">
<a href="https://example.com/register" target="_blank" th:href="${invitationLink}">지금 가입하기</a>
</div>
</div>
<div class="footer">
<span class="taskflow">TaskFlow</span><br>
스마트한 업무 관리를 위한<br>
"혁신적인 서비스"
</div>
</div>
</body>
</html>