Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
42a193a
:recycle: refactor: OrgMemberNotificationSetting 엔티티 내부 예산 관련 필드 제거 &…
ojy0903 Jul 10, 2026
5c22806
:recycle: refactor: NotificationConverter 내부 OrgMemberNotificationSet…
ojy0903 Jul 10, 2026
dc8f64f
:recycle: refactor: 알림 요청 DTO 내부 예산 관련 필드 삭제 & 클릭 급증, 봇 클릭, 리포트 알림 필드…
ojy0903 Jul 10, 2026
b83c4ab
:recycle: refactor: 알림 응답 DTO 내부 예산 관련 필드 삭제 & 클릭 급증, 봇 클릭, 리포트 알림 필드…
ojy0903 Jul 10, 2026
3913007
:recycle: refactor: 알림 설정 Service 로직 수정
ojy0903 Jul 10, 2026
e1cb3c0
:art: style: Swagger Docs 수정
ojy0903 Jul 10, 2026
d62feea
:sparkles: feat: OrgNotificationSetting 내부 알림 종류 (클릭수 급증, 봇 클릭, 리포트 알…
ojy0903 Jul 10, 2026
f9737be
:sparkles: feat: 알림 요청, 응답 DTO 내부 조직 단위 외부채널 알림 설정 필드 추가
ojy0903 Jul 10, 2026
5ebc9b8
:sparkles: feat: 알림 종류 구분을 위한 NotificationType enum 추가
ojy0903 Jul 10, 2026
d6e61af
:sparkles: feat: 조직별 외부채널 알림 설정을 위한 Converter 수정
ojy0903 Jul 10, 2026
e436f46
:sparkles: feat: 조직별 외부채널 알림 설정 추가 & 기존 웹훅 URL 처리 로직 마이그레이션
ojy0903 Jul 10, 2026
fc3e4b6
:sparkles: feat: 조직별 외부채널 알림 설정 API 추가
ojy0903 Jul 10, 2026
25d7338
:art: style: Swagger Docs 내용 추가
ojy0903 Jul 10, 2026
ea761f4
:sparkles: feat: OrgMemberNotificationSetting 내부 ColumnDefault 추가 (코드래빗)
ojy0903 Jul 10, 2026
2624c31
:sparkles: feat: OrgNotificationSetting 내부 ColumnDefault 추가 (코드래빗)
ojy0903 Jul 10, 2026
d122210
Merge remote-tracking branch 'origin/develop' into refactor/#172
ojy0903 Jul 11, 2026
ca41677
:recycle: refactor: 엔티티 내부 봇클릭 알림 & 클릭수 알림 -> 클릭 알림으로 통일
ojy0903 Jul 13, 2026
644460a
:recycle: refactor: NotificationType enum 값 RAPID_CLICKS, BOT_CLICKS …
ojy0903 Jul 13, 2026
9e71959
:recycle: refactor: 봇 클릭, 클릭수 알림 -> 클릭 알림으로 통합에 따른 요청 & 응답 DTO 수정
ojy0903 Jul 13, 2026
108a5bb
:recycle: refactor: 봇 클릭, 클릭수 알림 -> 클릭 알림으로 통합에 따른 Converter 메서드 수정
ojy0903 Jul 13, 2026
86ff63c
:recycle: refactor: 봇 클릭, 클릭수 알림 -> 클릭 알림으로 통합에 따른 Service 메서드 수정
ojy0903 Jul 13, 2026
fed53b2
:art: style: Swagger Docs 수정
ojy0903 Jul 13, 2026
b1f2c77
:recycle: refactor: 리포트 알림 기본값 true 로 반환하도록 Converter 값 변경
ojy0903 Jul 13, 2026
92612c2
:sparkles: feat: 외부 알림 채널 요청 DTO 내부 URL 검증 추가
ojy0903 Jul 13, 2026
3432bd5
:sparkles: feat: 외부 알림 채널 웹훅 URL 관련 오류코드 추가
ojy0903 Jul 13, 2026
178580d
:sparkles: feat: 외부 알림 채널 웹훅 URL 관련 Service 메서드 추가
ojy0903 Jul 13, 2026
e1a7956
:art: style: Swagger Docs 오류코드 내용 추가
ojy0903 Jul 13, 2026
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 @@ -6,6 +6,7 @@
import java.util.List;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;

public class NotificationRequest {

Expand All @@ -14,25 +15,37 @@ public record UpdateMaster(
Boolean isMasterEnabled
) {}

// 채널별 알림 설정 DTO (ADMIN 전용: slackWebhookUrl/discordWebhookUrl, disconnectSlack/disconnectDiscord)
// 외부 채널 URL: 값 있으면 설정 / disconnectXxx=true 면 삭제(연결 해제) / 둘 다 없으면 변경 없음
// 변경: 멤버 스코프(브라우저/이메일)만 남김. 슬랙/디스코드 웹훅·활성화는 UpdateOrgSettings 로 이동
public record UpdateChannels(
Boolean isBrowserPushEnabled,
Boolean isEmailEnabled,
Boolean isEmailEnabled
) {}

// 알림 기준 설정 DTO -> 각 멤버
public record UpdateAlerts(
Boolean alertClicks,
Boolean alertReport
) {}

// 조직 단위 외부 채널 알림 설정 (ADMIN 전용)
// 외부 채널 웹훅 연결/활성화 + 외부 채널로 내보낼 알림 종류 설정을 하나의 DTO 로 통합
public record UpdateOrgSettings(
Boolean isSlackEnabled,
@Pattern(
regexp = "^https://hooks\\.slack\\.com/services/.+",
message = "슬랙 웹훅 URL 형식이 올바르지 않습니다."
)
String slackWebhookUrl,
Boolean disconnectSlack,
Boolean isDiscordEnabled,
@Pattern(
regexp = "^https://(discord|discordapp)\\.com/api/webhooks/.+",
message = "디스코드 웹훅 URL 형식이 올바르지 않습니다."
)
String discordWebhookUrl,
Boolean disconnectDiscord
) {}

// 알림 기준 설정 DTO
public record UpdateAlerts(
Boolean alertBudget50,
Boolean alertBudget80,
Boolean alertBudget100,
Boolean alertRapidClicks
Boolean disconnectDiscord,
Boolean alertClicks,
Boolean alertReport
) {}

// 알림을 받을 멤버 설정 DTO(ADMIN 전용)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ public record MySettings(
boolean isSlackConnected,
boolean isDiscordEnabled,
boolean isDiscordConnected,
boolean alertBudget50,
boolean alertBudget80,
boolean alertBudget100,
boolean alertRapidClicks
boolean alertClicks,
boolean alertReport,
boolean orgAlertClicks,
boolean orgAlertReport
) {}

public record MemberSetting(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ public static NotificationResponse.MySettings toMySettings(
orgSetting != null && orgSetting.hasSlack(),
orgSetting != null && orgSetting.isDiscordEnabled(),
orgSetting != null && orgSetting.hasDiscord(),
setting.isAlertBudget50(),
setting.isAlertBudget80(),
setting.isAlertBudget100(),
setting.isAlertRapidClicks()
setting.isAlertClicks(),
setting.isAlertReport(),
orgSetting != null && orgSetting.isAlertClicks(),
orgSetting != null && orgSetting.isAlertReport()
);
}

Expand All @@ -47,17 +47,15 @@ public static NotificationResponse.MemberSetting toMemberSetting(
);
}

// 기본 알림 설정(entity -> dto), 기본값: 예산 80프로 소진 시 이메일 알림
// 기본 알림 설정(entity -> dto), 기본값: 마스터 알림 ON, 이메일 수신 ON / 그 외 알림 관련 모두 수신 X
public static OrgMemberNotificationSetting toDefaultMemberSetting(OrgMember member) {
return OrgMemberNotificationSetting.builder()
.orgMember(member)
.isMasterEnabled(true)
.isBrowserPushEnabled(false)
.isEmailEnabled(true)
.alertBudget50(false)
.alertBudget80(true)
.alertBudget100(false)
.alertRapidClicks(false)
.alertClicks(false)
.alertReport(true)
.build();
}

Expand All @@ -70,9 +68,9 @@ public static OrgNotificationSetting toDefaultOrgSetting(Organization organizati

public static DiscordMessage toDiscordMessage(String title, String message) {
DiscordMessage.Embed embed = new DiscordMessage.Embed(
title == null ? "" : title, message == null ? "" : message, 5814783 // TODO: 디스코드는 알림 메세지 설정 가능 : 현재는 파랑색
title == null ? "" : title, message == null ? "" : message, 5814783
);
return new DiscordMessage("WhereYouAd 알림", List.of(embed)); // TODO: 이름을 하드코딩 할지... 아니면 이것도 사용자에게 입력받을지..?
return new DiscordMessage("WhereYouAd 알림", List.of(embed));
}

public static SlackMessage toSlackMessage(String title, String message) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.whereyouad.WhereYouAd.domains.notification.domain.constant;

public enum NotificationType {
CLICKS,
REPORT
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.whereyouad.WhereYouAd.domains.notification.application.dto.request.NotificationRequest;
import com.whereyouad.WhereYouAd.domains.notification.application.dto.response.NotificationResponse;
import com.whereyouad.WhereYouAd.domains.notification.domain.constant.NotificationType;

public interface NotificationService {

Expand All @@ -13,11 +14,13 @@ public interface NotificationService {

void updateAlerts(Long userId, Long orgId, NotificationRequest.UpdateAlerts request);

void updateOrgSettings(Long userId, Long orgId, NotificationRequest.UpdateOrgSettings request);

NotificationResponse.MemberSettingList getMemberSettings(Long userId, Long orgId, String encodedCursor, Integer size);

void updateMemberSettings(Long userId, Long orgId, NotificationRequest.BulkUpdateMembers request);

void sendApiAlarmToOrg(Long orgId, String title, String message);
void sendApiAlarmToOrg(Long orgId, NotificationType type, String title, String message);

// 설정한 채널이 실제로 동작하는지 테스트 발송
void sendTest(Long orgId, NotificationRequest.TestSend request);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.whereyouad.WhereYouAd.domains.notification.application.dto.request.NotificationRequest;
import com.whereyouad.WhereYouAd.domains.notification.application.dto.response.NotificationResponse;
import com.whereyouad.WhereYouAd.domains.notification.application.mapper.NotificationConverter;
import com.whereyouad.WhereYouAd.domains.notification.domain.constant.NotificationType;
import com.whereyouad.WhereYouAd.domains.notification.exception.NotificationException;
import com.whereyouad.WhereYouAd.domains.notification.exception.code.NotificationErrorCode;
import com.whereyouad.WhereYouAd.domains.notification.persistence.entity.OrgMemberNotificationSetting;
Expand All @@ -28,7 +29,9 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;

import java.net.URISyntaxException;
import java.security.GeneralSecurityException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
Expand Down Expand Up @@ -67,62 +70,61 @@ public void updateMaster(Long userId, Long orgId, NotificationRequest.UpdateMast
setting.updateMaster(request.isMasterEnabled());
}

// 채널별 알림 설정 메서드
// 채널별 알림 설정 메서드 -> 회원 개인 알림설정(이메일, 브라우저 푸시)만
@Override
public void updateChannels(Long userId, Long orgId, NotificationRequest.UpdateChannels request) {
OrgMember member = findMember(userId, orgId);
OrgMemberNotificationSetting setting = findOrCreateSetting(member);
setting.updateChannels(request.isBrowserPushEnabled(), request.isEmailEnabled());

// ADMIN 인 경우에만 외부 채널 URL / 수신 토글 변경 가능
// URL 값 있으면 설정 / disconnectXxx=true 면 삭제 / 둘 다 없으면 변경 없음
if (member.getRole() == OrgRole.ADMIN) {
boolean hasOrgChannelUpdate =
StringUtils.hasText(request.slackWebhookUrl()) || Boolean.TRUE.equals(request.disconnectSlack())
|| StringUtils.hasText(request.discordWebhookUrl()) || Boolean.TRUE.equals(request.disconnectDiscord())
|| request.isSlackEnabled() != null || request.isDiscordEnabled() != null;

// url 필드 또는 활성화 여부(is~~Enabled) 가 요청에 포함된 경우만 업데이트 (평문 URL 은 암호화하여 저장)
if (hasOrgChannelUpdate) {
// 활성화와 연결 해제를 동시에 요청하는 모순 방지 (URL 삭제 전에 먼저 차단)
// ex. isSlackEnabled = true 로 하면서, disconnectSlack = true 로 전송하는 경우
if ((Boolean.TRUE.equals(request.isSlackEnabled()) && Boolean.TRUE.equals(request.disconnectSlack())) ||
(Boolean.TRUE.equals(request.isDiscordEnabled()) && Boolean.TRUE.equals(request.disconnectDiscord()))) {
throw new NotificationException(NotificationErrorCode.CHANNEL_REQUEST_CONFLICT);
}

OrgNotificationSetting orgSetting = findOrCreateOrgSetting(member.getOrganization());

// URL 반영: 삭제 플래그 우선, 아니면 값이 있을 때만 설정 (삭제 시 엔티티가 enabled=false 자동 처리)
if (Boolean.TRUE.equals(request.disconnectSlack())) {
orgSetting.updateSlackWebhookUrl(null);
} else if (StringUtils.hasText(request.slackWebhookUrl())) {
orgSetting.updateSlackWebhookUrl(encryptOrNull(request.slackWebhookUrl()));
}

if (Boolean.TRUE.equals(request.disconnectDiscord())) {
orgSetting.updateDiscordWebhookUrl(null);
} else if (StringUtils.hasText(request.discordWebhookUrl())) {
orgSetting.updateDiscordWebhookUrl(encryptOrNull(request.discordWebhookUrl()));
}

// URL(요청 또는 기존 DB)이 없는 채널을 활성화하려 하면 거부
if ((Boolean.TRUE.equals(request.isSlackEnabled()) && !orgSetting.hasSlack()) ||
(Boolean.TRUE.equals(request.isDiscordEnabled()) && !orgSetting.hasDiscord())) {
throw new NotificationException(NotificationErrorCode.NO_CHANNEL_URL);
}

orgSetting.updateChannelEnabled(request.isSlackEnabled(), request.isDiscordEnabled());
}
}
}

// 알림 기준 설정 메서드(예산 소진, 클릭 수 급증 등)
// 알림 기준 설정 메서드(클릭 급증 / 봇 클릭 감지 / 주,일간 보고서)
@Override
public void updateAlerts(Long userId, Long orgId, NotificationRequest.UpdateAlerts request) {
OrgMember member = findMember(userId, orgId);
OrgMemberNotificationSetting setting = findOrCreateSetting(member);
setting.updateAlerts(request.alertBudget50(), request.alertBudget80(), request.alertBudget100(), request.alertRapidClicks());
setting.updateAlerts(request.alertClicks(), request.alertReport());
}

// 조직 단위 알림 트리거 설정 (ADMIN 전용) — 외부 채널로 내보낼 알림 종류 (클릭 급증 / 봇 클릭 감지 / 주,일간 보고서) 설정
@Override
public void updateOrgSettings(Long userId, Long orgId, NotificationRequest.UpdateOrgSettings request) {
OrgMember member = findMember(userId, orgId);
requireAdmin(member);

OrgNotificationSetting orgSetting = findOrCreateOrgSetting(member.getOrganization());

// 활성화와 연결 해제를 동시에 요청하는 모순 방지 (URL 삭제 전에 먼저 차단)
if ((Boolean.TRUE.equals(request.isSlackEnabled()) && Boolean.TRUE.equals(request.disconnectSlack())) ||
(Boolean.TRUE.equals(request.isDiscordEnabled()) && Boolean.TRUE.equals(request.disconnectDiscord()))) {
throw new NotificationException(NotificationErrorCode.CHANNEL_REQUEST_CONFLICT);
}

// URL 반영: 삭제 플래그 우선, 아니면 값이 있을 때만 설정 (삭제 시 엔티티가 enabled=false 자동 처리)
if (Boolean.TRUE.equals(request.disconnectSlack())) {
orgSetting.updateSlackWebhookUrl(null);
} else if (StringUtils.hasText(request.slackWebhookUrl())) {
validateWebhookUrl(request.slackWebhookUrl(), "hooks.slack.com");
orgSetting.updateSlackWebhookUrl(encryptOrNull(request.slackWebhookUrl()));
Comment thread
ojy0903 marked this conversation as resolved.
}

if (Boolean.TRUE.equals(request.disconnectDiscord())) {
orgSetting.updateDiscordWebhookUrl(null);
} else if (StringUtils.hasText(request.discordWebhookUrl())) {
validateWebhookUrl(request.discordWebhookUrl(), "discord.com", "discordapp.com");
orgSetting.updateDiscordWebhookUrl(encryptOrNull(request.discordWebhookUrl()));
}

// URL(요청 또는 기존 DB)이 없는 채널을 활성화하려 하면 거부
if ((Boolean.TRUE.equals(request.isSlackEnabled()) && !orgSetting.hasSlack()) ||
(Boolean.TRUE.equals(request.isDiscordEnabled()) && !orgSetting.hasDiscord())) {
throw new NotificationException(NotificationErrorCode.NO_CHANNEL_URL);
}

orgSetting.updateChannelEnabled(request.isSlackEnabled(), request.isDiscordEnabled());

// 외부 채널로 내보낼 알림 종류 설정
orgSetting.updateAlerts(request.alertClicks(), request.alertReport());
}

@Transactional(readOnly = true)
Expand Down Expand Up @@ -182,13 +184,20 @@ public void updateMemberSettings(Long userId, Long orgId, NotificationRequest.Bu

// 디스코드 / 슬랙 알림 전송 메서드
// 조직 내에 웹훅 URL 이 설정되어 있는 경우 일괄 전송
// 추가 : NotificationType 을 입력받아 해당 조직의 수신여부를 확인하고 수신 차단되어 있을경우 알림을 발송하지 않습니다.
/// *** 각 플랫폼 스케줄러에서 조직으로 디스코드 / 슬랙 알림을 보내려면 이 메서드를 사용하면 됩니다 ***
@Override
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void sendApiAlarmToOrg(Long orgId, String title, String message) {
public void sendApiAlarmToOrg(Long orgId, NotificationType type, String title, String message) {

OrgNotificationSetting setting = orgSettingRepository.findById(orgId).orElse(null);

// 조직 단위로 외부 채널 알림 설정에 이 알림 종류가 꺼져 있으면(또는 설정 없음) 발송 skip
if (setting == null || !isAlertTypeEnabled(setting, type)) {
log.debug("[외부 알림 발송 skip] 알림 종류 비활성화/설정없음, type={}, orgId={}", type, orgId);
return;
}

boolean slackActive = setting != null && setting.hasSlack() && setting.isSlackEnabled();
boolean discordActive = setting != null && setting.hasDiscord() && setting.isDiscordEnabled();

Expand Down Expand Up @@ -230,6 +239,36 @@ private void sendApiAlarm(OrgNotificationSetting setting, Long orgId, String tit
}
}

// 웹훅 URL이 실제 파싱 가능한 https URL이며 허용 host인지 검증
private void validateWebhookUrl(String url, String... allowedHosts) {
final URI uri;
try {
uri = new URI(url.trim()); // 형식이 URL이 아니면 URISyntaxException
} catch (URISyntaxException e) {
throw new NotificationException(NotificationErrorCode.INVALID_CHANNEL_URL);
}

// https + host 존재 확인
if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null) {
throw new NotificationException(NotificationErrorCode.INVALID_CHANNEL_URL);
}

// 허용된 host 인지 확인 (slack 또는 discord 가 맞는가?)
boolean hostAllowed = Arrays.stream(allowedHosts)
.anyMatch(h -> h.equalsIgnoreCase(uri.getHost()));
if (!hostAllowed) {
throw new NotificationException(NotificationErrorCode.INVALID_CHANNEL_URL);
}
}

// 알림 종류 입력 받아 실제 조직에서 수신 설정 되어있는지 여부 반환
private boolean isAlertTypeEnabled(OrgNotificationSetting setting, NotificationType type) {
return switch (type) {
case CLICKS -> setting.isAlertClicks();
case REPORT -> setting.isAlertReport();
};
}

// 외부 채널 skip 사유 로깅: URL 미설정 vs 설정됐으나 수신 비활성화
private void logChannelSkip(DeliveryChannel channel, Long orgId, boolean hasUrl) {
if (hasUrl) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public enum NotificationErrorCode implements BaseErrorCode {
NO_CHANNEL_CONFIGURED(HttpStatus.BAD_REQUEST, "NOTIFICATION_400_1", "발송할 외부 채널(슬랙/디스코드)이 설정되어 있지 않습니다."),
NO_CHANNEL_URL(HttpStatus.BAD_REQUEST, "NOTIFICATION_400_2", "외부 채널 알림을 활성화 하기 위한 해당 채널 웹훅 URL 이 입력되지 않았습니다."),
CHANNEL_REQUEST_CONFLICT(HttpStatus.BAD_REQUEST, "NOTIFICATION_400_3", "채널 알림 활성화와 연결 해제(웹훅 삭제)를 동시에 요청할 수 없습니다."),
INVALID_CHANNEL_URL(HttpStatus.BAD_REQUEST, "NOTIFICATION_400_4", "웹훅 URL 형식이 올바르지 않습니다."),

// 403
FORBIDDEN(HttpStatus.FORBIDDEN, "NOTIFICATION_403_1", "해당 작업을 수행할 권한이 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.whereyouad.WhereYouAd.global.common.BaseEntity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.ColumnDefault;

@Entity
@Table(name = "org_member_notification_setting")
Expand Down Expand Up @@ -31,17 +32,13 @@ public class OrgMemberNotificationSetting extends BaseEntity {
@Column(name = "is_email_enabled", nullable = false)
private boolean isEmailEnabled;

@Column(name = "alert_budget_50", nullable = false)
private boolean alertBudget50;
@Column(name = "alert_clicks", nullable = false)
@ColumnDefault("false")
private boolean alertClicks;

@Column(name = "alert_budget_80", nullable = false)
private boolean alertBudget80;

@Column(name = "alert_budget_100", nullable = false)
private boolean alertBudget100;

@Column(name = "alert_rapid_clicks", nullable = false)
private boolean alertRapidClicks;
@Column(name = "alert_report", nullable = false)
@ColumnDefault("false")
private boolean alertReport;

public void updateMaster(Boolean isMasterEnabled) {
if (isMasterEnabled != null) this.isMasterEnabled = isMasterEnabled;
Expand All @@ -52,10 +49,8 @@ public void updateChannels(Boolean isBrowserPushEnabled, Boolean isEmailEnabled)
if (isEmailEnabled != null) this.isEmailEnabled = isEmailEnabled;
}

public void updateAlerts(Boolean alertBudget50, Boolean alertBudget80, Boolean alertBudget100, Boolean alertRapidClicks) {
if (alertBudget50 != null) this.alertBudget50 = alertBudget50;
if (alertBudget80 != null) this.alertBudget80 = alertBudget80;
if (alertBudget100 != null) this.alertBudget100 = alertBudget100;
if (alertRapidClicks != null) this.alertRapidClicks = alertRapidClicks;
public void updateAlerts(Boolean alertClicks, Boolean alertReport) {
if (alertClicks != null) this.alertClicks = alertClicks;
if (alertReport != null) this.alertReport = alertReport;
}
}
Loading
Loading