Skip to content
Merged
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
@@ -0,0 +1,44 @@
package devkor.com.teamcback.domain.notification.controller;

import devkor.com.teamcback.domain.notification.dto.request.AdminSurveyPushScheduleReq;
import devkor.com.teamcback.domain.notification.dto.response.AdminSurveyPushScheduleRes;
import devkor.com.teamcback.domain.notification.service.SurveyPushScheduleService;
import devkor.com.teamcback.global.response.CommonResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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
@RequiredArgsConstructor
@RequestMapping("/api/admin/notifications/survey-schedules")
public class AdminSurveyPushScheduleController {

private final SurveyPushScheduleService surveyPushScheduleService;

@PutMapping("/{surveyKey}")
public CommonResponse<AdminSurveyPushScheduleRes> upsertSurveySchedules(
@PathVariable String surveyKey,
@RequestBody AdminSurveyPushScheduleReq request
) {
return CommonResponse.success(surveyPushScheduleService.upsertAdminSchedules(surveyKey, request));
}

@GetMapping("/{surveyKey}")
public CommonResponse<AdminSurveyPushScheduleRes> getSurveySchedules(
@PathVariable String surveyKey
) {
return CommonResponse.success(surveyPushScheduleService.getAdminSchedules(surveyKey));
}

@DeleteMapping("/{surveyKey}")
public CommonResponse<AdminSurveyPushScheduleRes> cancelSurveySchedules(
@PathVariable String surveyKey
) {
return CommonResponse.success(surveyPushScheduleService.cancelSchedules(surveyKey));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package devkor.com.teamcback.domain.notification.controller;

import devkor.com.teamcback.domain.notification.dto.response.SurveyReminderRes;
import devkor.com.teamcback.domain.notification.service.SurveyPushScheduleService;
import devkor.com.teamcback.global.exception.exception.GlobalException;
import devkor.com.teamcback.global.response.CommonResponse;
import devkor.com.teamcback.global.security.UserDetailsImpl;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import static devkor.com.teamcback.global.response.ResultCode.UNAUTHORIZED;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/notifications/surveys")
public class SurveyNotificationController {

private final SurveyPushScheduleService surveyPushScheduleService;

@PostMapping("/{surveyKey}/reminders")
public CommonResponse<SurveyReminderRes> remindAfterLater(
@AuthenticationPrincipal UserDetailsImpl userDetail,
@PathVariable String surveyKey
) {
if (userDetail == null) {
throw new GlobalException(UNAUTHORIZED);
}

return CommonResponse.success(surveyPushScheduleService.remindAfterLater(
surveyKey,
userDetail.getUser().getUserId()
));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package devkor.com.teamcback.domain.notification.dto.request;

import java.time.LocalDateTime;

public record AdminSurveyPushScheduleReq(
LocalDateTime startNotificationAt,
LocalDateTime deadlineNotificationAt,
Integer rewardPoint
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package devkor.com.teamcback.domain.notification.dto.response;

public record AdminSurveyPushScheduleRes(
String surveyKey,
int rewardPoint,
AdminSurveyPushScheduleStageRes started,
AdminSurveyPushScheduleStageRes dMinus3,
AdminSurveyPushScheduleStageRes deadline
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package devkor.com.teamcback.domain.notification.dto.response;

import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule;
import devkor.com.teamcback.domain.notification.entity.type.SurveyPushScheduleStatus;
import java.time.LocalDateTime;

public record AdminSurveyPushScheduleStageRes(
SurveyPushScheduleStatus status,
LocalDateTime scheduledAt
) {

public static AdminSurveyPushScheduleStageRes from(SurveyPushSchedule schedule) {
if (schedule == null) {
return null;
}
return new AdminSurveyPushScheduleStageRes(
schedule.getStatus(),
schedule.getScheduledAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package devkor.com.teamcback.domain.notification.dto.response;

import devkor.com.teamcback.domain.notification.entity.type.SurveyReminderSuppressedBy;
import java.time.LocalDateTime;

public record SurveyReminderRes(
String surveyKey,
boolean scheduled,
LocalDateTime scheduledAt,
SurveyReminderSuppressedBy suppressedBy
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package devkor.com.teamcback.domain.notification.entity;

import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage;
import devkor.com.teamcback.domain.notification.entity.type.SurveyPushScheduleStatus;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(
name = "tb_survey_push_schedule",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_survey_push_schedule_idempotency_key",
columnNames = "idempotency_key"
)
},
indexes = {
@Index(
name = "idx_survey_push_schedule_status_scheduled_at",
columnList = "status, scheduled_at"
)
}
)
@NoArgsConstructor
@Getter
public class SurveyPushSchedule {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "survey_push_schedule_id")
private Long surveyPushScheduleId;

@Column(name = "survey_key", nullable = false, length = 64)
private String surveyKey;

@Enumerated(EnumType.STRING)
@Column(name = "notification_stage", nullable = false, length = 40)
private SurveyNotificationStage notificationStage;

@Column(name = "target_user_id")
private Long targetUserId;

@Column(name = "scheduled_at", nullable = false)
private LocalDateTime scheduledAt;

@Column(name = "reward_point", nullable = false)
private int rewardPoint;

@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 30)
private SurveyPushScheduleStatus status;

@Column(name = "idempotency_key", nullable = false, length = 128)
private String idempotencyKey;

@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

@Column(name = "processed_at")
private LocalDateTime processedAt;

public SurveyPushSchedule(
String surveyKey,
SurveyNotificationStage notificationStage,
Long targetUserId,
LocalDateTime scheduledAt,
int rewardPoint,
String idempotencyKey,
LocalDateTime createdAt
) {
this.surveyKey = surveyKey;
this.notificationStage = notificationStage;
this.targetUserId = targetUserId;
this.scheduledAt = scheduledAt;
this.rewardPoint = rewardPoint;
this.status = SurveyPushScheduleStatus.PENDING;
this.idempotencyKey = idempotencyKey;
this.createdAt = createdAt;
this.processedAt = null;
}

public boolean isPending() {
return SurveyPushScheduleStatus.PENDING.equals(status);
}

public void updatePendingSchedule(
LocalDateTime scheduledAt,
int rewardPoint
) {
if (!isPending()) {
return;
}
this.scheduledAt = scheduledAt;
this.rewardPoint = rewardPoint;
}

public void complete(LocalDateTime now) {
this.status = SurveyPushScheduleStatus.COMPLETED;
this.processedAt = now;
}

public void cancel(LocalDateTime now) {
this.status = SurveyPushScheduleStatus.CANCELLED;
this.processedAt = now;
}

public void skip(LocalDateTime now) {
this.status = SurveyPushScheduleStatus.SKIPPED;
this.processedAt = now;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
public enum PushEventType {
CROWD("push:event:crowd-enabled"),
REPORT("push:event:report-enabled"),
CHARACTER("push:event:character-enabled");
CHARACTER("push:event:character-enabled"),
SURVEY("push:event:survey-enabled");

private final String redisKey;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
public enum PushTargetType {
INSTALLATION,
USER,
USER_GROUP
USER_GROUP,
ALL
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package devkor.com.teamcback.domain.notification.entity.type;

public enum SurveyNotificationStage {
STARTED,
D_MINUS_3,
DEADLINE,
REMIND_AFTER_LATER
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package devkor.com.teamcback.domain.notification.entity.type;

public enum SurveyPushScheduleStatus {
PENDING,
COMPLETED,
CANCELLED,
SKIPPED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package devkor.com.teamcback.domain.notification.entity.type;

public enum SurveyReminderSuppressedBy {
NONE,
D3,
DEADLINE,
EXPIRED,
ALREADY_PROCESSED,
ALREADY_PARTICIPATED
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,19 @@ List<PushInstallation> findAllByUserIdAndAppVariantAndActiveTrue(
AppVariant appVariant
);

List<PushInstallation> findAllByAppVariantAndActiveTrue(
AppVariant appVariant
);

boolean existsByUserIdAndAppVariantAndActiveTrue(
Long userId,
AppVariant appVariant
);

boolean existsByAppVariantAndActiveTrue(
AppVariant appVariant
);

Optional<PushInstallation> findByPushInstallationIdAndInstallationIdAndAppVariantAndActiveTrue(
Long pushInstallationId,
String installationId,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package devkor.com.teamcback.domain.notification.repository;

import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule;
import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage;
import devkor.com.teamcback.domain.notification.entity.type.SurveyPushScheduleStatus;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface SurveyPushScheduleRepository extends JpaRepository<SurveyPushSchedule, Long> {

Optional<SurveyPushSchedule> findByIdempotencyKey(String idempotencyKey);

List<SurveyPushSchedule> findAllBySurveyKeyOrderByNotificationStageAscSurveyPushScheduleIdAsc(String surveyKey);

List<SurveyPushSchedule> findAllBySurveyKeyAndNotificationStageIn(
String surveyKey,
Collection<SurveyNotificationStage> notificationStages
);

Optional<SurveyPushSchedule> findBySurveyKeyAndNotificationStage(
String surveyKey,
SurveyNotificationStage notificationStage
);

@Query(
value = """
SELECT *
FROM tb_survey_push_schedule
WHERE status = 'PENDING'
AND scheduled_at <= :now
ORDER BY scheduled_at ASC, survey_push_schedule_id ASC
LIMIT :limit
FOR UPDATE SKIP LOCKED
""",
nativeQuery = true
)
List<SurveyPushSchedule> findDuePendingForUpdateSkipLocked(
@Param("now") LocalDateTime now,
@Param("limit") int limit
);

List<SurveyPushSchedule> findAllByStatusAndScheduledAtLessThanEqualOrderByScheduledAtAscSurveyPushScheduleIdAsc(
SurveyPushScheduleStatus status,
LocalDateTime now
);
}
Loading
Loading