diff --git a/src/main/java/com/recyclestudy/RecyclestudyApplication.java b/src/main/java/com/recyclestudy/RecyclestudyApplication.java index a95c1cd..89662b6 100644 --- a/src/main/java/com/recyclestudy/RecyclestudyApplication.java +++ b/src/main/java/com/recyclestudy/RecyclestudyApplication.java @@ -3,8 +3,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; @EnableAsync +@EnableScheduling @SpringBootApplication public class RecyclestudyApplication { diff --git a/src/main/java/com/recyclestudy/email/DeviceAuthEmailSender.java b/src/main/java/com/recyclestudy/email/DeviceAuthEmailSender.java new file mode 100644 index 0000000..63d8507 --- /dev/null +++ b/src/main/java/com/recyclestudy/email/DeviceAuthEmailSender.java @@ -0,0 +1,41 @@ +package com.recyclestudy.email; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DeviceAuthEmailSender { + + private final EmailSender emailSender; + private final TemplateEngine templateEngine; + + @Value("${auth.base-url}") + private String baseUrl; + + @Async + public void sendDeviceAuthMail(final String email, final String deviceId) { + final String authUrl = createAuthUrl(email, deviceId); + final String message = createMessage(authUrl); + + emailSender.send(email, "[Recycle Study] 디바이스 인증을 완료해주세요.", message); + + log.info("인증 메일 발송 성공: {}", email); + } + + private String createAuthUrl(final String email, final String deviceId) { + return String.format("%s/api/v1/device/auth?email=%s&identifier=%s", baseUrl, email, deviceId); + } + + private String createMessage(final String authUrl) { + final Context context = new Context(); + context.setVariable("authUrl", authUrl); + return templateEngine.process("auth_email", context); + } +} diff --git a/src/main/java/com/recyclestudy/email/EmailSender.java b/src/main/java/com/recyclestudy/email/EmailSender.java new file mode 100644 index 0000000..c6e500a --- /dev/null +++ b/src/main/java/com/recyclestudy/email/EmailSender.java @@ -0,0 +1,37 @@ +package com.recyclestudy.email; + +import com.recyclestudy.exception.EmailSendException; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class EmailSender { + + private final JavaMailSender javaMailSender; + + public void send(final String targetEmail, final String subject, final String content) { + try { + final MimeMessage mimeMessage = javaMailSender.createMimeMessage(); + final MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, false, "UTF-8"); + + helper.setTo(targetEmail); + helper.setSubject(subject); + helper.setText(content, true); + + javaMailSender.send(mimeMessage); + + log.info("메일 발송 성공: email={}", targetEmail); + + } catch (MessagingException e) { + log.error("메일 발송 실패: email={}", targetEmail, e); + throw new EmailSendException("메일 전송 중 오류가 발생했습니다.", e); + } + } +} diff --git a/src/main/java/com/recyclestudy/email/EmailService.java b/src/main/java/com/recyclestudy/email/EmailService.java deleted file mode 100644 index 93e5e76..0000000 --- a/src/main/java/com/recyclestudy/email/EmailService.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.recyclestudy.email; - -import com.recyclestudy.exception.EmailSendException; -import jakarta.mail.MessagingException; -import jakarta.mail.internet.MimeMessage; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.mail.javamail.MimeMessageHelper; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; -import org.thymeleaf.TemplateEngine; -import org.thymeleaf.context.Context; - -@Slf4j -@Service -@RequiredArgsConstructor -public class EmailService { - - private final JavaMailSender javaMailSender; - private final TemplateEngine templateEngine; - - @Value("${auth.base-url}") - private String baseUrl; - - @Async - public void sendDeviceAuthMail(String email, String deviceId) { - try { - final String authUrl = createAuthUrl(email, deviceId); - final String message = createMessage(authUrl); - - sendMail(email, "[Recycle Study] 디바이스 인증을 완료해주세요.", message); - - log.info("인증 메일 발송 성공: {}", email); - - } catch (Exception e) { - log.error("메일 전송 실패. email={}, deviceId={}", email, deviceId, e); - - throw new EmailSendException("메일 전송 중 오류가 발생했습니다.", e); - } - } - - private String createAuthUrl(String email, String deviceId) { - return String.format("%s/api/v1/device/auth?email=%s&identifier=%s", baseUrl, email, deviceId); - } - - private String createMessage(String authUrl) { - final Context context = new Context(); - context.setVariable("authUrl", authUrl); - return templateEngine.process("auth_email", context); - } - - private void sendMail(String to, String subject, String content) throws MessagingException { - final MimeMessage mimeMessage = javaMailSender.createMimeMessage(); - final MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, false, "UTF-8"); - - helper.setTo(to); - helper.setSubject(subject); - helper.setText(content, true); - - javaMailSender.send(mimeMessage); - } -} diff --git a/src/main/java/com/recyclestudy/email/ReviewEmailSender.java b/src/main/java/com/recyclestudy/email/ReviewEmailSender.java new file mode 100644 index 0000000..3844cec --- /dev/null +++ b/src/main/java/com/recyclestudy/email/ReviewEmailSender.java @@ -0,0 +1,80 @@ +package com.recyclestudy.email; + +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.review.domain.NotificationStatus; +import com.recyclestudy.review.domain.ReviewURL; +import com.recyclestudy.review.service.NotificationHistoryService; +import com.recyclestudy.review.service.ReviewCycleService; +import com.recyclestudy.review.service.input.ReviewSendInput; +import com.recyclestudy.review.service.output.ReviewSendOutput; +import com.recyclestudy.review.service.output.ReviewSendOutput.ReviewSendElement; +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ReviewEmailSender { + + private final EmailSender emailSender; + private final TemplateEngine templateEngine; + private final ReviewCycleService reviewCycleService; + private final NotificationHistoryService notificationHistoryService; + private final Clock clock; + + @Scheduled(cron = "0 0 8 * * *", zone = "Asia/Seoul") + public void sendReviewMail() { + + final LocalDate targetDate = LocalDate.now(clock); + final LocalTime targetTime = LocalTime.of(8, 0); + + final ReviewSendOutput targetReviewCycle = reviewCycleService.findTargetReviewCycle( + ReviewSendInput.from(targetDate, targetTime)); + + final List elements = targetReviewCycle.elements(); + log.info("복습 메일 발송 시작: 대상 {}명", elements.size()); + + int successCount = 0; + int failCount = 0; + + for (final ReviewSendElement element : elements) { + final String message = createMessage(element.targetUrls()); + final Email targetEmail = element.email(); + + final boolean success = sendToTargetEmail(targetEmail, message); + + if (success) { + notificationHistoryService.saveAll(element.reviewCycleIds(), NotificationStatus.SENT); + successCount++; + } else { + notificationHistoryService.saveAll(element.reviewCycleIds(), NotificationStatus.FAILED); + failCount++; + } + } + + log.info("복습 메일 발송 처리 완료: 성공 {}명, 실패 {}명", successCount, failCount); + } + + private boolean sendToTargetEmail(final Email targetEmail, final String message) { + try { + emailSender.send(targetEmail.getValue(), "[Recycle Study] 오늘의 복습 목록이 도착했습니다", message); + return true; + } catch (final Exception e) { + return false; + } + } + + private String createMessage(final List targetUrls) { + final Context context = new Context(); + context.setVariable("targetUrls", targetUrls); + return templateEngine.process("review_email", context); + } +} diff --git a/src/main/java/com/recyclestudy/member/controller/MemberController.java b/src/main/java/com/recyclestudy/member/controller/MemberController.java index fa195d6..0ab392c 100644 --- a/src/main/java/com/recyclestudy/member/controller/MemberController.java +++ b/src/main/java/com/recyclestudy/member/controller/MemberController.java @@ -1,6 +1,6 @@ package com.recyclestudy.member.controller; -import com.recyclestudy.email.EmailService; +import com.recyclestudy.email.DeviceAuthEmailSender; import com.recyclestudy.member.controller.request.MemberSaveRequest; import com.recyclestudy.member.controller.response.MemberFindResponse; import com.recyclestudy.member.controller.response.MemberSaveResponse; @@ -25,14 +25,14 @@ public class MemberController { private final MemberService memberService; - private final EmailService emailService; + private final DeviceAuthEmailSender deviceAuthEmailSender; @PostMapping public ResponseEntity saveMember(@RequestBody final MemberSaveRequest request) { final MemberSaveInput input = request.toInput(); final MemberSaveOutput output = memberService.saveDevice(input); - emailService.sendDeviceAuthMail(output.email().getValue(), output.identifier().getValue()); + deviceAuthEmailSender.sendDeviceAuthMail(output.email().getValue(), output.identifier().getValue()); final MemberSaveResponse response = MemberSaveResponse.from(output); return ResponseEntity.status(HttpStatus.CREATED).body(response); diff --git a/src/main/java/com/recyclestudy/member/domain/ActivationExpiredDateTime.java b/src/main/java/com/recyclestudy/member/domain/ActivationExpiredDateTime.java index 72bc023..08b1028 100644 --- a/src/main/java/com/recyclestudy/member/domain/ActivationExpiredDateTime.java +++ b/src/main/java/com/recyclestudy/member/domain/ActivationExpiredDateTime.java @@ -24,6 +24,8 @@ public class ActivationExpiredDateTime { private static final Duration EXPIRE_TIME_RATE = Duration.ofMinutes(5); + private LocalDateTime value; + public static ActivationExpiredDateTime create(final LocalDateTime currentTime) { validateNotNull(currentTime); return new ActivationExpiredDateTime(currentTime.plusMinutes(EXPIRE_TIME_RATE.toMinutes())); @@ -35,8 +37,6 @@ private static void validateNotNull(final LocalDateTime currentTime) { .validate(); } - private LocalDateTime value; - public void checkExpired(final LocalDateTime currentTime) { if (currentTime.isAfter(value)) { throw new DeviceActivationExpiredException("인증 유효 시간이 만료되었습니다."); diff --git a/src/main/java/com/recyclestudy/member/domain/Device.java b/src/main/java/com/recyclestudy/member/domain/Device.java index 4e0560d..6749bd5 100644 --- a/src/main/java/com/recyclestudy/member/domain/Device.java +++ b/src/main/java/com/recyclestudy/member/domain/Device.java @@ -26,20 +26,6 @@ @Getter public class Device extends BaseEntity { - public static Device withoutId( - final Member member, - final DeviceIdentifier deviceIdentifier, - final boolean isActive, - final ActivationExpiredDateTime activationExpiresAt - ) { - NullValidator.builder() - .add(Fields.member, member) - .add(Fields.identifier, deviceIdentifier) - .add(Fields.activationExpiresAt, activationExpiresAt) - .validate(); - return new Device(member, deviceIdentifier, isActive, activationExpiresAt); - } - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) private Member member; @@ -55,6 +41,20 @@ public static Device withoutId( @AttributeOverride(name = "value", column = @Column(name = "activation_expires_at", nullable = false)) private ActivationExpiredDateTime activationExpiresAt; + public static Device withoutId( + final Member member, + final DeviceIdentifier deviceIdentifier, + final boolean isActive, + final ActivationExpiredDateTime activationExpiresAt + ) { + NullValidator.builder() + .add(Fields.member, member) + .add(Fields.identifier, deviceIdentifier) + .add(Fields.activationExpiresAt, activationExpiresAt) + .validate(); + return new Device(member, deviceIdentifier, isActive, activationExpiresAt); + } + public void activate(final LocalDateTime currentTime) { activationExpiresAt.checkExpired(currentTime); this.isActive = true; diff --git a/src/main/java/com/recyclestudy/member/domain/DeviceIdentifier.java b/src/main/java/com/recyclestudy/member/domain/DeviceIdentifier.java index d031160..8e6e488 100644 --- a/src/main/java/com/recyclestudy/member/domain/DeviceIdentifier.java +++ b/src/main/java/com/recyclestudy/member/domain/DeviceIdentifier.java @@ -19,6 +19,8 @@ @EqualsAndHashCode public class DeviceIdentifier { + private String value; + public static DeviceIdentifier from(final String value) { validateNotNull(value); return new DeviceIdentifier(value); @@ -34,6 +36,4 @@ private static void validateNotNull(final String value) { .add(Fields.value, value) .validate(); } - - private String value; } diff --git a/src/main/java/com/recyclestudy/member/domain/Email.java b/src/main/java/com/recyclestudy/member/domain/Email.java index c5fd3cd..6c9dddb 100644 --- a/src/main/java/com/recyclestudy/member/domain/Email.java +++ b/src/main/java/com/recyclestudy/member/domain/Email.java @@ -23,6 +23,8 @@ public class Email { private static final String EMAIL_FORMAT = "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$"; private static final Pattern EMAIL_PATTERN = Pattern.compile(EMAIL_FORMAT); + private String value; + public static Email from(final String value) { validateNotNull(value); validateEmailFormat(value); @@ -40,6 +42,4 @@ private static void validateEmailFormat(final String emailValue) { throw new IllegalArgumentException("유효하지 않은 이메일 형식입니다."); } } - - private String value; } diff --git a/src/main/java/com/recyclestudy/member/domain/Member.java b/src/main/java/com/recyclestudy/member/domain/Member.java index 9dee912..b994ca1 100644 --- a/src/main/java/com/recyclestudy/member/domain/Member.java +++ b/src/main/java/com/recyclestudy/member/domain/Member.java @@ -21,6 +21,10 @@ @Getter public class Member extends BaseEntity { + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "email", nullable = false, unique = true)) + private Email email; + public static Member withoutId(final Email email) { validateNotNull(email); return new Member(email); @@ -32,10 +36,6 @@ private static void validateNotNull(final Email email) { .validate(); } - @Embedded - @AttributeOverride(name = "value", column = @Column(name = "email", nullable = false, unique = true)) - private Email email; - public boolean hasEmail(final Email email) { return this.email.equals(email); } diff --git a/src/main/java/com/recyclestudy/review/domain/NotificationHistory.java b/src/main/java/com/recyclestudy/review/domain/NotificationHistory.java new file mode 100644 index 0000000..500dc5d --- /dev/null +++ b/src/main/java/com/recyclestudy/review/domain/NotificationHistory.java @@ -0,0 +1,52 @@ +package com.recyclestudy.review.domain; + +import com.recyclestudy.common.BaseEntity; +import com.recyclestudy.common.NullValidator; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.FieldNameConstants; + +@Entity +@Table(name = "notification_history") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@FieldNameConstants(level = AccessLevel.PRIVATE) +@Getter +public class NotificationHistory extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "review_cycle_id", nullable = false) + private ReviewCycle reviewCycle; + + @Enumerated(value = EnumType.STRING) + @Column(name = "status", nullable = false) + private NotificationStatus status; + + public static NotificationHistory withoutId( + final ReviewCycle reviewCycle, + final NotificationStatus status + ) { + validateNotNull(reviewCycle, status); + return new NotificationHistory(reviewCycle, status); + } + + private static void validateNotNull( + final ReviewCycle reviewCycle, + final NotificationStatus status + ) { + NullValidator.builder() + .add(Fields.reviewCycle, reviewCycle) + .add(Fields.status, status) + .validate(); + } +} diff --git a/src/main/java/com/recyclestudy/review/domain/Review.java b/src/main/java/com/recyclestudy/review/domain/Review.java index 3b37465..a21b72c 100644 --- a/src/main/java/com/recyclestudy/review/domain/Review.java +++ b/src/main/java/com/recyclestudy/review/domain/Review.java @@ -2,10 +2,14 @@ import com.recyclestudy.common.BaseEntity; import com.recyclestudy.common.NullValidator; +import com.recyclestudy.member.domain.Member; import jakarta.persistence.AttributeOverride; import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -21,18 +25,23 @@ @Getter public class Review extends BaseEntity { - public static Review withoutId(final ReviewURL url) { - validateNotNull(url); - return new Review(url); + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "url", nullable = false, columnDefinition = "TEXT")) + private ReviewURL url; + + public static Review withoutId(final Member member, final ReviewURL url) { + validateNotNull(member, url); + return new Review(member, url); } - private static void validateNotNull(final ReviewURL url) { + private static void validateNotNull(final Member member, final ReviewURL url) { NullValidator.builder() + .add(Fields.member, member) .add(Fields.url, url) .validate(); } - - @Embedded - @AttributeOverride(name = "value", column = @Column(name = "url", nullable = false, columnDefinition = "TEXT")) - private ReviewURL url; } diff --git a/src/main/java/com/recyclestudy/review/domain/ReviewCycle.java b/src/main/java/com/recyclestudy/review/domain/ReviewCycle.java index a56df10..d4017b9 100644 --- a/src/main/java/com/recyclestudy/review/domain/ReviewCycle.java +++ b/src/main/java/com/recyclestudy/review/domain/ReviewCycle.java @@ -2,10 +2,7 @@ import com.recyclestudy.common.BaseEntity; import com.recyclestudy.common.NullValidator; -import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; @@ -18,41 +15,28 @@ import lombok.experimental.FieldNameConstants; @Entity -@Table(name = "cycle") +@Table(name = "review_cycle") @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) @FieldNameConstants(level = AccessLevel.PRIVATE) @Getter public class ReviewCycle extends BaseEntity { - public static ReviewCycle withoutId( - final Review review, - final LocalDateTime scheduledAt, - final NotificationStatus status - ) { - validateNotNull(review, scheduledAt, status); - return new ReviewCycle(review, scheduledAt, status); + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "review_id", nullable = false) + private Review review; + + private LocalDateTime scheduledAt; + + public static ReviewCycle withoutId(final Review review, final LocalDateTime scheduledAt) { + validateNotNull(review, scheduledAt); + return new ReviewCycle(review, scheduledAt); } - private static void validateNotNull( - final Review review, - final LocalDateTime scheduledAt, - final NotificationStatus status - ) { + private static void validateNotNull(final Review review, final LocalDateTime scheduledAt) { NullValidator.builder() .add(Fields.review, review) .add(Fields.scheduledAt, scheduledAt) - .add(Fields.status, status) .validate(); } - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "review_id", nullable = false) - private Review review; - - private LocalDateTime scheduledAt; - - @Enumerated(value = EnumType.STRING) - @Column(name = "status", nullable = false) - private NotificationStatus status; } diff --git a/src/main/java/com/recyclestudy/review/repository/NotificationHistoryRepository.java b/src/main/java/com/recyclestudy/review/repository/NotificationHistoryRepository.java new file mode 100644 index 0000000..fa496fd --- /dev/null +++ b/src/main/java/com/recyclestudy/review/repository/NotificationHistoryRepository.java @@ -0,0 +1,7 @@ +package com.recyclestudy.review.repository; + +import com.recyclestudy.review.domain.NotificationHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NotificationHistoryRepository extends JpaRepository { +} diff --git a/src/main/java/com/recyclestudy/review/repository/ReviewCycleRepository.java b/src/main/java/com/recyclestudy/review/repository/ReviewCycleRepository.java index 8630fac..3060ad0 100644 --- a/src/main/java/com/recyclestudy/review/repository/ReviewCycleRepository.java +++ b/src/main/java/com/recyclestudy/review/repository/ReviewCycleRepository.java @@ -1,7 +1,11 @@ package com.recyclestudy.review.repository; import com.recyclestudy.review.domain.ReviewCycle; +import java.time.LocalDateTime; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; public interface ReviewCycleRepository extends JpaRepository { + + List findAllByScheduledAt(LocalDateTime scheduledAt); } diff --git a/src/main/java/com/recyclestudy/review/service/NotificationHistoryService.java b/src/main/java/com/recyclestudy/review/service/NotificationHistoryService.java new file mode 100644 index 0000000..1e9e61a --- /dev/null +++ b/src/main/java/com/recyclestudy/review/service/NotificationHistoryService.java @@ -0,0 +1,30 @@ +package com.recyclestudy.review.service; + +import com.recyclestudy.review.domain.NotificationHistory; +import com.recyclestudy.review.domain.NotificationStatus; +import com.recyclestudy.review.domain.ReviewCycle; +import com.recyclestudy.review.repository.NotificationHistoryRepository; +import com.recyclestudy.review.repository.ReviewCycleRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class NotificationHistoryService { + + private final NotificationHistoryRepository notificationHistoryRepository; + private final ReviewCycleRepository reviewCycleRepository; + + @Transactional + public void saveAll(final List reviewCycleIds, final NotificationStatus status) { + final List reviewCycles = reviewCycleRepository.findAllById(reviewCycleIds); + + final List histories = reviewCycles.stream() + .map(cycle -> NotificationHistory.withoutId(cycle, status)) + .toList(); + + notificationHistoryRepository.saveAll(histories); + } +} diff --git a/src/main/java/com/recyclestudy/review/service/ReviewCycleService.java b/src/main/java/com/recyclestudy/review/service/ReviewCycleService.java new file mode 100644 index 0000000..9dc708d --- /dev/null +++ b/src/main/java/com/recyclestudy/review/service/ReviewCycleService.java @@ -0,0 +1,23 @@ +package com.recyclestudy.review.service; + +import com.recyclestudy.review.domain.ReviewCycle; +import com.recyclestudy.review.repository.ReviewCycleRepository; +import com.recyclestudy.review.service.input.ReviewSendInput; +import com.recyclestudy.review.service.output.ReviewSendOutput; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ReviewCycleService { + + private final ReviewCycleRepository reviewCycleRepository; + + @Transactional(readOnly = true) + public ReviewSendOutput findTargetReviewCycle(final ReviewSendInput input) { + final List targetCycle = reviewCycleRepository.findAllByScheduledAt(input.scheduledAt()); + return ReviewSendOutput.from(targetCycle); + } +} diff --git a/src/main/java/com/recyclestudy/review/service/ReviewService.java b/src/main/java/com/recyclestudy/review/service/ReviewService.java index 954a9da..19b174c 100644 --- a/src/main/java/com/recyclestudy/review/service/ReviewService.java +++ b/src/main/java/com/recyclestudy/review/service/ReviewService.java @@ -3,10 +3,12 @@ import com.recyclestudy.exception.UnauthorizedException; import com.recyclestudy.member.domain.Device; import com.recyclestudy.member.repository.DeviceRepository; +import com.recyclestudy.review.domain.NotificationHistory; import com.recyclestudy.review.domain.NotificationStatus; import com.recyclestudy.review.domain.Review; import com.recyclestudy.review.domain.ReviewCycle; import com.recyclestudy.review.domain.ReviewCycleDuration; +import com.recyclestudy.review.repository.NotificationHistoryRepository; import com.recyclestudy.review.repository.ReviewCycleRepository; import com.recyclestudy.review.repository.ReviewRepository; import com.recyclestudy.review.service.input.ReviewSaveInput; @@ -26,6 +28,7 @@ public class ReviewService { private final ReviewRepository reviewRepository; private final ReviewCycleRepository reviewCycleRepository; private final DeviceRepository deviceRepository; + private final NotificationHistoryRepository notificationHistoryRepository; private final Clock clock; @Transactional @@ -34,25 +37,36 @@ public ReviewSaveOutput saveReview(final ReviewSaveInput input) { .orElseThrow(() -> new UnauthorizedException("유효하지 않은 디바이스입니다")); checkValidDevice(device); - final Review review = Review.withoutId(input.url()); + final Review review = Review.withoutId(device.getMember(), input.url()); final Review savedReview = reviewRepository.save(review); final LocalDate current = LocalDate.now(clock); final List scheduledAts = ReviewCycleDuration.calculate(current); final List reviewCycles = scheduledAts.stream() - .map(scheduledAt -> ReviewCycle.withoutId(savedReview, scheduledAt, NotificationStatus.PENDING)) + .map(scheduledAt -> ReviewCycle.withoutId(savedReview, scheduledAt)) .toList(); - final List savedScheduledAts = reviewCycleRepository.saveAll(reviewCycles) - .stream().map(ReviewCycle::getScheduledAt) + final List savedReviewCycles = reviewCycleRepository.saveAll(reviewCycles); + final List savedScheduledAts = savedReviewCycles.stream() + .map(ReviewCycle::getScheduledAt) .toList(); + + savePendingNotificationHistory(savedReviewCycles); + return ReviewSaveOutput.of(savedReview.getUrl(), savedScheduledAts); } - private static void checkValidDevice(final Device device) { + private void checkValidDevice(final Device device) { if (!device.isActive()) { throw new UnauthorizedException("인증되지 않은 디바이스입니다"); } } + + private void savePendingNotificationHistory(final List savedReviewCycles) { + final List notificationHistories = savedReviewCycles.stream() + .map(reviewCycle -> NotificationHistory.withoutId(reviewCycle, NotificationStatus.PENDING)) + .toList(); + notificationHistoryRepository.saveAll(notificationHistories); + } } diff --git a/src/main/java/com/recyclestudy/review/service/input/ReviewSendInput.java b/src/main/java/com/recyclestudy/review/service/input/ReviewSendInput.java new file mode 100644 index 0000000..cee411c --- /dev/null +++ b/src/main/java/com/recyclestudy/review/service/input/ReviewSendInput.java @@ -0,0 +1,12 @@ +package com.recyclestudy.review.service.input; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +public record ReviewSendInput(LocalDateTime scheduledAt) { + + public static ReviewSendInput from(final LocalDate targetDate, final LocalTime targetTime) { + return new ReviewSendInput(LocalDateTime.of(targetDate, targetTime)); + } +} diff --git a/src/main/java/com/recyclestudy/review/service/output/ReviewSendOutput.java b/src/main/java/com/recyclestudy/review/service/output/ReviewSendOutput.java new file mode 100644 index 0000000..df9b0c1 --- /dev/null +++ b/src/main/java/com/recyclestudy/review/service/output/ReviewSendOutput.java @@ -0,0 +1,46 @@ +package com.recyclestudy.review.service.output; + +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.review.domain.ReviewCycle; +import com.recyclestudy.review.domain.ReviewURL; +import java.util.List; +import java.util.stream.Collectors; + +public record ReviewSendOutput(List elements) { + + public static ReviewSendOutput from(final List reviewCycles) { + final List elements = reviewCycles.stream() + .collect(Collectors.groupingBy( + cycle -> cycle.getReview().getMember().getEmail(), + Collectors.toUnmodifiableList() + )) + .entrySet().stream() + .map(entry -> { + final List cycles = entry.getValue(); + final List cycleIds = cycles.stream() + .map(ReviewCycle::getId) + .toList(); + final List urls = cycles.stream() + .map(cycle -> cycle.getReview().getUrl()) + .toList(); + return ReviewSendElement.of(entry.getKey(), cycleIds, urls); + }) + .toList(); + + return new ReviewSendOutput(elements); + } + + public record ReviewSendElement( + Email email, + List reviewCycleIds, + List targetUrls + ) { + public static ReviewSendElement of( + final Email email, + final List reviewCycleIds, + final List targetUrls + ) { + return new ReviewSendElement(email, reviewCycleIds, targetUrls); + } + } +} diff --git a/src/main/resources/templates/auth_email.html b/src/main/resources/templates/auth_email.html index fe01306..5a2aa4d 100644 --- a/src/main/resources/templates/auth_email.html +++ b/src/main/resources/templates/auth_email.html @@ -1,13 +1,13 @@ - + 이메일 인증

안녕하세요! 이메일 인증을 완료해주세요.

아래 버튼을 클릭하여 디바이스 인증을 완료하세요.

- + 인증하기 diff --git a/src/main/resources/templates/auth_success.html b/src/main/resources/templates/auth_success.html index 9019729..65639fa 100644 --- a/src/main/resources/templates/auth_success.html +++ b/src/main/resources/templates/auth_success.html @@ -2,7 +2,7 @@ - + 이메일 인증 완료