Merge branch 'feat/notifications-phase-a-foundation' into 'main'
feat(notifications): Phase A foundation — module + schema 0021 See merge request 2-6/2-6-4/terravault/ordinis!5
This commit is contained in:
+161
@@ -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.
|
||||
*
|
||||
* <p>UNIQUE {@code (event_id, channel, recipient)} — primary idempotency guard
|
||||
* (см. migration 0021 + design doc D2). Insert pattern в
|
||||
* {@code IdempotencyGuard.shouldSend()}:
|
||||
*
|
||||
* <pre>{@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;
|
||||
* }</pre>
|
||||
*
|
||||
* <p>Если RETURNING пустой — другой consumer уже claim'ил отправку (или sweep
|
||||
* job не освободил). Else — мы owner, dispatch + UPDATE status final.
|
||||
*
|
||||
* <p>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; }
|
||||
}
|
||||
+83
@@ -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<NotificationLog, Long> {
|
||||
|
||||
/**
|
||||
* Idempotency claim. Native query потому что Spring Data JPA не поддерживает
|
||||
* {@code INSERT ... ON CONFLICT DO NOTHING RETURNING} напрямую (PostgreSQL specific).
|
||||
*
|
||||
* <p>Returns inserted row's id если claim succeeded, или empty если конфликт
|
||||
* (другой consumer уже claim'ил, или sweep job не освободил stuck row).
|
||||
*
|
||||
* <p>Используется в {@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<Long> 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<OffsetDateTime> findLastSentAt(
|
||||
@Param("recipient") String recipient,
|
||||
@Param("channel") String channel);
|
||||
|
||||
/** Admin endpoint pagination. */
|
||||
Page<NotificationLog> findByRecipientOrderBySentAtDesc(String recipient, Pageable pageable);
|
||||
|
||||
/** Admin endpoint — full audit lookup by event. */
|
||||
Page<NotificationLog> findByEventIdOrderBySentAtDesc(UUID eventId, Pageable pageable);
|
||||
}
|
||||
+78
@@ -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).
|
||||
*
|
||||
* <p>Composite PK {@code (user_id, channel)}. Default {@code enabled=false} для
|
||||
* новых rows; bootstrap loadData может включить email для existing reviewer/maker
|
||||
* ролей при первом deploy.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
+16
@@ -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<UserNotificationPref, UserNotificationPref.Pk> {
|
||||
|
||||
/** Все каналы пользователя — для GET /api/v1/me/notifications page. */
|
||||
List<UserNotificationPref> findByIdUserId(String userId);
|
||||
|
||||
/** Single channel lookup для dispatcher (skip if disabled). */
|
||||
Optional<UserNotificationPref> findByIdUserIdAndIdChannel(String userId, String channel);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
|
||||
|
||||
<!--
|
||||
Notifications v1.2.0 — schema foundation.
|
||||
|
||||
Цель: persistent audit trail доставки + per-user channel preferences.
|
||||
Notification dispatcher leverages existing ordinis-outbox infra (OutboxRecorder
|
||||
+ OutboxPoller + KafkaTopicResolver). Этот migration добавляет ТОЛЬКО consumer-side
|
||||
state. События публикуются в outbox_events (existing table).
|
||||
|
||||
Decisions made (per design doc):
|
||||
- D2 idempotency: UNIQUE (event_id, channel, recipient) — primary defence от
|
||||
consumer crash mid-fan-out duplication. INSERT ON CONFLICT DO NOTHING RETURNING.
|
||||
- D11 PendingSweepJob: status='PENDING' rows старше 5 min удаляются → освобождают
|
||||
idempotency lock на crashed dispatch attempts.
|
||||
- D12 RetentionJob: SENT/RATE_LIMITED/SKIPPED_PREF/SKIPPED_TOO_OLD/UNSUBSCRIBED
|
||||
старше 90 дней удаляются ежедневно. FAILED rows preserved bessrochno.
|
||||
- D15 status enum UPPERCASE (Hibernate @Enumerated(EnumType.STRING) lesson из
|
||||
v1.1.0 P3 — migration 0020).
|
||||
|
||||
Indexes:
|
||||
- Partial idx_notif_log_pending_sweep — для @Scheduled sweep job (только PENDING).
|
||||
- Partial idx_notif_log_retention — для daily cleanup (terminal status'ы).
|
||||
- Composite idx_notif_log_recipient_time — для UI "last sent" lookup.
|
||||
- Single column idx_notif_log_draft / event для cross-references.
|
||||
|
||||
Source: ~/.gstack/projects/claude/zimin-main-design-notifications-v1.2.0-20260510-130000.md
|
||||
-->
|
||||
|
||||
<changeSet id="0021-1-notification-log" author="ordinis">
|
||||
<comment>notification_log — per-recipient delivery audit trail с UNIQUE idempotency guard</comment>
|
||||
<createTable tableName="notification_log">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<!-- domain event UUID из outbox_events — связывает notification с triggering event -->
|
||||
<column name="event_id" type="UUID">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="event_type" type="VARCHAR(64)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- nullable: некоторые events без draft контекста (future expansion) -->
|
||||
<column name="draft_id" type="UUID"/>
|
||||
<column name="channel" type="VARCHAR(16)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- email / express chat_id / telegram chat_id -->
|
||||
<column name="recipient" type="VARCHAR(256)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="status" type="VARCHAR(20)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="sent_at" type="TIMESTAMPTZ" defaultValueComputed="NOW()">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="error_msg" type="TEXT"/>
|
||||
</createTable>
|
||||
|
||||
<!-- D15 status enum UPPERCASE (Hibernate @Enumerated lesson) -->
|
||||
<sql>
|
||||
ALTER TABLE notification_log
|
||||
ADD CONSTRAINT notification_log_status_check
|
||||
CHECK (status IN (
|
||||
'PENDING', 'SENT', 'FAILED',
|
||||
'RATE_LIMITED', 'SKIPPED_PREF', 'SKIPPED_TOO_OLD',
|
||||
'UNSUBSCRIBED'
|
||||
));
|
||||
</sql>
|
||||
|
||||
<sql>
|
||||
ALTER TABLE notification_log
|
||||
ADD CONSTRAINT notification_log_channel_check
|
||||
CHECK (channel IN ('email', 'express', 'telegram'));
|
||||
</sql>
|
||||
|
||||
<!-- D2 idempotency guard — primary defence от consumer crash mid-fan-out duplication.
|
||||
(event_id, channel, recipient) UNIQUE: тот же event для того же канала и
|
||||
recipient'а никогда не send'ится дважды. INSERT ON CONFLICT DO NOTHING RETURNING
|
||||
в IdempotencyGuard.shouldSend(). -->
|
||||
<sql>
|
||||
ALTER TABLE notification_log
|
||||
ADD CONSTRAINT notification_log_idempotency
|
||||
UNIQUE (event_id, channel, recipient);
|
||||
</sql>
|
||||
|
||||
<!-- Partial index для PendingSweepJob: только PENDING rows нужно scan'ить
|
||||
каждые 5 минут. Без partial index full scan на growing table. -->
|
||||
<sql>
|
||||
CREATE INDEX idx_notif_log_pending_sweep
|
||||
ON notification_log (sent_at)
|
||||
WHERE status = 'PENDING';
|
||||
</sql>
|
||||
|
||||
<!-- Partial index для RetentionJob daily cleanup: только terminal "deletable"
|
||||
status'ы. FAILED rows preserved → не в индексе. -->
|
||||
<sql>
|
||||
CREATE INDEX idx_notif_log_retention
|
||||
ON notification_log (sent_at)
|
||||
WHERE status IN ('SENT', 'RATE_LIMITED', 'SKIPPED_PREF', 'SKIPPED_TOO_OLD', 'UNSUBSCRIBED');
|
||||
</sql>
|
||||
|
||||
<createIndex indexName="idx_notif_log_draft" tableName="notification_log">
|
||||
<column name="draft_id"/>
|
||||
</createIndex>
|
||||
|
||||
<!-- UI "last sent {N} min ago" lookup — recipient + sent_at DESC. -->
|
||||
<createIndex indexName="idx_notif_log_recipient_time" tableName="notification_log">
|
||||
<column name="recipient"/>
|
||||
<column name="sent_at" descending="true"/>
|
||||
</createIndex>
|
||||
|
||||
<createIndex indexName="idx_notif_log_event" tableName="notification_log">
|
||||
<column name="event_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0021-2-user-notification-prefs" author="ordinis">
|
||||
<comment>user_notification_prefs — per-user channel opt-in/out</comment>
|
||||
<createTable tableName="user_notification_prefs">
|
||||
<!-- Keycloak subject (sub claim) — same shape as record_drafts.maker_id (VARCHAR 128) -->
|
||||
<column name="user_id" type="VARCHAR(128)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="channel" type="VARCHAR(16)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- D3: email default ON для existing users применяется bootstrap loadData
|
||||
(отдельный changeSet опционально per env). Для новых юзеров — explicit
|
||||
opt-in через UI. -->
|
||||
<column name="enabled" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
|
||||
<sql>
|
||||
ALTER TABLE user_notification_prefs
|
||||
ADD CONSTRAINT user_notification_prefs_pkey
|
||||
PRIMARY KEY (user_id, channel);
|
||||
</sql>
|
||||
|
||||
<sql>
|
||||
ALTER TABLE user_notification_prefs
|
||||
ADD CONSTRAINT user_notification_prefs_channel_check
|
||||
CHECK (channel IN ('email', 'express', 'telegram'));
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
@@ -30,5 +30,6 @@
|
||||
<include file="changes/0018-trgm-search-index.xml" relativeToChangelogFile="true"/>
|
||||
<include file="changes/0019-approval-workflow-v2.xml" relativeToChangelogFile="true"/>
|
||||
<include file="changes/0020-fix-draft-status-case.xml" relativeToChangelogFile="true"/>
|
||||
<include file="changes/0021-notifications.xml" relativeToChangelogFile="true"/>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>cloud.nstart.terravault.ordinis</groupId>
|
||||
<artifactId>ordinis-parent</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>ordinis-notifications</artifactId>
|
||||
<name>Ordinis :: Notifications</name>
|
||||
<description>
|
||||
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
|
||||
</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-cache</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.kafka</groupId>
|
||||
<artifactId>spring-kafka</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cloud.nstart.terravault.ordinis</groupId>
|
||||
<artifactId>ordinis-domain</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cloud.nstart.terravault.ordinis</groupId>
|
||||
<artifactId>ordinis-events-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cloud.nstart.terravault.ordinis</groupId>
|
||||
<artifactId>ordinis-outbox</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Test scope -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.icegreen</groupId>
|
||||
<artifactId>greenmail</artifactId>
|
||||
<version>2.1.5</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package cloud.nstart.terravault.ordinis.notifications.channel;
|
||||
|
||||
/**
|
||||
* Канал доставки уведомления.
|
||||
*
|
||||
* <p><b>Wire format — lowercase</b> ('email' / 'express' / 'telegram'). Такой
|
||||
* формат используется в:
|
||||
* <ul>
|
||||
* <li>DB columns: {@code notification_log.channel}, {@code user_notification_prefs.channel}
|
||||
* (см. CHECK constraint {@code notification_log_channel_check} в migration 0021)</li>
|
||||
* <li>API JSON: {@code GET /api/v1/me/notifications}</li>
|
||||
* <li>Helm config keys: {@code notifications.express.*}</li>
|
||||
* <li>Metric labels: {@code nsi_notification_sent_total{channel="email"}}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>NB:</b> {@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).
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
+107
@@ -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.
|
||||
*
|
||||
* <p>Multipart text+HTML send (RFC 2387) — plain text fallback для accessibility,
|
||||
* HTML version с link styling. Per design § 8.6.
|
||||
*
|
||||
* <p>Sanitization happens upstream в {@code MessageRenderer.render()} —
|
||||
* {@code Sanitizer.forEmailHeader()} применяется ко всем dynamic params в subject.
|
||||
* Body тоже sanitized (CRLF strip preserves layout).
|
||||
*
|
||||
* <p>Per design § 5 EmailChannel:
|
||||
* <ul>
|
||||
* <li>SMTP timeouts через {@code spring.mail.properties.mail.smtp.timeout=10000}.</li>
|
||||
* <li>Hang >10s → {@code MailSendException} → channel returns failed.</li>
|
||||
* <li>FAILED row не освобождает idempotency lock (conscious choice — failed =
|
||||
* "tried, gave up", не долбим SMTP бесконечно).</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package cloud.nstart.terravault.ordinis.notifications.channel;
|
||||
|
||||
import cloud.nstart.terravault.ordinis.notifications.event.NotificationEvent;
|
||||
|
||||
/**
|
||||
* Channel adapter contract. Реализации:
|
||||
* <ul>
|
||||
* <li>{@code EmailChannel} — JavaMailSender + multipart text+HTML.</li>
|
||||
* <li>{@code ExpressChannel} — BotX bot API (Phase B).</li>
|
||||
* <li>{@code TelegramChannel} — Bot API (Phase C).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Caller responsibility (NotificationDispatcher):
|
||||
* <ol>
|
||||
* <li>IdempotencyGuard claim — INSERT ON CONFLICT DO NOTHING.</li>
|
||||
* <li>Pref check — skip если disabled.</li>
|
||||
* <li>Rate limit check — skip если exceeded.</li>
|
||||
* <li>Render через MessageRenderer (sanitization built-in).</li>
|
||||
* <li>Call {@link #dispatch(NotificationEvent, UserRef, RenderedMessage)}.</li>
|
||||
* <li>Update notification_log status based on result.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Channel responsibility:
|
||||
* <ul>
|
||||
* <li>Transport-level send (SMTP / HTTP).</li>
|
||||
* <li>Channel-specific timeout enforcement.</li>
|
||||
* <li>Return DispatchResult с error message если fail.</li>
|
||||
* <li>NOT retry — outbox poller / replay handles retry semantics.</li>
|
||||
* </ul>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -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)}.
|
||||
*
|
||||
* <p>{@code subject} — header / first line (email Subject, Express bold headline).
|
||||
* Не используется для Telegram (header inline в body).
|
||||
*
|
||||
* <p>{@code body} — main content. Channel-specific format (plain text для email,
|
||||
* BotX markdown для Express, MarkdownV2 для Telegram).
|
||||
*
|
||||
* <p>Обе строки уже прошли через {@code Sanitizer.forXxx()} per channel — caller
|
||||
* НЕ должен снова escape'ить.
|
||||
*/
|
||||
public record RenderedMessage(String subject, String body) {}
|
||||
+34
@@ -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.
|
||||
*
|
||||
* <p>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;
|
||||
};
|
||||
}
|
||||
}
|
||||
+49
@@ -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.
|
||||
*
|
||||
* <p>Активна только при {@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).
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
+126
@@ -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}).
|
||||
*
|
||||
* <p>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; }
|
||||
}
|
||||
}
|
||||
+131
@@ -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.
|
||||
*
|
||||
* <p>Использует UNIQUE {@code (event_id, channel, recipient)} на
|
||||
* {@code notification_log} (см. migration 0021) + PostgreSQL
|
||||
* {@code INSERT ... ON CONFLICT DO NOTHING RETURNING} pattern.
|
||||
*
|
||||
* <p>Concurrent semantics:
|
||||
* <ul>
|
||||
* <li>Two consumer threads parallel'но обрабатывают same event → один claim'ит row,
|
||||
* другой получает empty Optional → skip.</li>
|
||||
* <li>Consumer crash после email_OK + before slack → replay → idempotency guard
|
||||
* видит email row (status=SENT) → skip; express row отсутствует → claim + send.</li>
|
||||
* <li>Pod crash mid-INSERT-pending → row застряла PENDING → {@code PendingSweepJob}
|
||||
* через 5 min DELETE → next consumer poll → re-claim + send.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>{@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<NotificationLog> tryClaim(
|
||||
NotificationEvent event, ChannelKind channel, String recipient) {
|
||||
|
||||
Optional<Long> 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+55
@@ -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.
|
||||
*
|
||||
* <p>Three event types:
|
||||
* <ul>
|
||||
* <li>{@code draft.submitted} — DraftService.submit() success → notify reviewer pool.</li>
|
||||
* <li>{@code draft.decision} — approve/reject success → notify maker.</li>
|
||||
* <li>{@code draft.withdrawn} — withdraw success → notify reviewer pool (clear queue).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>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<String, Object> 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
|
||||
) {}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package cloud.nstart.terravault.ordinis.notifications.sanitizer;
|
||||
|
||||
/**
|
||||
* Sanitization adapter для user-controlled полей перед template render.
|
||||
*
|
||||
* <p><b>Purpose — defence-in-depth:</b> {@code business_key}, {@code maker},
|
||||
* {@code reviewer}, {@code comment} в notification templates приходят
|
||||
* непосредственно от пользователей. Без sanitization получаем CVE-class
|
||||
* injection vulnerabilities per channel:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>Email subject CRLF injection</b> (RFC 5322): {@code business_key
|
||||
* = "Союз\nBcc: attacker@evil.com"} → JavaMail {@code setSubject(String)}
|
||||
* не strip'ает CRLF → headers spoof'ятся → unintended recipients
|
||||
* получают draft content. Это primary CRITICAL gap из eng review v3.</li>
|
||||
* <li><b>Express BotX markdown spoof</b>: business_key с {@code *} или
|
||||
* {@code _} ломает rendering или открывает structure manipulation.</li>
|
||||
* <li><b>Telegram MarkdownV2 spec violations</b>: per Bot API spec
|
||||
* reserved chars должны быть escaped или Telegram rejects message
|
||||
* (или, хуже, рендерит unintended formatting).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Все методы — 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).
|
||||
*
|
||||
* <p>Sanitization вызывается дважды (defence-in-depth):
|
||||
* <ol>
|
||||
* <li>В {@code MessageRenderer} перед rendering template — params уже
|
||||
* safe для конкретного channel kind.</li>
|
||||
* <li>В каждом {@code NotificationChannel.dispatch()} перед фактическим
|
||||
* send — на случай если где-то забыли первый шаг.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Comprehensive test coverage в {@code SanitizerTest} — особенно
|
||||
* {@code emailHeaderInjectionRejected*} cases. Эти тесты — CI-gating; если
|
||||
* сломаются, build падает и CRITICAL gap re-opens.
|
||||
*
|
||||
* @see <a href="https://datatracker.ietf.org/doc/html/rfc5322">RFC 5322 §2.2 — header injection vector</a>
|
||||
* @see <a href="https://core.telegram.org/bots/api#markdownv2-style">Telegram Bot API MarkdownV2 reserved chars</a>
|
||||
*/
|
||||
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 длину.
|
||||
*
|
||||
* <p>Strips:
|
||||
* <ul>
|
||||
* <li>{@code \r} (CR, U+000D)</li>
|
||||
* <li>{@code \n} (LF, U+000A)</li>
|
||||
* <li>{@code U+0085} (NEL — Next Line, Outlook treats как line break)</li>
|
||||
* <li>{@code U+2028} (Line Separator)</li>
|
||||
* <li>{@code U+2029} (Paragraph Separator)</li>
|
||||
* </ul>
|
||||
* Заменяются на single space.
|
||||
*
|
||||
* <p>Используется для:
|
||||
* <ul>
|
||||
* <li>Email Subject (HIGHEST risk — RFC 5322 header injection).</li>
|
||||
* <li>Email From/To/Reply-To display names (вторичный risk).</li>
|
||||
* <li>Body параметров в email plain text (CRLF в body не injection,
|
||||
* но уродует layout).</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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 длину.
|
||||
*
|
||||
* <p>Express BotX поддерживает урезанный Markdown:
|
||||
* <ul>
|
||||
* <li>{@code **bold**}</li>
|
||||
* <li>{@code *italic*}</li>
|
||||
* <li>{@code `code`}</li>
|
||||
* <li>{@code ~~strike~~}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Если business_key содержит {@code *} или {@code _} или {@code `} или
|
||||
* {@code ~} — backslash-escape'аем чтобы:
|
||||
* <ol>
|
||||
* <li>Не сломать rendering (template "*Новый draft*" + business_key
|
||||
* "Союз*М-25" → "*Новый draft*Союз*М-25" → bold-spans поломаются).</li>
|
||||
* <li>Не открыть structure manipulation вектор (хотя BotX rendering
|
||||
* sandbox'нутый, defence-in-depth дёшев).</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>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 длину.
|
||||
*
|
||||
* <p>Per <a href="https://core.telegram.org/bots/api#markdownv2-style">
|
||||
* Telegram Bot API MarkdownV2</a>, reserved chars: <pre>
|
||||
* _ * [ ] ( ) ~ ` > # + - = | { } . !
|
||||
* </pre>
|
||||
* Plus backslash itself ({@code \}) escape character.
|
||||
*
|
||||
* <p>Все эти 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).
|
||||
*
|
||||
* <p>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]", "");
|
||||
}
|
||||
}
|
||||
+107
@@ -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.
|
||||
*
|
||||
* <p>Bundles в classpath {@code messages/notifications_{locale}.properties}.
|
||||
* Keys имеют shape {@code {eventKey}.{channel}.{slot}}, where:
|
||||
* <ul>
|
||||
* <li>{@code eventKey}: 'draft_submitted' | 'draft_decision' | 'draft_withdrawn'</li>
|
||||
* <li>{@code channel}: 'email' | 'express' | 'telegram'</li>
|
||||
* <li>{@code slot}: 'subject' | 'body'</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Example: {@code draft_submitted.email.subject} = {@code "[НСИ] Новый draft на review — {0} / {1}"}
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
cloud.nstart.terravault.ordinis.notifications.config.NotificationsAutoConfiguration
|
||||
@@ -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\\.
|
||||
@@ -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 сам отозвал\\.
|
||||
BIN
Binary file not shown.
+220
@@ -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:
|
||||
* <ul>
|
||||
* <li>Bundles parseable (no MessageFormat syntax errors).</li>
|
||||
* <li>RU + EN templates exist для всех 9 combinations (3 events × 3 channels).</li>
|
||||
* <li>Sanitization happens — malicious business_key не ломает rendering и не
|
||||
* inject'ит в email subject.</li>
|
||||
* </ul>
|
||||
*/
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
<module>ordinis-projection-writer</module>
|
||||
<module>ordinis-cuod-bundle</module>
|
||||
<module>ordinis-migrations</module>
|
||||
<module>ordinis-notifications</module>
|
||||
</modules>
|
||||
|
||||
<!--
|
||||
@@ -113,6 +114,11 @@
|
||||
<artifactId>ordinis-migrations</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cloud.nstart.terravault.ordinis</groupId>
|
||||
<artifactId>ordinis-notifications</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- External -->
|
||||
<dependency>
|
||||
|
||||
Reference in New Issue
Block a user