diff --git a/src/main/java/com/slotline/notification/EmailNotificationChannel.java b/src/main/java/com/slotline/notification/EmailNotificationChannel.java new file mode 100644 index 0000000..df5cdf3 --- /dev/null +++ b/src/main/java/com/slotline/notification/EmailNotificationChannel.java @@ -0,0 +1,23 @@ +package com.slotline.notification; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Component +public class EmailNotificationChannel implements NotificationChannel { + + private static final Logger log = LoggerFactory.getLogger(EmailNotificationChannel.class); + + @Override + public String name() { + return "email"; + } + + @Override + public void deliver(NotificationMessage message) { + log.info("[email] user={} type={} resource='{}' [{} - {}] :: {}", + message.recipientUserId(), message.type(), message.resourceName(), + message.startTime(), message.endTime(), message.summary()); + } +} diff --git a/src/main/java/com/slotline/notification/HttpWebhookGateway.java b/src/main/java/com/slotline/notification/HttpWebhookGateway.java new file mode 100644 index 0000000..0bfe586 --- /dev/null +++ b/src/main/java/com/slotline/notification/HttpWebhookGateway.java @@ -0,0 +1,38 @@ +package com.slotline.notification; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +@Component +public class HttpWebhookGateway implements WebhookGateway { + + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + private final ObjectMapper objectMapper; + + public HttpWebhookGateway(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void post(String url, NotificationMessage message) { + try { + String body = objectMapper.writeValueAsString(message); + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .header("Content-Type", "application/json") + .timeout(Duration.ofSeconds(5)) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + httpClient.send(request, HttpResponse.BodyHandlers.discarding()); + } catch (Exception failure) { + throw new WebhookDeliveryException(url, failure); + } + } +} diff --git a/src/main/java/com/slotline/notification/NotificationChannel.java b/src/main/java/com/slotline/notification/NotificationChannel.java new file mode 100644 index 0000000..2dbd0af --- /dev/null +++ b/src/main/java/com/slotline/notification/NotificationChannel.java @@ -0,0 +1,8 @@ +package com.slotline.notification; + +public interface NotificationChannel { + + String name(); + + void deliver(NotificationMessage message); +} diff --git a/src/main/java/com/slotline/notification/NotificationMessage.java b/src/main/java/com/slotline/notification/NotificationMessage.java new file mode 100644 index 0000000..012e467 --- /dev/null +++ b/src/main/java/com/slotline/notification/NotificationMessage.java @@ -0,0 +1,14 @@ +package com.slotline.notification; + +import java.time.Instant; + +public record NotificationMessage( + NotificationType type, + Long organizationId, + Long recipientUserId, + Long resourceId, + String resourceName, + Instant startTime, + Instant endTime, + String summary) { +} diff --git a/src/main/java/com/slotline/notification/NotificationService.java b/src/main/java/com/slotline/notification/NotificationService.java new file mode 100644 index 0000000..5e2800d --- /dev/null +++ b/src/main/java/com/slotline/notification/NotificationService.java @@ -0,0 +1,30 @@ +package com.slotline.notification; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class NotificationService { + + private static final Logger log = LoggerFactory.getLogger(NotificationService.class); + + private final List channels; + + public NotificationService(List channels) { + this.channels = channels; + } + + public void dispatch(NotificationMessage message) { + for (NotificationChannel channel : channels) { + try { + channel.deliver(message); + } catch (RuntimeException failure) { + log.warn("Notification channel '{}' failed to deliver {} for user {}", + channel.name(), message.type(), message.recipientUserId(), failure); + } + } + } +} diff --git a/src/main/java/com/slotline/notification/NotificationType.java b/src/main/java/com/slotline/notification/NotificationType.java new file mode 100644 index 0000000..ec4564a --- /dev/null +++ b/src/main/java/com/slotline/notification/NotificationType.java @@ -0,0 +1,7 @@ +package com.slotline.notification; + +public enum NotificationType { + RESERVATION_CREATED, + RESERVATION_CANCELLED, + WAITLIST_PROMOTED +} diff --git a/src/main/java/com/slotline/notification/WebhookDeliveryException.java b/src/main/java/com/slotline/notification/WebhookDeliveryException.java new file mode 100644 index 0000000..2005188 --- /dev/null +++ b/src/main/java/com/slotline/notification/WebhookDeliveryException.java @@ -0,0 +1,8 @@ +package com.slotline.notification; + +public class WebhookDeliveryException extends RuntimeException { + + public WebhookDeliveryException(String url, Throwable cause) { + super("Failed to deliver webhook to " + url, cause); + } +} diff --git a/src/main/java/com/slotline/notification/WebhookGateway.java b/src/main/java/com/slotline/notification/WebhookGateway.java new file mode 100644 index 0000000..b2b4e9b --- /dev/null +++ b/src/main/java/com/slotline/notification/WebhookGateway.java @@ -0,0 +1,6 @@ +package com.slotline.notification; + +public interface WebhookGateway { + + void post(String url, NotificationMessage message); +} diff --git a/src/main/java/com/slotline/notification/WebhookNotificationChannel.java b/src/main/java/com/slotline/notification/WebhookNotificationChannel.java new file mode 100644 index 0000000..574a933 --- /dev/null +++ b/src/main/java/com/slotline/notification/WebhookNotificationChannel.java @@ -0,0 +1,28 @@ +package com.slotline.notification; + +import com.slotline.config.SlotlineProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty(prefix = "slotline.notifications.webhook", name = "url") +public class WebhookNotificationChannel implements NotificationChannel { + + private final WebhookGateway gateway; + private final String url; + + public WebhookNotificationChannel(WebhookGateway gateway, SlotlineProperties properties) { + this.gateway = gateway; + this.url = properties.getNotifications().getWebhook().getUrl(); + } + + @Override + public String name() { + return "webhook"; + } + + @Override + public void deliver(NotificationMessage message) { + gateway.post(url, message); + } +}