diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 6b599ca..a0d54fb 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -19,6 +19,12 @@ jobs: java-version: '17' distribution: 'temurin' + - name: Create Firebase Config Directory + run: | + mkdir -p src/main/resources/firebase + echo "${{ secrets.FIREBASE_SERVICE_KEY }}" | base64 --decode > src/main/resources/firebase/firebaseServiceKey.json + shell: bash + - name: Setup Gradle uses: gradle/actions/setup-gradle@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0 diff --git a/.gitignore b/.gitignore index e9950f2..159c401 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,5 @@ out/ application-local.yml -.DS_Store \ No newline at end of file +.DS_Store +/src/main/resources/firebase diff --git a/build.gradle.kts b/build.gradle.kts index 68a61e8..2c4aef1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -59,6 +59,8 @@ dependencies { implementation("io.github.oshai:kotlin-logging-jvm:7.0.3") + implementation("com.google.firebase:firebase-admin:9.4.3") + compileOnly("org.projectlombok:lombok") runtimeOnly("com.h2database:h2") runtimeOnly("com.mysql:mysql-connector-j") diff --git a/src/main/kotlin/site/billilge/api/backend/domain/item/controller/ItemApi.kt b/src/main/kotlin/site/billilge/api/backend/domain/item/controller/ItemApi.kt new file mode 100644 index 0000000..773c6af --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/item/controller/ItemApi.kt @@ -0,0 +1,32 @@ +package site.billilge.api.backend.domain.item.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.ModelAttribute +import site.billilge.api.backend.domain.item.dto.response.ItemFindAllResponse +import site.billilge.api.backend.global.dto.SearchCondition + +@Tag(name = "Item", description = "대여 물품 조회 API") +interface ItemApi { + + @Operation( + summary = "대여 물품 목록 조회", + description = "검색어에 따라 물품 이름에 포함된 단어를 기반으로 대여 물품 목록을 조회합니다." + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "대여 물품 목록 조회 성공" + ) + ] + ) + @GetMapping + fun getItems( + @ModelAttribute searchCondition: SearchCondition + ): ResponseEntity +} \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/item/controller/ItemController.kt b/src/main/kotlin/site/billilge/api/backend/domain/item/controller/ItemController.kt new file mode 100644 index 0000000..260ab12 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/item/controller/ItemController.kt @@ -0,0 +1,24 @@ +package site.billilge.api.backend.domain.item.controller + +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.ModelAttribute +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import site.billilge.api.backend.domain.item.dto.response.ItemFindAllResponse +import site.billilge.api.backend.domain.item.service.ItemService +import site.billilge.api.backend.global.dto.SearchCondition + +@RestController +@RequestMapping("/items") +class ItemController( + private val itemService: ItemService +) : ItemApi { + @GetMapping + override fun getItems( + @ModelAttribute searchCondition: SearchCondition + ): ResponseEntity { + val response = itemService.searchItems(searchCondition) + return ResponseEntity.ok(response) + } +} \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/item/dto/response/ItemDetail.kt b/src/main/kotlin/site/billilge/api/backend/domain/item/dto/response/ItemDetail.kt index 47b722c..665f5c6 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/item/dto/response/ItemDetail.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/item/dto/response/ItemDetail.kt @@ -3,8 +3,6 @@ package site.billilge.api.backend.domain.item.dto.response import io.swagger.v3.oas.annotations.media.Schema import site.billilge.api.backend.domain.item.entity.Item import site.billilge.api.backend.domain.item.enums.ItemType -import site.billilge.api.backend.domain.item.exception.ItemErrorCode -import site.billilge.api.backend.global.exception.ApiException @Schema data class ItemDetail( @@ -23,8 +21,7 @@ data class ItemDetail( @JvmStatic fun from(item: Item): ItemDetail { return ItemDetail( - itemId = item.id - ?: throw ApiException(ItemErrorCode.ITEM_ID_IS_NULL), + itemId = item.id!!, itemName = item.name, itemType = item.type, count = item.count, diff --git a/src/main/kotlin/site/billilge/api/backend/domain/item/repository/ItemRepository.kt b/src/main/kotlin/site/billilge/api/backend/domain/item/repository/ItemRepository.kt index 37b856c..bc21adc 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/item/repository/ItemRepository.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/item/repository/ItemRepository.kt @@ -1,10 +1,15 @@ package site.billilge.api.backend.domain.item.repository import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import site.billilge.api.backend.domain.item.entity.Item import java.util.* interface ItemRepository: JpaRepository, ItemRepositoryCustom { override fun findById(id: Long): Optional fun existsByName(name: String): Boolean + + @Query("SELECT i FROM Item i WHERE i.name LIKE CONCAT('%', :search, '%')") + fun findByItemName(@Param("search") search: String): List } \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/item/service/ItemService.kt b/src/main/kotlin/site/billilge/api/backend/domain/item/service/ItemService.kt index 3801813..0c73cb5 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/item/service/ItemService.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/item/service/ItemService.kt @@ -117,4 +117,10 @@ class ItemService( if (image.contentType != "image/svg+xml") throw ApiException(ItemErrorCode.IMAGE_IS_NOT_SVG) } + + fun searchItems(searchCondition: SearchCondition): ItemFindAllResponse { + val items = itemRepository.findByItemName(searchCondition.search) + + return ItemFindAllResponse(items.map { ItemDetail.from(it) }) + } } \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/member/controller/MemberApi.kt b/src/main/kotlin/site/billilge/api/backend/domain/member/controller/MemberApi.kt new file mode 100644 index 0000000..ed13576 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/member/controller/MemberApi.kt @@ -0,0 +1,31 @@ +package site.billilge.api.backend.domain.member.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.RequestBody +import site.billilge.api.backend.domain.member.dto.request.MemberFCMTokenRequest +import site.billilge.api.backend.global.security.oauth2.UserAuthInfo + +@Tag(name = "Member", description = "회원 API") +interface MemberApi { + @Operation( + summary = "FCM 토큰 전송", + description = "서버 측으로 회원 기기의 FCM 토큰을 전송하는 API" + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "FCM 토큰 전송 성공" + ) + ] + ) + fun setFCMToken( + @AuthenticationPrincipal userAuthInfo: UserAuthInfo, + @RequestBody request: MemberFCMTokenRequest + ): ResponseEntity +} \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/member/controller/MemberController.kt b/src/main/kotlin/site/billilge/api/backend/domain/member/controller/MemberController.kt new file mode 100644 index 0000000..119bb00 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/member/controller/MemberController.kt @@ -0,0 +1,26 @@ +package site.billilge.api.backend.domain.member.controller + +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import site.billilge.api.backend.domain.member.dto.request.MemberFCMTokenRequest +import site.billilge.api.backend.domain.member.service.MemberService +import site.billilge.api.backend.global.security.oauth2.UserAuthInfo + +@RestController +@RequestMapping("/members") +class MemberController( + private val memberService: MemberService, +) : MemberApi { + @PostMapping("/me/fcm-token") + override fun setFCMToken( + @AuthenticationPrincipal userAuthInfo: UserAuthInfo, + @RequestBody request: MemberFCMTokenRequest + ): ResponseEntity { + memberService.setMemberFCMToken(userAuthInfo.memberId, request) + return ResponseEntity.ok().build() + } +} \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/member/dto/request/MemberFCMTokenRequest.kt b/src/main/kotlin/site/billilge/api/backend/domain/member/dto/request/MemberFCMTokenRequest.kt new file mode 100644 index 0000000..497dda6 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/member/dto/request/MemberFCMTokenRequest.kt @@ -0,0 +1,9 @@ +package site.billilge.api.backend.domain.member.dto.request + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema +data class MemberFCMTokenRequest( + @field:Schema(description = "회원의 디바이스 FCM 토큰") + val token: String +) diff --git a/src/main/kotlin/site/billilge/api/backend/domain/member/entity/Member.kt b/src/main/kotlin/site/billilge/api/backend/domain/member/entity/Member.kt index 86f47b8..1d428b5 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/member/entity/Member.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/member/entity/Member.kt @@ -40,6 +40,10 @@ class Member( var role: Role = Role.USER protected set + @Column(name = "fcm_token") + var fcmToken: String? = null + protected set + @CreatedDate @Column(name = "created_at", nullable = false) var createdAt: LocalDateTime = LocalDateTime.now() @@ -57,4 +61,8 @@ class Member( fun updateEmail(email: String) { this.email = email } + + fun updateFCMToken(fcmToken: String) { + this.fcmToken = fcmToken + } } \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/member/repository/MemberRepository.kt b/src/main/kotlin/site/billilge/api/backend/domain/member/repository/MemberRepository.kt index ac1f9d9..aceddf5 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/member/repository/MemberRepository.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/member/repository/MemberRepository.kt @@ -28,4 +28,6 @@ interface MemberRepository: JpaRepository { @Query("select m from Member m where m.studentId in :studentIds") fun findAllByStudentIds(@Param("studentIds") studentIds: List): List + + fun findAllByRole(role: Role): List } \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/member/service/MemberService.kt b/src/main/kotlin/site/billilge/api/backend/domain/member/service/MemberService.kt index 76132e7..92c35a9 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/member/service/MemberService.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/member/service/MemberService.kt @@ -2,9 +2,11 @@ package site.billilge.api.backend.domain.member.service import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Sort +import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import site.billilge.api.backend.domain.member.dto.request.AdminRequest +import site.billilge.api.backend.domain.member.dto.request.MemberFCMTokenRequest import site.billilge.api.backend.domain.member.dto.request.SignUpRequest import site.billilge.api.backend.domain.member.dto.response.* import site.billilge.api.backend.domain.member.exception.MemberErrorCode @@ -118,4 +120,12 @@ class MemberService( return MemberFindAllResponse(memberDetails.toList(), members.totalPages) } + + @Transactional + fun setMemberFCMToken(memberId: Long?, request: MemberFCMTokenRequest) { + val member = (memberRepository.findByIdOrNull(memberId!!) + ?: throw ApiException(MemberErrorCode.MEMBER_NOT_FOUND)) + + member.updateFCMToken(request.token) + } } \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/controller/AdminNotificationApi.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/controller/AdminNotificationApi.kt new file mode 100644 index 0000000..9aec2e0 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/controller/AdminNotificationApi.kt @@ -0,0 +1,27 @@ +package site.billilge.api.backend.domain.notification.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import site.billilge.api.backend.domain.notification.dto.response.NotificationFindAllResponse +import site.billilge.api.backend.global.security.oauth2.UserAuthInfo + +@Tag(name = "(Admin) Notification", description = "관리자용 알림 API") +interface AdminNotificationApi { + @Operation( + summary = "관리자 알림 목록 조회", + description = "로그인한 사용자의 관리자 알림 목록을 최신순으로 조회합니다." + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "관리자 알림 목록 조회 성공" + ) + ] + ) + fun getAdminNotifications(@AuthenticationPrincipal userAuthInfo: UserAuthInfo): ResponseEntity +} \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/controller/AdminNotificationController.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/controller/AdminNotificationController.kt new file mode 100644 index 0000000..54f1b1b --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/controller/AdminNotificationController.kt @@ -0,0 +1,21 @@ +package site.billilge.api.backend.domain.notification.controller + +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import site.billilge.api.backend.domain.notification.dto.response.NotificationFindAllResponse +import site.billilge.api.backend.domain.notification.service.NotificationService +import site.billilge.api.backend.global.security.oauth2.UserAuthInfo + +@RestController +@RequestMapping("/admin/notifications") +class AdminNotificationController( + private val notificationService: NotificationService +) : AdminNotificationApi { + @GetMapping + override fun getAdminNotifications(@AuthenticationPrincipal userAuthInfo: UserAuthInfo): ResponseEntity { + return ResponseEntity.ok(notificationService.getAdminNotifications(userAuthInfo.memberId)) + } +} \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/controller/NotificationApi.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/controller/NotificationApi.kt new file mode 100644 index 0000000..1da6455 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/controller/NotificationApi.kt @@ -0,0 +1,65 @@ +package site.billilge.api.backend.domain.notification.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.PathVariable +import site.billilge.api.backend.domain.notification.dto.response.NotificationCountResponse +import site.billilge.api.backend.domain.notification.dto.response.NotificationFindAllResponse +import site.billilge.api.backend.global.security.oauth2.UserAuthInfo + +@Tag(name = "Notification", description = "알림 API") +interface NotificationApi { + + @Operation( + summary = "사용자의 알림 목록 조회", + description = "로그인한 사용자의 알림 목록을 최신순으로 조회합니다." + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "알림 목록 조회 성공" + ) + ] + ) + fun getNotifications( + @AuthenticationPrincipal userAuthInfo: UserAuthInfo + ): ResponseEntity + + + @Operation( + summary = "알림 읽음 처리", + description = "사용자가 특정 알림을 읽음 처리합니다." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "알림 읽음 처리 완료"), + ApiResponse(responseCode = "403", description = "권한이 없는 사용자"), + ApiResponse(responseCode = "404", description = "알림을 찾을 수 없음") + ] + ) + fun readNotification( + @AuthenticationPrincipal userAuthInfo: UserAuthInfo, + @PathVariable notificationId: Long + ): ResponseEntity + + @Operation( + summary = "알림 개수 조회", + description = "로그인한 사용자의 알림 개수를 조회합니다. (메인 페이지)" + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "개수 조회 성공" + ) + ] + ) + fun getNotificationCount( + @AuthenticationPrincipal userAuthInfo: UserAuthInfo + ): ResponseEntity +} \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/controller/NotificationController.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/controller/NotificationController.kt new file mode 100644 index 0000000..5d8c1ec --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/controller/NotificationController.kt @@ -0,0 +1,42 @@ +package site.billilge.api.backend.domain.notification.controller + +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.* +import site.billilge.api.backend.domain.notification.dto.response.NotificationCountResponse +import site.billilge.api.backend.domain.notification.dto.response.NotificationFindAllResponse +import site.billilge.api.backend.domain.notification.service.NotificationService +import site.billilge.api.backend.global.security.oauth2.UserAuthInfo + +@RestController +@RequestMapping("/notifications") +class NotificationController ( + private val notificationService: NotificationService +): NotificationApi { + + @GetMapping + override fun getNotifications( + @AuthenticationPrincipal userAuthInfo: UserAuthInfo + ): ResponseEntity { + val memberId = userAuthInfo.memberId + return ResponseEntity.ok(notificationService.getNotifications(memberId)) + } + + @GetMapping("/count") + override fun getNotificationCount( + @AuthenticationPrincipal userAuthInfo: UserAuthInfo + ): ResponseEntity { + val memberId = userAuthInfo.memberId + return ResponseEntity.ok(notificationService.getNotificationCount(memberId)) + } + + @PatchMapping("/{notificationId}") + override fun readNotification( + @AuthenticationPrincipal userAuthInfo: UserAuthInfo, + @PathVariable notificationId: Long + ): ResponseEntity { + val memberId = userAuthInfo.memberId + notificationService.readNotification(memberId, notificationId) + return ResponseEntity.ok().build() + } +} \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/dto/response/NotificationCountResponse.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/dto/response/NotificationCountResponse.kt new file mode 100644 index 0000000..9f543a4 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/dto/response/NotificationCountResponse.kt @@ -0,0 +1,5 @@ +package site.billilge.api.backend.domain.notification.dto.response + +data class NotificationCountResponse( + val notificationCount: Int +) \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/dto/response/NotificationDetail.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/dto/response/NotificationDetail.kt new file mode 100644 index 0000000..eb4af61 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/dto/response/NotificationDetail.kt @@ -0,0 +1,32 @@ +package site.billilge.api.backend.domain.notification.dto.response + +import site.billilge.api.backend.domain.notification.entity.Notification +import site.billilge.api.backend.domain.notification.enums.NotificationStatus +import java.time.LocalDateTime + +data class NotificationDetail( + val notificationId: Long, + val status: NotificationStatus, + val title: String, + val message: String, + val link: String, + val isRead: Boolean, + val createdAt: LocalDateTime +) { + companion object { + @JvmStatic + fun from(notification: Notification): NotificationDetail { + val status = notification.status + + return NotificationDetail( + notificationId = notification.id!!, + status = status, + title = status.title, + message = status.formattedMessage(*notification.formatValueList.toTypedArray()), + link = status.link, + isRead = notification.isRead, + createdAt = notification.createdAt + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/dto/response/NotificationFindAllResponse.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/dto/response/NotificationFindAllResponse.kt new file mode 100644 index 0000000..9b3e967 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/dto/response/NotificationFindAllResponse.kt @@ -0,0 +1,10 @@ +package site.billilge.api.backend.domain.notification.dto.response + +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Schema + +@Schema +data class NotificationFindAllResponse( + @field:ArraySchema(schema = Schema(implementation = NotificationDetail::class)) + val messages: List +) diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/entity/Notification.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/entity/Notification.kt new file mode 100644 index 0000000..7415bcf --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/entity/Notification.kt @@ -0,0 +1,43 @@ +package site.billilge.api.backend.domain.notification.entity + +import jakarta.persistence.* +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import site.billilge.api.backend.domain.member.entity.Member +import site.billilge.api.backend.domain.notification.enums.NotificationStatus +import java.time.LocalDateTime + +@Entity +@Table(name = "notifications") +@EntityListeners(AuditingEntityListener::class) +class Notification( + @JoinColumn(name = "member_id", nullable = false) + @ManyToOne + val member: Member, + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false) + val status: NotificationStatus, + + @Column(name = "format_values") + val formatValues: String? = null, + + @Column(name = "is_read", nullable = false, columnDefinition = "TINYINT(1)") + var isRead: Boolean = false, + + @CreatedDate + @Column(name = "created_at", nullable = false) + val createdAt: LocalDateTime = LocalDateTime.now() +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "notification_id") + val id: Long? = null + + fun readNotification() { + this.isRead = true + } + + val formatValueList: List + get() = formatValues?.split(",") ?: emptyList() +} \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/enums/NotificationStatus.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/enums/NotificationStatus.kt new file mode 100644 index 0000000..779a1fd --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/enums/NotificationStatus.kt @@ -0,0 +1,60 @@ +package site.billilge.api.backend.domain.notification.enums + +enum class NotificationStatus( + val title: String, + val message: String, + val link: String +) { + ADMIN_RENTAL_APPLY( + "대여 신청", + "%s(%s) 님이 %s에 %s 대여를 신청했어요.", + "/mobile/admin/dashboard" + ), + ADMIN_RENTAL_CANCEL( + "신청 취소", + "%s(%s) 님이 %s 대여 신청을 취소했어요.", + "/mobile/admin/dashboard" + ), + ADMIN_RETURN_APPLY( + "반납 신청", + "%s(%s) 님이 %s 반납을 신청했어요.", + "/mobile/admin/dashboard" + ), + ADMIN_RETURN_CANCEL( + "반납 취소", + "%s(%s) 님이 %s 반납 신청을 취소했어요.", + "/mobile/admin/dashboard" + ), + USER_RENTAL_APPLY( + "대여 신청", + "%s 대여를 신청했어요! 관리자가 처리할 때까지 잠시만 기다려 주세요.", + "/mobile/history" + ), + USER_RENTAL_APPROVED( + "대여 승인", + "관리자가 %s 대여 신청을 승인했어요! 대여 시각에 과방으로 오시면 물품을 빌릴 수 있어요.", + "/mobile/history" + ), + USER_RENTAL_REJECTED( + "대여 반려", + "%s 대여 신청이 반려되었어요. 자세한 사항은 소프트웨어융합대학 카카오톡에 문의해 주세요.", + "/mobile/history" + ), + USER_RETURN_APPLY( + "반납 신청", + "%s 반납을 신청했어요! 관리자가 처리할 때까지 잠시만 기다려 주세요.", + "/mobile/history" + ), + USER_RETURN_APPROVED( + "반납 승인", + "관리자가 %s 반납 신청을 승인했어요! 과방으로 오시면 물품을 반납할 수 있어요.", + "/mobile/history" + ), + USER_RETURN_COMPLETED( + "반납 완료", + "%s 반납을 완료했어요.", + "/mobile/history" + ); + + fun formattedMessage(vararg formatValues: String): String = message.format(*formatValues) +} \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/exception/NotificationErrorCode.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/exception/NotificationErrorCode.kt new file mode 100644 index 0000000..6d0c8fb --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/exception/NotificationErrorCode.kt @@ -0,0 +1,12 @@ +package site.billilge.api.backend.domain.notification.exception + +import org.springframework.http.HttpStatus +import site.billilge.api.backend.global.exception.ErrorCode + +enum class NotificationErrorCode( + override val message: String, + override val httpStatus: HttpStatus +) : ErrorCode { + NOTIFICATION_NOT_FOUND("알림 정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + NOTIFICATION_ACCESS_DENIED("알림을 수정할 권한이 없습니다.", HttpStatus.FORBIDDEN) +} \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/repository/NotificationRepository.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/repository/NotificationRepository.kt new file mode 100644 index 0000000..1d423f4 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/repository/NotificationRepository.kt @@ -0,0 +1,6 @@ +package site.billilge.api.backend.domain.notification.repository + +import org.springframework.data.jpa.repository.JpaRepository +import site.billilge.api.backend.domain.notification.entity.Notification + +interface NotificationRepository : JpaRepository, NotificationRepositoryCustom \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/repository/NotificationRepositoryCustom.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/repository/NotificationRepositoryCustom.kt new file mode 100644 index 0000000..4112a54 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/repository/NotificationRepositoryCustom.kt @@ -0,0 +1,9 @@ +package site.billilge.api.backend.domain.notification.repository + +import site.billilge.api.backend.domain.notification.entity.Notification + +interface NotificationRepositoryCustom { + fun findAllUserNotificationsByMemberId(memberId: Long): List + fun findAllAdminNotificationsByMemberId(memberId: Long): List + fun countUserNotificationsByMemberId(memberId: Long): Int +} \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/repository/NotificationRepositoryCustomImpl.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/repository/NotificationRepositoryCustomImpl.kt new file mode 100644 index 0000000..b454474 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/repository/NotificationRepositoryCustomImpl.kt @@ -0,0 +1,79 @@ +package site.billilge.api.backend.domain.notification.repository + +import com.querydsl.core.types.dsl.BooleanExpression +import com.querydsl.jpa.impl.JPAQueryFactory +import site.billilge.api.backend.domain.notification.entity.Notification +import site.billilge.api.backend.domain.notification.entity.QNotification +import site.billilge.api.backend.domain.notification.enums.NotificationStatus + +class NotificationRepositoryCustomImpl( + private val queryFactory: JPAQueryFactory +) : NotificationRepositoryCustom { + override fun findAllUserNotificationsByMemberId(memberId: Long): List { + val notification = QNotification.notification + + return queryFactory + .select(notification) + .from(notification) + .where( + memberIdEquals(notification, memberId) + .and( + notification.status.`in`(USER_TYPE) + ) + ) + .orderBy(notification.createdAt.desc()) + .fetch() + } + + override fun findAllAdminNotificationsByMemberId(memberId: Long): List { + val notification = QNotification.notification + + return queryFactory + .select(notification) + .from(notification) + .where( + memberIdEquals(notification, memberId) + .and( + notification.status.`in`(ADMIN_TYPE) + ) + ) + .orderBy(notification.createdAt.desc()) + .fetch() + } + + override fun countUserNotificationsByMemberId(memberId: Long): Int { + val notification = QNotification.notification + + return queryFactory + .select(notification.count()) + .from(notification) + .where( + memberIdEquals(notification, memberId) + .and( + notification.status.`in`(USER_TYPE) + ) + ) + .fetchOne()?.toInt() ?: 0 + } + + private fun memberIdEquals(notification: QNotification, memberId: Long): BooleanExpression { + return notification.member.id.eq(memberId) + } + + companion object { + private val USER_TYPE = listOf( + NotificationStatus.USER_RENTAL_APPLY, + NotificationStatus.USER_RENTAL_APPROVED, + NotificationStatus.USER_RENTAL_REJECTED, + NotificationStatus.USER_RETURN_APPLY, + NotificationStatus.USER_RETURN_COMPLETED + ) + + private val ADMIN_TYPE = listOf( + NotificationStatus.ADMIN_RENTAL_APPLY, + NotificationStatus.ADMIN_RENTAL_CANCEL, + NotificationStatus.ADMIN_RETURN_APPLY, + NotificationStatus.ADMIN_RETURN_CANCEL + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationService.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationService.kt new file mode 100644 index 0000000..9f5a08b --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationService.kt @@ -0,0 +1,98 @@ +package site.billilge.api.backend.domain.notification.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import site.billilge.api.backend.domain.member.entity.Member +import site.billilge.api.backend.domain.member.enums.Role +import site.billilge.api.backend.domain.member.repository.MemberRepository +import site.billilge.api.backend.domain.notification.dto.response.NotificationCountResponse +import site.billilge.api.backend.domain.notification.dto.response.NotificationDetail +import site.billilge.api.backend.domain.notification.dto.response.NotificationFindAllResponse +import site.billilge.api.backend.domain.notification.entity.Notification +import site.billilge.api.backend.domain.notification.enums.NotificationStatus +import site.billilge.api.backend.domain.notification.exception.NotificationErrorCode +import site.billilge.api.backend.domain.notification.repository.NotificationRepository +import site.billilge.api.backend.global.exception.ApiException +import site.billilge.api.backend.global.external.fcm.FCMService + +private val log = KotlinLogging.logger {} + +@Service +@Transactional(readOnly = true) +class NotificationService( + private val notificationRepository: NotificationRepository, + private val fcmService: FCMService, + private val memberRepository: MemberRepository, +) { + fun getNotifications(memberId: Long?): NotificationFindAllResponse { + val notifications = notificationRepository.findAllUserNotificationsByMemberId(memberId!!) + + return NotificationFindAllResponse( + notifications + .map { NotificationDetail.from(it) }) + } + + @Transactional + fun readNotification(memberId: Long?, notificationId: Long) { + val notification = notificationRepository.findById(notificationId) + .orElseThrow { ApiException(NotificationErrorCode.NOTIFICATION_NOT_FOUND) } + + if (notification.member.id != memberId) { + throw ApiException(NotificationErrorCode.NOTIFICATION_ACCESS_DENIED) + } + + notification.readNotification() + } + + fun getAdminNotifications(memberId: Long?): NotificationFindAllResponse { + val notifications = notificationRepository.findAllAdminNotificationsByMemberId(memberId!!) + + return NotificationFindAllResponse( + notifications + .map { NotificationDetail.from(it) }) + } + + @Transactional + fun sendNotification( + member: Member, + status: NotificationStatus, + formatValues: List, + needPush: Boolean = false + ) { + val notification = Notification( + member = member, + status = status, + formatValues = formatValues.joinToString(",") + ) + + notificationRepository.save(notification) + + if (needPush) { + if (member.fcmToken == null) { + log.warn { "(studentId=${member.studentId}) FCM Token is null" } + return + } + + fcmService.sendPushNotification(member.fcmToken!!, status.title, status.formattedMessage(*formatValues.toTypedArray())) + } + } + + fun sendNotificationToAdmin( + type: NotificationStatus, + formatValues: List, + needPush: Boolean = false + ) { + val admins = memberRepository.findAllByRole(Role.ADMIN) + + admins.forEach { admin -> + sendNotification(admin, type, formatValues, needPush) + } + } + + fun getNotificationCount(memberId: Long?): NotificationCountResponse { + val count = notificationRepository.countUserNotificationsByMemberId(memberId!!) + + return NotificationCountResponse(count) + } +} \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/rental/enums/RentalStatus.kt b/src/main/kotlin/site/billilge/api/backend/domain/rental/enums/RentalStatus.kt index 4ed4794..7e37ca6 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/rental/enums/RentalStatus.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/rental/enums/RentalStatus.kt @@ -4,8 +4,9 @@ enum class RentalStatus(val displayName: String) { PENDING("승인 대기 중"), CANCEL("대기 취소"), CONFIRMED("승인 완료"), + REJECTED("대여 반려"), RENTAL("대여 중"), - RETURN_PENDING("반납 대기중"), + RETURN_PENDING("반납 대기 중"), RETURN_CONFIRMED("반납 승인"), RETURNED("반납 완료") } diff --git a/src/main/kotlin/site/billilge/api/backend/domain/rental/service/RentalService.kt b/src/main/kotlin/site/billilge/api/backend/domain/rental/service/RentalService.kt index c04d152..efb621e 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/rental/service/RentalService.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/rental/service/RentalService.kt @@ -6,6 +6,8 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import site.billilge.api.backend.domain.item.repository.ItemRepository import site.billilge.api.backend.domain.member.repository.MemberRepository +import site.billilge.api.backend.domain.notification.enums.NotificationStatus +import site.billilge.api.backend.domain.notification.service.NotificationService import site.billilge.api.backend.domain.rental.dto.request.RentalHistoryRequest import site.billilge.api.backend.domain.rental.dto.request.RentalStatusUpdateRequest import site.billilge.api.backend.domain.rental.dto.response.* @@ -27,6 +29,7 @@ class RentalService( private val memberRepository: MemberRepository, private val rentalRepository: RentalRepository, private val itemRepository: ItemRepository, + private val notificationService: NotificationService ) { @Transactional fun createRental(memberId: Long?, rentalHistoryRequest: RentalHistoryRequest) { @@ -63,6 +66,7 @@ class RentalService( } val rentalHour = requestedRentalDateTime.hour + val rentalMinute = requestedRentalDateTime.minute if (rentalHour < 10 || rentalHour > 17) { throw ApiException(RentalErrorCode.INVALID_RENTAL_TIME_PAST) } @@ -77,6 +81,26 @@ class RentalService( ) rentalRepository.save(newRental) + + notificationService.sendNotification( + rentUser, + NotificationStatus.USER_RENTAL_APPLY, + listOf( + item.name + ), + true + ) + + notificationService.sendNotificationToAdmin( + NotificationStatus.ADMIN_RENTAL_APPLY, + listOf( + rentUser.name, + rentUser.studentId, + "${String.format("%02d", rentalHour)}:${String.format("%02d", rentalMinute)}", + item.name + ), + true + ) } fun getMemberRentalHistory(memberId: Long?, rentalStatus: RentalStatus?): RentalHistoryFindAllResponse { @@ -94,20 +118,47 @@ class RentalService( fun cancelRental(memberId: Long?, rentalHistoryId: Long) { val rentalHistory = rentalRepository.findById(rentalHistoryId) .orElseThrow { ApiException(RentalErrorCode.RENTAL_NOT_FOUND) } + val renter = rentalHistory.member rentalHistory.updateStatus(RentalStatus.CANCEL) - rentalRepository.save(rentalHistory) + notificationService.sendNotificationToAdmin( + NotificationStatus.ADMIN_RENTAL_CANCEL, + listOf( + renter.name, + renter.studentId, + rentalHistory.item.name + ), + true + ) } @Transactional fun returnRental(memberId: Long?, rentalHistoryId: Long) { val rentalHistory = rentalRepository.findById(rentalHistoryId) .orElseThrow { ApiException(RentalErrorCode.RENTAL_NOT_FOUND) } + val renter = rentalHistory.member rentalHistory.updateStatus(RentalStatus.RETURN_PENDING) - rentalRepository.save(rentalHistory) + notificationService.sendNotification( + renter, + NotificationStatus.USER_RETURN_APPLY, + listOf( + rentalHistory.item.name + ), + true + ) + + notificationService.sendNotificationToAdmin( + NotificationStatus.ADMIN_RETURN_APPLY, + listOf( + renter.name, + renter.studentId, + rentalHistory.item.name + ), + true + ) } fun getReturnRequiredItems(memberId: Long?): ReturnRequiredItemFindAllResponse { @@ -145,8 +196,62 @@ class RentalService( fun updateRentalStatus(rentalHistoryId: Long, request: RentalStatusUpdateRequest) { val rentalHistory = rentalRepository.findById(rentalHistoryId) .orElseThrow { ApiException(RentalErrorCode.RENTAL_NOT_FOUND) } + val renter = rentalHistory.member rentalHistory.updateStatus(request.rentalStatus) + + val itemName = rentalHistory.item.name + + when (rentalHistory.rentalStatus) { + RentalStatus.CONFIRMED -> { + //승인 + notificationService.sendNotification( + renter, + NotificationStatus.USER_RENTAL_APPROVED, + listOf( + itemName + ), + true + ) + } + + RentalStatus.REJECTED -> { + //대여 반려 + notificationService.sendNotification( + renter, + NotificationStatus.USER_RENTAL_REJECTED, + listOf( + itemName + ), + true + ) + } + + RentalStatus.RETURN_CONFIRMED -> { + //반납 승인 + notificationService.sendNotification( + renter, + NotificationStatus.USER_RETURN_APPROVED, + listOf( + itemName + ), + true + ) + } + + RentalStatus.RETURNED -> { + //반납 완료 + notificationService.sendNotification( + renter, + NotificationStatus.USER_RETURN_COMPLETED, + listOf( + itemName + ) + ) + } + + else -> return + } } companion object { diff --git a/src/main/kotlin/site/billilge/api/backend/global/dto/SearchCondition.kt b/src/main/kotlin/site/billilge/api/backend/global/dto/SearchCondition.kt index 1e84ba6..b2345a9 100644 --- a/src/main/kotlin/site/billilge/api/backend/global/dto/SearchCondition.kt +++ b/src/main/kotlin/site/billilge/api/backend/global/dto/SearchCondition.kt @@ -14,4 +14,4 @@ data class SearchCondition( schema = Schema(description = "검색어", defaultValue = "") ) val search: String = "" -) +) \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/global/external/fcm/FCMConfig.kt b/src/main/kotlin/site/billilge/api/backend/global/external/fcm/FCMConfig.kt new file mode 100644 index 0000000..b92acef --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/global/external/fcm/FCMConfig.kt @@ -0,0 +1,40 @@ +package site.billilge.api.backend.global.external.fcm + +import com.google.auth.oauth2.GoogleCredentials +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.messaging.FirebaseMessaging +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.io.ClassPathResource +import java.io.IOException + + +@Configuration +class FCMConfig { + @Bean + @Throws(IOException::class) + fun firebaseApp(): FirebaseApp { + val serviceAccountStream = ClassPathResource("firebase/firebaseServiceKey.json").inputStream + + val googleCredentials = GoogleCredentials.fromStream(serviceAccountStream) + .createScoped( + listOf( + "https://www.googleapis.com/auth/firebase.messaging", + "https://www.googleapis.com/auth/cloud-platform" + ) + ) + + googleCredentials.refreshIfExpired() + + val options = FirebaseOptions.builder() + .setCredentials(googleCredentials) + .build() + return FirebaseApp.initializeApp(options) + } + + @Bean + fun firebaseMessaging(firebaseApp: FirebaseApp): FirebaseMessaging { + return FirebaseMessaging.getInstance(firebaseApp) + } +} \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/global/external/fcm/FCMService.kt b/src/main/kotlin/site/billilge/api/backend/global/external/fcm/FCMService.kt new file mode 100644 index 0000000..a6db832 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/global/external/fcm/FCMService.kt @@ -0,0 +1,27 @@ +package site.billilge.api.backend.global.external.fcm + +import com.google.firebase.messaging.FirebaseMessaging +import com.google.firebase.messaging.FirebaseMessagingException +import com.google.firebase.messaging.Message +import com.google.firebase.messaging.Notification +import org.springframework.stereotype.Service + +@Service +class FCMService( + private val firebaseMessaging: FirebaseMessaging, +) { + @Throws(FirebaseMessagingException::class) + fun sendPushNotification(fcmToken: String, title: String, body: String) { + val notification = Notification.builder() + .setTitle(title) + .setBody(body) + .build() + + val fcmMessage = Message.builder() + .setNotification(notification) + .setToken(fcmToken) + .build() + + firebaseMessaging.send(fcmMessage) + } +} \ No newline at end of file