Skip to content
Merged

Dev #73

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,7 @@ enum class NotificationEventType {

/** 미확인 일정 확인 요청 */
SCHEDULE_CONFIRMATION_REQUESTED,

/** 가족에게 전하기 */
SCHEDULE_FAMILY_NOTIFICATION_REQUESTED,
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,10 @@ enum class NotificationType(
settingType = NotificationSettingType.FAMILY_SCHEDULE_CHECK,
defaultRouteType = NotificationRouteType.SCHEDULE_DETAIL,
),
SCHEDULE_FAMILY_NOTIFICATION_REQUESTED(
createsInbox = true,
sendsPush = true,
settingType = NotificationSettingType.FAMILY_SCHEDULE_CHECK,
defaultRouteType = NotificationRouteType.SCHEDULE_DETAIL,
),
}
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,17 @@ data class ScheduleConfirmationRequestedPayload(

override fun toVariables(): Map<String, String> = mapOf("schedule_title" to scheduleTitle)
}

data class ScheduleFamilyNotificationPayload(
val senderName: String,
val scheduleTitle: String,
val dDay: String,
) : NotificationEventPayload {
override val eventType: NotificationEventType = NotificationEventType.SCHEDULE_FAMILY_NOTIFICATION_REQUESTED

override fun toVariables(): Map<String, String> = mapOf(
"sender_name" to senderName,
"schedule_title" to scheduleTitle,
"d_day" to dDay,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.unicorn.server.domain.schedule.event

import com.unicorn.server.common.domain.Event

class FamilyScheduleNotificationRequestedEvent(
val requestId: String,
val scheduleId: String,
val circleId: String,
val senderMemberId: String,
val scheduleTitle: String,
val dDay: String,
) : Event()
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ enum class ScheduleErrorCode(
MEMO_TOO_LONG("S400_10", "Memo must not exceed 500 characters", HttpStatus.BAD_REQUEST),
CONFIRMATION_NOT_SUPPORTED("S400_11", "This schedule does not support confirmation", HttpStatus.BAD_REQUEST),
INVALID_CONFIRMATION_TYPE("S400_12", "Invalid confirmation type", HttpStatus.BAD_REQUEST),
FAMILY_SCHEDULE_NOTIFICATION_NOT_AVAILABLE("S400_13", "Family schedule notification is only available on or before the schedule start date", HttpStatus.BAD_REQUEST),
CIRCLE_ACCESS_DENIED("S403_1", "No access to this circle", HttpStatus.FORBIDDEN),
SCHEDULE_MODIFICATION_DENIED("S403_2", "Only the author or circle initiator can modify this schedule", HttpStatus.FORBIDDEN),
CONFIRMATION_ACCESS_DENIED("S403_3", "Only circle members can register confirmation", HttpStatus.FORBIDDEN),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.unicorn.server.domain.schedule.port.dto

import com.unicorn.server.domain.schedule.vo.ScheduleId

data class RequestFamilyScheduleNotificationCommand(
val scheduleId: ScheduleId,
val circleId: String,
val memberId: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.unicorn.server.domain.schedule.port.`in`

import com.unicorn.server.domain.schedule.port.dto.RequestFamilyScheduleNotificationCommand

interface RequestFamilyScheduleNotificationInPort {
fun request(command: RequestFamilyScheduleNotificationCommand)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.unicorn.server.domain.schedule.service

import com.unicorn.server.common.exception.BusinessException
import com.unicorn.server.common.port.out.event.EventPublisher
import com.unicorn.server.domain.schedule.event.FamilyScheduleNotificationRequestedEvent
import com.unicorn.server.domain.schedule.exception.ScheduleErrorCode
import com.unicorn.server.domain.schedule.port.`in`.RequestFamilyScheduleNotificationInPort
import com.unicorn.server.domain.schedule.port.dto.RequestFamilyScheduleNotificationCommand
import com.unicorn.server.domain.schedule.port.out.CircleAccessOutPort
import com.unicorn.server.domain.schedule.port.out.ScheduleOutPort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDate
import java.time.ZoneId
import java.util.UUID

@Service
@Transactional
class FamilyScheduleNotificationService(
private val scheduleOutPort: ScheduleOutPort,
private val circleAccessOutPort: CircleAccessOutPort,
private val eventPublisher: EventPublisher,
) : RequestFamilyScheduleNotificationInPort {

override fun request(command: RequestFamilyScheduleNotificationCommand) {
if (!circleAccessOutPort.isMember(command.circleId, command.memberId)) {
throw BusinessException(ScheduleErrorCode.CIRCLE_ACCESS_DENIED)
}

val schedule = scheduleOutPort.findActiveByIdAndCircleId(command.scheduleId, command.circleId)
?: throw BusinessException(ScheduleErrorCode.SCHEDULE_NOT_FOUND)
val dDay = schedule.computeDDay(today())
?: throw BusinessException(ScheduleErrorCode.FAMILY_SCHEDULE_NOTIFICATION_NOT_AVAILABLE)

eventPublisher.publish(
FamilyScheduleNotificationRequestedEvent(
requestId = UUID.randomUUID().toString(),
scheduleId = schedule.id.value,
circleId = schedule.circleId,
senderMemberId = command.memberId,
scheduleTitle = schedule.title,
dDay = dDay.toLabel(),
),
)
}

private fun Int.toLabel(): String = if (this == 0) "D-day" else "D-$this"

private fun today(): LocalDate = LocalDate.now(KST)

companion object {
private val KST: ZoneId = ZoneId.of("Asia/Seoul")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.unicorn.server.domain.notification.event.NotificationRequestedEvent
import com.unicorn.server.domain.notification.event.ScheduleCreatedPayload
import com.unicorn.server.domain.notification.event.ScheduleConfirmationRequestedPayload
import com.unicorn.server.domain.notification.event.ScheduleConfirmedByFamilyPayload
import com.unicorn.server.domain.notification.event.ScheduleFamilyNotificationPayload
import com.unicorn.server.domain.notification.event.ScheduleReminderD1Payload
import com.unicorn.server.domain.notification.event.ScheduleReminderDDayAllDayPayload
import com.unicorn.server.domain.notification.event.ScheduleReminderDDayTimedPayload
Expand All @@ -18,6 +19,7 @@ import com.unicorn.server.domain.notification.port.`in`.NotificationSettingInPor
import com.unicorn.server.domain.schedule.event.ScheduleCreatedEvent
import com.unicorn.server.domain.schedule.event.ScheduleConfirmationRequestDueEvent
import com.unicorn.server.domain.schedule.event.ScheduleConfirmedEvent
import com.unicorn.server.domain.schedule.event.FamilyScheduleNotificationRequestedEvent
import com.unicorn.server.domain.schedule.enums.ScheduleReminderType
import com.unicorn.server.domain.schedule.event.ScheduleReminderDueEvent
import com.unicorn.server.domain.schedule.port.`in`.ScheduleConfirmationStatusInPort
Expand Down Expand Up @@ -130,6 +132,34 @@ class ScheduleNotificationEventListener(
}
}

@EventListener
fun handle(event: FamilyScheduleNotificationRequestedEvent) {
val members = circleMemberInPort.getCircleMembers(event.circleId)
val payload = ScheduleFamilyNotificationPayload(
senderName = members.nicknameOf(event.senderMemberId),
scheduleTitle = event.scheduleTitle,
dDay = event.dDay,
)

members
.asSequence()
.filter { it.active && it.memberId != event.senderMemberId }
.filter { notificationSettingInPort.getSetting(it.memberId).isEnabled(NotificationSettingType.FAMILY_SCHEDULE_CHECK) }
.forEach { member ->
val receiverDedupKey = "schedule-family-notification:${event.requestId}:${member.memberId}"
notificationPushTokenInPort.getActiveReceivable(member.memberId).forEach { pushToken ->
eventPublisher.publish(
NotificationRequestedEvent(
channel = NotificationChannel.PUSH,
receiver = pushToken.token,
payload = payload,
dedupKey = "$receiverDedupKey:token:${requireNotNull(pushToken.id).value}",
),
)
}
}
}

private fun List<CircleMemberDto>.nicknameOf(memberId: String): String =
firstOrNull { it.memberId == memberId }?.nickname
?: error("Active circle member not found: memberId=$memberId")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,33 @@ interface ScheduleApiDoc {
@PathVariable confirmationId: Long,
): ApiResponse<Unit>

@Operation(
summary = "가족에게 전하기",
description = """
일정을 써클의 다른 활성 구성원에게 전합니다.

**권한**: 써클 구성원(MEMBER 이상)만 요청 가능합니다.
**수신 대상**: 요청자를 제외한 활성 구성원입니다. 확인 여부와 무관하게 발송합니다.
**알림 설정**: 가족 일정 확인 알림을 ON으로 설정한 구성원에게만 푸시 요청을 생성합니다.
**발송 횟수**: 제한 없이 요청할 수 있습니다.
""",
)
@ApiErrorCodeExamples(
ApiErrorCodeExample(codeType = CommonErrorCode::class, code = "UNAUTHORIZED"),
ApiErrorCodeExample(codeType = ScheduleErrorCode::class, code = "FAMILY_SCHEDULE_NOTIFICATION_NOT_AVAILABLE"),
ApiErrorCodeExample(codeType = ScheduleErrorCode::class, code = "CIRCLE_ACCESS_DENIED"),
ApiErrorCodeExample(codeType = ScheduleErrorCode::class, code = "SCHEDULE_NOT_FOUND"),
)
@ApiSuccessCodeExample(Unit::class)
fun requestFamilyNotification(
@Parameter(hidden = true)
@AuthenticationPrincipal memberId: String,
@Parameter(description = "써클 ID", example = "CC202506010000000001")
@PathVariable circleId: String,
@Parameter(description = "일정 ID", example = "SC202407070000000001")
@PathVariable scheduleId: String,
): ApiResponse<Unit>

@Operation(
summary = "일정 확인 종류 조회",
description = """
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ import com.unicorn.server.domain.schedule.port.`in`.DeleteScheduleInPort
import com.unicorn.server.domain.schedule.port.`in`.GetScheduleDetailInPort
import com.unicorn.server.domain.schedule.port.`in`.GetScheduleListInPort
import com.unicorn.server.domain.schedule.port.`in`.RegisterConfirmationInPort

import com.unicorn.server.domain.schedule.port.`in`.RequestFamilyScheduleNotificationInPort
import com.unicorn.server.domain.schedule.port.`in`.UpdateScheduleInPort
import com.unicorn.server.domain.schedule.port.dto.CreateScheduleCommand
import com.unicorn.server.domain.schedule.port.dto.RegisterConfirmationCommand
import com.unicorn.server.domain.schedule.port.dto.RequestFamilyScheduleNotificationCommand
import com.unicorn.server.domain.schedule.port.dto.UpdateScheduleCommand
import com.unicorn.server.domain.schedule.vo.ScheduleId
import com.unicorn.server.infrastructure.adapter.`in`.web.common.dto.ApiResponse
Expand Down Expand Up @@ -43,6 +46,7 @@ class ScheduleController(
private val getScheduleDetailInPort: GetScheduleDetailInPort,
private val registerConfirmationInPort: RegisterConfirmationInPort,
private val cancelConfirmationInPort: CancelConfirmationInPort,
private val requestFamilyScheduleNotificationInPort: RequestFamilyScheduleNotificationInPort,
) : ScheduleApiDoc {

@PostMapping
Expand Down Expand Up @@ -154,6 +158,22 @@ class ScheduleController(
return ApiResponse.success()
}

@PostMapping("/{scheduleId}/family-notifications")
override fun requestFamilyNotification(
@AuthenticationPrincipal memberId: String,
@PathVariable circleId: String,
@PathVariable scheduleId: String,
): ApiResponse<Unit> {
requestFamilyScheduleNotificationInPort.request(
RequestFamilyScheduleNotificationCommand(
scheduleId = ScheduleId.of(scheduleId),
circleId = circleId,
memberId = memberId,
),
)
return ApiResponse.success()
}

@GetMapping("/confirmations")
override fun getConfirmationTypes(
@AuthenticationPrincipal memberId: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
alter table notification_template
drop constraint if exists notification_template_event_type_check;

alter table notification_template
add constraint notification_template_event_type_check check (
event_type in (
'CIRCLE_JOIN_COMPLETED',
'SCHEDULE_CREATED',
'SCHEDULE_DELETED',
'SCHEDULE_REMINDER_D7',
'SCHEDULE_REMINDER_D1',
'SCHEDULE_REMINDER_DDAY_ALL_DAY',
'SCHEDULE_REMINDER_DDAY_TIMED',
'SCHEDULE_CONFIRMED_BY_FAMILY',
'SCHEDULE_CONFIRMATION_REQUESTED',
'SCHEDULE_FAMILY_NOTIFICATION_REQUESTED'
)
);

alter table notification
drop constraint if exists notification_event_type_check;

alter table notification
add constraint notification_event_type_check check (
event_type in (
'CIRCLE_JOIN_COMPLETED',
'SCHEDULE_CREATED',
'SCHEDULE_DELETED',
'SCHEDULE_REMINDER_D7',
'SCHEDULE_REMINDER_D1',
'SCHEDULE_REMINDER_DDAY_ALL_DAY',
'SCHEDULE_REMINDER_DDAY_TIMED',
'SCHEDULE_CONFIRMED_BY_FAMILY',
'SCHEDULE_CONFIRMATION_REQUESTED',
'SCHEDULE_FAMILY_NOTIFICATION_REQUESTED'
)
);

alter table notification_inbox_item
drop constraint if exists notification_inbox_item_notification_type_check;

alter table notification_inbox_item
add constraint notification_inbox_item_notification_type_check check (
notification_type in (
'CIRCLE_JOIN_COMPLETED',
'SCHEDULE_CREATED',
'SCHEDULE_DELETED',
'SCHEDULE_REMINDER_D7',
'SCHEDULE_REMINDER_D1',
'SCHEDULE_REMINDER_DDAY_ALL_DAY',
'SCHEDULE_REMINDER_DDAY_TIMED',
'SCHEDULE_CONFIRMED_BY_FAMILY',
'SCHEDULE_CONFIRMATION_REQUESTED',
'SCHEDULE_FAMILY_NOTIFICATION_REQUESTED'
)
);

insert into notification_template (event_type, title_template, body_template, active, created_at)
values (
'SCHEDULE_FAMILY_NOTIFICATION_REQUESTED',
'{sender_name}님이 알림을 보냈어요',
'{schedule_title} · {d_day}, 확인하고 같이 챙겨봐요!',
true,
current_timestamp
);
Loading
Loading