diff --git a/build.gradle b/build.gradle index 2ebfc1c..08ff0e8 100644 --- a/build.gradle +++ b/build.gradle @@ -67,6 +67,9 @@ dependencies { testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testImplementation 'com.h2database:h2' + + // firebase + implementation 'com.google.firebase:firebase-admin:9.1.0' } tasks.named('test') { diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/factory/AlarmSenderFactory.java b/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/factory/AlarmSenderFactory.java new file mode 100644 index 0000000..c725758 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/factory/AlarmSenderFactory.java @@ -0,0 +1,43 @@ +package org.withtime.be.withtimebe.domain.member.alarm.factory; + +import com.google.firebase.messaging.Message; +import jakarta.mail.internet.MimeMessage; +import jakarta.validation.constraints.NotNull; +import org.springframework.stereotype.Component; +import org.withtime.be.withtimebe.domain.member.alarm.service.AlarmSender; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class AlarmSenderFactory { + + private static final Map, AlarmSender> alarmSenderRepository = new ConcurrentHashMap<>(); + + public AlarmSenderFactory(@NotNull List> alarmSenders) { + alarmSenders.forEach(sender -> alarmSenderRepository.put(sender.supportedClass(), sender)); + } + + public AlarmSender getAlarmSender(Class messageType) { + try { + @SuppressWarnings("unchecked") + AlarmSender alarmSender = (AlarmSender) alarmSenderRepository.get(messageType); + return alarmSender; + } catch (Exception e) { + return null; + } + } + + public Class getPushAlarmClass() { + return Message.class; + } + + public Class getEmailAlarmClass() { + return MimeMessage.class; + } + + public Class getSMSAlarmClass() { + return String.class; + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/firebase/FirebaseInitialization.java b/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/firebase/FirebaseInitialization.java new file mode 100644 index 0000000..5aeb7e8 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/firebase/FirebaseInitialization.java @@ -0,0 +1,37 @@ +package org.withtime.be.withtimebe.domain.member.alarm.firebase; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.withtime.be.withtimebe.global.data.FirebaseConfigData; + +import java.io.ByteArrayInputStream; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FirebaseInitialization { + + private final FirebaseConfigData firebaseConfigData; + + @PostConstruct + public void initialize() { + try { + if (firebaseConfigData.isEnabled()) { + ByteArrayInputStream serviceAccountStream = new ByteArrayInputStream(firebaseConfigData.getConfig().getBytes()); + + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(serviceAccountStream)) + .build(); + + FirebaseApp.initializeApp(options); + } + } catch (Exception e) { + log.warn("Firebase Initialize", e); + } + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/generator/AlarmMessageGenerator.java b/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/generator/AlarmMessageGenerator.java new file mode 100644 index 0000000..01ddf22 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/generator/AlarmMessageGenerator.java @@ -0,0 +1,8 @@ +package org.withtime.be.withtimebe.domain.member.alarm.generator; + +import org.withtime.be.withtimebe.domain.member.dto.AlarmRequestDTO; +import org.withtime.be.withtimebe.domain.member.entity.Member; + +public interface AlarmMessageGenerator { + T generate(Member member, AlarmRequestDTO.SendAlarm request); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/generator/EmailSMTPAlarmMessageGenerator.java b/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/generator/EmailSMTPAlarmMessageGenerator.java new file mode 100644 index 0000000..61ccced --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/generator/EmailSMTPAlarmMessageGenerator.java @@ -0,0 +1,33 @@ +package org.withtime.be.withtimebe.domain.member.alarm.generator; + +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; +import org.withtime.be.withtimebe.domain.member.dto.AlarmRequestDTO; +import org.withtime.be.withtimebe.domain.member.entity.Member; + +@Component +@RequiredArgsConstructor +public class EmailSMTPAlarmMessageGenerator implements AlarmMessageGenerator { + + private static final String TITLE_FORMAT = "[WithTime] %s: %s"; + private final JavaMailSender javaMailSender; + + @Override + public MimeMessage generate(Member member, AlarmRequestDTO.SendAlarm request) { + try { + MimeMessage mimeMessage = javaMailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, "UTF-8"); + helper.setTo(member.getEmail()); + helper.setSubject(String.format(TITLE_FORMAT, request.alarmType(), request.title())); + helper.setText(request.description()); + + return mimeMessage; + } catch (Exception e) { + return null; + } + } + +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/generator/FCMAlarmMessageGenerator.java b/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/generator/FCMAlarmMessageGenerator.java new file mode 100644 index 0000000..788abd7 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/generator/FCMAlarmMessageGenerator.java @@ -0,0 +1,28 @@ +package org.withtime.be.withtimebe.domain.member.alarm.generator; + +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.withtime.be.withtimebe.domain.member.dto.AlarmRequestDTO; +import org.withtime.be.withtimebe.domain.member.entity.Member; + +@Component +@Transactional +public class FCMAlarmMessageGenerator implements AlarmMessageGenerator { + + @Override + public Message generate(Member member, AlarmRequestDTO.SendAlarm request) { + return Message.builder() + .setNotification(toNotification(request)) + .setToken(member.getDeviceToken()) + .build(); + } + + private Notification toNotification(AlarmRequestDTO.SendAlarm request) { + return Notification.builder() + .setTitle(request.title()) + .setBody(request.description()) + .build(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/sender/AlarmSendUtil.java b/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/sender/AlarmSendUtil.java new file mode 100644 index 0000000..d1ea2e1 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/sender/AlarmSendUtil.java @@ -0,0 +1,7 @@ +package org.withtime.be.withtimebe.domain.member.alarm.sender; + +import org.withtime.be.withtimebe.domain.member.entity.Member; + +public interface AlarmSendUtil { + void send(Member member, T message) throws Exception; +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/sender/EmailSMTPAlarmSendUtil.java b/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/sender/EmailSMTPAlarmSendUtil.java new file mode 100644 index 0000000..5e96c8e --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/sender/EmailSMTPAlarmSendUtil.java @@ -0,0 +1,19 @@ +package org.withtime.be.withtimebe.domain.member.alarm.sender; + +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Component; +import org.withtime.be.withtimebe.domain.member.entity.Member; + +@Component +@RequiredArgsConstructor +public class EmailSMTPAlarmSendUtil implements AlarmSendUtil { + + private final JavaMailSender javaMailSender; + + @Override + public void send(Member member, MimeMessage message) throws Exception { + javaMailSender.send(message); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/sender/FCMAlarmSendUtil.java b/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/sender/FCMAlarmSendUtil.java new file mode 100644 index 0000000..54e7364 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/sender/FCMAlarmSendUtil.java @@ -0,0 +1,15 @@ +package org.withtime.be.withtimebe.domain.member.alarm.sender; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; +import org.springframework.stereotype.Component; +import org.withtime.be.withtimebe.domain.member.entity.Member; + +@Component +public class FCMAlarmSendUtil implements AlarmSendUtil { + + @Override + public void send(Member member, Message message) throws Exception { + FirebaseMessaging.getInstance().send(message); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/service/AbstractAlarmSender.java b/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/service/AbstractAlarmSender.java new file mode 100644 index 0000000..ef9c8a5 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/service/AbstractAlarmSender.java @@ -0,0 +1,33 @@ +package org.withtime.be.withtimebe.domain.member.alarm.service; + +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.withtime.be.withtimebe.domain.member.alarm.generator.AlarmMessageGenerator; +import org.withtime.be.withtimebe.domain.member.alarm.sender.AlarmSendUtil; +import org.withtime.be.withtimebe.domain.member.dto.AlarmRequestDTO; +import org.withtime.be.withtimebe.domain.member.entity.Member; + +@Slf4j +@RequiredArgsConstructor +public abstract class AbstractAlarmSender implements AlarmSender { + + private final AlarmMessageGenerator alarmMessageGenerator; + private final AlarmSendUtil alarmSendUtil; + + @Override + public void send(Member member, AlarmRequestDTO.SendAlarm request) throws Exception { + try { + T message = alarmMessageGenerator.generate(member, request); + + alarmSendUtil.send(member, message); + } catch (Exception e) { + handleException(e); + } + } + + protected void handleException(Exception e) throws Exception{ + log.warn("Alarm error", e); + throw e; + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/service/AlarmSender.java b/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/service/AlarmSender.java new file mode 100644 index 0000000..178670d --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/service/AlarmSender.java @@ -0,0 +1,10 @@ +package org.withtime.be.withtimebe.domain.member.alarm.service; + +import org.withtime.be.withtimebe.domain.member.dto.AlarmRequestDTO; +import org.withtime.be.withtimebe.domain.member.entity.Member; + +public interface AlarmSender { + + void send(Member member, AlarmRequestDTO.SendAlarm request) throws Exception; + Class supportedClass(); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/service/EmailSMTPAlarmSender.java b/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/service/EmailSMTPAlarmSender.java new file mode 100644 index 0000000..4ed344a --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/service/EmailSMTPAlarmSender.java @@ -0,0 +1,22 @@ +package org.withtime.be.withtimebe.domain.member.alarm.service; + +import jakarta.mail.internet.MimeMessage; +import org.springframework.stereotype.Component; +import org.withtime.be.withtimebe.domain.member.alarm.generator.AlarmMessageGenerator; +import org.withtime.be.withtimebe.domain.member.alarm.sender.AlarmSendUtil; + +@Component +public class EmailSMTPAlarmSender extends AbstractAlarmSender { + + private static final Class SUPPORTED_CLASS = MimeMessage.class; + + public EmailSMTPAlarmSender(AlarmMessageGenerator alarmMessageGenerator, + AlarmSendUtil alarmSendUtil) { + super(alarmMessageGenerator, alarmSendUtil); + } + + @Override + public Class supportedClass() { + return SUPPORTED_CLASS; + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/service/FCMAlarmSender.java b/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/service/FCMAlarmSender.java new file mode 100644 index 0000000..2c585e4 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/alarm/service/FCMAlarmSender.java @@ -0,0 +1,22 @@ +package org.withtime.be.withtimebe.domain.member.alarm.service; + +import com.google.firebase.messaging.Message; +import org.springframework.stereotype.Component; +import org.withtime.be.withtimebe.domain.member.alarm.generator.AlarmMessageGenerator; +import org.withtime.be.withtimebe.domain.member.alarm.sender.AlarmSendUtil; + +@Component +public class FCMAlarmSender extends AbstractAlarmSender { + + private static final Class SUPPORTED_CLASS= Message.class; + + public FCMAlarmSender(AlarmMessageGenerator alarmMessageGenerator, + AlarmSendUtil alarmSendUtil) { + super(alarmMessageGenerator, alarmSendUtil); + } + + @Override + public Class supportedClass() { + return SUPPORTED_CLASS; + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/controller/AlarmController.java b/src/main/java/org/withtime/be/withtimebe/domain/member/controller/AlarmController.java new file mode 100644 index 0000000..d4aaadd --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/controller/AlarmController.java @@ -0,0 +1,64 @@ +package org.withtime.be.withtimebe.domain.member.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.namul.api.payload.response.DefaultResponse; +import org.springframework.web.bind.annotation.*; +import org.withtime.be.withtimebe.domain.member.converter.AlarmConverter; +import org.withtime.be.withtimebe.domain.member.dto.AlarmRequestDTO; +import org.withtime.be.withtimebe.domain.member.dto.AlarmResponseDTO; +import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.domain.member.service.AlarmCommandService; +import org.withtime.be.withtimebe.domain.member.service.AlarmQueryService; +import org.withtime.be.withtimebe.global.security.annotation.AuthenticatedMember; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/alarms") +@Tag(name = "알림 API") +public class AlarmController { + + private final AlarmCommandService alarmCommandService; + private final AlarmQueryService alarmQueryService; + + @Operation(summary = "알림 테스트용 API by 요시", description = "알림 테스트하기 위해 생성한 API") + @ApiResponse(responseCode = "204", description = "알림 전송 성공, 해당 API는 일림 전송 실패로 따로 에러 메시지를 전송하지 않습니다.") + @PostMapping + public DefaultResponse alarm(@AuthenticatedMember Member member, @RequestBody AlarmRequestDTO.SendAlarm request) { + alarmCommandService.send(member, request); + return DefaultResponse.noContent(); + } + + @Operation(summary = "푸시알림 기기 업데이트 API by 요시", description = "푸시 알림 받을 기기에서 얻은 토큰을 적용하여 해당 기기로 받도록 하는 API") + @ApiResponse(responseCode = "204", description = "알림 받을 기기 변경에 성공했습니다.") + @PostMapping("/device-tokens") + public DefaultResponse updateDeviceToken(@AuthenticatedMember Member member, @RequestBody AlarmRequestDTO.UpdateDeviceToken request) { + alarmCommandService.updateDeviceToken(member, request); + return DefaultResponse.noContent(); + } + + @Operation(summary = "알림 설정 업데이트 API by 요시", description = "사용자의 알림 설정을 변경하는 API") + @ApiResponse(responseCode = "200", description = "알림 설정 변경에 성공하였습니다.") + @PatchMapping("/settings") + public DefaultResponse updateAlarmSetting(@AuthenticatedMember Member member, @RequestBody AlarmRequestDTO.UpdateSetting request) { + AlarmResponseDTO.UpdateSetting response = alarmCommandService.updateAlarmSetting(member, request); + return DefaultResponse.ok(response); + } + + @Operation(summary = "알림 설정 조회 API by 요시", description = "사용자 알림 설정 상태를 조회합니다.") + @ApiResponse(responseCode = "200", description = "알림 설정 조회에 성공하였습니다.") + @GetMapping("/settings") + public DefaultResponse findSettingInfo(@AuthenticatedMember Member member) { + return DefaultResponse.ok(AlarmConverter.toSettingInfo(member)); + } + + @Operation(summary = "알림 조회 API by 요시", description = "알림 조회 API") + @ApiResponse(responseCode = "200", description = "알림 조회에 성공했습니다.") + @GetMapping + public DefaultResponse findAlarms(@RequestParam(defaultValue = "10") Integer size, + @RequestParam(defaultValue = "0") Long cursor) { + return DefaultResponse.ok(alarmQueryService.findAlarms(cursor, size)); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/converter/AlarmConverter.java b/src/main/java/org/withtime/be/withtimebe/domain/member/converter/AlarmConverter.java new file mode 100644 index 0000000..a025609 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/converter/AlarmConverter.java @@ -0,0 +1,56 @@ +package org.withtime.be.withtimebe.domain.member.converter; + +import org.springframework.data.domain.Slice; +import org.withtime.be.withtimebe.domain.member.dto.AlarmRequestDTO; +import org.withtime.be.withtimebe.domain.member.dto.AlarmResponseDTO; +import org.withtime.be.withtimebe.domain.member.entity.Alarm; +import org.withtime.be.withtimebe.domain.member.entity.Member; + +import java.util.List; + +public class AlarmConverter { + public static Alarm toAlarm(Member member, AlarmRequestDTO.SendAlarm request) { + return Alarm.builder() + .title(request.title()) + .description(request.description()) + .alarmType(request.alarmType()) + .member(member) + .isRead(false) + .build(); + } + + public static AlarmResponseDTO.UpdateSetting toUpdateSetting(Member member) { + return AlarmResponseDTO.UpdateSetting.builder() + .pushAlarm(member.getPushAlarm()) + .emailAlarm(member.getEmailAlarm()) + .smsAlarm(member.getSmsAlarm()) + .build(); + } + + public static AlarmResponseDTO.SettingInfo toSettingInfo(Member member) { + return AlarmResponseDTO.SettingInfo.builder() + .pushAlarm(member.getPushAlarm()) + .emailAlarm(member.getEmailAlarm()) + .smsAlarm(member.getSmsAlarm()) + .build(); + } + + public static AlarmResponseDTO.FindAlarmList toFindAlarmList(Slice alarmSlice) { + List alarms = alarmSlice.getContent(); + return AlarmResponseDTO.FindAlarmList.builder() + .alarmList(alarms.stream().map(AlarmConverter::toFindAlarmListItem).toList()) + .cursor(alarmSlice.hasNext() ? alarms.get(alarms.size() - 1).getId() : null) + .hasNext(alarmSlice.hasNext()) + .size(alarmSlice.getNumberOfElements()) + .build(); + } + + public static AlarmResponseDTO.FindAlarmListItem toFindAlarmListItem(Alarm alarm) { + return AlarmResponseDTO.FindAlarmListItem.builder() + .id(alarm.getId()) + .title(alarm.getTitle()) + .alarmType(alarm.getAlarmType()) + .isRead(alarm.getIsRead()) + .build(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/dto/AlarmRequestDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/member/dto/AlarmRequestDTO.java new file mode 100644 index 0000000..1f50381 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/dto/AlarmRequestDTO.java @@ -0,0 +1,27 @@ +package org.withtime.be.withtimebe.domain.member.dto; + +import org.withtime.be.withtimebe.domain.member.entity.enums.AlarmType; + +public record AlarmRequestDTO() { + public record SendAlarm( + String title, + String description, + AlarmType alarmType + ) { + + } + + public record UpdateDeviceToken( + String deviceToken + ) { + + } + + public record UpdateSetting( + Boolean emailAlarm, + Boolean pushAlarm, + Boolean smsAlarm + ) { + + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/dto/AlarmResponseDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/member/dto/AlarmResponseDTO.java new file mode 100644 index 0000000..4c3b87d --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/dto/AlarmResponseDTO.java @@ -0,0 +1,46 @@ +package org.withtime.be.withtimebe.domain.member.dto; + +import lombok.Builder; +import org.withtime.be.withtimebe.domain.member.entity.enums.AlarmType; + +import java.util.List; + +public record AlarmResponseDTO() { + @Builder + public record UpdateSetting( + Boolean emailAlarm, + Boolean pushAlarm, + Boolean smsAlarm + ) { + + } + + @Builder + public record SettingInfo( + Boolean emailAlarm, + Boolean pushAlarm, + Boolean smsAlarm + ) { + + } + + @Builder + public record FindAlarmList( + List alarmList, + Integer size, + Boolean hasNext, + Long cursor + ) { + + } + + @Builder + public record FindAlarmListItem( + Long id, + String title, + AlarmType alarmType, + boolean isRead + ) { + + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/entity/Alarm.java b/src/main/java/org/withtime/be/withtimebe/domain/member/entity/Alarm.java index e514ecb..ad8589b 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/member/entity/Alarm.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/entity/Alarm.java @@ -21,13 +21,13 @@ public class Alarm extends BaseEntity { @Column(name = "title") private String title; + @Column(name = "description", columnDefinition = "TEXT") + private String description; + @Enumerated(EnumType.STRING) @Column(name = "alarm_type") private AlarmType alarmType; - @Column(name = "target_key") - private Long targetKey; - @Column(name = "is_read", nullable = false) private Boolean isRead; diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/entity/Member.java b/src/main/java/org/withtime/be/withtimebe/domain/member/entity/Member.java index d838788..9969f52 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/member/entity/Member.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/entity/Member.java @@ -49,6 +49,21 @@ public class Member extends BaseEntity { @Column(name = "birth") private LocalDate birth; + @Column(name = "device_token") + private String deviceToken; + + @Column(name = "push_alarm", nullable = false) + @Builder.Default + private Boolean pushAlarm = true; + + @Column(name = "email_alarm", nullable = false) + @Builder.Default + private Boolean emailAlarm = true; + + @Column(name = "sms_alarm", nullable = false) + @Builder.Default + private Boolean smsAlarm = true; + @Column(name = "deleted_at") private LocalDateTime deletedAt; @@ -63,4 +78,14 @@ public void changeUsername(String newUsername) { public void changePassword(String encodedPassword) { this.password = encodedPassword; } + + public void updateDeviceToken(String deviceToken) { + this.deviceToken = deviceToken; + } + + public void updateAlarmSetting(Boolean pushAlarm, Boolean emailAlarm, Boolean smsAlarm) { + this.pushAlarm = pushAlarm; + this.emailAlarm = emailAlarm; + this.smsAlarm = smsAlarm; + } } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/repository/AlarmRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/member/repository/AlarmRepository.java new file mode 100644 index 0000000..9923a1e --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/repository/AlarmRepository.java @@ -0,0 +1,20 @@ +package org.withtime.be.withtimebe.domain.member.repository; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.withtime.be.withtimebe.domain.member.entity.Alarm; + +import java.time.LocalDateTime; + +public interface AlarmRepository extends JpaRepository { + + @Query(""" + SELECT a FROM Alarm a WHERE a.createdAt < ALL (SELECT a1.createdAt FROM Alarm a1 WHERE a1.id = :cursor) + ORDER BY a.createdAt DESC, a.id DESC + """) + Slice findAllByCreatedAtLessThanOrderByCreatedAtDesc(@Param("cursor") Long cursor, Pageable pageable); + Slice findAllByCreatedAtLessThanOrderByCreatedAtDesc(LocalDateTime createdAt, Pageable pageable); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/service/AlarmCommandService.java b/src/main/java/org/withtime/be/withtimebe/domain/member/service/AlarmCommandService.java new file mode 100644 index 0000000..c164492 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/service/AlarmCommandService.java @@ -0,0 +1,11 @@ +package org.withtime.be.withtimebe.domain.member.service; + +import org.withtime.be.withtimebe.domain.member.dto.AlarmRequestDTO; +import org.withtime.be.withtimebe.domain.member.dto.AlarmResponseDTO; +import org.withtime.be.withtimebe.domain.member.entity.Member; + +public interface AlarmCommandService { + void send(Member member, AlarmRequestDTO.SendAlarm... request); + void updateDeviceToken(Member member, AlarmRequestDTO.UpdateDeviceToken request); + AlarmResponseDTO.UpdateSetting updateAlarmSetting(Member member, AlarmRequestDTO.UpdateSetting request); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/service/AlarmCommandServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/member/service/AlarmCommandServiceImpl.java new file mode 100644 index 0000000..21b0b14 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/service/AlarmCommandServiceImpl.java @@ -0,0 +1,86 @@ +package org.withtime.be.withtimebe.domain.member.service; + +import lombok.RequiredArgsConstructor; +import org.namul.api.payload.error.exception.ServerApplicationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.withtime.be.withtimebe.domain.member.alarm.factory.AlarmSenderFactory; +import org.withtime.be.withtimebe.domain.member.converter.AlarmConverter; +import org.withtime.be.withtimebe.domain.member.dto.AlarmRequestDTO; +import org.withtime.be.withtimebe.domain.member.dto.AlarmResponseDTO; +import org.withtime.be.withtimebe.domain.member.entity.Alarm; +import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.domain.member.repository.AlarmRepository; +import org.withtime.be.withtimebe.domain.member.repository.MemberRepository; +import org.withtime.be.withtimebe.global.error.code.AlarmErrorCode; +import org.withtime.be.withtimebe.global.error.exception.AlarmException; + +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class AlarmCommandServiceImpl implements AlarmCommandService { + + private final AlarmSenderFactory alarmSenderFactory; + private final AlarmRepository alarmRepository; + private final MemberRepository memberRepository; + + @Override + public void send(Member member, AlarmRequestDTO.SendAlarm... request) { + // 알림 만들기 + List alarms = new ArrayList<>(); + for (AlarmRequestDTO.SendAlarm req : request) { + try { + sendAlarm(member, getScope(member), req); + + alarms.add(AlarmConverter.toAlarm(member, req)); + } catch (Exception ignored){} + } + alarmRepository.saveAll(alarms); + } + + @Override + public void updateDeviceToken(Member member, AlarmRequestDTO.UpdateDeviceToken request) { + member.updateDeviceToken(request.deviceToken()); + memberRepository.save(member); + } + + @Override + public AlarmResponseDTO.UpdateSetting updateAlarmSetting(Member member, AlarmRequestDTO.UpdateSetting request) { + member.updateAlarmSetting( + request.pushAlarm(), + request.emailAlarm(), + request.smsAlarm() + ); + memberRepository.save(member); + return AlarmConverter.toUpdateSetting(member); + } + + private List> getScope(Member member) { + List> classes = new ArrayList<>(); + if (Boolean.TRUE.equals(member.getEmailAlarm())) { + classes.add(alarmSenderFactory.getEmailAlarmClass()); + } + if (Boolean.TRUE.equals(member.getPushAlarm())) { + classes.add(alarmSenderFactory.getPushAlarmClass()); + } + if (Boolean.TRUE.equals(member.getSmsAlarm())) { + classes.add(alarmSenderFactory.getSMSAlarmClass()); + } + return classes; + } + + private void sendAlarm(Member member, List> alarmScope, AlarmRequestDTO.SendAlarm request) throws ServerApplicationException { + alarmScope.forEach(clz -> { + try { + alarmSenderFactory.getAlarmSender(clz).send(member, request); + } catch (NullPointerException e) { + throw new AlarmException(AlarmErrorCode.NOT_FOUND_ALARM_SENDER); + } catch (Exception e) { + throw new AlarmException(AlarmErrorCode.ALARM_SEND_ERROR); + } + }); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/service/AlarmQueryService.java b/src/main/java/org/withtime/be/withtimebe/domain/member/service/AlarmQueryService.java new file mode 100644 index 0000000..4d6ee27 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/service/AlarmQueryService.java @@ -0,0 +1,7 @@ +package org.withtime.be.withtimebe.domain.member.service; + +import org.withtime.be.withtimebe.domain.member.dto.AlarmResponseDTO; + +public interface AlarmQueryService { + AlarmResponseDTO.FindAlarmList findAlarms(Long cursor, Integer size); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/service/AlarmQueryServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/member/service/AlarmQueryServiceImpl.java new file mode 100644 index 0000000..afe6658 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/service/AlarmQueryServiceImpl.java @@ -0,0 +1,35 @@ +package org.withtime.be.withtimebe.domain.member.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.withtime.be.withtimebe.domain.member.converter.AlarmConverter; +import org.withtime.be.withtimebe.domain.member.dto.AlarmResponseDTO; +import org.withtime.be.withtimebe.domain.member.entity.Alarm; +import org.withtime.be.withtimebe.domain.member.repository.AlarmRepository; + +import java.time.LocalDateTime; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AlarmQueryServiceImpl implements AlarmQueryService { + + private final AlarmRepository alarmRepository; + + @Override + public AlarmResponseDTO.FindAlarmList findAlarms(Long cursor, Integer size) { + Slice slice; + Pageable pageable = PageRequest.of(0, size); + if (cursor.equals(0L)) { + slice = alarmRepository.findAllByCreatedAtLessThanOrderByCreatedAtDesc(LocalDateTime.now(), pageable); + } + else { + slice = alarmRepository.findAllByCreatedAtLessThanOrderByCreatedAtDesc(cursor, pageable); + } + return AlarmConverter.toFindAlarmList(slice); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/data/FirebaseConfigData.java b/src/main/java/org/withtime/be/withtimebe/global/data/FirebaseConfigData.java new file mode 100644 index 0000000..92fd294 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/data/FirebaseConfigData.java @@ -0,0 +1,16 @@ +package org.withtime.be.withtimebe.global.data; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "spring.firebase") +public class FirebaseConfigData { + + private boolean enabled = false; + private String config; +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/error/code/AlarmErrorCode.java b/src/main/java/org/withtime/be/withtimebe/global/error/code/AlarmErrorCode.java new file mode 100644 index 0000000..d4af63c --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/error/code/AlarmErrorCode.java @@ -0,0 +1,26 @@ +package org.withtime.be.withtimebe.global.error.code; + +import lombok.AllArgsConstructor; +import org.namul.api.payload.code.BaseErrorCode; +import org.namul.api.payload.code.dto.supports.DefaultResponseErrorReasonDTO; +import org.springframework.http.HttpStatus; + +@AllArgsConstructor +public enum AlarmErrorCode implements BaseErrorCode { + + NOT_FOUND_ALARM_SENDER(HttpStatus.INTERNAL_SERVER_ERROR, "ALARM500_1", "알림을 보내는 장치가 없습니다."), + ALARM_SEND_ERROR(HttpStatus.INTERNAL_SERVER_ERROR," ALARM500_2", "알림 보내기에 실패했습니다.") + ; + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public DefaultResponseErrorReasonDTO getReason() { + return DefaultResponseErrorReasonDTO.builder() + .httpStatus(this.httpStatus) + .code(this.code) + .message(this.message) + .build(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/error/code/FCMErrorCode.java b/src/main/java/org/withtime/be/withtimebe/global/error/code/FCMErrorCode.java new file mode 100644 index 0000000..702fae3 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/error/code/FCMErrorCode.java @@ -0,0 +1,26 @@ +package org.withtime.be.withtimebe.global.error.code; + +import lombok.RequiredArgsConstructor; +import org.namul.api.payload.code.BaseErrorCode; +import org.namul.api.payload.code.dto.supports.DefaultResponseErrorReasonDTO; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum FCMErrorCode implements BaseErrorCode { + FIREBASE_APP_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "FIREBASE500_1", "알림 메시지를 보내는 데 실패했습니다."), + UNKNOWN_FIREBASE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "FIREBASE500_2", "알 수 없는 알림 메시지 에러입니다.") + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public DefaultResponseErrorReasonDTO getReason() { + return DefaultResponseErrorReasonDTO.builder() + .httpStatus(this.httpStatus) + .code(this.code) + .message(this.message) + .build(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/error/exception/AlarmException.java b/src/main/java/org/withtime/be/withtimebe/global/error/exception/AlarmException.java new file mode 100644 index 0000000..c750c6c --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/error/exception/AlarmException.java @@ -0,0 +1,11 @@ +package org.withtime.be.withtimebe.global.error.exception; + +import org.namul.api.payload.code.BaseErrorCode; +import org.namul.api.payload.error.exception.ServerApplicationException; + +public class AlarmException extends ServerApplicationException { + + public AlarmException(BaseErrorCode code) { + super(code); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/error/exception/FCMException.java b/src/main/java/org/withtime/be/withtimebe/global/error/exception/FCMException.java new file mode 100644 index 0000000..d00914e --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/error/exception/FCMException.java @@ -0,0 +1,11 @@ +package org.withtime.be.withtimebe.global.error.exception; + +import org.namul.api.payload.code.BaseErrorCode; +import org.namul.api.payload.error.exception.ServerApplicationException; + +public class FCMException extends ServerApplicationException { + + public FCMException(BaseErrorCode code) { + super(code); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d06fb42..8811d38 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -65,6 +65,9 @@ spring: authorization-uri: https://accounts.google.com/o/oauth2/v2/auth token-uri: https://oauth2.googleapis.com/token user-info-uri: https://www.googleapis.com/userinfo/v2/me + firebase: + enabled: true + config: ${FIREBASE_CONFIG} jwt: secret: ${JWT_SECRET}