feat(notification): implement the notification delivery platform
Maps the 31-module plan onto the registry's 19 leaves as packages; the two edges the registry forbids (provider->httpclient, inbox->messaging) are replaced by application-owned ports. See docs/notification/module-mapping.md. Acceptance is not delivery: ProviderSubmissionResult refuses to carry a delivery outcome, and AMBIGUOUS is a first-class terminal state that blocks automatic retry and fallback until reconciliation resolves it. Providers: SES (SigV4 + SNS callback), Twilio (X-Twilio-Signature + reconciliation), FCM (FID-primary batch), APNs, Web Push (RFC 8030/8291/8292), SMTP and webhook. Contact points are AES-256-GCM encrypted with a separate HMAC lookup fingerprint; nothing raw reaches a log, metric tag or exception. Dispatch commits the attempt row, calls the provider with no transaction open, then records the outcome; the durable queue uses FOR UPDATE SKIP LOCKED. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3b5aee50e3
commit
701ba67456
+26
@@ -0,0 +1,26 @@
|
||||
package dev.caskeleton.application.notification.platform.admin;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationException;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
|
||||
|
||||
/** Raised when an actor lacks the operator authority an action requires. */
|
||||
public class AdminAccessDeniedException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public AdminAccessDeniedException(NotificationAdminAuthority required) {
|
||||
super(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.AUTHORIZATION));
|
||||
this.required = required;
|
||||
}
|
||||
|
||||
private final transient NotificationAdminAuthority required;
|
||||
|
||||
/** Authority that was missing. */
|
||||
public NotificationAdminAuthority required() {
|
||||
return required;
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.application.notification.platform.admin;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.TenantId;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/** Who is performing an operator action, and what they are allowed to do. */
|
||||
public record AdminActor(
|
||||
String actorRef, Set<NotificationAdminAuthority> authorities, Optional<TenantId> tenantId) {
|
||||
|
||||
public AdminActor {
|
||||
Objects.requireNonNull(actorRef, "actorRef");
|
||||
authorities = Set.copyOf(Objects.requireNonNull(authorities, "authorities"));
|
||||
Objects.requireNonNull(tenantId, "tenantId");
|
||||
if (actorRef.isBlank()) {
|
||||
throw new IllegalArgumentException("actorRef");
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this actor holds an authority. */
|
||||
public boolean holds(NotificationAdminAuthority authority) {
|
||||
return authorities.contains(authority);
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package dev.caskeleton.application.notification.platform.admin;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationId;
|
||||
import dev.caskeleton.application.notification.platform.api.RecipientDeliveryId;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Outcome of one audited operator action. */
|
||||
public record AdminOperationResult(
|
||||
String operationId,
|
||||
boolean dryRun,
|
||||
int affected,
|
||||
Optional<NotificationId> notificationId,
|
||||
Optional<RecipientDeliveryId> recipientDeliveryId,
|
||||
Optional<DeliveryAttemptId> newAttemptId,
|
||||
List<String> reasonCodes) {
|
||||
|
||||
public AdminOperationResult {
|
||||
Objects.requireNonNull(operationId, "operationId");
|
||||
Objects.requireNonNull(notificationId, "notificationId");
|
||||
Objects.requireNonNull(recipientDeliveryId, "recipientDeliveryId");
|
||||
Objects.requireNonNull(newAttemptId, "newAttemptId");
|
||||
reasonCodes = List.copyOf(Objects.requireNonNull(reasonCodes, "reasonCodes"));
|
||||
if (operationId.isBlank()) {
|
||||
throw new IllegalArgumentException("operationId");
|
||||
}
|
||||
if (affected < 0) {
|
||||
throw new IllegalArgumentException("affected");
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.caskeleton.application.notification.platform.admin;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/** Idempotent record of operator actions, so a retried request cannot redrive twice. */
|
||||
public interface AdminOperationStorePort {
|
||||
|
||||
/** Claim an operation id, or return the previous result for it. */
|
||||
Optional<AdminOperationResult> findByOperationId(String operationId);
|
||||
|
||||
/** Store the result of an operation. */
|
||||
AdminOperationResult save(AdminOperationResult result, AdminActor actor, String action);
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.application.notification.platform.admin;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationException;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
|
||||
|
||||
/**
|
||||
* Raised when an operator tries to redrive an ambiguous attempt without accepting the duplicate
|
||||
* risk. The platform cannot tell whether the first submission reached the user, so the decision has
|
||||
* to be recorded as a human one.
|
||||
*/
|
||||
public class DuplicateRiskApprovalRequiredException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public DuplicateRiskApprovalRequiredException() {
|
||||
super(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.AMBIGUOUS_SUBMISSION));
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.admin;
|
||||
|
||||
/** Separate authorities for the operator plane; application authority never grants these. */
|
||||
public enum NotificationAdminAuthority {
|
||||
REDRIVE,
|
||||
RECONCILE,
|
||||
SUPPRESS,
|
||||
PROVIDER_CONTROL,
|
||||
CREDENTIAL_ROTATION,
|
||||
PROJECTION_REPLAY
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.application.notification.platform.admin;
|
||||
|
||||
/**
|
||||
* N4 operator plane.
|
||||
*
|
||||
* <p>Every operation requires a separate authority, an idempotent operation id, a reason and an
|
||||
* audit record. It is deliberately not reachable from the application-facing APIs.
|
||||
*/
|
||||
public interface NotificationAdminService {
|
||||
|
||||
/** Re-run a delivery while preserving its logical identity. */
|
||||
AdminOperationResult redrive(RedriveCommand command, AdminActor actor);
|
||||
|
||||
/** Force reconciliation of a bounded batch. */
|
||||
AdminOperationResult reconcile(ReconcileCommand command, AdminActor actor);
|
||||
|
||||
/** Add or remove a suppression entry. */
|
||||
AdminOperationResult suppress(SuppressCommand command, AdminActor actor);
|
||||
|
||||
/** Change a provider runtime state. */
|
||||
AdminOperationResult setProviderState(SetProviderStateCommand command, AdminActor actor);
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.caskeleton.application.notification.platform.admin;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Operator-triggered reconciliation over a bounded batch of attempts. */
|
||||
public record ReconcileCommand(
|
||||
String operationId, List<DeliveryAttemptId> attemptIds, String reason, boolean dryRun) {
|
||||
|
||||
public static final int MAX_BATCH = 200;
|
||||
|
||||
public ReconcileCommand {
|
||||
Objects.requireNonNull(operationId, "operationId");
|
||||
attemptIds = List.copyOf(Objects.requireNonNull(attemptIds, "attemptIds"));
|
||||
Objects.requireNonNull(reason, "reason");
|
||||
if (attemptIds.isEmpty() || attemptIds.size() > MAX_BATCH) {
|
||||
throw new IllegalArgumentException("attemptIds must be 1.." + MAX_BATCH);
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package dev.caskeleton.application.notification.platform.admin;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Re-run one previously failed delivery.
|
||||
*
|
||||
* <p>A redrive reuses the pinned template version and rendered digest: it re-executes the original
|
||||
* notification rather than sending a new, possibly different message under an old identity.
|
||||
*/
|
||||
public record RedriveCommand(
|
||||
String operationId,
|
||||
DeliveryAttemptId attemptId,
|
||||
String reason,
|
||||
boolean approveDuplicateRisk,
|
||||
boolean dryRun) {
|
||||
|
||||
public RedriveCommand {
|
||||
Objects.requireNonNull(operationId, "operationId");
|
||||
Objects.requireNonNull(attemptId, "attemptId");
|
||||
Objects.requireNonNull(reason, "reason");
|
||||
if (operationId.isBlank() || reason.isBlank()) {
|
||||
throw new IllegalArgumentException("operationId and reason must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.application.notification.platform.admin;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Enable, disable or drain a provider profile. */
|
||||
public record SetProviderStateCommand(
|
||||
String operationId,
|
||||
ProviderProfileId profileId,
|
||||
ProviderRuntimeState desiredState,
|
||||
String reason,
|
||||
boolean dryRun) {
|
||||
|
||||
public SetProviderStateCommand {
|
||||
Objects.requireNonNull(operationId, "operationId");
|
||||
Objects.requireNonNull(profileId, "profileId");
|
||||
Objects.requireNonNull(desiredState, "desiredState");
|
||||
Objects.requireNonNull(reason, "reason");
|
||||
if (reason.isBlank()) {
|
||||
throw new IllegalArgumentException("reason");
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package dev.caskeleton.application.notification.platform.admin;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.TenantId;
|
||||
import dev.caskeleton.application.notification.platform.policy.SuppressionReason;
|
||||
import dev.caskeleton.application.notification.platform.policy.SuppressionScope;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Add or remove a suppression entry as an operator. */
|
||||
public record SuppressCommand(
|
||||
String operationId,
|
||||
TenantId tenantId,
|
||||
SuppressionScope scope,
|
||||
SuppressionReason reason,
|
||||
String targetFingerprint,
|
||||
Optional<Instant> expiresAt,
|
||||
boolean remove,
|
||||
String reasonText,
|
||||
boolean dryRun) {
|
||||
|
||||
public SuppressCommand {
|
||||
Objects.requireNonNull(operationId, "operationId");
|
||||
Objects.requireNonNull(tenantId, "tenantId");
|
||||
Objects.requireNonNull(scope, "scope");
|
||||
Objects.requireNonNull(reason, "reason");
|
||||
Objects.requireNonNull(targetFingerprint, "targetFingerprint");
|
||||
Objects.requireNonNull(expiresAt, "expiresAt");
|
||||
Objects.requireNonNull(reasonText, "reasonText");
|
||||
if (targetFingerprint.length() != 64) {
|
||||
throw new IllegalArgumentException("targetFingerprint must be a keyed SHA-256 hex digest");
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Cancellation intent.
|
||||
*
|
||||
* <p>Cancellation stops future logical attempts. It never claims that an already accepted provider
|
||||
* submission was withdrawn.
|
||||
*/
|
||||
public record CancelCommand(String reasonCode, boolean cancelAmbiguousFollowUps) {
|
||||
|
||||
private static final int MAX_REASON_LENGTH = 120;
|
||||
|
||||
public CancelCommand {
|
||||
Objects.requireNonNull(reasonCode, "reasonCode");
|
||||
if (reasonCode.isBlank() || reasonCode.length() > MAX_REASON_LENGTH) {
|
||||
throw new IllegalArgumentException("reasonCode");
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Outcome of a cancellation request, including how much external effect stays unknown. */
|
||||
public record CancelResult(
|
||||
NotificationId notificationId,
|
||||
int canceledRecipients,
|
||||
int alreadyTerminalRecipients,
|
||||
boolean externalSideEffectUncertain,
|
||||
List<String> reasonCodes) {
|
||||
|
||||
public CancelResult {
|
||||
Objects.requireNonNull(notificationId, "notificationId");
|
||||
reasonCodes = List.copyOf(Objects.requireNonNull(reasonCodes, "reasonCodes"));
|
||||
if (canceledRecipients < 0 || alreadyTerminalRecipients < 0) {
|
||||
throw new IllegalArgumentException("counts must not be negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Caller-supplied channel preference for one recipient. Preference selects among channels that are
|
||||
* already eligible; it can never re-enable a suppressed channel.
|
||||
*/
|
||||
public record ChannelPreferenceOverride(
|
||||
List<Channel> preferredOrder, Set<Channel> blockedChannels) {
|
||||
|
||||
public ChannelPreferenceOverride {
|
||||
preferredOrder = List.copyOf(Objects.requireNonNull(preferredOrder, "preferredOrder"));
|
||||
blockedChannels = Set.copyOf(Objects.requireNonNull(blockedChannels, "blockedChannels"));
|
||||
if (preferredOrder.stream().anyMatch(blockedChannels::contains)) {
|
||||
throw new IllegalArgumentException("a channel cannot be preferred and blocked");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
/** Scope a collapse key is unique within. */
|
||||
public enum CollapseScope {
|
||||
RECIPIENT,
|
||||
TENANT_CATEGORY
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Provider transport hint. Collapse replaces an undelivered message; it never cancels a
|
||||
* notification that already reached the device and never guarantees a single user-visible
|
||||
* notification.
|
||||
*/
|
||||
public record CollapseSpec(String key, CollapseScope scope) {
|
||||
|
||||
private static final int MAX_KEY_LENGTH = 64;
|
||||
|
||||
public CollapseSpec {
|
||||
if (key == null || key.isBlank()) {
|
||||
throw new IllegalArgumentException("key");
|
||||
}
|
||||
if (key.length() > MAX_KEY_LENGTH) {
|
||||
throw new IllegalArgumentException("key exceeds " + MAX_KEY_LENGTH);
|
||||
}
|
||||
Objects.requireNonNull(scope, "scope");
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Identity of a protected contact point. Never the raw address. */
|
||||
public record ContactPointId(UUID value) {
|
||||
|
||||
public ContactPointId {
|
||||
Objects.requireNonNull(value, "value");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ContactPointId[redacted]";
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Binds a channel of a recipient to one protected contact point. */
|
||||
public record ContactPointSelector(Channel channel, ContactPointId contactPointId) {
|
||||
|
||||
public ContactPointSelector {
|
||||
Objects.requireNonNull(channel, "channel");
|
||||
Objects.requireNonNull(contactPointId, "contactPointId");
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
/** Caller-supplied correlation identity used for trace linking only. */
|
||||
public record CorrelationId(String value) {
|
||||
|
||||
private static final int MAX_LENGTH = 160;
|
||||
|
||||
public CorrelationId {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("value");
|
||||
}
|
||||
if (value.length() > MAX_LENGTH) {
|
||||
throw new IllegalArgumentException("value exceeds " + MAX_LENGTH + " characters");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
/** What to do when a later request falls inside an existing deduplication window. */
|
||||
public enum DeduplicationAction {
|
||||
RETURN_EXISTING,
|
||||
DROP
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Opt-in business deduplication. This is not idempotency: it answers "are these different requests
|
||||
* the same user-facing notification for a while?".
|
||||
*/
|
||||
public record DeduplicationSpec(String dedupKey, Duration window, DeduplicationAction action) {
|
||||
|
||||
private static final int MAX_KEY_LENGTH = 200;
|
||||
private static final Duration MAX_WINDOW = Duration.ofDays(7);
|
||||
|
||||
public DeduplicationSpec {
|
||||
if (dedupKey == null || dedupKey.isBlank()) {
|
||||
throw new IllegalArgumentException("dedupKey");
|
||||
}
|
||||
if (dedupKey.length() > MAX_KEY_LENGTH) {
|
||||
throw new IllegalArgumentException("dedupKey exceeds " + MAX_KEY_LENGTH);
|
||||
}
|
||||
Objects.requireNonNull(window, "window");
|
||||
Objects.requireNonNull(action, "action");
|
||||
if (window.isZero() || window.isNegative() || window.compareTo(MAX_WINDOW) > 0) {
|
||||
throw new IllegalArgumentException("window must be positive and at most " + MAX_WINDOW);
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Identity of one physical provider attempt. */
|
||||
public record DeliveryAttemptId(UUID value) {
|
||||
|
||||
public DeliveryAttemptId {
|
||||
Objects.requireNonNull(value, "value");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "DeliveryAttemptId[redacted]";
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
/** Caller-supplied API idempotency key, unique inside one tenant. */
|
||||
public record IdempotencyKey(String value) {
|
||||
|
||||
private static final int MAX_LENGTH = 200;
|
||||
|
||||
public IdempotencyKey {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("value");
|
||||
}
|
||||
if (value.length() > MAX_LENGTH) {
|
||||
throw new IllegalArgumentException("value exceeds " + MAX_LENGTH + " characters");
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Identity of one logical notification request. Internal UUIDv7. */
|
||||
public record NotificationId(UUID value) {
|
||||
|
||||
public NotificationId {
|
||||
Objects.requireNonNull(value, "value");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "NotificationId[redacted]";
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* N2 advanced API.
|
||||
*
|
||||
* <p>{@code submit} and {@code schedule} return once the logical request and its recipient jobs are
|
||||
* durably committed. Neither waits for a provider call.
|
||||
*/
|
||||
public interface NotificationOrchestrator {
|
||||
|
||||
/** Accept a plan for immediate dispatch. */
|
||||
NotificationReceipt submit(NotificationPlan plan);
|
||||
|
||||
/** Accept a plan that must not be activated before {@code scheduleAt}. */
|
||||
NotificationReceipt schedule(NotificationPlan plan, Instant scheduleAt);
|
||||
|
||||
/** Stop future logical attempts for a notification. */
|
||||
CancelResult cancel(NotificationId notificationId, CancelCommand command);
|
||||
|
||||
/** Read the current projection of a notification. */
|
||||
NotificationSnapshot get(NotificationId notificationId);
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.routing.DeliveryStrategy;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* N2 submission intent.
|
||||
*
|
||||
* <p>{@code variables} is the notification-wide normalized variable map. It is pinned into the
|
||||
* request fingerprint together with the template coordinate, so a later template publish cannot
|
||||
* change what an accepted notification means.
|
||||
*/
|
||||
public record NotificationPlan(
|
||||
TenantId tenantId,
|
||||
IdempotencyKey idempotencyKey,
|
||||
String category,
|
||||
TemplateSelection template,
|
||||
Map<String, Object> variables,
|
||||
List<RecipientSpec> recipients,
|
||||
DeliveryStrategy deliveryStrategy,
|
||||
Optional<Instant> notBefore,
|
||||
Optional<Instant> expiresAt,
|
||||
Optional<DeduplicationSpec> deduplication,
|
||||
Optional<CollapseSpec> collapse,
|
||||
CorrelationId correlationId,
|
||||
Map<String, String> boundedMetadata) {
|
||||
|
||||
/** Hard limits from the design contract; a profile may lower them but never raise them. */
|
||||
public static final int MAX_METADATA_ENTRIES = 16;
|
||||
|
||||
public static final int MAX_METADATA_KEY_BYTES = 64;
|
||||
public static final int MAX_METADATA_VALUE_BYTES = 256;
|
||||
public static final int MAX_RECIPIENTS = 1000;
|
||||
private static final int MAX_CATEGORY_LENGTH = 120;
|
||||
|
||||
public NotificationPlan {
|
||||
Objects.requireNonNull(tenantId, "tenantId");
|
||||
Objects.requireNonNull(idempotencyKey, "idempotencyKey");
|
||||
Objects.requireNonNull(template, "template");
|
||||
Objects.requireNonNull(deliveryStrategy, "deliveryStrategy");
|
||||
Objects.requireNonNull(correlationId, "correlationId");
|
||||
Objects.requireNonNull(notBefore, "notBefore");
|
||||
Objects.requireNonNull(expiresAt, "expiresAt");
|
||||
Objects.requireNonNull(deduplication, "deduplication");
|
||||
Objects.requireNonNull(collapse, "collapse");
|
||||
variables = Map.copyOf(Objects.requireNonNull(variables, "variables"));
|
||||
recipients = List.copyOf(Objects.requireNonNull(recipients, "recipients"));
|
||||
boundedMetadata = Map.copyOf(Objects.requireNonNull(boundedMetadata, "boundedMetadata"));
|
||||
|
||||
if (category == null || category.isBlank() || category.length() > MAX_CATEGORY_LENGTH) {
|
||||
throw new IllegalArgumentException("category");
|
||||
}
|
||||
if (recipients.isEmpty() || recipients.size() > MAX_RECIPIENTS) {
|
||||
throw new IllegalArgumentException("recipients must be 1.." + MAX_RECIPIENTS);
|
||||
}
|
||||
if (boundedMetadata.size() > MAX_METADATA_ENTRIES) {
|
||||
throw new IllegalArgumentException("boundedMetadata exceeds " + MAX_METADATA_ENTRIES);
|
||||
}
|
||||
boundedMetadata.forEach(NotificationPlan::requireBoundedMetadataEntry);
|
||||
|
||||
if (expiresAt.isPresent()
|
||||
&& notBefore.isPresent()
|
||||
&& !expiresAt.get().isAfter(notBefore.get())) {
|
||||
throw new IllegalArgumentException("expiresAt must be after notBefore");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireBoundedMetadataEntry(String key, String value) {
|
||||
Objects.requireNonNull(key, "metadata key");
|
||||
Objects.requireNonNull(value, "metadata value");
|
||||
if (key.getBytes(StandardCharsets.UTF_8).length > MAX_METADATA_KEY_BYTES) {
|
||||
throw new IllegalArgumentException(
|
||||
"metadata key exceeds " + MAX_METADATA_KEY_BYTES + " bytes");
|
||||
}
|
||||
if (value.getBytes(StandardCharsets.UTF_8).length > MAX_METADATA_VALUE_BYTES) {
|
||||
throw new IllegalArgumentException(
|
||||
"metadata value exceeds " + MAX_METADATA_VALUE_BYTES + " bytes");
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Result of a durable acceptance.
|
||||
*
|
||||
* <p>There is deliberately no {@code delivered}, {@code sent} or {@code read} component: a receipt
|
||||
* proves the logical request and its recipient jobs are committed, nothing about any provider.
|
||||
*/
|
||||
public record NotificationReceipt(
|
||||
NotificationId notificationId, RequestStatus status, Instant acceptedAt) {
|
||||
|
||||
public NotificationReceipt {
|
||||
Objects.requireNonNull(notificationId, "notificationId");
|
||||
Objects.requireNonNull(status, "status");
|
||||
Objects.requireNonNull(acceptedAt, "acceptedAt");
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.DeliveryOutcome;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.RecipientDeliveryState;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Read model of one logical notification: status plus the evidence held per recipient and attempt.
|
||||
*/
|
||||
public record NotificationSnapshot(
|
||||
NotificationId notificationId,
|
||||
TenantId tenantId,
|
||||
RequestStatus status,
|
||||
Instant acceptedAt,
|
||||
Instant updatedAt,
|
||||
List<RecipientSnapshot> recipients) {
|
||||
|
||||
public NotificationSnapshot {
|
||||
Objects.requireNonNull(notificationId, "notificationId");
|
||||
Objects.requireNonNull(tenantId, "tenantId");
|
||||
Objects.requireNonNull(status, "status");
|
||||
Objects.requireNonNull(acceptedAt, "acceptedAt");
|
||||
Objects.requireNonNull(updatedAt, "updatedAt");
|
||||
recipients = List.copyOf(Objects.requireNonNull(recipients, "recipients"));
|
||||
}
|
||||
|
||||
/** Per-recipient projection. Contact point values are never exposed here. */
|
||||
public record RecipientSnapshot(
|
||||
RecipientDeliveryId recipientDeliveryId,
|
||||
RecipientDeliveryState state,
|
||||
SubmissionOutcome submissionOutcome,
|
||||
DeliveryOutcome deliveryOutcome,
|
||||
EvidenceLevel evidenceLevel,
|
||||
boolean ambiguousAttemptExists,
|
||||
boolean duplicateRisk,
|
||||
int attemptCount,
|
||||
List<AttemptSnapshot> attempts) {
|
||||
|
||||
public RecipientSnapshot {
|
||||
Objects.requireNonNull(recipientDeliveryId, "recipientDeliveryId");
|
||||
Objects.requireNonNull(state, "state");
|
||||
Objects.requireNonNull(submissionOutcome, "submissionOutcome");
|
||||
Objects.requireNonNull(deliveryOutcome, "deliveryOutcome");
|
||||
Objects.requireNonNull(evidenceLevel, "evidenceLevel");
|
||||
attempts = List.copyOf(Objects.requireNonNull(attempts, "attempts"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-attempt projection. */
|
||||
public record AttemptSnapshot(
|
||||
DeliveryAttemptId attemptId,
|
||||
int attemptNo,
|
||||
Channel channel,
|
||||
ProviderProfileId providerProfileId,
|
||||
AttemptConfirmation confirmation,
|
||||
SubmissionOutcome submissionOutcome,
|
||||
DeliveryOutcome deliveryOutcome,
|
||||
EvidenceLevel evidenceLevel,
|
||||
Optional<String> failureCode,
|
||||
Instant startedAt,
|
||||
Optional<Instant> completedAt) {
|
||||
|
||||
public AttemptSnapshot {
|
||||
Objects.requireNonNull(attemptId, "attemptId");
|
||||
Objects.requireNonNull(channel, "channel");
|
||||
Objects.requireNonNull(providerProfileId, "providerProfileId");
|
||||
Objects.requireNonNull(confirmation, "confirmation");
|
||||
Objects.requireNonNull(submissionOutcome, "submissionOutcome");
|
||||
Objects.requireNonNull(deliveryOutcome, "deliveryOutcome");
|
||||
Objects.requireNonNull(evidenceLevel, "evidenceLevel");
|
||||
Objects.requireNonNull(failureCode, "failureCode");
|
||||
Objects.requireNonNull(startedAt, "startedAt");
|
||||
Objects.requireNonNull(completedAt, "completedAt");
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
/**
|
||||
* Provider-assigned event identity. Absent for providers that do not emit one; the ledger then
|
||||
* falls back to a deterministic fingerprint.
|
||||
*/
|
||||
public record ProviderEventId(String value) {
|
||||
|
||||
private static final int MAX_LENGTH = 300;
|
||||
|
||||
public ProviderEventId {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("value");
|
||||
}
|
||||
if (value.length() > MAX_LENGTH) {
|
||||
throw new IllegalArgumentException("value exceeds " + MAX_LENGTH + " characters");
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
/** Provider family identity, for example {@code ses}, {@code twilio}, {@code fcm}. */
|
||||
public record ProviderId(String value) {
|
||||
|
||||
private static final int MAX_LENGTH = 80;
|
||||
|
||||
public ProviderId {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("value");
|
||||
}
|
||||
if (value.length() > MAX_LENGTH) {
|
||||
throw new IllegalArgumentException("value exceeds " + MAX_LENGTH + " characters");
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
/** Configured provider profile identity, for example {@code ses-primary}. */
|
||||
public record ProviderProfileId(String value) {
|
||||
|
||||
private static final int MAX_LENGTH = 120;
|
||||
|
||||
public ProviderProfileId {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("value");
|
||||
}
|
||||
if (value.length() > MAX_LENGTH) {
|
||||
throw new IllegalArgumentException("value exceeds " + MAX_LENGTH + " characters");
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Identity of the logical delivery job for a single recipient. */
|
||||
public record RecipientDeliveryId(UUID value) {
|
||||
|
||||
public RecipientDeliveryId {
|
||||
Objects.requireNonNull(value, "value");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "RecipientDeliveryId[redacted]";
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* One recipient of a logical notification.
|
||||
*
|
||||
* <p>{@code recipientRef} is the caller's stable reference. It is never a contact point value and
|
||||
* is never used as a metric label.
|
||||
*/
|
||||
public record RecipientSpec(
|
||||
String recipientRef,
|
||||
Optional<Locale> locale,
|
||||
Optional<ZoneId> timeZone,
|
||||
List<ContactPointSelector> contactPoints,
|
||||
Optional<ChannelPreferenceOverride> channelOverride) {
|
||||
|
||||
private static final int MAX_RECIPIENT_REF_LENGTH = 200;
|
||||
|
||||
public RecipientSpec {
|
||||
if (recipientRef == null || recipientRef.isBlank()) {
|
||||
throw new IllegalArgumentException("recipientRef");
|
||||
}
|
||||
if (recipientRef.length() > MAX_RECIPIENT_REF_LENGTH) {
|
||||
throw new IllegalArgumentException("recipientRef exceeds " + MAX_RECIPIENT_REF_LENGTH);
|
||||
}
|
||||
Objects.requireNonNull(locale, "locale");
|
||||
Objects.requireNonNull(timeZone, "timeZone");
|
||||
contactPoints = List.copyOf(Objects.requireNonNull(contactPoints, "contactPoints"));
|
||||
Objects.requireNonNull(channelOverride, "channelOverride");
|
||||
if (contactPoints.isEmpty()) {
|
||||
throw new IllegalArgumentException("contactPoints must not be empty");
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
/**
|
||||
* Projection of the logical request. This never expresses a provider status: a request may be
|
||||
* {@code COMPLETED} while individual recipients hold only {@code PROVIDER_ACCEPTED} evidence.
|
||||
*/
|
||||
public enum RequestStatus {
|
||||
CREATED,
|
||||
VALIDATED,
|
||||
SCHEDULED,
|
||||
PROCESSING,
|
||||
PARTIALLY_COMPLETED,
|
||||
COMPLETED,
|
||||
CANCELED,
|
||||
EXPIRED,
|
||||
FAILED
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Exact template coordinate pinned at submit time. Retry and redrive reuse this selection instead
|
||||
* of resolving the newest published version.
|
||||
*/
|
||||
public record TemplateSelection(String templateId, long version, Locale locale) {
|
||||
|
||||
private static final int MAX_TEMPLATE_ID_LENGTH = 160;
|
||||
|
||||
public TemplateSelection {
|
||||
if (templateId == null || templateId.isBlank()) {
|
||||
throw new IllegalArgumentException("templateId");
|
||||
}
|
||||
if (templateId.length() > MAX_TEMPLATE_ID_LENGTH) {
|
||||
throw new IllegalArgumentException("templateId exceeds " + MAX_TEMPLATE_ID_LENGTH);
|
||||
}
|
||||
if (version <= 0) {
|
||||
throw new IllegalArgumentException("version");
|
||||
}
|
||||
Objects.requireNonNull(locale, "locale");
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
/** Tenant boundary every repository query must carry. */
|
||||
public record TenantId(String value) {
|
||||
|
||||
private static final int MAX_LENGTH = 100;
|
||||
|
||||
public TenantId {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("value");
|
||||
}
|
||||
if (value.length() > MAX_LENGTH) {
|
||||
throw new IllegalArgumentException("value exceeds " + MAX_LENGTH + " characters");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package dev.caskeleton.application.notification.platform.api.content;
|
||||
|
||||
/** How an attachment reference is presented in the rendered message. */
|
||||
public enum AttachmentDisposition {
|
||||
ATTACHMENT,
|
||||
INLINE
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package dev.caskeleton.application.notification.platform.api.content;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Immutable reference to content owned by the file server or object storage capability. The
|
||||
* notification platform never stores attachment bytes.
|
||||
*/
|
||||
public record AttachmentRef(
|
||||
String contentReference,
|
||||
String displayName,
|
||||
String contentType,
|
||||
long expectedSize,
|
||||
String expectedDigest,
|
||||
AttachmentDisposition disposition) {
|
||||
|
||||
public AttachmentRef {
|
||||
Objects.requireNonNull(contentReference, "contentReference");
|
||||
Objects.requireNonNull(displayName, "displayName");
|
||||
Objects.requireNonNull(contentType, "contentType");
|
||||
Objects.requireNonNull(expectedDigest, "expectedDigest");
|
||||
Objects.requireNonNull(disposition, "disposition");
|
||||
if (contentReference.isBlank()) {
|
||||
throw new IllegalArgumentException("contentReference");
|
||||
}
|
||||
if (expectedSize < 0) {
|
||||
throw new IllegalArgumentException("expectedSize");
|
||||
}
|
||||
if (expectedDigest.isBlank()) {
|
||||
throw new IllegalArgumentException("expectedDigest");
|
||||
}
|
||||
if (displayName.indexOf('\r') >= 0 || displayName.indexOf('\n') >= 0) {
|
||||
throw new IllegalArgumentException("displayName must not contain CR or LF");
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package dev.caskeleton.application.notification.platform.api.content;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Rendered email body: text plus optional HTML for a multipart/alternative message. */
|
||||
public record EmailContent(
|
||||
String subject,
|
||||
String textBody,
|
||||
Optional<String> htmlBody,
|
||||
List<AttachmentRef> attachments,
|
||||
EmailOptions options)
|
||||
implements NotificationContent {
|
||||
|
||||
private static final int MAX_ATTACHMENTS = 20;
|
||||
|
||||
public EmailContent {
|
||||
Objects.requireNonNull(subject, "subject");
|
||||
Objects.requireNonNull(textBody, "textBody");
|
||||
Objects.requireNonNull(htmlBody, "htmlBody");
|
||||
attachments = List.copyOf(Objects.requireNonNull(attachments, "attachments"));
|
||||
Objects.requireNonNull(options, "options");
|
||||
if (subject.isBlank()) {
|
||||
throw new IllegalArgumentException("subject");
|
||||
}
|
||||
if (subject.indexOf('\r') >= 0 || subject.indexOf('\n') >= 0) {
|
||||
throw new IllegalArgumentException("subject must not contain CR or LF");
|
||||
}
|
||||
if (attachments.size() > MAX_ATTACHMENTS) {
|
||||
throw new IllegalArgumentException("too many attachments");
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package dev.caskeleton.application.notification.platform.api.content;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.ContactPointId;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Typed email transport options. Copy recipients and reply-to are carried as protected contact
|
||||
* point references, never as raw addresses, and header values are rejected when they contain CR or
|
||||
* LF.
|
||||
*/
|
||||
public record EmailOptions(
|
||||
List<ContactPointId> cc,
|
||||
List<ContactPointId> bcc,
|
||||
Optional<ContactPointId> replyTo,
|
||||
Map<String, String> approvedHeaders,
|
||||
boolean listUnsubscribeEnabled) {
|
||||
|
||||
private static final int MAX_COPY_RECIPIENTS = 50;
|
||||
private static final int MAX_HEADERS = 16;
|
||||
|
||||
public static final EmailOptions DEFAULT =
|
||||
new EmailOptions(List.of(), List.of(), Optional.empty(), Map.of(), false);
|
||||
|
||||
public EmailOptions {
|
||||
cc = List.copyOf(Objects.requireNonNull(cc, "cc"));
|
||||
bcc = List.copyOf(Objects.requireNonNull(bcc, "bcc"));
|
||||
Objects.requireNonNull(replyTo, "replyTo");
|
||||
approvedHeaders = Map.copyOf(Objects.requireNonNull(approvedHeaders, "approvedHeaders"));
|
||||
if (cc.size() + bcc.size() > MAX_COPY_RECIPIENTS) {
|
||||
throw new IllegalArgumentException("too many copy recipients");
|
||||
}
|
||||
if (approvedHeaders.size() > MAX_HEADERS) {
|
||||
throw new IllegalArgumentException("too many approved headers");
|
||||
}
|
||||
approvedHeaders.forEach(
|
||||
(name, value) -> {
|
||||
if (containsControlCharacter(name) || containsControlCharacter(value)) {
|
||||
throw new IllegalArgumentException("header must not contain CR or LF");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean containsControlCharacter(String value) {
|
||||
Objects.requireNonNull(value, "value");
|
||||
return value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\0') >= 0;
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.caskeleton.application.notification.platform.api.content;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** UI-independent semantic action attached to an in-app inbox item. */
|
||||
public record InAppAction(String actionId, String label, Optional<URI> deepLink) {
|
||||
|
||||
public InAppAction {
|
||||
Objects.requireNonNull(actionId, "actionId");
|
||||
Objects.requireNonNull(label, "label");
|
||||
Objects.requireNonNull(deepLink, "deepLink");
|
||||
if (actionId.isBlank()) {
|
||||
throw new IllegalArgumentException("actionId");
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package dev.caskeleton.application.notification.platform.api.content;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Rendered in-app inbox content. The database row is the source of truth for this channel. */
|
||||
public record InAppContent(
|
||||
String title, String body, Optional<URI> deepLink, List<InAppAction> actions, String category)
|
||||
implements NotificationContent {
|
||||
|
||||
private static final int MAX_ACTIONS = 8;
|
||||
|
||||
public InAppContent {
|
||||
Objects.requireNonNull(title, "title");
|
||||
Objects.requireNonNull(body, "body");
|
||||
Objects.requireNonNull(deepLink, "deepLink");
|
||||
actions = List.copyOf(Objects.requireNonNull(actions, "actions"));
|
||||
Objects.requireNonNull(category, "category");
|
||||
if (title.isBlank()) {
|
||||
throw new IllegalArgumentException("title");
|
||||
}
|
||||
if (category.isBlank()) {
|
||||
throw new IllegalArgumentException("category");
|
||||
}
|
||||
if (actions.size() > MAX_ACTIONS) {
|
||||
throw new IllegalArgumentException("too many actions");
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package dev.caskeleton.application.notification.platform.api.content;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Rendered mobile push content. The data map is the one place where a string-to-string map is the
|
||||
* platform-native shape; reserved provider keys are rejected by the provider adapters.
|
||||
*/
|
||||
public record MobilePushContent(
|
||||
String title,
|
||||
String body,
|
||||
Optional<URI> deepLink,
|
||||
Map<String, String> data,
|
||||
PushPresentation presentation)
|
||||
implements NotificationContent {
|
||||
|
||||
private static final int MAX_DATA_ENTRIES = 32;
|
||||
|
||||
public MobilePushContent {
|
||||
Objects.requireNonNull(title, "title");
|
||||
Objects.requireNonNull(body, "body");
|
||||
Objects.requireNonNull(deepLink, "deepLink");
|
||||
data = Map.copyOf(Objects.requireNonNull(data, "data"));
|
||||
Objects.requireNonNull(presentation, "presentation");
|
||||
if (title.isBlank()) {
|
||||
throw new IllegalArgumentException("title");
|
||||
}
|
||||
if (data.size() > MAX_DATA_ENTRIES) {
|
||||
throw new IllegalArgumentException("too many data entries");
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience factory that accepts a nullable deep link. */
|
||||
public static MobilePushContent of(
|
||||
String title, String body, URI deepLink, Map<String, String> data) {
|
||||
return new MobilePushContent(
|
||||
title, body, Optional.ofNullable(deepLink), data, PushPresentation.DEFAULT);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package dev.caskeleton.application.notification.platform.api.content;
|
||||
|
||||
/**
|
||||
* Channel content after rendering. Provider SDK objects, arbitrary option maps, raw credentials,
|
||||
* unbounded headers and binary attachment bytes are deliberately absent from this hierarchy.
|
||||
*/
|
||||
public sealed interface NotificationContent
|
||||
permits EmailContent, SmsContent, MobilePushContent, WebPushContent, InAppContent {}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.application.notification.platform.api.content;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Presentation hints shared by mobile push providers. */
|
||||
public record PushPresentation(Optional<String> sound, Optional<Integer> badge) {
|
||||
|
||||
public static final PushPresentation DEFAULT =
|
||||
new PushPresentation(Optional.empty(), Optional.empty());
|
||||
|
||||
public PushPresentation {
|
||||
Objects.requireNonNull(sound, "sound");
|
||||
Objects.requireNonNull(badge, "badge");
|
||||
badge.ifPresent(
|
||||
value -> {
|
||||
if (value < 0) {
|
||||
throw new IllegalArgumentException("badge");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.caskeleton.application.notification.platform.api.content;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Rendered SMS body. Segment and encoding analysis is performed by the SMS estimator, not here. */
|
||||
public record SmsContent(String text, SmsOptions options) implements NotificationContent {
|
||||
|
||||
public SmsContent {
|
||||
if (text == null || text.isBlank()) {
|
||||
throw new IllegalArgumentException("text");
|
||||
}
|
||||
Objects.requireNonNull(options, "options");
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package dev.caskeleton.application.notification.platform.api.content;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Typed SMS transport options. The sender is a configured profile reference; business code never
|
||||
* supplies an arbitrary sender string.
|
||||
*/
|
||||
public record SmsOptions(Optional<String> senderProfileRef, boolean transactional) {
|
||||
|
||||
public static final SmsOptions DEFAULT = new SmsOptions(Optional.empty(), true);
|
||||
|
||||
public SmsOptions {
|
||||
Objects.requireNonNull(senderProfileRef, "senderProfileRef");
|
||||
senderProfileRef.ifPresent(
|
||||
reference -> {
|
||||
if (reference.isBlank()) {
|
||||
throw new IllegalArgumentException("senderProfileRef");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package dev.caskeleton.application.notification.platform.api.content;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Rendered Web Push content. The encrypted payload size is validated before the provider call. */
|
||||
public record WebPushContent(
|
||||
String title,
|
||||
String body,
|
||||
Optional<URI> deepLink,
|
||||
Map<String, String> data,
|
||||
WebPushOptions options)
|
||||
implements NotificationContent {
|
||||
|
||||
private static final int MAX_DATA_ENTRIES = 32;
|
||||
|
||||
public WebPushContent {
|
||||
Objects.requireNonNull(title, "title");
|
||||
Objects.requireNonNull(body, "body");
|
||||
Objects.requireNonNull(deepLink, "deepLink");
|
||||
data = Map.copyOf(Objects.requireNonNull(data, "data"));
|
||||
Objects.requireNonNull(options, "options");
|
||||
if (title.isBlank()) {
|
||||
throw new IllegalArgumentException("title");
|
||||
}
|
||||
if (data.size() > MAX_DATA_ENTRIES) {
|
||||
throw new IllegalArgumentException("too many data entries");
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package dev.caskeleton.application.notification.platform.api.content;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Typed RFC 8030 options. TTL is derived from the delivery expiry, never supplied here. */
|
||||
public record WebPushOptions(Urgency urgency, Optional<String> topic) {
|
||||
|
||||
private static final int MAX_TOPIC_LENGTH = 32;
|
||||
|
||||
public static final WebPushOptions DEFAULT = new WebPushOptions(Urgency.NORMAL, Optional.empty());
|
||||
|
||||
/** RFC 8030 urgency values. */
|
||||
public enum Urgency {
|
||||
VERY_LOW,
|
||||
LOW,
|
||||
NORMAL,
|
||||
HIGH;
|
||||
|
||||
/** Wire token used in the Urgency header. */
|
||||
public String headerValue() {
|
||||
return name().toLowerCase(Locale.ROOT).replace('_', '-');
|
||||
}
|
||||
}
|
||||
|
||||
public WebPushOptions {
|
||||
Objects.requireNonNull(urgency, "urgency");
|
||||
Objects.requireNonNull(topic, "topic");
|
||||
topic.ifPresent(
|
||||
value -> {
|
||||
if (value.isBlank() || value.length() > MAX_TOPIC_LENGTH) {
|
||||
throw new IllegalArgumentException("topic");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package dev.caskeleton.application.notification.platform.api.delivery;
|
||||
|
||||
/** How certain the platform is about the completion of one physical attempt. */
|
||||
public enum AttemptConfirmation {
|
||||
CONFIRMED,
|
||||
REJECTED,
|
||||
AMBIGUOUS
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.api.delivery;
|
||||
|
||||
/** What the platform knows about delivery after provider callbacks or reconciliation. */
|
||||
public enum DeliveryOutcome {
|
||||
UNKNOWN,
|
||||
SENT,
|
||||
DELIVERED,
|
||||
UNDELIVERED,
|
||||
BOUNCED,
|
||||
EXPIRED
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package dev.caskeleton.application.notification.platform.api.delivery;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* The strongest evidence obtained so far.
|
||||
*
|
||||
* <p>The explicit {@code strength} rank exists so that no code has to reach for {@code ordinal()}.
|
||||
* It compares two evidence levels <em>about the same attempt</em> and nothing else: merging
|
||||
* provider events is done by channel-specific projectors with real transition tables, because
|
||||
* "delivered then complaint" and "delivered then late sent" are not comparisons at all.
|
||||
*/
|
||||
public enum EvidenceLevel {
|
||||
NONE(0),
|
||||
PLATFORM_QUEUED(1),
|
||||
PROVIDER_ACCEPTED(2),
|
||||
NETWORK_OR_CARRIER_ACCEPTED(3),
|
||||
DEVICE_DELIVERED(4),
|
||||
USER_AGENT_DISPLAYED(5),
|
||||
USER_READ(6);
|
||||
|
||||
private final int strength;
|
||||
|
||||
EvidenceLevel(int strength) {
|
||||
this.strength = strength;
|
||||
}
|
||||
|
||||
/** Rank of this level; only meaningful against another level of the same attempt. */
|
||||
public int strength() {
|
||||
return strength;
|
||||
}
|
||||
|
||||
/** Whether this level is at least as strong as another. */
|
||||
public boolean atLeast(EvidenceLevel other) {
|
||||
Objects.requireNonNull(other, "other");
|
||||
return strength >= other.strength;
|
||||
}
|
||||
|
||||
/** The stronger of two levels, so evidence never moves backwards. */
|
||||
public EvidenceLevel strongerOf(EvidenceLevel other) {
|
||||
Objects.requireNonNull(other, "other");
|
||||
return strength >= other.strength ? this : other;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.caskeleton.application.notification.platform.api.delivery;
|
||||
|
||||
/** Lifecycle projection of one recipient delivery job. */
|
||||
public enum RecipientDeliveryState {
|
||||
PENDING,
|
||||
READY_TO_DISPATCH,
|
||||
SUPPRESSED,
|
||||
DISPATCHING,
|
||||
RETRY_WAITING,
|
||||
RECONCILIATION_REQUIRED,
|
||||
COMPLETED,
|
||||
FAILED,
|
||||
EXPIRED,
|
||||
CANCELED
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package dev.caskeleton.application.notification.platform.api.delivery;
|
||||
|
||||
/** What the platform knows about the submission itself, independent of any delivery evidence. */
|
||||
public enum SubmissionOutcome {
|
||||
NOT_SUBMITTED,
|
||||
CONFIRMED_ACCEPTED,
|
||||
CONFIRMED_REJECTED,
|
||||
AMBIGUOUS
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/** The submission may or may not have been accepted; automatic retry and fallback stay blocked. */
|
||||
public class AmbiguousSubmissionException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public AmbiguousSubmissionException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/** Attachment size or digest did not match the pinned reference. */
|
||||
public class AttachmentIntegrityException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public AttachmentIntegrityException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/** Attachment reference is not readable or not READY. */
|
||||
public class AttachmentUnavailableException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public AttachmentUnavailableException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/** A stored provider event could not be projected. */
|
||||
public class CallbackProjectionException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public CallbackProjectionException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/** Callback failed signature, size, content-type or replay validation. */
|
||||
public class CallbackValidationException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public CallbackValidationException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/** Retry-relevant classification of a failure. Provider status strings never reach this enum. */
|
||||
public enum FailureCategory {
|
||||
TRANSIENT_PROVIDER,
|
||||
THROTTLED,
|
||||
AUTHENTICATION,
|
||||
AUTHORIZATION,
|
||||
INVALID_RECIPIENT,
|
||||
INVALID_PAYLOAD,
|
||||
TEMPLATE_FAILURE,
|
||||
PERMANENT_PROVIDER,
|
||||
AMBIGUOUS_SUBMISSION,
|
||||
CALLBACK_VALIDATION_FAILURE,
|
||||
CAPACITY_REJECTED,
|
||||
EXPIRED
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/** Same idempotency key reused with a different request fingerprint. */
|
||||
public class IdempotencyConflictException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public IdempotencyConflictException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/** The contact point is not usable for this channel. */
|
||||
public class InvalidContactPointException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public InvalidContactPointException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/** Intake refused because the durable queue is at its bound. */
|
||||
public class NotificationCapacityException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public NotificationCapacityException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/**
|
||||
* Root of the stable notification error hierarchy.
|
||||
*
|
||||
* <p>The message is the registered failure code and nothing else. Recipient addresses, message
|
||||
* bodies, template variables and provider exception text never reach it; diagnostics that need more
|
||||
* detail go to the audit trail with protected identifiers.
|
||||
*/
|
||||
public abstract class NotificationException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final transient NotificationFailureDescriptor descriptor;
|
||||
|
||||
protected NotificationException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor.code());
|
||||
this.descriptor = descriptor;
|
||||
}
|
||||
|
||||
/** Stable, low-cardinality metadata describing this failure. */
|
||||
public NotificationFailureDescriptor descriptor() {
|
||||
return descriptor;
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/** The delivery expiry passed before a new attempt could start. */
|
||||
public class NotificationExpiredException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public NotificationExpiredException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Bounded failure-code registry.
|
||||
*
|
||||
* <p>Codes are safe as metric tags precisely because this set is closed. An adapter that meets an
|
||||
* unknown provider error maps it onto one of these codes and keeps the native status in the ledger.
|
||||
*/
|
||||
public final class NotificationFailureCode {
|
||||
|
||||
public static final String VALIDATION_FAILED = "VALIDATION_FAILED";
|
||||
public static final String IDEMPOTENCY_CONFLICT = "IDEMPOTENCY_CONFLICT";
|
||||
public static final String TEMPLATE_NOT_FOUND = "TEMPLATE_NOT_FOUND";
|
||||
public static final String TEMPLATE_RENDERING_FAILED = "TEMPLATE_RENDERING_FAILED";
|
||||
public static final String TEMPLATE_VARIABLES_INVALID = "TEMPLATE_VARIABLES_INVALID";
|
||||
public static final String CONTACT_POINT_INVALID = "CONTACT_POINT_INVALID";
|
||||
public static final String CONTACT_POINT_SUPPRESSED = "CONTACT_POINT_SUPPRESSED";
|
||||
public static final String NOTIFICATION_EXPIRED = "NOTIFICATION_EXPIRED";
|
||||
public static final String NOTIFICATION_SUPPRESSED = "NOTIFICATION_SUPPRESSED";
|
||||
public static final String PROVIDER_AUTHENTICATION_FAILED = "PROVIDER_AUTHENTICATION_FAILED";
|
||||
public static final String PROVIDER_AUTHORIZATION_FAILED = "PROVIDER_AUTHORIZATION_FAILED";
|
||||
public static final String PROVIDER_THROTTLED = "PROVIDER_THROTTLED";
|
||||
public static final String PROVIDER_TRANSIENT_FAILURE = "PROVIDER_TRANSIENT_FAILURE";
|
||||
public static final String PROVIDER_PERMANENT_FAILURE = "PROVIDER_PERMANENT_FAILURE";
|
||||
public static final String PROVIDER_REJECTED = "PROVIDER_REJECTED";
|
||||
public static final String PROVIDER_RESPONSE_LOST = "PROVIDER_RESPONSE_LOST";
|
||||
public static final String PROVIDER_PAYLOAD_LIMIT = "PROVIDER_PAYLOAD_LIMIT";
|
||||
public static final String PROVIDER_CONFIGURATION_INVALID = "PROVIDER_CONFIGURATION_INVALID";
|
||||
public static final String PROVIDER_UNAVAILABLE = "PROVIDER_UNAVAILABLE";
|
||||
public static final String CALLBACK_SIGNATURE_INVALID = "CALLBACK_SIGNATURE_INVALID";
|
||||
public static final String CALLBACK_PAYLOAD_REJECTED = "CALLBACK_PAYLOAD_REJECTED";
|
||||
public static final String CALLBACK_PROJECTION_FAILED = "CALLBACK_PROJECTION_FAILED";
|
||||
public static final String RECONCILIATION_FAILED = "RECONCILIATION_FAILED";
|
||||
public static final String CAPACITY_REJECTED = "CAPACITY_REJECTED";
|
||||
public static final String ATTACHMENT_UNAVAILABLE = "ATTACHMENT_UNAVAILABLE";
|
||||
public static final String ATTACHMENT_INTEGRITY_FAILED = "ATTACHMENT_INTEGRITY_FAILED";
|
||||
|
||||
private static final Set<String> REGISTERED =
|
||||
Set.of(
|
||||
VALIDATION_FAILED,
|
||||
IDEMPOTENCY_CONFLICT,
|
||||
TEMPLATE_NOT_FOUND,
|
||||
TEMPLATE_RENDERING_FAILED,
|
||||
TEMPLATE_VARIABLES_INVALID,
|
||||
CONTACT_POINT_INVALID,
|
||||
CONTACT_POINT_SUPPRESSED,
|
||||
NOTIFICATION_EXPIRED,
|
||||
NOTIFICATION_SUPPRESSED,
|
||||
PROVIDER_AUTHENTICATION_FAILED,
|
||||
PROVIDER_AUTHORIZATION_FAILED,
|
||||
PROVIDER_THROTTLED,
|
||||
PROVIDER_TRANSIENT_FAILURE,
|
||||
PROVIDER_PERMANENT_FAILURE,
|
||||
PROVIDER_REJECTED,
|
||||
PROVIDER_RESPONSE_LOST,
|
||||
PROVIDER_PAYLOAD_LIMIT,
|
||||
PROVIDER_CONFIGURATION_INVALID,
|
||||
PROVIDER_UNAVAILABLE,
|
||||
CALLBACK_SIGNATURE_INVALID,
|
||||
CALLBACK_PAYLOAD_REJECTED,
|
||||
CALLBACK_PROJECTION_FAILED,
|
||||
RECONCILIATION_FAILED,
|
||||
CAPACITY_REJECTED,
|
||||
ATTACHMENT_UNAVAILABLE,
|
||||
ATTACHMENT_INTEGRITY_FAILED);
|
||||
|
||||
private NotificationFailureCode() {}
|
||||
|
||||
/** Whether a code belongs to the closed registry. */
|
||||
public static boolean isRegistered(String code) {
|
||||
return REGISTERED.contains(code);
|
||||
}
|
||||
|
||||
/** All registered codes. */
|
||||
public static Set<String> registered() {
|
||||
return REGISTERED;
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderId;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Low-cardinality failure metadata that is safe to log, tag and audit.
|
||||
*
|
||||
* <p>{@code channel} and {@code providerId} are optional because failures such as an idempotency
|
||||
* conflict happen before a route is chosen; modelling that as a sentinel value would push a fake
|
||||
* provider into metrics.
|
||||
*/
|
||||
public record NotificationFailureDescriptor(
|
||||
String code,
|
||||
FailureCategory category,
|
||||
boolean retryable,
|
||||
boolean ambiguous,
|
||||
Optional<Channel> channel,
|
||||
Optional<ProviderId> providerId,
|
||||
int attemptNumber,
|
||||
Duration elapsed) {
|
||||
|
||||
public NotificationFailureDescriptor {
|
||||
Objects.requireNonNull(code, "code");
|
||||
Objects.requireNonNull(category, "category");
|
||||
Objects.requireNonNull(channel, "channel");
|
||||
Objects.requireNonNull(providerId, "providerId");
|
||||
Objects.requireNonNull(elapsed, "elapsed");
|
||||
if (!NotificationFailureCode.isRegistered(code)) {
|
||||
throw new IllegalArgumentException("unregistered failure code");
|
||||
}
|
||||
if (attemptNumber < 0) {
|
||||
throw new IllegalArgumentException("attemptNumber");
|
||||
}
|
||||
if (ambiguous && retryable) {
|
||||
throw new IllegalArgumentException("an ambiguous failure is never automatically retryable");
|
||||
}
|
||||
}
|
||||
|
||||
/** Descriptor for a failure raised before any provider attempt. */
|
||||
public static NotificationFailureDescriptor preDispatch(String code, FailureCategory category) {
|
||||
return new NotificationFailureDescriptor(
|
||||
code, category, false, false, Optional.empty(), Optional.empty(), 0, Duration.ZERO);
|
||||
}
|
||||
|
||||
/** Descriptor for a submission whose result could not be determined. */
|
||||
public static NotificationFailureDescriptor ambiguous(
|
||||
String code, Channel channel, ProviderId providerId, int attemptNumber) {
|
||||
return new NotificationFailureDescriptor(
|
||||
code,
|
||||
FailureCategory.AMBIGUOUS_SUBMISSION,
|
||||
false,
|
||||
true,
|
||||
Optional.of(channel),
|
||||
Optional.of(providerId),
|
||||
attemptNumber,
|
||||
Duration.ZERO);
|
||||
}
|
||||
|
||||
/** Descriptor for a delivery blocked by suppression or injected eligibility. */
|
||||
public static NotificationFailureDescriptor suppressed(String reasonCode, Channel channel) {
|
||||
return new NotificationFailureDescriptor(
|
||||
NotificationFailureCode.NOTIFICATION_SUPPRESSED,
|
||||
FailureCategory.INVALID_RECIPIENT,
|
||||
false,
|
||||
false,
|
||||
Optional.of(channel),
|
||||
Optional.empty(),
|
||||
0,
|
||||
Duration.ZERO)
|
||||
.withReasonSuffix(reasonCode);
|
||||
}
|
||||
|
||||
/** Descriptor for a provider failure with a known category. */
|
||||
public static NotificationFailureDescriptor provider(
|
||||
String code,
|
||||
FailureCategory category,
|
||||
boolean retryable,
|
||||
Channel channel,
|
||||
ProviderId providerId,
|
||||
int attemptNumber,
|
||||
Duration elapsed) {
|
||||
return new NotificationFailureDescriptor(
|
||||
code,
|
||||
category,
|
||||
retryable,
|
||||
false,
|
||||
Optional.of(channel),
|
||||
Optional.of(providerId),
|
||||
attemptNumber,
|
||||
elapsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reason code is intentionally not concatenated into {@link #code()}: keeping the tag closed
|
||||
* is what makes it safe for metrics. This accessor exists so that callers can express intent
|
||||
* without widening the registry.
|
||||
*/
|
||||
private NotificationFailureDescriptor withReasonSuffix(String reasonCode) {
|
||||
Objects.requireNonNull(reasonCode, "reasonCode");
|
||||
return this;
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/** Dispatch blocked by suppression or injected eligibility. */
|
||||
public class NotificationSuppressedException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public NotificationSuppressedException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/** Request rejected before any durable write. */
|
||||
public class NotificationValidationException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public NotificationValidationException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/**
|
||||
* Provider credentials are invalid; this opens the provider route rather than retrying one message.
|
||||
*/
|
||||
public class ProviderAuthenticationException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public ProviderAuthenticationException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/** Provider credentials are valid but not permitted for this operation. */
|
||||
public class ProviderAuthorizationException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public ProviderAuthorizationException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/** Provider profile and target disagree, for example APNs environment mismatch. */
|
||||
public class ProviderConfigurationException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public ProviderConfigurationException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/** Payload or batch exceeded a provider limit before the call. */
|
||||
public class ProviderPayloadLimitException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public ProviderPayloadLimitException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/** Provider failed in a way that repeating cannot fix. */
|
||||
public class ProviderPermanentException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public ProviderPermanentException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/** Provider explicitly refused the submission. */
|
||||
public class ProviderRejectedException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public ProviderRejectedException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/** Provider rate limit reached. */
|
||||
public class ProviderThrottledException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public ProviderThrottledException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/** Provider failed in a way that is safe to retry. */
|
||||
public class ProviderTransientException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public ProviderTransientException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/** Provider runtime refused a new attempt because it is unhealthy, disabled or draining. */
|
||||
public class ProviderUnavailableException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public ProviderUnavailableException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/** Provider status query failed. */
|
||||
public class ReconciliationException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public ReconciliationException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/** No template version matched the pinned selection and locale chain. */
|
||||
public class TemplateNotFoundException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public TemplateNotFoundException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/** Rendering failed for a template that exists. */
|
||||
public class TemplateRenderingException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public TemplateRenderingException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.notification.platform.api.error;
|
||||
|
||||
/** Template variables failed JSON Schema validation before any provider call. */
|
||||
public class TemplateVariableValidationException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public TemplateVariableValidationException(NotificationFailureDescriptor descriptor) {
|
||||
super(descriptor);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.caskeleton.application.notification.platform.api.routing;
|
||||
|
||||
/**
|
||||
* Stable delivery channels. Provider families are configured behind a channel, never in place of
|
||||
* one.
|
||||
*/
|
||||
public enum Channel {
|
||||
EMAIL,
|
||||
SMS,
|
||||
PUSH,
|
||||
WEB_PUSH,
|
||||
IN_APP,
|
||||
WEBHOOK
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.caskeleton.application.notification.platform.api.routing;
|
||||
|
||||
/**
|
||||
* Stable routing strategies.
|
||||
*
|
||||
* <p>{@code PARALLEL_MULTI_CHANNEL} and {@code FIRST_SUCCESS} are deliberately absent: both commit
|
||||
* two irreversible provider submissions before either outcome is known, so they stay experimental
|
||||
* and are never reachable through this sealed hierarchy.
|
||||
*/
|
||||
public sealed interface DeliveryStrategy permits ExplicitChannel, OrderedFallback {
|
||||
|
||||
/** First channel this strategy will attempt. */
|
||||
Channel primaryChannel();
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package dev.caskeleton.application.notification.platform.api.routing;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Send on exactly one channel; no automatic fallback exists for this strategy. */
|
||||
public record ExplicitChannel(Channel channel) implements DeliveryStrategy {
|
||||
|
||||
public ExplicitChannel {
|
||||
Objects.requireNonNull(channel, "channel");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Channel primaryChannel() {
|
||||
return channel;
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.application.notification.platform.api.routing;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Try the channels in order. Moving to the next channel is still gated by the routing decision
|
||||
* engine, which refuses to fall back while an ambiguous attempt exists.
|
||||
*/
|
||||
public record OrderedFallback(List<Channel> channels) implements DeliveryStrategy {
|
||||
|
||||
public OrderedFallback {
|
||||
channels = List.copyOf(Objects.requireNonNull(channels, "channels"));
|
||||
if (channels.isEmpty() || new HashSet<>(channels).size() != channels.size()) {
|
||||
throw new IllegalArgumentException("channels must be non-empty and distinct");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Channel primaryChannel() {
|
||||
return channels.get(0);
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.application.notification.platform.callback;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Outcome of an append. Duplicates are a normal, expected mode, not an error. */
|
||||
public record AppendEventResult(
|
||||
List<ProviderEventRecord> newEvents, List<ProviderEventRecord> duplicates) {
|
||||
|
||||
public AppendEventResult {
|
||||
newEvents = List.copyOf(Objects.requireNonNull(newEvents, "newEvents"));
|
||||
duplicates = List.copyOf(Objects.requireNonNull(duplicates, "duplicates"));
|
||||
}
|
||||
|
||||
/** Whether at least one event was newly stored. */
|
||||
public boolean created() {
|
||||
return !newEvents.isEmpty();
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package dev.caskeleton.application.notification.platform.callback;
|
||||
|
||||
/** How many events an ingestion stored and how many were already known. */
|
||||
public record CallbackIngestionResult(int appended, int duplicates) {
|
||||
|
||||
public CallbackIngestionResult {
|
||||
if (appended < 0 || duplicates < 0) {
|
||||
throw new IllegalArgumentException("counts must not be negative");
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the whole callback was a repeat of one already stored. */
|
||||
public boolean duplicate() {
|
||||
return appended == 0 && duplicates > 0;
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package dev.caskeleton.application.notification.platform.callback;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Hard bounds every callback endpoint applies before doing any work. */
|
||||
public record CallbackLimits(long maxBodyBytes, Set<String> allowedContentTypes) {
|
||||
|
||||
public static final long DEFAULT_MAX_BODY_BYTES = 65_536L;
|
||||
|
||||
public CallbackLimits {
|
||||
allowedContentTypes =
|
||||
Set.copyOf(Objects.requireNonNull(allowedContentTypes, "allowedContentTypes"));
|
||||
if (maxBodyBytes < 1) {
|
||||
throw new IllegalArgumentException("maxBodyBytes");
|
||||
}
|
||||
if (allowedContentTypes.isEmpty()) {
|
||||
throw new IllegalArgumentException("allowedContentTypes must not be empty");
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a content type is accepted, ignoring parameters such as charset. */
|
||||
public boolean acceptsContentType(String contentType) {
|
||||
if (contentType == null) {
|
||||
return false;
|
||||
}
|
||||
String mediaType = contentType.split(";", 2)[0].trim().toLowerCase(Locale.ROOT);
|
||||
return allowedContentTypes.contains(mediaType);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.caskeleton.application.notification.platform.callback;
|
||||
|
||||
/** Bounded, encrypted retention of raw callback payloads. */
|
||||
public interface CallbackPayloadProtectionPort {
|
||||
|
||||
/** Encrypt at most the configured number of bytes of the raw payload. */
|
||||
byte[] protectRawPayload(byte[] rawBody);
|
||||
|
||||
/** Hex SHA-256 digest of the raw payload, used for fingerprinting and audit. */
|
||||
String digest(byte[] rawBody);
|
||||
|
||||
/** Deterministic fingerprint for providers that do not supply an event id. */
|
||||
String fingerprint(
|
||||
dev.caskeleton.application.notification.platform.api.ProviderProfileId profileId,
|
||||
NormalizedProviderEvent event,
|
||||
String rawPayloadDigest);
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package dev.caskeleton.application.notification.platform.callback;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderId;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* A provider callback as received.
|
||||
*
|
||||
* <p>The raw body is kept as bytes because several providers sign the exact octets; decoding first
|
||||
* and re-encoding later is the most common cause of false signature failures.
|
||||
*/
|
||||
@SuppressWarnings("ArrayRecordComponent") // defensive copies on construction and on every accessor
|
||||
public record CallbackRequest(
|
||||
ProviderId providerId,
|
||||
ProviderProfileId providerProfileId,
|
||||
String externalUrl,
|
||||
String httpMethod,
|
||||
Optional<String> contentType,
|
||||
Map<String, List<String>> headers,
|
||||
byte[] body,
|
||||
Instant receivedAt) {
|
||||
|
||||
public CallbackRequest {
|
||||
Objects.requireNonNull(providerId, "providerId");
|
||||
Objects.requireNonNull(providerProfileId, "providerProfileId");
|
||||
Objects.requireNonNull(externalUrl, "externalUrl");
|
||||
Objects.requireNonNull(httpMethod, "httpMethod");
|
||||
Objects.requireNonNull(contentType, "contentType");
|
||||
Objects.requireNonNull(headers, "headers");
|
||||
Objects.requireNonNull(body, "body");
|
||||
Objects.requireNonNull(receivedAt, "receivedAt");
|
||||
if (externalUrl.isBlank()) {
|
||||
throw new IllegalArgumentException("externalUrl");
|
||||
}
|
||||
headers =
|
||||
headers.entrySet().stream()
|
||||
.collect(
|
||||
java.util.stream.Collectors.toUnmodifiableMap(
|
||||
entry -> entry.getKey().toLowerCase(Locale.ROOT),
|
||||
entry -> List.copyOf(entry.getValue())));
|
||||
body = body.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] body() {
|
||||
return body.clone();
|
||||
}
|
||||
|
||||
/** First value of a header, matched case-insensitively. */
|
||||
public Optional<String> header(String name) {
|
||||
List<String> values = headers.get(name.toLowerCase(Locale.ROOT));
|
||||
return values == null || values.isEmpty() ? Optional.empty() : Optional.of(values.get(0));
|
||||
}
|
||||
|
||||
/** Size of the raw body. */
|
||||
public int bodyLength() {
|
||||
return body.length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) {
|
||||
return true;
|
||||
}
|
||||
if (!(other instanceof CallbackRequest value)) {
|
||||
return false;
|
||||
}
|
||||
return providerId.equals(value.providerId)
|
||||
&& providerProfileId.equals(value.providerProfileId)
|
||||
&& externalUrl.equals(value.externalUrl)
|
||||
&& httpMethod.equals(value.httpMethod)
|
||||
&& contentType.equals(value.contentType)
|
||||
&& headers.equals(value.headers)
|
||||
&& Arrays.equals(body, value.body)
|
||||
&& receivedAt.equals(value.receivedAt);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(
|
||||
providerId,
|
||||
providerProfileId,
|
||||
externalUrl,
|
||||
httpMethod,
|
||||
contentType,
|
||||
headers,
|
||||
Arrays.hashCode(body),
|
||||
receivedAt);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "CallbackRequest[provider="
|
||||
+ providerId
|
||||
+ ", profile="
|
||||
+ providerProfileId
|
||||
+ ", bytes="
|
||||
+ body.length
|
||||
+ ", body=redacted]";
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package dev.caskeleton.application.notification.platform.callback;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Signature verification outcome. A rejection never reaches the ledger. */
|
||||
public record CallbackVerificationResult(
|
||||
boolean valid, String reasonCode, Optional<VerifiedCallback> verifiedCallback) {
|
||||
|
||||
public CallbackVerificationResult {
|
||||
Objects.requireNonNull(reasonCode, "reasonCode");
|
||||
Objects.requireNonNull(verifiedCallback, "verifiedCallback");
|
||||
if (valid && verifiedCallback.isEmpty()) {
|
||||
throw new IllegalArgumentException("a valid result must carry the verified callback");
|
||||
}
|
||||
if (!valid && verifiedCallback.isPresent()) {
|
||||
throw new IllegalArgumentException("an invalid result must not carry a verified callback");
|
||||
}
|
||||
}
|
||||
|
||||
/** Accepted signature. */
|
||||
public static CallbackVerificationResult valid(VerifiedCallback callback) {
|
||||
return new CallbackVerificationResult(true, "OK", Optional.of(callback));
|
||||
}
|
||||
|
||||
/** Rejected signature. */
|
||||
public static CallbackVerificationResult invalid(String reasonCode) {
|
||||
return new CallbackVerificationResult(false, reasonCode, Optional.empty());
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.caskeleton.application.notification.platform.callback;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Resolves the attempt an incoming provider event belongs to. */
|
||||
public interface DeliveryAttemptResolverPort {
|
||||
|
||||
/** Look up by the attempt identity carried in the event. */
|
||||
Optional<DeliveryAttemptSnapshot> byAttemptId(DeliveryAttemptId attemptId);
|
||||
|
||||
/**
|
||||
* Look up by the provider request id.
|
||||
*
|
||||
* <p>Implementations index a keyed hash of the value, never the raw provider request id.
|
||||
*/
|
||||
Optional<DeliveryAttemptSnapshot> byProviderRequestId(
|
||||
ProviderProfileId profileId, String providerRequestId);
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package dev.caskeleton.application.notification.platform.callback;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.ContactPointId;
|
||||
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationId;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderId;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import dev.caskeleton.application.notification.platform.api.RecipientDeliveryId;
|
||||
import dev.caskeleton.application.notification.platform.api.TenantId;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.DeliveryOutcome;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Read-only view of an attempt used by projectors, reconciliation and admin operations. */
|
||||
public record DeliveryAttemptSnapshot(
|
||||
DeliveryAttemptId attemptId,
|
||||
RecipientDeliveryId recipientDeliveryId,
|
||||
NotificationId notificationId,
|
||||
TenantId tenantId,
|
||||
int attemptNo,
|
||||
Channel channel,
|
||||
ProviderId providerId,
|
||||
ProviderProfileId providerProfileId,
|
||||
Optional<String> providerRequestId,
|
||||
ContactPointId contactPointId,
|
||||
AttemptConfirmation confirmation,
|
||||
SubmissionOutcome submissionOutcome,
|
||||
DeliveryOutcome deliveryOutcome,
|
||||
EvidenceLevel evidenceLevel,
|
||||
long credentialGeneration,
|
||||
String renderedContentDigest,
|
||||
Instant startedAt,
|
||||
Optional<Instant> completedAt,
|
||||
Optional<Instant> expiresAt) {
|
||||
|
||||
public DeliveryAttemptSnapshot {
|
||||
Objects.requireNonNull(attemptId, "attemptId");
|
||||
Objects.requireNonNull(recipientDeliveryId, "recipientDeliveryId");
|
||||
Objects.requireNonNull(notificationId, "notificationId");
|
||||
Objects.requireNonNull(tenantId, "tenantId");
|
||||
Objects.requireNonNull(channel, "channel");
|
||||
Objects.requireNonNull(providerId, "providerId");
|
||||
Objects.requireNonNull(providerProfileId, "providerProfileId");
|
||||
Objects.requireNonNull(providerRequestId, "providerRequestId");
|
||||
Objects.requireNonNull(contactPointId, "contactPointId");
|
||||
Objects.requireNonNull(confirmation, "confirmation");
|
||||
Objects.requireNonNull(submissionOutcome, "submissionOutcome");
|
||||
Objects.requireNonNull(deliveryOutcome, "deliveryOutcome");
|
||||
Objects.requireNonNull(evidenceLevel, "evidenceLevel");
|
||||
Objects.requireNonNull(renderedContentDigest, "renderedContentDigest");
|
||||
Objects.requireNonNull(startedAt, "startedAt");
|
||||
Objects.requireNonNull(completedAt, "completedAt");
|
||||
Objects.requireNonNull(expiresAt, "expiresAt");
|
||||
if (attemptNo < 1) {
|
||||
throw new IllegalArgumentException("attemptNo");
|
||||
}
|
||||
}
|
||||
|
||||
/** Current projection derived from the stored attempt columns. */
|
||||
public DeliveryProjection projection() {
|
||||
return new DeliveryProjection(
|
||||
submissionOutcome,
|
||||
deliveryOutcome,
|
||||
evidenceLevel,
|
||||
EngagementFacts.NONE,
|
||||
SuppressionFacts.NONE);
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package dev.caskeleton.application.notification.platform.callback;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.DeliveryOutcome;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Current projected view of one attempt, rebuildable from the event ledger at any time. */
|
||||
public record DeliveryProjection(
|
||||
SubmissionOutcome submissionOutcome,
|
||||
DeliveryOutcome deliveryOutcome,
|
||||
EvidenceLevel evidenceLevel,
|
||||
EngagementFacts engagementFacts,
|
||||
SuppressionFacts suppressionFacts) {
|
||||
|
||||
public DeliveryProjection {
|
||||
Objects.requireNonNull(submissionOutcome, "submissionOutcome");
|
||||
Objects.requireNonNull(deliveryOutcome, "deliveryOutcome");
|
||||
Objects.requireNonNull(evidenceLevel, "evidenceLevel");
|
||||
Objects.requireNonNull(engagementFacts, "engagementFacts");
|
||||
Objects.requireNonNull(suppressionFacts, "suppressionFacts");
|
||||
}
|
||||
|
||||
/** Projection of an attempt that has produced no evidence yet. */
|
||||
public static DeliveryProjection empty() {
|
||||
return new DeliveryProjection(
|
||||
SubmissionOutcome.NOT_SUBMITTED,
|
||||
DeliveryOutcome.UNKNOWN,
|
||||
EvidenceLevel.NONE,
|
||||
EngagementFacts.NONE,
|
||||
SuppressionFacts.NONE);
|
||||
}
|
||||
|
||||
/** Projection of an accepted submission with no delivery evidence yet. */
|
||||
public static DeliveryProjection accepted() {
|
||||
return new DeliveryProjection(
|
||||
SubmissionOutcome.CONFIRMED_ACCEPTED,
|
||||
DeliveryOutcome.UNKNOWN,
|
||||
EvidenceLevel.PROVIDER_ACCEPTED,
|
||||
EngagementFacts.NONE,
|
||||
SuppressionFacts.NONE);
|
||||
}
|
||||
|
||||
/** Copy with a different delivery outcome and evidence level. */
|
||||
public DeliveryProjection withDelivery(DeliveryOutcome outcome, EvidenceLevel evidence) {
|
||||
return new DeliveryProjection(
|
||||
submissionOutcome, outcome, evidence, engagementFacts, suppressionFacts);
|
||||
}
|
||||
|
||||
/** Copy with a different evidence level only. */
|
||||
public DeliveryProjection withEvidence(EvidenceLevel evidence) {
|
||||
return new DeliveryProjection(
|
||||
submissionOutcome, deliveryOutcome, evidence, engagementFacts, suppressionFacts);
|
||||
}
|
||||
|
||||
/** Copy with a different submission outcome. */
|
||||
public DeliveryProjection withSubmission(SubmissionOutcome outcome, EvidenceLevel evidence) {
|
||||
return new DeliveryProjection(
|
||||
outcome, deliveryOutcome, evidence, engagementFacts, suppressionFacts);
|
||||
}
|
||||
|
||||
/** Copy with new engagement facts. */
|
||||
public DeliveryProjection withEngagement(EngagementFacts facts) {
|
||||
return new DeliveryProjection(
|
||||
submissionOutcome, deliveryOutcome, evidenceLevel, facts, suppressionFacts);
|
||||
}
|
||||
|
||||
/** Copy with new suppression facts. */
|
||||
public DeliveryProjection withSuppression(SuppressionFacts facts) {
|
||||
return new DeliveryProjection(
|
||||
submissionOutcome, deliveryOutcome, evidenceLevel, engagementFacts, facts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the delivery outcome has reached a state that later, weaker events must not overwrite.
|
||||
*/
|
||||
public boolean hasTerminalDelivery() {
|
||||
return switch (deliveryOutcome) {
|
||||
case DELIVERED, BOUNCED, UNDELIVERED, EXPIRED -> true;
|
||||
case UNKNOWN, SENT -> false;
|
||||
};
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.caskeleton.application.notification.platform.callback;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
|
||||
|
||||
/** Persists the projection produced from the ledger. */
|
||||
public interface DeliveryProjectionStorePort {
|
||||
|
||||
/** Current stored projection of an attempt. */
|
||||
DeliveryProjection load(DeliveryAttemptId attemptId);
|
||||
|
||||
/** Store a projection for an attempt and roll it up into the recipient and request. */
|
||||
void save(DeliveryAttemptId attemptId, DeliveryProjection projection);
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package dev.caskeleton.application.notification.platform.callback;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Engagement is stored beside delivery, never instead of it.
|
||||
*
|
||||
* <p>An open or a click is telemetry: it is not proof of delivery reliability and never downgrades
|
||||
* or upgrades the delivery outcome.
|
||||
*/
|
||||
public record EngagementFacts(
|
||||
boolean opened, boolean clicked, boolean read, Optional<Instant> lastEngagedAt) {
|
||||
|
||||
public static final EngagementFacts NONE =
|
||||
new EngagementFacts(false, false, false, Optional.empty());
|
||||
|
||||
public EngagementFacts {
|
||||
Objects.requireNonNull(lastEngagedAt, "lastEngagedAt");
|
||||
}
|
||||
|
||||
/** Merge that never removes a fact that was already observed. */
|
||||
public EngagementFacts merge(EngagementFacts other, Instant at) {
|
||||
Objects.requireNonNull(other, "other");
|
||||
return new EngagementFacts(
|
||||
opened || other.opened,
|
||||
clicked || other.clicked,
|
||||
read || other.read,
|
||||
Optional.ofNullable(at));
|
||||
}
|
||||
|
||||
/** Copy with the opened fact set. */
|
||||
public EngagementFacts withOpened(Instant at) {
|
||||
return new EngagementFacts(true, clicked, read, Optional.ofNullable(at));
|
||||
}
|
||||
|
||||
/** Copy with the clicked fact set. */
|
||||
public EngagementFacts withClicked(Instant at) {
|
||||
return new EngagementFacts(opened, true, read, Optional.ofNullable(at));
|
||||
}
|
||||
|
||||
/** Copy with the read fact set. */
|
||||
public EngagementFacts withRead(Instant at) {
|
||||
return new EngagementFacts(opened, clicked, true, Optional.ofNullable(at));
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package dev.caskeleton.application.notification.platform.callback;
|
||||
|
||||
/**
|
||||
* Stable event vocabulary.
|
||||
*
|
||||
* <p>Provider status strings are mapped onto this closed set and the native string is kept beside
|
||||
* it, so a provider adding a status cannot force a change to the public contract.
|
||||
*/
|
||||
public enum NormalizedEventType {
|
||||
PROVIDER_ACCEPTED,
|
||||
PROVIDER_REJECTED,
|
||||
SENT,
|
||||
DELIVERY_CONFIRMED,
|
||||
DELIVERY_DELAYED,
|
||||
UNDELIVERED,
|
||||
BOUNCED_HARD,
|
||||
BOUNCED_SOFT,
|
||||
COMPLAINT,
|
||||
INVALID_RECIPIENT,
|
||||
OPENED,
|
||||
CLICKED,
|
||||
DISPLAYED,
|
||||
READ,
|
||||
EXPIRED,
|
||||
TEMPLATE_FAILURE,
|
||||
UNKNOWN
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.caskeleton.application.notification.platform.callback;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Adapter output: a provider payload expressed in the stable vocabulary.
|
||||
*
|
||||
* <p>Unknown provider fields are preserved in the raw payload, not rejected: a webhook contract
|
||||
* that grows a field must not start failing ingestion.
|
||||
*/
|
||||
public record NormalizedProviderEvent(
|
||||
NormalizedEventType type,
|
||||
String providerNativeType,
|
||||
Optional<String> providerEventId,
|
||||
Optional<String> providerRequestId,
|
||||
Optional<Instant> providerOccurredAt,
|
||||
Map<String, String> attributes) {
|
||||
|
||||
private static final int MAX_ATTRIBUTES = 32;
|
||||
|
||||
public NormalizedProviderEvent {
|
||||
Objects.requireNonNull(type, "type");
|
||||
Objects.requireNonNull(providerNativeType, "providerNativeType");
|
||||
Objects.requireNonNull(providerEventId, "providerEventId");
|
||||
Objects.requireNonNull(providerRequestId, "providerRequestId");
|
||||
Objects.requireNonNull(providerOccurredAt, "providerOccurredAt");
|
||||
attributes = Map.copyOf(Objects.requireNonNull(attributes, "attributes"));
|
||||
if (providerNativeType.isBlank()) {
|
||||
throw new IllegalArgumentException("providerNativeType");
|
||||
}
|
||||
if (attributes.size() > MAX_ATTRIBUTES) {
|
||||
throw new IllegalArgumentException("too many normalized attributes");
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.caskeleton.application.notification.platform.callback;
|
||||
|
||||
/**
|
||||
* Applies the consequences a provider event has outside the ledger.
|
||||
*
|
||||
* <p>Kept behind a port so a projector stays a pure merge function: a projector that also wrote to
|
||||
* the contact point store could not be replayed safely.
|
||||
*/
|
||||
public interface NotificationSideEffectPort {
|
||||
|
||||
/** Invalidate contact points and add suppression entries implied by an event. */
|
||||
void apply(DeliveryAttemptSnapshot attempt, SuppressionFacts facts);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user