1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
//! Repository for the message_post pipeline (hand-written; user-owned).
//!
//! Holds the SQL the [`crate::application::service::MessageWriteService`] orchestrates:
//! minting `mail_messages`, subtype resolution, the per-channel notification/mail/sms
//! fan-out inserts, and the monotonic notification-status advance (the tracker pump's
//! sink, SM-M1). Runtime queries (no compile-time macros) — hence no `.sqlx` cache.
//!
//! NOTE: this file deliberately does NOT import the generated per-entity structs —
//! at authoring time the generated `domain/entity/mod.rs` is still the skeleton and
//! does not re-export the 21 messaging entities. The row structs below mirror the
//! committed migration shapes directly, which also keeps this file immune to
//! entity-regen churn.
use sqlx::{PgConnection, Row};
use uuid::Uuid;
use crate::application::service::message_write_service::NotificationChannel;
/// One recipient's notification as minted by the notify pump.
#[derive(Debug, Clone)]
pub struct MintedNotification {
pub id: Uuid,
pub res_partner_id: Option<Uuid>,
pub notification_type: NotificationChannel,
pub notification_status: String,
}
/// The exact `mail_messages` row a post writes.
pub struct NewMailMessageRow<'a> {
pub id: Uuid,
pub subject: Option<&'a str>,
pub body: &'a str,
pub message_type: &'a str,
pub subtype_id: Option<Uuid>,
pub is_internal: bool,
pub author_id: Option<Uuid>,
pub author_guest_id: Option<Uuid>,
pub email_from: Option<&'a str>,
pub message_id: Option<&'a str>,
pub reply_to: Option<&'a str>,
pub model: Option<&'a str>,
pub res_id: Option<Uuid>,
pub record_name: Option<&'a str>,
}
/// The exact `mail_notifications` row the pump writes per recipient/channel.
pub struct NewMailNotificationRow<'a> {
pub id: Uuid,
pub mail_message_id: Uuid,
pub res_partner_id: Option<Uuid>,
pub notification_type: &'a str,
pub notification_status: &'a str,
pub mail_mail_id_int: Option<Uuid>,
}
/// The exact `mails` row the email channel enqueues (MAIL-M2).
pub struct NewMailQueueRow<'a> {
pub id: Uuid,
pub mail_message_id: Uuid,
pub email_to: &'a str,
pub email_cc: Option<&'a str>,
pub reply_to: Option<&'a str>,
pub scheduled_date: Option<chrono::DateTime<chrono::Utc>>,
}
/// The `sms` + `sms_tracker` pair the sms channel mints (SM-M1/SM-M21: correlated
/// by uuid, deliberately NO FK — the tracker must outlive the sms row's GC).
pub struct NewSmsRow<'a> {
pub id: Uuid,
pub uuid: String,
pub number: &'a str,
pub body: &'a str,
pub mail_message_id: Option<Uuid>,
/// The per-recipient notification this sms will drive (sms channel, 1:many by uuid).
pub notification_id: Option<Uuid>,
}
/// Hand-written messaging SQL. Services orchestrate the unit of work; this holds SQL.
pub struct MessagePipelineRepository;
impl MessagePipelineRepository {
pub fn new() -> Self {
Self
}
}
impl Default for MessagePipelineRepository {
fn default() -> Self {
Self::new()
}
}
impl MessagePipelineRepository {
/// Resolve the subtype for a post: explicit id wins, else by name, else the
/// module default (`default = true`). `Ok(None)` = no subtype (a bare note).
pub async fn resolve_subtype(
conn: &mut PgConnection,
subtype_id: Option<Uuid>,
subtype_name: Option<&str>,
) -> Result<Option<Uuid>, sqlx::Error> {
if let Some(id) = subtype_id {
return Ok(sqlx::query_scalar::<_, Uuid>(
r#"SELECT id FROM messaging.mail_message_subtypes WHERE id = $1"#,
)
.bind(id)
.fetch_optional(&mut *conn)
.await?);
}
if let Some(name) = subtype_name {
return Ok(sqlx::query_scalar::<_, Uuid>(
r#"SELECT id FROM messaging.mail_message_subtypes
WHERE name = $1 AND (metadata->>'deleted_at') IS NULL
ORDER BY id LIMIT 1"#,
)
.bind(name)
.fetch_optional(&mut *conn)
.await?);
}
Ok(sqlx::query_scalar::<_, Uuid>(
r#"SELECT id FROM messaging.mail_message_subtypes
WHERE "default" = TRUE AND (metadata->>'deleted_at') IS NULL
ORDER BY id LIMIT 1"#,
)
.fetch_optional(&mut *conn)
.await?)
}
/// Mint the `mail_messages` row (TR-MAIL-1 `_message_create`).
pub async fn insert_mail_message(
conn: &mut PgConnection,
m: &NewMailMessageRow<'_>,
) -> Result<(), sqlx::Error> {
sqlx::query(
r#"INSERT INTO messaging.mail_messages
(id, subject, body, message_type, subtype_id, is_internal, author_id,
author_guest_id, email_from, message_id, reply_to, model, res_id, record_name)
VALUES ($1,$2,$3,$4::mail_message_type,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)"#,
)
.bind(m.id)
.bind(m.subject)
.bind(m.body)
.bind(m.message_type)
.bind(m.subtype_id)
.bind(m.is_internal)
.bind(m.author_id)
.bind(m.author_guest_id)
.bind(m.email_from)
.bind(m.message_id)
.bind(m.reply_to)
.bind(m.model)
.bind(m.res_id)
.bind(m.record_name)
.execute(&mut *conn)
.await?;
Ok(())
}
/// Mint one per-recipient `mail_notifications` row (TR-MAIL-2). The status is
/// channel-decided by the caller: inbox mints `'sent'` instantly (instant
/// delivered — no MTA hop), email mints `'ready'`, sms mints `'ready'` (the
/// mirror of the sms row's `'outgoing'`, per SMS_STATE_TO_NOTIFICATION_STATUS).
pub async fn insert_mail_notification(
conn: &mut PgConnection,
n: &NewMailNotificationRow<'_>,
) -> Result<Uuid, sqlx::Error> {
sqlx::query_scalar::<_, Uuid>(
r#"INSERT INTO messaging.mail_notifications
(id, mail_message_id, res_partner_id, notification_type, notification_status,
mail_mail_id_int)
VALUES ($1,$2,$3,$4::mail_notification_type,$5::mail_notification_status,$6)
RETURNING id"#,
)
.bind(n.id)
.bind(n.mail_message_id)
.bind(n.res_partner_id)
.bind(n.notification_type)
.bind(n.notification_status)
.bind(n.mail_mail_id_int)
.fetch_one(&mut *conn)
.await
}
/// Enqueue the `mails` send-queue row for the email channel (MAIL-M2). ONE row
/// carries the whole recipient list (comma-joined) — the per-recipient status
/// lives on the notification rows, not duplicated on the mail.
pub async fn insert_mail(
conn: &mut PgConnection,
m: &NewMailQueueRow<'_>,
) -> Result<Uuid, sqlx::Error> {
sqlx::query_scalar::<_, Uuid>(
r#"INSERT INTO messaging.mails
(id, mail_message_id, state, email_to, email_cc, reply_to, scheduled_date)
VALUES ($1,$2,'outgoing'::mail_state,$3,$4,$5,$6)
RETURNING id"#,
)
.bind(m.id)
.bind(m.mail_message_id)
.bind(m.email_to)
.bind(m.email_cc)
.bind(m.reply_to)
.bind(m.scheduled_date)
.fetch_one(&mut *conn)
.await
}
/// Mint the `sms` row + its uuid-correlated `sms_tracker` in one statement pair
/// (SM-M1/SM-M21; TR-SM-11's inline tracker). The tracker starts `'process'`
/// (its schema default) — the pump's ignore-table makes that a no-op until a
/// real state lands on it.
pub async fn insert_sms_with_tracker(
conn: &mut PgConnection,
s: &NewSmsRow<'_>,
) -> Result<(), sqlx::Error> {
sqlx::query(
r#"INSERT INTO messaging.sms (id, uuid, number, body, state, mail_message_id)
VALUES ($1,$2,$3,$4,'outgoing'::sms_state,$5)"#,
)
.bind(s.id)
.bind(&s.uuid)
.bind(s.number)
.bind(s.body)
.bind(s.mail_message_id)
.execute(&mut *conn)
.await?;
sqlx::query(
r#"INSERT INTO messaging.sms_trackers (sms_uuid, message_id, notification_id, state)
VALUES ($1,$2,$3,'process'::mail_notification_status)
ON CONFLICT (sms_uuid) DO NOTHING"#,
)
.bind(&s.uuid)
.bind(s.mail_message_id)
.bind(s.notification_id)
.execute(&mut *conn)
.await?;
Ok(())
}
/// Advance a notification's status through the monotonic lattice — the tracker
/// pump's write (TR-SM-15), and the port of `notifications_statuses_to_ignore`
/// as a service-level rank guard that MIRRORS the SM-B6 DB trigger. `Ok(None)` =
/// the advance was a regression/equal no-op (a replayed webhook, an
/// out-of-order receipt), exactly as Odoo's filter table intended.
///
/// Ranks: ready=0 < process=1 < pending=2 < sent=3; the terminal statuses
/// (bounce/exception/canceled) are 100 — once terminal, only equal-rank writes
/// pass. Both sides rank the same through `messaging_status_rank` semantics.
#[allow(clippy::too_many_arguments)]
pub async fn advance_notification_status(
conn: &mut PgConnection,
notification_id: Uuid,
target: &str,
failure_type: Option<&str>,
failure_reason: Option<&str>,
) -> Result<Option<String>, sqlx::Error> {
sqlx::query_scalar::<_, String>(
r#"UPDATE messaging.mail_notifications AS n
SET notification_status = $2::mail_notification_status,
failure_type = COALESCE($3::notification_failure_type, n.failure_type),
failure_reason = COALESCE($4, n.failure_reason)
WHERE n.id = $1
AND messaging.messaging_status_rank($2)
> messaging.messaging_status_rank(n.notification_status::text)
RETURNING n.notification_status::text"#,
)
.bind(notification_id)
.bind(target)
.bind(failure_type)
.bind(failure_reason)
.fetch_optional(&mut *conn)
.await
}
/// Find the notification a tracker drives, by the sms uuid correlation key.
pub async fn find_notification_id_by_sms_uuid(
conn: &mut PgConnection,
sms_uuid: &str,
) -> Result<Option<Uuid>, sqlx::Error> {
// Option<Option<Uuid>>: outer = no tracker row, inner = tracker row whose
// notification link is NULL (a composer-sent sms with no message — the
// common bulk case). A bare Uuid scalar would CRASH the drainer on that
// row (UnexpectedNullError), so flatten here.
Ok(sqlx::query_scalar::<_, Option<Uuid>>(
r#"SELECT notification_id FROM messaging.sms_trackers WHERE sms_uuid = $1"#,
)
.bind(sms_uuid)
.fetch_optional(&mut *conn)
.await?
.flatten())
}
/// Read a notification's current status (for staging NotificationStatusChanged
/// with the right before/after shape).
pub async fn notification_status(
conn: &mut PgConnection,
notification_id: Uuid,
) -> Result<Option<(Option<Uuid>, String)>, sqlx::Error> {
let row = sqlx::query(
r#"SELECT res_partner_id, notification_status::text AS status
FROM messaging.mail_notifications WHERE id = $1"#,
)
.bind(notification_id)
.fetch_optional(&mut *conn)
.await?;
Ok(row.map(|r| (r.get::<Option<Uuid>, _>("res_partner_id"), r.get::<String, _>("status"))))
}
}