diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/NotificationLog.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/NotificationLog.java new file mode 100644 index 0000000..12b1db3 --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/NotificationLog.java @@ -0,0 +1,161 @@ +package cloud.nstart.terravault.ordinis.domain.notification; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Per-recipient notification delivery audit row. + * + *

UNIQUE {@code (event_id, channel, recipient)} — primary idempotency guard + * (см. migration 0021 + design doc D2). Insert pattern в + * {@code IdempotencyGuard.shouldSend()}: + * + *

{@code
+ * INSERT INTO notification_log (event_id, channel, recipient, status, sent_at)
+ * VALUES (?, ?, ?, 'PENDING', NOW())
+ * ON CONFLICT (event_id, channel, recipient) DO NOTHING
+ * RETURNING id;
+ * }
+ * + *

Если RETURNING пустой — другой consumer уже claim'ил отправку (или sweep + * job не освободил). Else — мы owner, dispatch + UPDATE status final. + * + *

Status enum UPPERCASE (Hibernate {@code @Enumerated(EnumType.STRING)} + * lesson из v1.1.0 P3 — migration 0020 fix). DB CHECK constraint matches + * Java enum names exactly. + */ +@Entity +@Table( + name = "notification_log", + uniqueConstraints = { + // Mirrors notification_log_idempotency UNIQUE constraint в migration 0021. + @UniqueConstraint( + name = "notification_log_idempotency", + columnNames = {"event_id", "channel", "recipient"} + ) + }, + indexes = { + @Index(name = "idx_notif_log_draft", columnList = "draft_id"), + @Index(name = "idx_notif_log_recipient_time", columnList = "recipient,sent_at DESC"), + @Index(name = "idx_notif_log_event", columnList = "event_id") + } +) +public class NotificationLog { + + /** Lifecycle status. UPPERCASE — matches CHECK constraint в 0021. */ + public enum Status { + /** Inserted but not yet dispatched (или crashed mid-dispatch). Pending sweep job + * cleans up stuck rows старше {@code notifications.pendingSweep.olderThanMs}. */ + PENDING, + /** Successfully sent через channel. Idempotency lock — не replay'ится. */ + SENT, + /** Channel returned non-retryable error (auth, malformed, etc). */ + FAILED, + /** Rate limiter denied — recipient hit per-hour cap. */ + RATE_LIMITED, + /** User opted out этого channel в user_notification_prefs. */ + SKIPPED_PREF, + /** Event старше maxEventAgeHours — backlog burst guard fired. */ + SKIPPED_TOO_OLD, + /** One-click unsubscribe — токен HMAC validated, prefs.enabled=false. */ + UNSUBSCRIBED + } + + /** Wire format channel — lowercase strings ('email', 'express', 'telegram') + * matches CHECK constraint и Helm config keys. NOT EnumType.STRING на channel + * потому что хотим lowercase (брендовые токены), не Java UPPERCASE conv. */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "event_id", nullable = false) + private UUID eventId; + + @Column(name = "event_type", nullable = false, length = 64) + private String eventType; + + /** Nullable: некоторые events без draft контекста (future expansion). */ + @Column(name = "draft_id") + private UUID draftId; + + /** Lower-case wire format. См. {@code ChannelKind.wireName()} в notifications module. */ + @Column(name = "channel", nullable = false, length = 16) + private String channel; + + /** Email / express chat_id (UUID string) / telegram chat_id (numeric string). */ + @Column(name = "recipient", nullable = false, length = 256) + private String recipient; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private Status status; + + @Column(name = "sent_at", nullable = false) + private OffsetDateTime sentAt; + + @Column(name = "error_msg", columnDefinition = "TEXT") + private String errorMsg; + + protected NotificationLog() {} + + /** Constructs row in PENDING state — typical entry point для IdempotencyGuard. */ + public NotificationLog(UUID eventId, String eventType, UUID draftId, + String channel, String recipient) { + this.eventId = eventId; + this.eventType = eventType; + this.draftId = draftId; + this.channel = channel; + this.recipient = recipient; + this.status = Status.PENDING; + this.sentAt = OffsetDateTime.now(); + } + + public void markSent() { + this.status = Status.SENT; + this.errorMsg = null; + } + + public void markFailed(String error) { + this.status = Status.FAILED; + this.errorMsg = error; + } + + public void markRateLimited() { + this.status = Status.RATE_LIMITED; + } + + public void markSkippedPref() { + this.status = Status.SKIPPED_PREF; + } + + public void markSkippedTooOld() { + this.status = Status.SKIPPED_TOO_OLD; + } + + public void markUnsubscribed() { + this.status = Status.UNSUBSCRIBED; + } + + // Getters (no setters — entity мутирует через named transitions) + public Long getId() { return id; } + public UUID getEventId() { return eventId; } + public String getEventType() { return eventType; } + public UUID getDraftId() { return draftId; } + public String getChannel() { return channel; } + public String getRecipient() { return recipient; } + public Status getStatus() { return status; } + public OffsetDateTime getSentAt() { return sentAt; } + public String getErrorMsg() { return errorMsg; } +} diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/NotificationLogRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/NotificationLogRepository.java new file mode 100644 index 0000000..06e713a --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/NotificationLogRepository.java @@ -0,0 +1,83 @@ +package cloud.nstart.terravault.ordinis.domain.notification; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.OffsetDateTime; +import java.util.Optional; +import java.util.UUID; + +public interface NotificationLogRepository extends JpaRepository { + + /** + * Idempotency claim. Native query потому что Spring Data JPA не поддерживает + * {@code INSERT ... ON CONFLICT DO NOTHING RETURNING} напрямую (PostgreSQL specific). + * + *

Returns inserted row's id если claim succeeded, или empty если конфликт + * (другой consumer уже claim'ил, или sweep job не освободил stuck row). + * + *

Используется в {@code IdempotencyGuard.shouldSend()}. + */ + @Modifying + @Query(value = """ + INSERT INTO notification_log (event_id, event_type, draft_id, channel, recipient, status, sent_at) + VALUES (:eventId, :eventType, :draftId, :channel, :recipient, 'PENDING', NOW()) + ON CONFLICT (event_id, channel, recipient) DO NOTHING + RETURNING id + """, nativeQuery = true) + Optional tryClaim( + @Param("eventId") UUID eventId, + @Param("eventType") String eventType, + @Param("draftId") UUID draftId, + @Param("channel") String channel, + @Param("recipient") String recipient); + + /** + * Pending sweep — освобождает stuck PENDING rows старше {@code threshold}. + * Returns deleted count для metrics. Использует partial index + * {@code idx_notif_log_pending_sweep}. + */ + @Modifying + @Query(value = """ + DELETE FROM notification_log + WHERE status = 'PENDING' AND sent_at < :threshold + """, nativeQuery = true) + int deletePendingOlderThan(@Param("threshold") OffsetDateTime threshold); + + /** + * Retention sweep — удаляет terminal status rows старше {@code threshold}. + * FAILED rows preserved bessrochno (для post-mortem). Partial index + * {@code idx_notif_log_retention}. + */ + @Modifying + @Query(value = """ + DELETE FROM notification_log + WHERE status IN ('SENT','RATE_LIMITED','SKIPPED_PREF','SKIPPED_TOO_OLD','UNSUBSCRIBED') + AND sent_at < :threshold + """, nativeQuery = true) + int deleteRetainedOlderThan(@Param("threshold") OffsetDateTime threshold); + + /** + * UI "Last sent" lookup для {@code GET /api/v1/me/notifications}. + * Cache 60s в service layer. + */ + @Query(""" + SELECT MAX(n.sentAt) FROM NotificationLog n + WHERE n.recipient = :recipient + AND n.channel = :channel + AND n.status = cloud.nstart.terravault.ordinis.domain.notification.NotificationLog.Status.SENT + """) + Optional findLastSentAt( + @Param("recipient") String recipient, + @Param("channel") String channel); + + /** Admin endpoint pagination. */ + Page findByRecipientOrderBySentAtDesc(String recipient, Pageable pageable); + + /** Admin endpoint — full audit lookup by event. */ + Page findByEventIdOrderBySentAtDesc(UUID eventId, Pageable pageable); +} diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/UserNotificationPref.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/UserNotificationPref.java new file mode 100644 index 0000000..f0914be --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/UserNotificationPref.java @@ -0,0 +1,78 @@ +package cloud.nstart.terravault.ordinis.domain.notification; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +import java.io.Serializable; +import java.util.Objects; + +/** + * Per-user channel opt-in/out (см. migration 0021 + design doc D3). + * + *

Composite PK {@code (user_id, channel)}. Default {@code enabled=false} для + * новых rows; bootstrap loadData может включить email для existing reviewer/maker + * ролей при первом deploy. + * + *

Channel — lowercase wire format ('email' / 'express' / 'telegram'). + */ +@Entity +@Table(name = "user_notification_prefs") +public class UserNotificationPref { + + @Embeddable + public static class Pk implements Serializable { + + @Column(name = "user_id", length = 128, nullable = false) + private String userId; + + @Column(name = "channel", length = 16, nullable = false) + private String channel; + + protected Pk() {} + + public Pk(String userId, String channel) { + this.userId = userId; + this.channel = channel; + } + + public String getUserId() { return userId; } + public String getChannel() { return channel; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Pk pk)) return false; + return Objects.equals(userId, pk.userId) && Objects.equals(channel, pk.channel); + } + + @Override + public int hashCode() { + return Objects.hash(userId, channel); + } + } + + @EmbeddedId + private Pk id; + + @Column(name = "enabled", nullable = false) + private boolean enabled; + + protected UserNotificationPref() {} + + public UserNotificationPref(String userId, String channel, boolean enabled) { + this.id = new Pk(userId, channel); + this.enabled = enabled; + } + + public Pk getId() { return id; } + public String getUserId() { return id.getUserId(); } + public String getChannel() { return id.getChannel(); } + public boolean isEnabled() { return enabled; } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } +} diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/UserNotificationPrefRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/UserNotificationPrefRepository.java new file mode 100644 index 0000000..721c83f --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/UserNotificationPrefRepository.java @@ -0,0 +1,16 @@ +package cloud.nstart.terravault.ordinis.domain.notification; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface UserNotificationPrefRepository + extends JpaRepository { + + /** Все каналы пользователя — для GET /api/v1/me/notifications page. */ + List findByIdUserId(String userId); + + /** Single channel lookup для dispatcher (skip if disabled). */ + Optional findByIdUserIdAndIdChannel(String userId, String channel); +} diff --git a/ordinis-migrations/src/main/resources/db/changelog/changes/0021-notifications.xml b/ordinis-migrations/src/main/resources/db/changelog/changes/0021-notifications.xml new file mode 100644 index 0000000..e53445b --- /dev/null +++ b/ordinis-migrations/src/main/resources/db/changelog/changes/0021-notifications.xml @@ -0,0 +1,155 @@ + + + + + + + notification_log — per-recipient delivery audit trail с UNIQUE idempotency guard + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ALTER TABLE notification_log + ADD CONSTRAINT notification_log_status_check + CHECK (status IN ( + 'PENDING', 'SENT', 'FAILED', + 'RATE_LIMITED', 'SKIPPED_PREF', 'SKIPPED_TOO_OLD', + 'UNSUBSCRIBED' + )); + + + + ALTER TABLE notification_log + ADD CONSTRAINT notification_log_channel_check + CHECK (channel IN ('email', 'express', 'telegram')); + + + + + ALTER TABLE notification_log + ADD CONSTRAINT notification_log_idempotency + UNIQUE (event_id, channel, recipient); + + + + + CREATE INDEX idx_notif_log_pending_sweep + ON notification_log (sent_at) + WHERE status = 'PENDING'; + + + + + CREATE INDEX idx_notif_log_retention + ON notification_log (sent_at) + WHERE status IN ('SENT', 'RATE_LIMITED', 'SKIPPED_PREF', 'SKIPPED_TOO_OLD', 'UNSUBSCRIBED'); + + + + + + + + + + + + + + + + + + + user_notification_prefs — per-user channel opt-in/out + + + + + + + + + + + + + + + + ALTER TABLE user_notification_prefs + ADD CONSTRAINT user_notification_prefs_pkey + PRIMARY KEY (user_id, channel); + + + + ALTER TABLE user_notification_prefs + ADD CONSTRAINT user_notification_prefs_channel_check + CHECK (channel IN ('email', 'express', 'telegram')); + + + + diff --git a/ordinis-migrations/src/main/resources/db/changelog/master.xml b/ordinis-migrations/src/main/resources/db/changelog/master.xml index d8f77cf..c83b3d8 100644 --- a/ordinis-migrations/src/main/resources/db/changelog/master.xml +++ b/ordinis-migrations/src/main/resources/db/changelog/master.xml @@ -30,5 +30,6 @@ + diff --git a/ordinis-notifications/pom.xml b/ordinis-notifications/pom.xml new file mode 100644 index 0000000..19697b9 --- /dev/null +++ b/ordinis-notifications/pom.xml @@ -0,0 +1,84 @@ + + + 4.0.0 + + + cloud.nstart.terravault.ordinis + ordinis-parent + 0.1.0-SNAPSHOT + + + ordinis-notifications + Ordinis :: Notifications + + Push-notifications module (email + Express + Telegram) для Approval Workflow v2 events. + Leverages existing ordinis-outbox infra (OutboxRecorder + OutboxPoller + + KafkaTopicResolver). Этот модуль = consumer side: Kafka @KafkaListener, + fan-out per recipient × channel, rate limiter, idempotency guard через PG UNIQUE. + + Включается через ordinis.notifications.enabled=true (writer pod). + См. ~/.gstack/projects/claude/zimin-main-design-notifications-v1.2.0-20260510-130000.md + + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-mail + + + org.springframework.boot + spring-boot-starter-cache + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.kafka + spring-kafka + + + com.fasterxml.jackson.core + jackson-databind + + + io.micrometer + micrometer-core + + + cloud.nstart.terravault.ordinis + ordinis-domain + + + cloud.nstart.terravault.ordinis + ordinis-events-api + + + cloud.nstart.terravault.ordinis + ordinis-outbox + + + + + org.springframework.boot + spring-boot-starter-test + test + + + com.icegreen + greenmail + 2.1.5 + test + + + diff --git a/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/ChannelKind.java b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/ChannelKind.java new file mode 100644 index 0000000..dd85f18 --- /dev/null +++ b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/ChannelKind.java @@ -0,0 +1,58 @@ +package cloud.nstart.terravault.ordinis.notifications.channel; + +/** + * Канал доставки уведомления. + * + *

Wire format — lowercase ('email' / 'express' / 'telegram'). Такой + * формат используется в: + *

+ * + *

NB: {@code channel} column НЕ использует {@code @Enumerated(EnumType.STRING)} — + * mapping ручной через {@link #wireName()}, чтобы DB хранила lowercase wire-format. + * Это отличается от {@code notification_log.status}, который UPPERCASE + * (carry-forward lesson из v1.1.0 P3 — Hibernate {@code @Enumerated(EnumType.STRING)} + * пишет UPPERCASE, поэтому status CHECK в 0021 тоже UPPERCASE). + * + *

Persist: всегда {@link #wireName()} (lowercase). Read из DB: {@link #fromWireName(String)}. + * Никогда не используй {@link Enum#name()} для DB/API — это вернёт UPPERCASE и + * порушит CHECK constraint на insert. + */ +public enum ChannelKind { + EMAIL("email"), + EXPRESS("express"), + TELEGRAM("telegram"); + + private final String wireName; + + ChannelKind(String wireName) { + this.wireName = wireName; + } + + /** + * Lower-case wire-format. Используется wherever DB / API / config / metrics + * касаются channel identity. См. class-level Javadoc для полного списка + * usage points. + */ + public String wireName() { + return wireName; + } + + /** Парсинг из wire-format. Throws IllegalArgumentException на unknown value. */ + public static ChannelKind fromWireName(String wire) { + if (wire == null) { + throw new IllegalArgumentException("channel wire name is null"); + } + for (ChannelKind k : values()) { + if (k.wireName.equals(wire)) { + return k; + } + } + throw new IllegalArgumentException("unknown channel: " + wire); + } +} diff --git a/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/EmailChannel.java b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/EmailChannel.java new file mode 100644 index 0000000..880d29b --- /dev/null +++ b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/EmailChannel.java @@ -0,0 +1,107 @@ +package cloud.nstart.terravault.ordinis.notifications.channel; + +import cloud.nstart.terravault.ordinis.notifications.config.NotificationsProperties; +import cloud.nstart.terravault.ordinis.notifications.event.NotificationEvent; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.mail.MailException; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +/** + * SMTP-based email channel. Phase A delivery target. + * + *

Multipart text+HTML send (RFC 2387) — plain text fallback для accessibility, + * HTML version с link styling. Per design § 8.6. + * + *

Sanitization happens upstream в {@code MessageRenderer.render()} — + * {@code Sanitizer.forEmailHeader()} применяется ко всем dynamic params в subject. + * Body тоже sanitized (CRLF strip preserves layout). + * + *

Per design § 5 EmailChannel: + *

+ */ +@Component +@ConditionalOnProperty(name = "ordinis.notifications.enabled", havingValue = "true", matchIfMissing = false) +// Без JavaMailSender bean (т.е. spring.mail.host не задан) ChannelKind.EMAIL +// не активируется. Это позволяет включить notifications.enabled=true для +// Express-only deploy без обязательной SMTP-конфигурации, и не вешает Spring +// boot на UnsatisfiedDependencyException когда mail starter в classpath, но +// инфра почты не подведена. +@ConditionalOnBean(JavaMailSender.class) +public class EmailChannel implements NotificationChannel { + + private static final Logger log = LoggerFactory.getLogger(EmailChannel.class); + + private final JavaMailSender mailSender; + private final NotificationsProperties props; + private final Timer dispatchTimer; + + public EmailChannel( + JavaMailSender mailSender, + NotificationsProperties props, + MeterRegistry meterRegistry) { + this.mailSender = mailSender; + this.props = props; + this.dispatchTimer = Timer.builder("nsi_notification_dispatch_seconds") + .description("Per-channel dispatch latency") + .tag("channel", "email") + .publishPercentileHistogram() + .register(meterRegistry); + } + + @Override + public ChannelKind kind() { + return ChannelKind.EMAIL; + } + + @Override + public boolean isEnabled() { + String from = props.getEmail().getFromAddress(); + return from != null && !from.isBlank(); + } + + @Override + public DispatchResult dispatch(NotificationEvent event, UserRef recipient, RenderedMessage msg) { + if (recipient.email() == null || recipient.email().isBlank()) { + return DispatchResult.failure("recipient has no email address"); + } + long start = System.nanoTime(); + try { + MimeMessage mime = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mime, true, StandardCharsets.UTF_8.name()); + helper.setFrom(props.getEmail().getFromAddress()); + helper.setTo(recipient.email()); + helper.setSubject(msg.subject()); + // For Phase A: plain text body. HTML multipart можно добавить позже + // через помощник setText(text, html) в MimeMessageHelper. + helper.setText(msg.body(), false); + + mailSender.send(mime); + return DispatchResult.success(); + } catch (MailException e) { + log.warn("Email send failed для {}: {}", recipient.email(), e.getMessage()); + return DispatchResult.failure("MailException: " + e.getMessage()); + } catch (MessagingException e) { + log.warn("Email build failed для {}: {}", recipient.email(), e.getMessage()); + return DispatchResult.failure("MessagingException: " + e.getMessage()); + } finally { + dispatchTimer.record(Duration.ofNanos(System.nanoTime() - start)); + } + } +} diff --git a/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/NotificationChannel.java b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/NotificationChannel.java new file mode 100644 index 0000000..0473116 --- /dev/null +++ b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/NotificationChannel.java @@ -0,0 +1,58 @@ +package cloud.nstart.terravault.ordinis.notifications.channel; + +import cloud.nstart.terravault.ordinis.notifications.event.NotificationEvent; + +/** + * Channel adapter contract. Реализации: + * + * + *

Caller responsibility (NotificationDispatcher): + *

    + *
  1. IdempotencyGuard claim — INSERT ON CONFLICT DO NOTHING.
  2. + *
  3. Pref check — skip если disabled.
  4. + *
  5. Rate limit check — skip если exceeded.
  6. + *
  7. Render через MessageRenderer (sanitization built-in).
  8. + *
  9. Call {@link #dispatch(NotificationEvent, UserRef, RenderedMessage)}.
  10. + *
  11. Update notification_log status based on result.
  12. + *
+ * + *

Channel responsibility: + *

+ */ +public interface NotificationChannel { + + ChannelKind kind(); + + /** True если channel конфигурирован (creds present, etc). False → channel skip'ится. */ + boolean isEnabled(); + + /** + * Send the rendered message к recipient. + * + * @param event original event для context (event_id для tracing, etc) + * @param recipient resolved user ref (channel-specific identifier через + * {@code recipient.recipientFor(kind())}) + * @param msg pre-rendered, pre-sanitized message + * @return success/failure with optional error message + */ + DispatchResult dispatch(NotificationEvent event, UserRef recipient, RenderedMessage msg); + + record DispatchResult(boolean ok, String errorMsg) { + public static DispatchResult success() { + return new DispatchResult(true, null); + } + + public static DispatchResult failure(String errorMsg) { + return new DispatchResult(false, errorMsg); + } + } +} diff --git a/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/RenderedMessage.java b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/RenderedMessage.java new file mode 100644 index 0000000..6cdbfed --- /dev/null +++ b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/RenderedMessage.java @@ -0,0 +1,16 @@ +package cloud.nstart.terravault.ordinis.notifications.channel; + +/** + * Pre-rendered message ready для dispatch. Result of + * {@code MessageRenderer.render(eventKey, channel, locale, params)}. + * + *

{@code subject} — header / first line (email Subject, Express bold headline). + * Не используется для Telegram (header inline в body). + * + *

{@code body} — main content. Channel-specific format (plain text для email, + * BotX markdown для Express, MarkdownV2 для Telegram). + * + *

Обе строки уже прошли через {@code Sanitizer.forXxx()} per channel — caller + * НЕ должен снова escape'ить. + */ +public record RenderedMessage(String subject, String body) {} diff --git a/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/UserRef.java b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/UserRef.java new file mode 100644 index 0000000..9544305 --- /dev/null +++ b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/UserRef.java @@ -0,0 +1,34 @@ +package cloud.nstart.terravault.ordinis.notifications.channel; + +import java.util.Locale; + +/** + * Канал-агностичная ссылка на recipient'а. Все поля nullable — null = recipient + * не имеет identifier'а для соответствующего канала → channel skip'ит с + * {@code SKIPPED_PREF} status. + * + *

Resolved через {@code RecipientResolver} (Keycloak admin API + cached 5 min). + * + * @param userId Keycloak subject (always present) + * @param email email address (null если у юзера нет email атрибута) + * @param expressHuid Express HUID UUID string (null если не configured) + * @param telegramChatId Telegram chat_id numeric string (null если не configured) + * @param locale предпочитаемая локаль (RU fallback если Keycloak attribute отсутствует) + */ +public record UserRef( + String userId, + String email, + String expressHuid, + String telegramChatId, + Locale locale +) { + + /** Shortcut для recipient string per channel — используется как {@code notification_log.recipient}. */ + public String recipientFor(ChannelKind channel) { + return switch (channel) { + case EMAIL -> email; + case EXPRESS -> expressHuid; + case TELEGRAM -> telegramChatId; + }; + } +} diff --git a/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/config/NotificationsAutoConfiguration.java b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/config/NotificationsAutoConfiguration.java new file mode 100644 index 0000000..6d54931 --- /dev/null +++ b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/config/NotificationsAutoConfiguration.java @@ -0,0 +1,49 @@ +package cloud.nstart.terravault.ordinis.notifications.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.ReloadableResourceBundleMessageSource; +import org.springframework.scheduling.annotation.EnableScheduling; + +import java.nio.charset.StandardCharsets; + +/** + * Auto-configuration для notifications module. + * + *

Активна только при {@code ordinis.notifications.enabled=true}. Без этого + * флага consumer не запускается, channels не register'ятся, sweep/retention + * jobs не start'ят. Outbox events накапливаются в Kafka topic, на enable + * консьюмер начнёт с {@code auto-offset-reset: latest} (backlog burst guard). + * + *

Module включается в writer pod через {@code @ComponentScan} + Helm value + * {@code writer.notificationsEnabled}. Read-api / projection-writer — disabled. + */ +@Configuration +@ConditionalOnProperty(name = "ordinis.notifications.enabled", havingValue = "true", matchIfMissing = false) +@EnableConfigurationProperties(NotificationsProperties.class) +@EnableScheduling +@EnableCaching +@ComponentScan(basePackages = "cloud.nstart.terravault.ordinis.notifications") +public class NotificationsAutoConfiguration { + + /** + * MessageSource для notifications templates. Отдельный bean (не shared с + * application-wide MessageSource) — изолирует наши bundles от других modules. + * Filename {@code messages/notifications} → resolves к + * {@code messages/notifications_ru.properties} / {@code _en.properties}. + */ + @Bean + public MessageSource notificationsMessageSource() { + ReloadableResourceBundleMessageSource ms = new ReloadableResourceBundleMessageSource(); + ms.setBasename("classpath:messages/notifications"); + ms.setDefaultEncoding(StandardCharsets.UTF_8.name()); + ms.setFallbackToSystemLocale(false); + ms.setUseCodeAsDefaultMessage(false); // throw NoSuchMessageException — fail loud + return ms; + } +} diff --git a/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/config/NotificationsProperties.java b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/config/NotificationsProperties.java new file mode 100644 index 0000000..490c03e --- /dev/null +++ b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/config/NotificationsProperties.java @@ -0,0 +1,126 @@ +package cloud.nstart.terravault.ordinis.notifications.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Per-feature config (D9 — никаких magic numbers, всё через + * {@code @ConfigurationProperties}). + * + *

Bound из Spring config (application.yml + Helm values), prefix + * {@code ordinis.notifications}. Module enables через + * {@link #enabled} = true (см. {@code NotificationsAutoConfiguration} + * @ConditionalOnProperty). + */ +@ConfigurationProperties(prefix = "ordinis.notifications") +public class NotificationsProperties { + + /** Главный feature flag. False — module disabled, dispatcher не register'ится. */ + private boolean enabled = false; + + private final Dispatcher dispatcher = new Dispatcher(); + private final RateLimit rateLimit = new RateLimit(); + private final Retention retention = new Retention(); + private final PendingSweep pendingSweep = new PendingSweep(); + private final Email email = new Email(); + private final Express express = new Express(); + private final Telegram telegram = new Telegram(); + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public Dispatcher getDispatcher() { return dispatcher; } + public RateLimit getRateLimit() { return rateLimit; } + public Retention getRetention() { return retention; } + public PendingSweep getPendingSweep() { return pendingSweep; } + public Email getEmail() { return email; } + public Express getExpress() { return express; } + public Telegram getTelegram() { return telegram; } + + public static class Dispatcher { + private int concurrency = 2; + private int executorPoolSize = 20; + private int maxPollRecords = 20; + private int maxEventAgeHours = 1; + private long sendTimeoutMs = 10_000L; + + public int getConcurrency() { return concurrency; } + public void setConcurrency(int v) { this.concurrency = v; } + public int getExecutorPoolSize() { return executorPoolSize; } + public void setExecutorPoolSize(int v) { this.executorPoolSize = v; } + public int getMaxPollRecords() { return maxPollRecords; } + public void setMaxPollRecords(int v) { this.maxPollRecords = v; } + public int getMaxEventAgeHours() { return maxEventAgeHours; } + public void setMaxEventAgeHours(int v) { this.maxEventAgeHours = v; } + public long getSendTimeoutMs() { return sendTimeoutMs; } + public void setSendTimeoutMs(long v) { this.sendTimeoutMs = v; } + } + + public static class RateLimit { + private int perHour = 10; + + public int getPerHour() { return perHour; } + public void setPerHour(int v) { this.perHour = v; } + } + + public static class Retention { + private int days = 90; + private String cron = "0 0 3 * * *"; // 03:00 UTC daily + + public int getDays() { return days; } + public void setDays(int v) { this.days = v; } + public String getCron() { return cron; } + public void setCron(String v) { this.cron = v; } + } + + public static class PendingSweep { + private long olderThanMs = 5 * 60 * 1000L; // 5 min + private long runEveryMs = 5 * 60 * 1000L; + + public long getOlderThanMs() { return olderThanMs; } + public void setOlderThanMs(long v) { this.olderThanMs = v; } + public long getRunEveryMs() { return runEveryMs; } + public void setRunEveryMs(long v) { this.runEveryMs = v; } + } + + public static class Email { + /** Used as From header. SMTP server config — через standard spring.mail.* properties. */ + private String fromAddress; + /** Base URL для deep links в email body / unsub links. */ + private String baseUrl; + + public String getFromAddress() { return fromAddress; } + public void setFromAddress(String v) { this.fromAddress = v; } + public String getBaseUrl() { return baseUrl; } + public void setBaseUrl(String v) { this.baseUrl = v; } + } + + public static class Express { + /** Corporate Transport Server URL — per-corp on-prem. */ + private String cts; + /** BotX bot UUID — Vault. */ + private String botId; + /** BotX bot HMAC base — Vault. */ + private String botSecret; + /** Group chat UUID per env — Vault или Helm. */ + private String chatId; + + public String getCts() { return cts; } + public void setCts(String v) { this.cts = v; } + public String getBotId() { return botId; } + public void setBotId(String v) { this.botId = v; } + public String getBotSecret() { return botSecret; } + public void setBotSecret(String v) { this.botSecret = v; } + public String getChatId() { return chatId; } + public void setChatId(String v) { this.chatId = v; } + } + + public static class Telegram { + /** Bot token — Vault. */ + private String botToken; + private String chatId; + + public String getBotToken() { return botToken; } + public void setBotToken(String v) { this.botToken = v; } + public String getChatId() { return chatId; } + public void setChatId(String v) { this.chatId = v; } + } +} diff --git a/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/IdempotencyGuard.java b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/IdempotencyGuard.java new file mode 100644 index 0000000..0560291 --- /dev/null +++ b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/IdempotencyGuard.java @@ -0,0 +1,131 @@ +package cloud.nstart.terravault.ordinis.notifications.dispatcher; + +import cloud.nstart.terravault.ordinis.domain.notification.NotificationLog; +import cloud.nstart.terravault.ordinis.domain.notification.NotificationLogRepository; +import cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind; +import cloud.nstart.terravault.ordinis.notifications.event.NotificationEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +/** + * Закрывает CRITICAL gap (idempotency) из v3 review. + * + *

Использует UNIQUE {@code (event_id, channel, recipient)} на + * {@code notification_log} (см. migration 0021) + PostgreSQL + * {@code INSERT ... ON CONFLICT DO NOTHING RETURNING} pattern. + * + *

Concurrent semantics: + *

+ * + *

{@code @Transactional(REQUIRES_NEW)} — claim isolated от outer dispatcher transaction + * чтобы commit'ить claim immediately, before actual send. Send fail'ит → UPDATE status='FAILED' + * via {@code markFailed()} в отдельной tx. + */ +// Gate must mirror NotificationsAutoConfiguration. Writer's @SpringBootApplication +// scanBasePackages = "cloud.nstart.terravault.ordinis" — superset, picks up +// этот @Component даже когда notifications module dormant. Без явного +// ConditionalOnProperty bean регается до того, как notification_log table +// создан → при первом invocation NPE / table-not-found. +@Component +@ConditionalOnProperty(name = "ordinis.notifications.enabled", havingValue = "true", matchIfMissing = false) +public class IdempotencyGuard { + + private static final Logger log = LoggerFactory.getLogger(IdempotencyGuard.class); + + private final NotificationLogRepository repo; + + public IdempotencyGuard(NotificationLogRepository repo) { + this.repo = repo; + } + + /** + * Try to claim the (event_id, channel, recipient) slot. Returns the claimed + * row if INSERT succeeded, empty if already claimed (другой consumer / replay). + * + * @param event source event для idempotency key + audit context + * @param channel target channel + * @param recipient target recipient identifier (email / huid / chat_id) + * @return populated NotificationLog if claimed (PENDING status), empty if conflict + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Optional tryClaim( + NotificationEvent event, ChannelKind channel, String recipient) { + + Optional claimedId = repo.tryClaim( + event.eventId(), + event.eventType(), + event.draft() != null ? event.draft().id() : null, + channel.wireName(), + recipient + ); + + if (claimedId.isEmpty()) { + log.debug("Idempotency conflict — already claimed: event={}, channel={}, recipient={}", + event.eventId(), channel, recipient); + return Optional.empty(); + } + + return repo.findById(claimedId.get()); + } + + /** + * Mark claimed row as SENT. Called после successful channel.dispatch(). + * @Transactional(REQUIRES_NEW) для commit'а independent от dispatcher loop. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void markSent(Long rowId) { + repo.findById(rowId).ifPresent(row -> { + row.markSent(); + repo.save(row); + }); + } + + /** Mark claimed row as FAILED with error message. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void markFailed(Long rowId, String errorMsg) { + repo.findById(rowId).ifPresent(row -> { + row.markFailed(errorMsg); + repo.save(row); + }); + } + + /** Mark claimed row as RATE_LIMITED — recipient hit per-hour cap. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void markRateLimited(Long rowId) { + repo.findById(rowId).ifPresent(row -> { + row.markRateLimited(); + repo.save(row); + }); + } + + /** Mark claimed row as SKIPPED_PREF — user opted out. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void markSkippedPref(Long rowId) { + repo.findById(rowId).ifPresent(row -> { + row.markSkippedPref(); + repo.save(row); + }); + } + + /** Mark claimed row as SKIPPED_TOO_OLD — backlog burst guard fired. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void markSkippedTooOld(Long rowId) { + repo.findById(rowId).ifPresent(row -> { + row.markSkippedTooOld(); + repo.save(row); + }); + } +} diff --git a/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/event/NotificationEvent.java b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/event/NotificationEvent.java new file mode 100644 index 0000000..0143a24 --- /dev/null +++ b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/event/NotificationEvent.java @@ -0,0 +1,55 @@ +package cloud.nstart.terravault.ordinis.notifications.event; + +import java.time.OffsetDateTime; +import java.util.Map; +import java.util.UUID; + +/** + * Canonical notification event published в Kafka topic {@code ordinis.notifications.v1} + * через existing {@code OutboxRecorder}. Consumer ({@code NotificationDispatcher}) + * deserialize'ит и fan-out'ит per recipient × channel. + * + *

Three event types: + *

+ * + *

Schema versioned via {@code v1} suffix в topic name. Future schema changes — + * new topic {@code v2}, dual-publish during migration. + * + * @param eventId UUID of the originating outbox event (idempotency key) + * @param eventType wire string ('draft.submitted' / 'draft.decision' / 'draft.withdrawn') + * @param occurredAt timestamp + * @param draft draft context (always present для draft.* events) + * @param decision present только для 'draft.decision' (APPROVE/REJECT + decidedBy + comment) + * @param context routing hints — approvalMinRole, dictDisplayName ru/en + */ +public record NotificationEvent( + UUID eventId, + String eventType, + OffsetDateTime occurredAt, + DraftRef draft, + Decision decision, + Map context +) { + + public static final String TYPE_SUBMITTED = "draft.submitted"; + public static final String TYPE_DECISION = "draft.decision"; + public static final String TYPE_WITHDRAWN = "draft.withdrawn"; + + public record DraftRef( + UUID id, + String businessKey, + String dictionary, + String operation, + String maker + ) {} + + public record Decision( + String outcome, // 'APPROVE' | 'REJECT' + String decidedBy, + String comment + ) {} +} diff --git a/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/sanitizer/Sanitizer.java b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/sanitizer/Sanitizer.java new file mode 100644 index 0000000..40e2f68 --- /dev/null +++ b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/sanitizer/Sanitizer.java @@ -0,0 +1,170 @@ +package cloud.nstart.terravault.ordinis.notifications.sanitizer; + +/** + * Sanitization adapter для user-controlled полей перед template render. + * + *

Purpose — defence-in-depth: {@code business_key}, {@code maker}, + * {@code reviewer}, {@code comment} в notification templates приходят + * непосредственно от пользователей. Без sanitization получаем CVE-class + * injection vulnerabilities per channel: + * + *

+ * + *

Все методы — pure static, no Spring DI, fully unit-testable. Trade-off: + * {@code MAX_LEN} cap в 200 chars применяется ко всем channels uniformly + * (email subject + chat message длинные ключи всё равно truncate'ятся в + * клиентах, поэтому 200 chars — sane upper bound). + * + *

Sanitization вызывается дважды (defence-in-depth): + *

    + *
  1. В {@code MessageRenderer} перед rendering template — params уже + * safe для конкретного channel kind.
  2. + *
  3. В каждом {@code NotificationChannel.dispatch()} перед фактическим + * send — на случай если где-то забыли первый шаг.
  4. + *
+ * + *

Comprehensive test coverage в {@code SanitizerTest} — особенно + * {@code emailHeaderInjectionRejected*} cases. Эти тесты — CI-gating; если + * сломаются, build падает и CRITICAL gap re-opens. + * + * @see RFC 5322 §2.2 — header injection vector + * @see Telegram Bot API MarkdownV2 reserved chars + */ +public final class Sanitizer { + + /** + * Maximum length применяется ко всем sanitized output strings. 200 chars — + * email subject reasonable cap (Outlook truncates ~150-200), Express BotX + * body line readable limit, Telegram hard limit 4096 (но за 200 chars + * scannable format ломается). + */ + public static final int MAX_LEN = 200; + + // Prevent instantiation. + private Sanitizer() {} + + /** + * Email-safe sanitization: strip CRLF (RFC 5322 §2.2 header injection + * vector) + cap длину. + * + *

Strips: + *

+ * Заменяются на single space. + * + *

Используется для: + *

+ * + * @param raw произвольный user-controlled input, может быть null + * @return sanitized string, never null (на null → empty string) + */ + public static String forEmailHeader(String raw) { + if (raw == null) { + return ""; + } + String stripped = raw.replaceAll("[\\r\\n\\u0085\\u2028\\u2029]", " "); + return stripped.length() > MAX_LEN ? stripped.substring(0, MAX_LEN) : stripped; + } + + /** + * Express BotX-safe sanitization: escape Markdown subset reserved chars + * + cap длину. + * + *

Express BotX поддерживает урезанный Markdown: + *

+ * + *

Если business_key содержит {@code *} или {@code _} или {@code `} или + * {@code ~} — backslash-escape'аем чтобы: + *

    + *
  1. Не сломать rendering (template "*Новый draft*" + business_key + * "Союз*М-25" → "*Новый draft*Союз*М-25" → bold-spans поломаются).
  2. + *
  3. Не открыть structure manipulation вектор (хотя BotX rendering + * sandbox'нутый, defence-in-depth дёшев).
  4. + *
+ * + *

Backslash сам тоже escape'ится — иначе input "test\n" станет литерал + * "test\\n" в output (escaping issue). + * + * @param raw произвольный user-controlled input, может быть null + * @return sanitized string, never null + */ + public static String forExpressText(String raw) { + if (raw == null) { + return ""; + } + String capped = raw.length() > MAX_LEN ? raw.substring(0, MAX_LEN) + "…" : raw; + // Order matters: escape backslash first, иначе двойной escape на + // уже-escaped chars. Pattern matches: \ * _ ` ~ + return capped.replaceAll("([\\\\*_`~])", "\\\\$1"); + } + + /** + * Telegram MarkdownV2-safe sanitization: escape reserved chars per Bot + * API spec + cap длину. + * + *

Per + * Telegram Bot API MarkdownV2, reserved chars:

+   * _ * [ ] ( ) ~ ` > # + - = | { } . !
+   * 
+ * Plus backslash itself ({@code \}) escape character. + * + *

Все эти chars в сыром user input должны быть prefix'нуты {@code \\} + * иначе Telegram API rejects message (или хуже — рендерит unintended + * formatting). + * + * @param raw произвольный user-controlled input, может быть null + * @return sanitized string, never null + */ + public static String forTelegramMarkdown(String raw) { + if (raw == null) { + return ""; + } + String capped = raw.length() > MAX_LEN ? raw.substring(0, MAX_LEN) + "…" : raw; + // Order matters: backslash first. + return capped.replaceAll("([\\\\_*\\[\\]()~`>#+=|{}.!\\-])", "\\\\$1"); + } + + /** + * Plain-text body sanitization: NOT escape markdown, но strip control chars. + * Используется для plain text email body, plain Telegram (если не MarkdownV2), + * plain Express messages (если не markdown mode). + * + *

Strips: ASCII control chars (0x00-0x1F кроме {@code \t \r \n}) + DEL (0x7F). + * НЕ truncate'ит до MAX_LEN — bodies больше subjects, размер контролируется + * на template level или channel-specific limits. + * + * @param raw произвольный user-controlled input, может быть null + * @return sanitized string, never null + */ + public static String forPlainTextBody(String raw) { + if (raw == null) { + return ""; + } + return raw.replaceAll("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]", ""); + } +} diff --git a/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/template/MessageRenderer.java b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/template/MessageRenderer.java new file mode 100644 index 0000000..d571ab7 --- /dev/null +++ b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/template/MessageRenderer.java @@ -0,0 +1,107 @@ +package cloud.nstart.terravault.ordinis.notifications.template; + +import cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind; +import cloud.nstart.terravault.ordinis.notifications.channel.RenderedMessage; +import cloud.nstart.terravault.ordinis.notifications.sanitizer.Sanitizer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.MessageSource; +import org.springframework.context.NoSuchMessageException; +import org.springframework.stereotype.Component; + +import java.util.Locale; +import java.util.Map; + +/** + * Renders notification messages из Spring {@code MessageSource} bundles. + * + *

Bundles в classpath {@code messages/notifications_{locale}.properties}. + * Keys имеют shape {@code {eventKey}.{channel}.{slot}}, where: + *

+ * + *

Example: {@code draft_submitted.email.subject} = {@code "[НСИ] Новый draft на review — {0} / {1}"} + * + *

Sanitization built-in: каждый dynamic param проходит {@code Sanitizer.forXxx()} + * per channel kind ДО {@code MessageSource.getMessage()}. Defence-in-depth — даже + * если template содержит markdown, sanitized params не сломают rendering. + */ +// Gate must mirror NotificationsAutoConfiguration. Writer's +// @SpringBootApplication scanBasePackages — superset, может ловить этот +// @Component до того как notifications сконфигурированы. Plus +// @Qualifier на конструкторе — Spring Boot auto-creates default +// `messageSource` bean (для app-wide i18n) → ambiguous-by-type injection без +// явного qualifier'а. +@Component +@ConditionalOnProperty(name = "ordinis.notifications.enabled", havingValue = "true", matchIfMissing = false) +public class MessageRenderer { + + private static final Logger log = LoggerFactory.getLogger(MessageRenderer.class); + + /** Default locale если recipient.locale=null или unsupported. */ + public static final Locale DEFAULT_LOCALE = new Locale("ru"); + + private final MessageSource messageSource; + + public MessageRenderer(@Qualifier("notificationsMessageSource") MessageSource messageSource) { + this.messageSource = messageSource; + } + + /** + * Render subject + body для (eventKey, channel, locale) с sanitized params. + * + *

Params порядок MUST соответствовать {0} {1} {2}... placeholders в template + * (Java MessageFormat conventions). См. notifications_ru.properties для reference. + * + * @param eventKey 'draft_submitted' | 'draft_decision' | 'draft_withdrawn' + * @param channel target channel — определяет sanitization rules + * @param locale recipient locale (RU/EN), fallback DEFAULT_LOCALE + * @param params positional params {0..N} (sanitization применяется автоматически + * через {@link #sanitizeForChannel}) + * @return RenderedMessage с subject + body + * @throws NoSuchMessageException если template missing — лучше fail loud чем + * silent empty render + */ + public RenderedMessage render( + String eventKey, ChannelKind channel, Locale locale, Object... params) { + + Locale effectiveLocale = (locale != null) ? locale : DEFAULT_LOCALE; + Object[] safe = sanitizeForChannel(channel, params); + + String channelKey = channel.wireName(); + String subjectKey = eventKey + "." + channelKey + ".subject"; + String bodyKey = eventKey + "." + channelKey + ".body"; + + String subject = messageSource.getMessage(subjectKey, safe, effectiveLocale); + String body = messageSource.getMessage(bodyKey, safe, effectiveLocale); + + return new RenderedMessage(subject, body); + } + + /** + * Apply per-channel sanitization. String params get channel-appropriate escape; + * non-string params (numbers, dates) pass through. + */ + private Object[] sanitizeForChannel(ChannelKind channel, Object[] params) { + if (params == null) return new Object[0]; + Object[] out = new Object[params.length]; + for (int i = 0; i < params.length; i++) { + Object p = params[i]; + if (p instanceof String s) { + out[i] = switch (channel) { + case EMAIL -> Sanitizer.forEmailHeader(s); + case EXPRESS -> Sanitizer.forExpressText(s); + case TELEGRAM -> Sanitizer.forTelegramMarkdown(s); + }; + } else { + out[i] = p; + } + } + return out; + } +} diff --git a/ordinis-notifications/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ordinis-notifications/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..edd4d3b --- /dev/null +++ b/ordinis-notifications/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +cloud.nstart.terravault.ordinis.notifications.config.NotificationsAutoConfiguration diff --git a/ordinis-notifications/src/main/resources/messages/notifications_en.properties b/ordinis-notifications/src/main/resources/messages/notifications_en.properties new file mode 100644 index 0000000..d92dd25 --- /dev/null +++ b/ordinis-notifications/src/main/resources/messages/notifications_en.properties @@ -0,0 +1,35 @@ +# Notifications message bundle — English. +# Same key shape as notifications_ru.properties. + +# === draft.submitted === + +draft_submitted.email.subject=[NSI] New draft for review — {0} / {1} +draft_submitted.email.body=Maker {2} submitted {3} on {0}, key {1}.\n\nOpen queue: {4}/reviews\nDirect link: {4}/reviews/{5}\n\n—\nAutomated NSI notification. To unsubscribe, visit {4}/me/notifications. + +draft_submitted.express.subject=*🔔 New draft for review* +draft_submitted.express.body=*🔔 New draft for review*\n«{0}» / `{1}`\nMaker: {2} ({3})\n\nOpen queue: {4}/reviews\nDirect link: {4}/reviews/{5} + +draft_submitted.telegram.subject= +draft_submitted.telegram.body=*🔔 New draft for review*\n«{0}» / `{1}`\nMaker: {2} \\({3}\\)\n\nOpen: {4}/reviews/{5} + +# === draft.decision === + +draft_decision.email.subject=[NSI] {6,choice,0#Draft rejected|1#Draft approved} — {0} / {1} +draft_decision.email.body=Your draft {3} on {0} (key {1}) has been {6,choice,0#rejected|1#approved} by {7}.{8,choice,0#|1# Comment: {9}}\n\nOpen: {4}/my-drafts/{5} + +draft_decision.express.subject={6,choice,0#*❌ Draft rejected*|1#*✅ Draft approved*} +draft_decision.express.body={6,choice,0#*❌ Draft rejected*|1#*✅ Draft approved*}\n«{0}» / `{1}`\nReviewer: {7}{8,choice,0#|1#\nComment: {9}}\n\nOpen: {4}/my-drafts/{5} + +draft_decision.telegram.subject= +draft_decision.telegram.body={6,choice,0#*❌ Draft rejected*|1#*✅ Draft approved*}\n«{0}» / `{1}`\nReviewer: {7}\n\nOpen: {4}/my\\-drafts/{5} + +# === draft.withdrawn === + +draft_withdrawn.email.subject=[NSI] Draft withdrawn by maker — {0} / {1} +draft_withdrawn.email.body=Maker {2} withdrew their draft {3} on {0}, key {1}.\nNo action needed — already removed from queue.\n\n{4}/reviews + +draft_withdrawn.express.subject=*↩ Draft withdrawn* +draft_withdrawn.express.body=*↩ Draft withdrawn*\n«{0}» / `{1}`\nMaker withdrew — removed from queue. + +draft_withdrawn.telegram.subject= +draft_withdrawn.telegram.body=*↩ Draft withdrawn*\n«{0}» / `{1}`\nMaker withdrew\\. diff --git a/ordinis-notifications/src/main/resources/messages/notifications_ru.properties b/ordinis-notifications/src/main/resources/messages/notifications_ru.properties new file mode 100644 index 0000000..f188782 --- /dev/null +++ b/ordinis-notifications/src/main/resources/messages/notifications_ru.properties @@ -0,0 +1,51 @@ +# Notifications message bundle — Russian (default). +# +# Key shape: {eventKey}.{channel}.{slot} +# Params (positional, MessageFormat style): +# {0} dictDisplayName (e.g. "Космические аппараты") +# {1} businessKey (e.g. "Союз-МС-25") +# {2} maker user_id (e.g. "user.tech@nstart.space") +# {3} operation (e.g. "UPDATE") +# {4} baseUrl (e.g. "https://ordinis.k8s.../") +# {5} draftId +# {6} decision outcome (0=REJECT, 1=APPROVE — ChoiceFormat) +# {7} reviewer user_id +# {8} comment present (0=no, 1=yes) +# {9} comment text +# +# All dynamic params уже sanitized per channel в MessageRenderer. + +# === draft.submitted (reviewer pool ping) === + +draft_submitted.email.subject=[НСИ] Новый draft на review — {0} / {1} +draft_submitted.email.body=Maker {2} отправил {3} на {0}, ключ {1}.\n\nОткрыть очередь: {4}/reviews\nПрямая ссылка: {4}/reviews/{5}\n\n—\nЭто автоматическое уведомление НСИ. Чтобы отписаться, зайдите в {4}/me/notifications. + +draft_submitted.express.subject=*🔔 Новый draft на review* +draft_submitted.express.body=*🔔 Новый draft на review*\n«{0}» / `{1}`\nMaker: {2} ({3})\n\nОткрыть очередь: {4}/reviews\nПрямая ссылка: {4}/reviews/{5} + +draft_submitted.telegram.subject= +draft_submitted.telegram.body=*🔔 Новый draft на review*\n«{0}» / `{1}`\nMaker: {2} \\({3}\\)\n\nОткрыть: {4}/reviews/{5} + +# === draft.decision (notify maker) === +# {6} = 0 (REJECT) or 1 (APPROVE) — ChoiceFormat +# {8} = 0 (no comment) or 1 (has comment) — ChoiceFormat + +draft_decision.email.subject=[НСИ] {6,choice,0#Draft отклонён|1#Draft одобрен} — {0} / {1} +draft_decision.email.body=Ваш draft {3} на {0} (ключ {1}) был {6,choice,0#отклонён|1#одобрен} пользователем {7}.{8,choice,0#|1# Комментарий: {9}}\n\nОткрыть: {4}/my-drafts/{5} + +draft_decision.express.subject={6,choice,0#*❌ Draft отклонён*|1#*✅ Draft одобрен*} +draft_decision.express.body={6,choice,0#*❌ Draft отклонён*|1#*✅ Draft одобрен*}\n«{0}» / `{1}`\nReviewer: {7}{8,choice,0#|1#\nКомментарий: {9}}\n\nОткрыть: {4}/my-drafts/{5} + +draft_decision.telegram.subject= +draft_decision.telegram.body={6,choice,0#*❌ Draft отклонён*|1#*✅ Draft одобрен*}\n«{0}» / `{1}`\nReviewer: {7}\n\nОткрыть: {4}/my\\-drafts/{5} + +# === draft.withdrawn (clear queue) === + +draft_withdrawn.email.subject=[НСИ] Draft отозван maker'ом — {0} / {1} +draft_withdrawn.email.body=Maker {2} отозвал свой draft {3} на {0}, ключ {1}.\nУбрать из очереди не требуется — уже убран автоматически.\n\n{4}/reviews + +draft_withdrawn.express.subject=*↩ Draft withdrawn* +draft_withdrawn.express.body=*↩ Draft withdrawn*\n«{0}» / `{1}`\nMaker сам отозвал — убрано из очереди. + +draft_withdrawn.telegram.subject= +draft_withdrawn.telegram.body=*↩ Draft withdrawn*\n«{0}» / `{1}`\nMaker сам отозвал\\. diff --git a/ordinis-notifications/src/test/java/cloud/nstart/terravault/ordinis/notifications/sanitizer/SanitizerTest.java b/ordinis-notifications/src/test/java/cloud/nstart/terravault/ordinis/notifications/sanitizer/SanitizerTest.java new file mode 100644 index 0000000..3445427 Binary files /dev/null and b/ordinis-notifications/src/test/java/cloud/nstart/terravault/ordinis/notifications/sanitizer/SanitizerTest.java differ diff --git a/ordinis-notifications/src/test/java/cloud/nstart/terravault/ordinis/notifications/template/MessageRendererTest.java b/ordinis-notifications/src/test/java/cloud/nstart/terravault/ordinis/notifications/template/MessageRendererTest.java new file mode 100644 index 0000000..e579d04 --- /dev/null +++ b/ordinis-notifications/src/test/java/cloud/nstart/terravault/ordinis/notifications/template/MessageRendererTest.java @@ -0,0 +1,220 @@ +package cloud.nstart.terravault.ordinis.notifications.template; + +import cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind; +import cloud.nstart.terravault.ordinis.notifications.channel.RenderedMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.context.MessageSource; +import org.springframework.context.support.ReloadableResourceBundleMessageSource; + +import java.nio.charset.StandardCharsets; +import java.util.Locale; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration test для MessageRenderer + bundle templates + Sanitizer chain. + * Verifies: + *

+ */ +class MessageRendererTest { + + private MessageRenderer renderer; + + @BeforeEach + void setUp() { + ReloadableResourceBundleMessageSource ms = new ReloadableResourceBundleMessageSource(); + ms.setBasename("classpath:messages/notifications"); + ms.setDefaultEncoding(StandardCharsets.UTF_8.name()); + ms.setFallbackToSystemLocale(false); + ms.setUseCodeAsDefaultMessage(false); + renderer = new MessageRenderer(ms); + } + + // === draft.submitted === + + @Test + @DisplayName("draft_submitted email RU rendered с business_key и link") + void draftSubmittedEmailRu() { + RenderedMessage msg = renderer.render( + "draft_submitted", ChannelKind.EMAIL, new Locale("ru"), + "Космические аппараты", "Союз-МС-25", "user.tech@nstart.space", + "UPDATE", "https://ordinis.example", 123L); + + assertTrue(msg.subject().contains("Космические аппараты")); + assertTrue(msg.subject().contains("Союз-МС-25")); + assertTrue(msg.subject().startsWith("[НСИ]")); + assertTrue(msg.body().contains("UPDATE")); + assertTrue(msg.body().contains("https://ordinis.example/reviews/123")); + } + + @Test + @DisplayName("draft_submitted email EN rendered correctly") + void draftSubmittedEmailEn() { + RenderedMessage msg = renderer.render( + "draft_submitted", ChannelKind.EMAIL, Locale.ENGLISH, + "Spacecraft", "Soyuz-MS-25", "user.tech@nstart.space", + "UPDATE", "https://ordinis.example", 123L); + + assertTrue(msg.subject().startsWith("[NSI]")); + assertTrue(msg.subject().contains("Soyuz-MS-25")); + assertTrue(msg.body().contains("Maker user.tech@nstart.space")); + } + + // === draft.decision ChoiceFormat === + + @Test + @DisplayName("draft_decision APPROVE с comment renders ✅") + void draftDecisionApproveWithComment() { + RenderedMessage msg = renderer.render( + "draft_decision", ChannelKind.EMAIL, new Locale("ru"), + "Космические аппараты", "Союз-МС-25", "user.tech@nstart.space", + "UPDATE", "https://ordinis.example", 123L, + 1, // {6} = 1 → APPROVE + "reviewer.tech@nstart.space", // {7} + 1, // {8} = 1 → has comment + "Согласовано"); // {9} + + assertTrue(msg.subject().contains("одобрен"), + "RU APPROVE subject должен содержать 'одобрен', got: " + msg.subject()); + assertTrue(msg.body().contains("Согласовано")); + assertTrue(msg.body().contains("reviewer.tech@nstart.space")); + } + + @Test + @DisplayName("draft_decision REJECT без comment не показывает comment field") + void draftDecisionRejectNoComment() { + RenderedMessage msg = renderer.render( + "draft_decision", ChannelKind.EMAIL, new Locale("ru"), + "Космические аппараты", "Союз-МС-25", "user.tech@nstart.space", + "UPDATE", "https://ordinis.example", 123L, + 0, // REJECT + "reviewer.tech@nstart.space", + 0, // no comment + ""); + + assertTrue(msg.subject().contains("отклонён")); + assertFalse(msg.body().contains("Комментарий:"), + "При no-comment не должен show 'Комментарий:' field"); + } + + // === Express channel === + + @Test + @DisplayName("Express body содержит status icon + bold + business_key code") + void expressSubmittedFormat() { + RenderedMessage msg = renderer.render( + "draft_submitted", ChannelKind.EXPRESS, new Locale("ru"), + "Космические аппараты", "Союз-МС-25", "user.tech@nstart.space", + "UPDATE", "https://ordinis.example", 123L); + + assertTrue(msg.body().contains("🔔"), "должен иметь bell emoji status"); + // BotX bold = single asterisks (not CommonMark **double**). Template начинается с *🔔...* + assertTrue(msg.body().startsWith("*"), "должен начинаться с BotX bold marker (*)"); + // Sanitizer.forExpressText escape's [* _ ` ~ \\] — НЕ hyphen, так что Союз-МС-25 остаётся as-is + assertTrue(msg.body().contains("`Союз-МС-25`"), + "business_key должен быть в code marker (BotX backticks). Body: " + msg.body()); + } + + // === Telegram channel === + + @Test + @DisplayName("Telegram body уважает MarkdownV2 escape (no raw dot in URL)") + void telegramMarkdownEscape() { + RenderedMessage msg = renderer.render( + "draft_submitted", ChannelKind.TELEGRAM, new Locale("ru"), + "Космические аппараты", "Союз-МС-25", "user.tech@nstart.space", + "UPDATE", "https://ordinis.example", 123L); + + // В template /reviews/{5} — без escape \., но URL contains "/" + // (pass через ChoiceFormat) — тут проверяем что хотя бы template без issue. + assertNotNull(msg.body()); + assertTrue(msg.body().contains("🔔")); + } + + // === withdrawn === + + @Test + @DisplayName("draft_withdrawn renders RU") + void draftWithdrawnEmailRu() { + RenderedMessage msg = renderer.render( + "draft_withdrawn", ChannelKind.EMAIL, new Locale("ru"), + "Космические аппараты", "Союз-МС-25", "user.tech@nstart.space", + "UPDATE", "https://ordinis.example", 123L); + + assertTrue(msg.subject().contains("отозван")); + assertTrue(msg.body().contains("user.tech@nstart.space")); + } + + // === SECURITY: Email header injection через malicious business_key === + + @Test + @DisplayName("CRITICAL: malicious business_key с CRLF не может inject email headers") + void emailSubjectImmuneToHeaderInjection() { + String malicious = "Союз\nBcc: attacker@evil.com\nFrom: spoof@evil.com"; + + RenderedMessage msg = renderer.render( + "draft_submitted", ChannelKind.EMAIL, new Locale("ru"), + "Космические аппараты", malicious, "user@nstart.space", + "UPDATE", "https://ordinis.example", 123L); + + // Critical assertions: + assertFalse(msg.subject().contains("\n"), + "Email subject не должен содержать LF — иначе header injection"); + assertFalse(msg.subject().contains("\r"), + "Email subject не должен содержать CR — иначе header injection"); + + // String "Bcc:" может остаться (это просто текст в subject), но не на отдельной + // line — что и проверяет no-newline assert выше. + String[] lines = msg.subject().split("\\r?\\n"); + assertEquals(1, lines.length, "Subject должен быть single line"); + } + + @Test + @DisplayName("Express body sanitization escape's markdown-confusing chars в business_key") + void expressMarkdownInjectionEscaped() { + // Maker создал business_key с *_ — без escape это сломает bold rendering + String malicious = "test*key_with*markers"; + + RenderedMessage msg = renderer.render( + "draft_submitted", ChannelKind.EXPRESS, new Locale("ru"), + "Космические аппараты", malicious, "user@nstart.space", + "UPDATE", "https://ordinis.example", 123L); + + // В rendered body должны быть escape'нутые \* \_: + assertTrue(msg.body().contains("\\*"), "asterisk должен быть escaped"); + assertTrue(msg.body().contains("\\_"), "underscore должен быть escaped"); + } + + // === missing key fail-loud === + + @Test + @DisplayName("missing template key → throws NoSuchMessageException (не silent empty)") + void missingKeyFailsLoud() { + assertThrows( + org.springframework.context.NoSuchMessageException.class, + () -> renderer.render( + "nonexistent_event", ChannelKind.EMAIL, new Locale("ru"), + "x", "y", "z", "w", "u", 1L)); + } + + // === fallback locale === + + @Test + @DisplayName("null locale → DEFAULT_LOCALE (RU) fallback") + void nullLocaleFallsBackToRu() { + RenderedMessage msg = renderer.render( + "draft_submitted", ChannelKind.EMAIL, null, + "Космические аппараты", "Союз-МС-25", "user@nstart.space", + "UPDATE", "https://ordinis.example", 123L); + + assertTrue(msg.subject().startsWith("[НСИ]"), + "Null locale → RU bundle, subject должен быть RU"); + } +} diff --git a/pom.xml b/pom.xml index fb5a8f3..7ed5c14 100644 --- a/pom.xml +++ b/pom.xml @@ -31,6 +31,7 @@ ordinis-projection-writer ordinis-cuod-bundle ordinis-migrations + ordinis-notifications