Skip to content

Commit 107f398

Browse files
committed
refactor(events): centralize lifecycle-event payload on the domain entity
Add email_id()/sender_name()/error_class()/payload() accessors to LifecycleEvent (mirroring event_type()) and make all four event publishers match-free, so the payload JSON has one source of truth instead of being hand-duplicated across webhook/NATS/sqlite/postgres (where it had already drifted — sender_name was missing from the SQL payloads). Full-body contract tests lock the webhook and NATS shapes and prove them identical. The SQL publishers now always store the payload as an object, so a queued event without a correlation id stores {"correlation_id": null} instead of NULL, matching the pushed payload. The read path stays nullable and old rows keep their NULL payload (no data migration).
1 parent 7e95b82 commit 107f398

5 files changed

Lines changed: 526 additions & 232 deletions

File tree

Lines changed: 71 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
use anyhow::Context;
22
use catapulte_domain::entity::lifecycle_event::LifecycleEvent;
3-
use catapulte_domain::entity::sender::SenderName;
43
use catapulte_domain::port::event_publisher::{EventPublisher, EventPublisherError};
54

65
#[derive(Clone)]
@@ -17,59 +16,10 @@ impl NatsEventPublisher {
1716
}
1817

1918
fn event_to_json(event: &LifecycleEvent) -> serde_json::Value {
20-
let (email_id, extra) = match event {
21-
LifecycleEvent::Queued { id, correlation_id } => {
22-
(id, serde_json::json!({ "correlation_id": correlation_id }))
23-
}
24-
LifecycleEvent::Sending {
25-
id,
26-
attempt,
27-
correlation_id,
28-
} => (
29-
id,
30-
serde_json::json!({ "attempt": attempt, "correlation_id": correlation_id }),
31-
),
32-
LifecycleEvent::Sent {
33-
id,
34-
sender_name,
35-
correlation_id,
36-
} => (
37-
id,
38-
serde_json::json!({
39-
"sender_name": sender_name.as_str(),
40-
"correlation_id": correlation_id,
41-
}),
42-
),
43-
LifecycleEvent::Retrying {
44-
id,
45-
attempt,
46-
reason,
47-
error_class,
48-
sender_name,
49-
correlation_id,
50-
}
51-
| LifecycleEvent::Failed {
52-
id,
53-
attempt,
54-
reason,
55-
error_class,
56-
sender_name,
57-
correlation_id,
58-
} => (
59-
id,
60-
serde_json::json!({
61-
"attempt": attempt,
62-
"reason": reason,
63-
"error_class": error_class.as_str(),
64-
"sender_name": sender_name.as_ref().map(SenderName::as_str),
65-
"correlation_id": correlation_id,
66-
}),
67-
),
68-
};
6919
serde_json::json!({
7020
"event_type": event.event_type(),
71-
"email_id": email_id.as_uuid().to_string(),
72-
"payload": extra,
21+
"email_id": event.email_id().as_uuid().to_string(),
22+
"payload": event.payload(),
7323
})
7424
}
7525

@@ -116,3 +66,72 @@ impl NatsEventConfig {
11666
Ok(Some(NatsEventPublisher::new(client, self.subject)))
11767
}
11868
}
69+
70+
#[cfg(test)]
71+
mod tests {
72+
use catapulte_domain::entity::email::EmailId;
73+
use catapulte_domain::entity::error_class::ErrorClass;
74+
use catapulte_domain::entity::lifecycle_event::LifecycleEvent;
75+
use catapulte_domain::entity::sender::SenderName;
76+
77+
use super::event_to_json;
78+
79+
/// Build the canonical expected body for a given event — shared between the
80+
/// webhook and NATS contract-lock tests so they are provably identical.
81+
fn expected_body(event: &LifecycleEvent) -> serde_json::Value {
82+
serde_json::json!({
83+
"event_type": event.event_type(),
84+
"email_id": event.email_id().as_uuid().to_string(),
85+
"payload": event.payload(),
86+
})
87+
}
88+
89+
/// Contract-lock: Sent event — full JSON body equals canonical shape.
90+
#[test]
91+
fn contract_sent_full_body() {
92+
let id = EmailId::default();
93+
let event = LifecycleEvent::Sent {
94+
id,
95+
sender_name: SenderName::new("primary"),
96+
correlation_id: Some("corr-sent".to_owned()),
97+
};
98+
let expected = serde_json::json!({
99+
"event_type": "delivery.succeeded",
100+
"email_id": id.as_uuid().to_string(),
101+
"payload": {
102+
"sender_name": "primary",
103+
"correlation_id": "corr-sent",
104+
},
105+
});
106+
assert_eq!(event_to_json(&event), expected);
107+
// Confirm parity with the helper used by the webhook contract-lock test.
108+
assert_eq!(event_to_json(&event), expected_body(&event));
109+
}
110+
111+
/// Contract-lock: Failed event — full JSON body equals canonical shape.
112+
#[test]
113+
fn contract_failed_full_body() {
114+
let id = EmailId::default();
115+
let event = LifecycleEvent::Failed {
116+
id,
117+
attempt: 3,
118+
reason: "smtp error".to_owned(),
119+
error_class: ErrorClass::Delivery,
120+
sender_name: Some(SenderName::new("primary")),
121+
correlation_id: Some("corr-fail".to_owned()),
122+
};
123+
let expected = serde_json::json!({
124+
"event_type": "delivery.failed",
125+
"email_id": id.as_uuid().to_string(),
126+
"payload": {
127+
"attempt": 3,
128+
"reason": "smtp error",
129+
"error_class": "delivery",
130+
"sender_name": "primary",
131+
"correlation_id": "corr-fail",
132+
},
133+
});
134+
assert_eq!(event_to_json(&event), expected);
135+
assert_eq!(event_to_json(&event), expected_body(&event));
136+
}
137+
}

adapter/outbound-postgres/src/event_publisher.rs

Lines changed: 17 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use anyhow::Context;
22
use catapulte_domain::entity::lifecycle_event::LifecycleEvent;
3+
use catapulte_domain::entity::sender::SenderName;
34
use catapulte_domain::port::event_publisher::{EventPublisher, EventPublisherError};
45

56
use crate::PostgresAdapter;
@@ -9,76 +10,25 @@ impl EventPublisher for PostgresAdapter {
910
///
1011
/// Returns `EventPublisherError::Publish` when the database insert fails.
1112
async fn publish(&self, event: &LifecycleEvent) -> Result<(), EventPublisherError> {
12-
let (email_id_uuid, payload, sender_name, error_class) = match event {
13-
LifecycleEvent::Queued { id, correlation_id } => (
14-
id.as_uuid(),
15-
correlation_id
16-
.as_ref()
17-
.map(|cid| serde_json::json!({ "correlation_id": cid })),
18-
None,
19-
None,
20-
),
21-
LifecycleEvent::Sending {
22-
id,
23-
attempt,
24-
correlation_id,
25-
} => (
26-
id.as_uuid(),
27-
Some(serde_json::json!({ "attempt": attempt, "correlation_id": correlation_id })),
28-
None,
29-
None,
30-
),
31-
LifecycleEvent::Sent {
32-
id,
33-
sender_name,
34-
correlation_id,
35-
} => (
36-
id.as_uuid(),
37-
Some(serde_json::json!({
38-
"sender_name": sender_name.as_str(),
39-
"correlation_id": correlation_id,
40-
})),
41-
Some(sender_name.as_str().to_owned()),
42-
None,
43-
),
44-
LifecycleEvent::Retrying {
45-
id,
46-
attempt,
47-
reason,
48-
error_class,
49-
sender_name,
50-
correlation_id,
51-
}
52-
| LifecycleEvent::Failed {
53-
id,
54-
attempt,
55-
reason,
56-
error_class,
57-
sender_name,
58-
correlation_id,
59-
} => (
60-
id.as_uuid(),
61-
Some(serde_json::json!({
62-
"attempt": attempt,
63-
"reason": reason,
64-
"error_class": error_class.as_str(),
65-
"sender_name": sender_name.as_ref().map(catapulte_domain::entity::sender::SenderName::as_str),
66-
"correlation_id": correlation_id,
67-
})),
68-
sender_name.as_ref().map(|s| s.as_str().to_owned()),
69-
Some(error_class.as_str().to_owned()),
70-
),
71-
};
72-
7313
let event_id = uuid::Uuid::now_v7();
14+
let email_id_uuid = event.email_id().as_uuid();
15+
// payload is always written as a JSON object so new rows are consistent
16+
// with the pushed (webhook/NATS) payload. The column remains nullable so
17+
// rows written before this change keep their NULL value; do not add NOT
18+
// NULL to the column without a data migration.
19+
let payload = event.payload();
20+
let sender_name = event.sender_name().map(SenderName::as_str);
21+
let error_class = event
22+
.error_class()
23+
.map(catapulte_domain::entity::error_class::ErrorClass::as_str);
7424

7525
sqlx::query(
7626
"INSERT INTO lifecycle_events (id, email_id, event_type, payload, sender_name, error_class) VALUES ($1, $2, $3, $4, $5, $6)",
7727
)
7828
.bind(event_id)
7929
.bind(email_id_uuid)
8030
.bind(event.event_type())
81-
.bind(payload)
31+
.bind(Some(payload))
8232
.bind(sender_name)
8333
.bind(error_class)
8434
.execute(self.pool())
@@ -224,8 +174,10 @@ mod tests {
224174
assert_eq!(p["error_class"].as_str(), Some("delivery"));
225175
}
226176

177+
/// Queued without `correlation_id` now stores `{"correlation_id":null}` — an
178+
/// object — matching the webhook/NATS wire shape (parity fix).
227179
#[tokio::test]
228-
async fn publish_queued_inserts_row_with_event_type_queued_and_null_payload() {
180+
async fn publish_queued_inserts_row_with_correlation_id_payload() {
229181
let id = EmailId::default();
230182
let adapter = adapter_with_email(id).await;
231183
adapter
@@ -247,7 +199,8 @@ mod tests {
247199
.fetch_one(adapter.pool())
248200
.await
249201
.unwrap();
250-
assert!(payload.is_none());
202+
let p = payload.unwrap();
203+
assert_eq!(p, serde_json::json!({ "correlation_id": null }));
251204
}
252205

253206
#[tokio::test]

adapter/outbound-sqlite/src/event_publisher.rs

Lines changed: 17 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use anyhow::Context;
22
use catapulte_domain::entity::lifecycle_event::LifecycleEvent;
3+
use catapulte_domain::entity::sender::SenderName;
34
use catapulte_domain::port::event_publisher::{EventPublisher, EventPublisherError};
45

56
use crate::SqliteAdapter;
@@ -9,76 +10,25 @@ impl EventPublisher for SqliteAdapter {
910
///
1011
/// Returns `EventPublisherError::Publish` when the database insert fails.
1112
async fn publish(&self, event: &LifecycleEvent) -> Result<(), EventPublisherError> {
12-
let (email_id_uuid, payload, sender_name, error_class) = match event {
13-
LifecycleEvent::Queued { id, correlation_id } => (
14-
id.as_uuid(),
15-
correlation_id
16-
.as_ref()
17-
.map(|cid| serde_json::json!({ "correlation_id": cid })),
18-
None,
19-
None,
20-
),
21-
LifecycleEvent::Sending {
22-
id,
23-
attempt,
24-
correlation_id,
25-
} => (
26-
id.as_uuid(),
27-
Some(serde_json::json!({ "attempt": attempt, "correlation_id": correlation_id })),
28-
None,
29-
None,
30-
),
31-
LifecycleEvent::Sent {
32-
id,
33-
sender_name,
34-
correlation_id,
35-
} => (
36-
id.as_uuid(),
37-
Some(serde_json::json!({
38-
"sender_name": sender_name.as_str(),
39-
"correlation_id": correlation_id,
40-
})),
41-
Some(sender_name.as_str().to_owned()),
42-
None,
43-
),
44-
LifecycleEvent::Retrying {
45-
id,
46-
attempt,
47-
reason,
48-
error_class,
49-
sender_name,
50-
correlation_id,
51-
}
52-
| LifecycleEvent::Failed {
53-
id,
54-
attempt,
55-
reason,
56-
error_class,
57-
sender_name,
58-
correlation_id,
59-
} => (
60-
id.as_uuid(),
61-
Some(serde_json::json!({
62-
"attempt": attempt,
63-
"reason": reason,
64-
"error_class": error_class.as_str(),
65-
"sender_name": sender_name.as_ref().map(catapulte_domain::entity::sender::SenderName::as_str),
66-
"correlation_id": correlation_id,
67-
})),
68-
sender_name.as_ref().map(|s| s.as_str().to_owned()),
69-
Some(error_class.as_str().to_owned()),
70-
),
71-
};
72-
let email_id_bytes = email_id_uuid.as_bytes().to_vec();
13+
let email_id_bytes = event.email_id().as_uuid().as_bytes().to_vec();
7314
let event_id_bytes = uuid::Uuid::now_v7().as_bytes().to_vec();
15+
// payload is always written as a JSON object so new rows are consistent
16+
// with the pushed (webhook/NATS) payload. The column remains nullable so
17+
// rows written before this change keep their NULL value; do not add NOT
18+
// NULL to the column without a data migration.
19+
let payload = sqlx::types::Json(event.payload());
20+
let sender_name = event.sender_name().map(SenderName::as_str);
21+
let error_class = event
22+
.error_class()
23+
.map(catapulte_domain::entity::error_class::ErrorClass::as_str);
7424

7525
sqlx::query(
7626
"INSERT INTO lifecycle_events (id, email_id, event_type, payload, sender_name, error_class) VALUES (?, ?, ?, ?, ?, ?)",
7727
)
7828
.bind(event_id_bytes)
7929
.bind(email_id_bytes)
8030
.bind(event.event_type())
81-
.bind(payload.as_ref().map(sqlx::types::Json))
31+
.bind(Some(payload))
8232
.bind(sender_name)
8333
.bind(error_class)
8434
.execute(self.pool())
@@ -165,8 +115,10 @@ mod tests {
165115
assert_eq!(p["error_class"], "delivery");
166116
}
167117

118+
/// Queued without `correlation_id` now stores `{"correlation_id":null}` — an
119+
/// object — matching the webhook/NATS wire shape (parity fix).
168120
#[tokio::test]
169-
async fn publish_queued_inserts_row_with_event_type_queued_and_null_payload() {
121+
async fn publish_queued_inserts_row_with_correlation_id_payload() {
170122
let id = EmailId::default();
171123
let adapter = adapter_with_email(id).await;
172124
adapter
@@ -188,7 +140,8 @@ mod tests {
188140
.fetch_one(adapter.pool())
189141
.await
190142
.unwrap();
191-
assert!(payload.is_none());
143+
let p = payload.unwrap().0;
144+
assert_eq!(p, serde_json::json!({ "correlation_id": null }));
192145
}
193146

194147
#[tokio::test]

0 commit comments

Comments
 (0)