Skip to content

Commit a9dff20

Browse files
authored
Merge pull request #171 from TaskFlow-CLAP/CLAP-117
CLAP-117 feat:이메일 전송 API 구현
2 parents 5dda52b + 40e0d17 commit a9dff20

File tree

10 files changed

+228
-21
lines changed

10 files changed

+228
-21
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package clap.server.adapter.inbound.web.admin;
2+
3+
import clap.server.adapter.inbound.web.dto.admin.SendInvitationRequest;
4+
import clap.server.application.port.inbound.admin.SendInvitationUsecase;
5+
import clap.server.common.annotation.architecture.WebAdapter;
6+
import io.swagger.v3.oas.annotations.Operation;
7+
import io.swagger.v3.oas.annotations.tags.Tag;
8+
import jakarta.validation.Valid;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.security.access.annotation.Secured;
11+
import org.springframework.web.bind.annotation.*;
12+
13+
@Tag(name = "05. Admin")
14+
@WebAdapter
15+
@RequiredArgsConstructor
16+
@RequestMapping("/api/managements")
17+
public class SendInvitationController {
18+
private final SendInvitationUsecase sendInvitationUsecase;
19+
20+
@Operation(summary = "회원 초대 이메일 발송 API")
21+
@Secured("ROLE_ADMIN")
22+
@PostMapping("/members/invite")
23+
public void sendInvitation(@RequestBody @Valid SendInvitationRequest request) {
24+
sendInvitationUsecase.sendInvitation(request);
25+
}
26+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package clap.server.adapter.inbound.web.dto.admin;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import jakarta.validation.constraints.NotNull;
5+
6+
public record SendInvitationRequest(
7+
@Schema(description = "회원 ID", required = true)
8+
@NotNull Long memberId
9+
) {}

src/main/java/clap/server/adapter/outbound/api/EmailClient.java

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,38 +36,40 @@ public void sendEmail(SendWebhookRequest request) {
3636
context.setVariable("title", request.taskName());
3737

3838
body = templateEngine.process("task-request", context);
39-
}
40-
else if (request.notificationType() == NotificationType.STATUS_SWITCHED) {
39+
} else if (request.notificationType() == NotificationType.STATUS_SWITCHED) {
4140
helper.setTo(request.email());
4241
helper.setSubject("[TaskFlow 알림] 작업 상태가 변경되었습니다.");
4342

4443
context.setVariable("status", request.message());
4544
context.setVariable("title", request.taskName());
4645

4746
body = templateEngine.process("status-switch", context);
48-
}
49-
50-
else if (request.notificationType() == NotificationType.PROCESSOR_CHANGED) {
47+
} else if (request.notificationType() == NotificationType.PROCESSOR_CHANGED) {
5148
helper.setTo(request.email());
5249
helper.setSubject("[TaskFlow 알림] 작업 담당자가 변경되었습니다.");
5350

5451
context.setVariable("processorName", request.message());
5552
context.setVariable("title", request.taskName());
5653

5754
body = templateEngine.process("processor-change", context);
58-
}
59-
60-
else if (request.notificationType() == NotificationType.PROCESSOR_ASSIGNED) {
55+
} else if (request.notificationType() == NotificationType.PROCESSOR_ASSIGNED) {
6156
helper.setTo(request.email());
6257
helper.setSubject("[TaskFlow 알림] 작업 담당자가 지정되었습니다.");
6358

6459
context.setVariable("processorName", request.message());
6560
context.setVariable("title", request.taskName());
6661

6762
body = templateEngine.process("processor-assign", context);
68-
}
63+
} else if (request.notificationType() == NotificationType.INVITATION) {
64+
helper.setTo(request.email());
65+
helper.setSubject("[TaskFlow 초대] 회원가입을 환영합니다.");
66+
67+
context.setVariable("invitationLink", "https://example.com/reset-password"); //TODO:비밀번호 설정 링크로 변경 예정
68+
context.setVariable("initialPassword", request.message());
69+
context.setVariable("receiverName", request.senderName());
6970

70-
else {
71+
body = templateEngine.process("invitation", context);
72+
} else {
7173
helper.setTo(request.email());
7274
helper.setSubject("[TaskFlow 알림] 댓글이 작성되었습니다.");
7375

@@ -83,4 +85,26 @@ else if (request.notificationType() == NotificationType.PROCESSOR_ASSIGNED) {
8385
throw new ApplicationException(NotificationErrorCode.EMAIL_SEND_FAILED);
8486
}
8587
}
88+
89+
public void sendInvitationEmail(String memberEmail, String receiverName, String initialPassword) {
90+
try {
91+
MimeMessage mimeMessage = mailSender.createMimeMessage();
92+
MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8");
93+
94+
helper.setTo(memberEmail);
95+
helper.setSubject("[TaskFlow 초대] 회원가입을 환영합니다.");
96+
97+
Context context = new Context();
98+
context.setVariable("invitationLink", "https://example.com/reset-password"); // TODO: 비밀번호 재설정 링크로 변경
99+
context.setVariable("initialPassword", initialPassword);
100+
context.setVariable("receiverName", receiverName);
101+
102+
String body = templateEngine.process("invitation", context);
103+
helper.setText(body, true);
104+
105+
mailSender.send(mimeMessage);
106+
} catch (Exception e) {
107+
throw new ApplicationException(NotificationErrorCode.EMAIL_SEND_FAILED);
108+
}
109+
}
86110
}

src/main/java/clap/server/adapter/outbound/persistense/entity/notification/constant/NotificationType.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ public enum NotificationType {
1010
TASK_REQUESTED("작업 요청"),
1111
STATUS_SWITCHED("상태 전환"),
1212
PROCESSOR_ASSIGNED("처리자 할당"),
13-
PROCESSOR_CHANGED("처리자 변경");
13+
PROCESSOR_CHANGED("처리자 변경"),
14+
INVITATION("회원가입 초대");
1415

1516
private final String description;
1617
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package clap.server.application.port.inbound.admin;
2+
3+
import clap.server.adapter.inbound.web.dto.admin.SendInvitationRequest;
4+
5+
public interface SendInvitationUsecase {
6+
void sendInvitation(SendInvitationRequest request);
7+
}

src/main/java/clap/server/application/port/outbound/webhook/SendEmailPort.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,7 @@
55
public interface SendEmailPort {
66

77
void sendEmail(SendWebhookRequest request);
8+
9+
void sendInvitationEmail(String memberEmail, String receiverName, String initialPassword);
10+
811
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package clap.server.application.service.admin;
2+
3+
import clap.server.adapter.inbound.web.dto.admin.SendInvitationRequest;
4+
import clap.server.application.port.inbound.admin.SendInvitationUsecase;
5+
import clap.server.application.port.outbound.member.CommandMemberPort;
6+
import clap.server.application.port.outbound.member.LoadMemberPort;
7+
import clap.server.application.port.outbound.webhook.SendEmailPort;
8+
import clap.server.common.annotation.architecture.ApplicationService;
9+
import clap.server.common.utils.InitialPasswordGenerator;
10+
import clap.server.domain.model.member.Member;
11+
import clap.server.exception.ApplicationException;
12+
import clap.server.exception.code.MemberErrorCode;
13+
import lombok.RequiredArgsConstructor;
14+
import org.springframework.security.crypto.password.PasswordEncoder;
15+
import org.springframework.transaction.annotation.Transactional;
16+
17+
@ApplicationService
18+
@RequiredArgsConstructor
19+
public class SendInvitationService implements SendInvitationUsecase {
20+
private final LoadMemberPort loadMemberPort;
21+
private final CommandMemberPort commandMemberPort;
22+
private final SendEmailPort sendEmailPort;
23+
private final InitialPasswordGenerator passwordGenerator;
24+
private final PasswordEncoder passwordEncoder;
25+
26+
@Override
27+
@Transactional
28+
public void sendInvitation(SendInvitationRequest request) {
29+
// 회원 조회
30+
Member member = loadMemberPort.findById(request.memberId())
31+
.orElseThrow(() -> new ApplicationException(MemberErrorCode.MEMBER_NOT_FOUND));
32+
33+
// 초기 비밀번호 생성
34+
String initialPassword = passwordGenerator.generateRandomPassword(8);
35+
String encodedPassword = passwordEncoder.encode(initialPassword);
36+
37+
// 회원 비밀번호 업데이트
38+
member.resetPassword(encodedPassword);
39+
commandMemberPort.save(member);
40+
41+
// 회원 상태를 APPROVAL_REQUEST으로 변경
42+
member.changeStatusToAPPROVAL_REQUEST();
43+
44+
sendEmailPort.sendInvitationEmail(
45+
member.getMemberInfo().getEmail(),
46+
member.getMemberInfo().getName(),
47+
initialPassword
48+
);
49+
}
50+
}
Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,14 @@
11
package clap.server.common.utils;
22

33
import org.springframework.beans.factory.annotation.Value;
4+
import org.springframework.stereotype.Component;
45

56
import java.security.SecureRandom;
6-
7+
@Component
78
public class InitialPasswordGenerator {
8-
9-
@Value("${password.policy.characters}")
9+
@Value("${password.policy.characters:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()}")
1010
private String characters;
1111

12-
private static final int PASSWORD_LENGTH = 8;
13-
14-
private InitialPasswordGenerator() {
15-
throw new IllegalStateException("Utility class");
16-
}
17-
1812
public String generateRandomPassword(int length) {
1913
if (length <= 0) {
2014
throw new IllegalArgumentException("Password length must be greater than 0");
@@ -24,10 +18,11 @@ public String generateRandomPassword(int length) {
2418
StringBuilder password = new StringBuilder(length);
2519

2620
for (int i = 0; i < length; i++) {
27-
int randomIndex = secureRandom.nextInt(PASSWORD_LENGTH);
21+
int randomIndex = secureRandom.nextInt(characters.length());
2822
password.append(characters.charAt(randomIndex));
2923
}
3024

3125
return password.toString();
3226
}
3327
}
28+

src/main/java/clap/server/domain/model/member/Member.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ public boolean isReviewer() {
7070
return this.memberInfo != null && this.memberInfo.isReviewer();
7171
}
7272

73+
public void changeStatusToAPPROVAL_REQUEST() {
74+
this.status = MemberStatus.APPROVAL_REQUEST;
75+
}
7376
public void updateMemberInfo(String name, Boolean agitNotificationEnabled, Boolean emailNotificationEnabled, Boolean kakaoWorkNotificationEnabled, String imageUrl) {
7477
this.memberInfo.updateName(name);
7578
this.agitNotificationEnabled = agitNotificationEnabled;
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<!DOCTYPE html>
2+
<html lang="ko" xmlns:th="http://www.w3.org/1999/xhtml">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>TaskFlow 초대 이메일</title>
6+
<style>
7+
/* CSS 스타일 */
8+
body {
9+
font-family: Arial, sans-serif;
10+
line-height: 1.6;
11+
background-color: #f9f9f9;
12+
margin: 0;
13+
padding: 0;
14+
}
15+
.email-container {
16+
max-width: 500px;
17+
margin: 20px auto;
18+
background: #ffffff;
19+
border: 1px solid #eaeaea;
20+
border-radius: 8px;
21+
overflow: hidden;
22+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
23+
}
24+
.header {
25+
background-color: #0052cc;
26+
color: #ffffff;
27+
padding: 15px;
28+
text-align: center;
29+
}
30+
.content {
31+
padding: 20px;
32+
color: #333333;
33+
}
34+
.content p {
35+
margin: 10px 0;
36+
}
37+
.cta-button {
38+
text-align: center;
39+
margin: 20px 0;
40+
}
41+
.cta-button a {
42+
background-color: #0052cc;
43+
color: #ffffff;
44+
text-decoration: none;
45+
padding: 10px 20px;
46+
border-radius: 5px;
47+
font-weight: bold;
48+
}
49+
.cta-button a:hover {
50+
background-color: #0041a7;
51+
}
52+
.footer {
53+
text-align: center;
54+
padding: 10px;
55+
font-size: 0.9em;
56+
color: #777777;
57+
background-color: #f4f4f4;
58+
border-top: 1px solid #eaeaea;
59+
}
60+
.footer .taskflow {
61+
font-size: 1.2em; /* 글자 크기 조정 */
62+
font-weight: bold; /* 글자 굵게 */
63+
}
64+
</style>
65+
</head>
66+
<body>
67+
<div class="email-container">
68+
<div class="header">
69+
TaskFlow 초대 서비스
70+
</div>
71+
<div class="content">
72+
<p>안녕하세요, <strong th:text="${receiverName}"></strong>님!</p>
73+
<p>TaskFlow 회원가입 초대 메일입니다.</p>
74+
<ul>
75+
<li>초대 링크: <a href="https://example.com/register" target="_blank" th:href="${invitationLink}">회원가입 링크</a></li>
76+
<li>초기 비밀번호: <strong th:text="${initialPassword}"></strong></li>
77+
</ul>
78+
<div class="cta-button">
79+
<a href="https://example.com/register" target="_blank" th:href="${invitationLink}">지금 가입하기</a>
80+
</div>
81+
</div>
82+
<div class="footer">
83+
<span class="taskflow">TaskFlow</span><br>
84+
스마트한 업무 관리를 위한<br>
85+
"혁신적인 서비스"
86+
</div>
87+
</div>
88+
</body>
89+
</html>

0 commit comments

Comments
 (0)