diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/AppMemberApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/AppMemberApi.java index a13b436c..c9fa9913 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/AppMemberApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/AppMemberApi.java @@ -20,7 +20,8 @@ public interface AppMemberApi { @Operation(summary = "회원가입", description = """ 로그인한 회원의 전화번호와 약관 동의 여부를 저장한다. - 전화번호는 하이픈을 넣어도 되고 빼도 된다. 필수 약관(PRIVACY_POLICY, TERMS_OF_SERVICE)에 모두 동의해야 한다.""") + 전화번호는 하이픈을 넣어도 되고 빼도 된다. 필수 약관(PRIVACY_POLICY, TERMS_OF_SERVICE)에 모두 동의해야 한다. + 가입하면 알림 설정이 모두 켜진 상태로 만들어진다.""") @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) @ApiErrorCode(type = MemberErrorCode.class, codes = { "MEMBER_NOT_FOUND", diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/AppMemberController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/AppMemberController.java index 67d0ae93..22fd2ed0 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/AppMemberController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/AppMemberController.java @@ -4,6 +4,7 @@ import kr.ac.kookmin.stream.api.app.AppApiUser; import kr.ac.kookmin.stream.api.app.core.member.request.FcmTokenUpdateRequest; import kr.ac.kookmin.stream.api.app.core.member.request.MemberSignUpRequest; +import kr.ac.kookmin.stream.api.app.core.member.usecase.MemberSignUpUseCase; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.member.domain.member.service.MemberService; import lombok.RequiredArgsConstructor; @@ -19,6 +20,7 @@ public class AppMemberController implements AppMemberApi { private final MemberService memberService; + private final MemberSignUpUseCase memberSignUpUseCase; @Override @PostMapping("/sign-up") @@ -26,7 +28,7 @@ public ApiResponse signUp( AppApiUser apiUser, @Valid @RequestBody MemberSignUpRequest request ) { - memberService.signUp(apiUser.userId(), request.toCommand()); + memberSignUpUseCase.signUp(apiUser.userId(), request.toCommand()); return ApiResponse.success(); } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/usecase/MemberSignUpUseCase.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/usecase/MemberSignUpUseCase.java new file mode 100644 index 00000000..9762661d --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/member/usecase/MemberSignUpUseCase.java @@ -0,0 +1,26 @@ +package kr.ac.kookmin.stream.api.app.core.member.usecase; + +import kr.ac.kookmin.stream.member.domain.member.domain.MemberSignUpCommand; +import kr.ac.kookmin.stream.member.domain.member.service.MemberService; +import kr.ac.kookmin.stream.member.domain.notification.service.MemberNotificationSettingService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 회원가입. {@code member}(전화번호, 약관 동의)와 {@code notification}(기본 알림 설정) 두 도메인을 조합한다. + */ +@Component +@RequiredArgsConstructor +public class MemberSignUpUseCase { + + private final MemberService memberService; + private final MemberNotificationSettingService memberNotificationSettingService; + + // 가입을 마친 회원에게는 항상 알림 설정이 있도록 한 트랜잭션으로 묶는다 + @Transactional + public void signUp(Long memberId, MemberSignUpCommand command) { + memberService.signUp(memberId, command); + memberNotificationSettingService.createDefaultIfAbsent(memberId); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/notification/AppNotificationApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/notification/AppNotificationApi.java new file mode 100644 index 00000000..21a245b6 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/notification/AppNotificationApi.java @@ -0,0 +1,40 @@ +package kr.ac.kookmin.stream.api.app.core.notification; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.ac.kookmin.stream.api.app.AppApiUser; +import kr.ac.kookmin.stream.api.app.core.notification.request.MemberNotificationSettingUpdateRequest; +import kr.ac.kookmin.stream.api.app.core.notification.response.MemberNotificationSettingResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; +import kr.ac.kookmin.stream.common.CommonErrorCode; +import kr.ac.kookmin.stream.member.domain.notification.domain.NotificationErrorCode; + +/** + * 학생 앱 알림 API의 문서 명세. 구현은 {@link AppNotificationController}가 맡는다. + */ +@Tag(name = "알림", description = "학생 앱 알림 설정") +public interface AppNotificationApi { + + /** 알림 설정 조회. */ + @Operation(summary = "알림 설정 조회", + description = """ + 로그인한 회원의 카테고리별 푸시 수신 여부를 조회한다. + rentalEnabled는 빌릴게, eventEnabled는 행사, lockerEnabled는 사물함, noticeEnabled는 게시판(새 공지·열린피드백 답변) 알림이다. + 전체 알림은 따로 저장하지 않는다. 네 값이 모두 true일 때 켜진 것으로 보여준다.""") + @ApiErrorCode(type = NotificationErrorCode.class, codes = {"NOTIFICATION_SETTING_NOT_FOUND"}) + ApiResponse getSetting(AppApiUser apiUser); + + /** 알림 설정 수정. */ + @Operation(summary = "알림 설정 수정", + description = """ + 로그인한 회원의 카테고리별 푸시 수신 여부를 바꾼다. 네 값을 모두 보내야 하고, 보낸 값으로 덮어쓴다. + 전체 알림을 켜거나 끌 때는 네 값을 모두 true나 false로 보낸다. + 꺼진 카테고리의 알림은 푸시로 보내지 않지만 알림함에는 남는다.""") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = NotificationErrorCode.class, codes = {"NOTIFICATION_SETTING_NOT_FOUND"}) + ApiResponse updateSetting( + AppApiUser apiUser, + MemberNotificationSettingUpdateRequest request + ); +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/notification/AppNotificationController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/notification/AppNotificationController.java new file mode 100644 index 00000000..b1288a53 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/notification/AppNotificationController.java @@ -0,0 +1,40 @@ +package kr.ac.kookmin.stream.api.app.core.notification; + +import jakarta.validation.Valid; +import kr.ac.kookmin.stream.api.app.AppApiUser; +import kr.ac.kookmin.stream.api.app.core.notification.request.MemberNotificationSettingUpdateRequest; +import kr.ac.kookmin.stream.api.app.core.notification.response.MemberNotificationSettingResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.member.domain.notification.domain.MemberNotificationSetting; +import kr.ac.kookmin.stream.member.domain.notification.service.MemberNotificationSettingService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/app/notifications") +@RequiredArgsConstructor +public class AppNotificationController implements AppNotificationApi { + + private final MemberNotificationSettingService memberNotificationSettingService; + + @Override + @GetMapping("/settings") + public ApiResponse getSetting(AppApiUser apiUser) { + MemberNotificationSetting setting = memberNotificationSettingService.getByMemberId(apiUser.userId()); + return ApiResponse.success(MemberNotificationSettingResponse.from(setting)); + } + + @Override + @PutMapping("/settings") + public ApiResponse updateSetting( + AppApiUser apiUser, + @Valid @RequestBody MemberNotificationSettingUpdateRequest request + ) { + MemberNotificationSetting setting = memberNotificationSettingService.update(apiUser.userId(), request.toCommand()); + return ApiResponse.success(MemberNotificationSettingResponse.from(setting)); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/notification/request/MemberNotificationSettingUpdateRequest.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/notification/request/MemberNotificationSettingUpdateRequest.java new file mode 100644 index 00000000..14aedd8c --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/notification/request/MemberNotificationSettingUpdateRequest.java @@ -0,0 +1,23 @@ +package kr.ac.kookmin.stream.api.app.core.notification.request; + +import jakarta.validation.constraints.NotNull; +import kr.ac.kookmin.stream.member.domain.notification.domain.MemberNotificationSettingUpdateCommand; + +public record MemberNotificationSettingUpdateRequest( + @NotNull(message = "빌릴게 알림 수신 여부를 입력해 주세요.") + Boolean rentalEnabled, + + @NotNull(message = "행사 알림 수신 여부를 입력해 주세요.") + Boolean eventEnabled, + + @NotNull(message = "사물함 알림 수신 여부를 입력해 주세요.") + Boolean lockerEnabled, + + @NotNull(message = "게시판 알림 수신 여부를 입력해 주세요.") + Boolean noticeEnabled +) { + + public MemberNotificationSettingUpdateCommand toCommand() { + return new MemberNotificationSettingUpdateCommand(rentalEnabled, eventEnabled, lockerEnabled, noticeEnabled); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/notification/response/MemberNotificationSettingResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/notification/response/MemberNotificationSettingResponse.java new file mode 100644 index 00000000..7c46782c --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/notification/response/MemberNotificationSettingResponse.java @@ -0,0 +1,19 @@ +package kr.ac.kookmin.stream.api.app.core.notification.response; + +import kr.ac.kookmin.stream.member.domain.notification.domain.MemberNotificationSetting; + +public record MemberNotificationSettingResponse( + boolean rentalEnabled, + boolean eventEnabled, + boolean lockerEnabled, + boolean noticeEnabled +) { + public static MemberNotificationSettingResponse from(MemberNotificationSetting setting) { + return new MemberNotificationSettingResponse( + setting.isRentalEnabled(), + setting.isEventEnabled(), + setting.isLockerEnabled(), + setting.isNoticeEnabled() + ); + } +} diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/MemberNotificationSetting.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/MemberNotificationSetting.java index feaeefcf..3e0ac35a 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/MemberNotificationSetting.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/MemberNotificationSetting.java @@ -15,8 +15,14 @@ public class MemberNotificationSetting { private boolean rentalEnabled; private boolean eventEnabled; private boolean lockerEnabled; + // 게시판 알림. 새 공지와 열린피드백 답변을 함께 다룬다 private boolean noticeEnabled; + // 회원가입할 때 만든다. 처음에는 모든 알림을 받는다 + public static MemberNotificationSetting createDefault(Long memberId) { + return new MemberNotificationSetting(null, memberId, true, true, true, true); + } + public static MemberNotificationSetting of( Long id, Long memberId, @@ -27,4 +33,19 @@ public static MemberNotificationSetting of( ) { return new MemberNotificationSetting(id, memberId, rentalEnabled, eventEnabled, lockerEnabled, noticeEnabled); } + + // 바뀐 값이 있으면 true를 돌려준다 + public boolean update(MemberNotificationSettingUpdateCommand command) { + if (this.rentalEnabled == command.rentalEnabled() + && this.eventEnabled == command.eventEnabled() + && this.lockerEnabled == command.lockerEnabled() + && this.noticeEnabled == command.noticeEnabled()) { + return false; + } + this.rentalEnabled = command.rentalEnabled(); + this.eventEnabled = command.eventEnabled(); + this.lockerEnabled = command.lockerEnabled(); + this.noticeEnabled = command.noticeEnabled(); + return true; + } } diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/MemberNotificationSettingUpdateCommand.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/MemberNotificationSettingUpdateCommand.java new file mode 100644 index 00000000..e5fbdf47 --- /dev/null +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/MemberNotificationSettingUpdateCommand.java @@ -0,0 +1,11 @@ +package kr.ac.kookmin.stream.member.domain.notification.domain; + +/** + * 알림 설정 수정 요청. 전체 알림은 저장하지 않으므로 네 카테고리 값을 모두 받아 그대로 덮어쓴다. + */ +public record MemberNotificationSettingUpdateCommand( + boolean rentalEnabled, + boolean eventEnabled, + boolean lockerEnabled, + boolean noticeEnabled +) {} diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationErrorCode.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationErrorCode.java new file mode 100644 index 00000000..42281643 --- /dev/null +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationErrorCode.java @@ -0,0 +1,18 @@ +package kr.ac.kookmin.stream.member.domain.notification.domain; + +import kr.ac.kookmin.stream.common.ErrorCode; +import kr.ac.kookmin.stream.common.ErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.experimental.Accessors; + +@Getter +@Accessors(fluent = true) +@AllArgsConstructor +public enum NotificationErrorCode implements ErrorCode { + + NOTIFICATION_SETTING_NOT_FOUND(ErrorStatus.NOT_FOUND, "알림 설정을 찾을 수 없습니다."); + + private final int status; + private final String message; +} diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/repository/MemberNotificationSettingRepository.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/repository/MemberNotificationSettingRepository.java new file mode 100644 index 00000000..1daae19c --- /dev/null +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/repository/MemberNotificationSettingRepository.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.member.domain.notification.repository; + +import java.util.Optional; +import kr.ac.kookmin.stream.member.domain.notification.domain.MemberNotificationSetting; + +public interface MemberNotificationSettingRepository { + Optional findByMemberId(Long memberId); + boolean existsByMemberId(Long memberId); + MemberNotificationSetting save(MemberNotificationSetting setting); +} diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/MemberNotificationSettingService.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/MemberNotificationSettingService.java new file mode 100644 index 00000000..df337c67 --- /dev/null +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/MemberNotificationSettingService.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.member.domain.notification.service; + +import kr.ac.kookmin.stream.member.domain.notification.domain.MemberNotificationSetting; +import kr.ac.kookmin.stream.member.domain.notification.domain.MemberNotificationSettingUpdateCommand; + +public interface MemberNotificationSettingService { + MemberNotificationSetting getByMemberId(Long memberId); + void createDefaultIfAbsent(Long memberId); + MemberNotificationSetting update(Long memberId, MemberNotificationSettingUpdateCommand command); +} diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/MemberNotificationSettingServiceImpl.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/MemberNotificationSettingServiceImpl.java new file mode 100644 index 00000000..1c4187a7 --- /dev/null +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/MemberNotificationSettingServiceImpl.java @@ -0,0 +1,44 @@ +package kr.ac.kookmin.stream.member.domain.notification.service.impl; + +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.member.domain.notification.domain.MemberNotificationSetting; +import kr.ac.kookmin.stream.member.domain.notification.domain.MemberNotificationSettingUpdateCommand; +import kr.ac.kookmin.stream.member.domain.notification.domain.NotificationErrorCode; +import kr.ac.kookmin.stream.member.domain.notification.repository.MemberNotificationSettingRepository; +import kr.ac.kookmin.stream.member.domain.notification.service.MemberNotificationSettingService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +class MemberNotificationSettingServiceImpl implements MemberNotificationSettingService { + + private final MemberNotificationSettingRepository memberNotificationSettingRepository; + + @Override + public MemberNotificationSetting getByMemberId(Long memberId) { + return memberNotificationSettingRepository.findByMemberId(memberId) + .orElseThrow(() -> new BusinessException(NotificationErrorCode.NOTIFICATION_SETTING_NOT_FOUND)); + } + + // 필수 약관이 늘어 다시 가입하는 회원은 이미 설정이 있으므로 바꾼 값을 그대로 둔다 + @Override + @Transactional + public void createDefaultIfAbsent(Long memberId) { + if (memberNotificationSettingRepository.existsByMemberId(memberId)) { + return; + } + memberNotificationSettingRepository.save(MemberNotificationSetting.createDefault(memberId)); + } + + @Override + @Transactional + public MemberNotificationSetting update(Long memberId, MemberNotificationSettingUpdateCommand command) { + MemberNotificationSetting setting = getByMemberId(memberId); + if (!setting.update(command)) { + return setting; + } + return memberNotificationSettingRepository.save(setting); + } +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberNotificationSettingJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberNotificationSettingJpaRepository.java new file mode 100644 index 00000000..355d8bd2 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberNotificationSettingJpaRepository.java @@ -0,0 +1,11 @@ +package kr.ac.kookmin.stream.db.member; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberNotificationSettingJpaRepository extends JpaRepository { + + Optional findByMemberId(Long memberId); + + boolean existsByMemberId(Long memberId); +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberNotificationSettingRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberNotificationSettingRepositoryImpl.java new file mode 100644 index 00000000..dfab9245 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberNotificationSettingRepositoryImpl.java @@ -0,0 +1,30 @@ +package kr.ac.kookmin.stream.db.member; + +import java.util.Optional; +import kr.ac.kookmin.stream.member.domain.notification.domain.MemberNotificationSetting; +import kr.ac.kookmin.stream.member.domain.notification.repository.MemberNotificationSettingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class MemberNotificationSettingRepositoryImpl implements MemberNotificationSettingRepository { + + private final MemberNotificationSettingJpaRepository memberNotificationSettingJpaRepository; + + @Override + public Optional findByMemberId(Long memberId) { + return memberNotificationSettingJpaRepository.findByMemberId(memberId) + .map(MemberNotificationSettingJpaEntity::toDomain); + } + + @Override + public boolean existsByMemberId(Long memberId) { + return memberNotificationSettingJpaRepository.existsByMemberId(memberId); + } + + @Override + public MemberNotificationSetting save(MemberNotificationSetting setting) { + return memberNotificationSettingJpaRepository.save(MemberNotificationSettingJpaEntity.from(setting)).toDomain(); + } +} diff --git a/infrastructure/db/src/main/resources/db/migration/V17__insert_default_member_notification_settings.sql b/infrastructure/db/src/main/resources/db/migration/V17__insert_default_member_notification_settings.sql new file mode 100644 index 00000000..79082f5f --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V17__insert_default_member_notification_settings.sql @@ -0,0 +1,11 @@ +-- 알림 설정은 회원가입할 때 만든다. 그 전에 들어온 회원에게도 기본 설정(모두 켜짐)을 만들어 둔다. +-- 아직 가입을 마치지 않은 회원도 포함한다. 나중에 가입하면 이미 있는 설정을 그대로 쓴다. +INSERT INTO member_notification_settings (member_id) +SELECT m.member_id +FROM members m +WHERE m.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM member_notification_settings s + WHERE s.member_id = m.member_id + );