1use std::marker::PhantomData;
4use std::sync::Arc;
5
6use prost_types::value::Kind as PbValueKind;
7use prost_types::{Struct as PbStruct, Value as PbValue};
8use ppoppo_sdk_core::grpc::{Error, PathPrefixChannel, classify_status};
9use ppoppo_sdk_core::interceptor::{AuthInterceptor, BearerCredential};
10use ppoppo_sdk_core::token_cache::TokenCache;
11
12use crate::proto::{
13 CreateSendRequestReq, GetSendRequestStatusReq, PollConfig as PbPollConfig, RecipientInput,
14 SendRequestEvent as PbSendRequestEvent, SendRequestEventType as PbEventType,
15 SendRequestStatus as PbSendStatus, StreamSendRequestEventsReq,
16 external_message_service_client::ExternalMessageServiceClient,
17};
18use crate::scopes::{GetSendStatusCapable, PcsExternalScopeSet, SendAlertCapable};
19use crate::types::{
20 DeliveryEvent, DeliveryEventKind, DeliveryStream, PollConfig, RecipientList, SendOutcome,
21 SendRequestId, SendRequestState, SendStatus, SendStatusTotals, TemplateId,
22};
23
24pub struct PcsExternalClient<S: PcsExternalScopeSet> {
39 pub(crate) channel: PathPrefixChannel,
40 pub(crate) cache: Arc<TokenCache>,
41 pub(crate) _scope: PhantomData<S>,
42}
43
44impl<S: PcsExternalScopeSet> Clone for PcsExternalClient<S> {
46 fn clone(&self) -> Self {
47 Self {
48 channel: self.channel.clone(),
49 cache: Arc::clone(&self.cache),
50 _scope: PhantomData,
51 }
52 }
53}
54
55impl<S: SendAlertCapable> PcsExternalClient<S> {
56 pub async fn send_alert(
69 &self,
70 template: &TemplateId,
71 recipients: &RecipientList,
72 poll: Option<&PollConfig>,
73 ) -> Result<SendOutcome, Error> {
74 let token = self.cache.get().await?;
75 let interceptor = AuthInterceptor::new(BearerCredential::new(token));
76 let mut client =
77 ExternalMessageServiceClient::with_interceptor(self.channel.clone(), interceptor);
78
79 let req = CreateSendRequestReq {
80 template_id: template.0.clone(),
81 recipients: recipients
82 .iter()
83 .map(|r| RecipientInput {
84 ppnum: r.ppnum.as_str().to_string(),
85 vars: Some(vars_to_pb_struct(&r.vars)),
86 })
87 .collect(),
88 poll_config: poll.map(|p| PbPollConfig {
89 expires_in_hours: p.expires_in_hours,
90 allow_multiple: p.allow_multiple,
91 }),
92 };
93
94 let resp = client
95 .create_send_request(tonic::Request::new(req))
96 .await
97 .map_err(|s| classify_status(&s))?
98 .into_inner();
99
100 Ok(SendOutcome {
101 id: SendRequestId(resp.id),
102 state: pb_status_to_state(resp.status),
103 total_recipients: i32_to_u32(resp.total_recipients),
104 })
105 }
106}
107
108impl<S: GetSendStatusCapable> PcsExternalClient<S> {
109 pub async fn get_send_status(&self, id: &SendRequestId) -> Result<SendStatus, Error> {
117 let token = self.cache.get().await?;
118 let interceptor = AuthInterceptor::new(BearerCredential::new(token));
119 let mut client =
120 ExternalMessageServiceClient::with_interceptor(self.channel.clone(), interceptor);
121
122 let req = GetSendRequestStatusReq { id: id.0.clone() };
123 let resp = client
124 .get_send_request_status(tonic::Request::new(req))
125 .await
126 .map_err(|s| classify_status(&s))?
127 .into_inner();
128
129 let summary = resp
130 .request
131 .ok_or_else(|| Error::ProtoMismatch("GetSendRequestStatusResp.request was None".into()))?;
132
133 Ok(SendStatus {
134 id: SendRequestId(summary.id),
135 state: pb_status_to_state(summary.status),
136 totals: SendStatusTotals {
137 total: i32_to_u32(summary.total_recipients),
138 delivered: i32_to_u32(summary.delivered),
139 pending_consent: i32_to_u32(summary.pending_consent),
140 failed: i32_to_u32(summary.failed),
141 },
142 })
143 }
144
145 pub async fn stream_send_request_events(
158 &self,
159 send_request_id: Option<&SendRequestId>,
160 after_event_id: Option<&str>,
161 ) -> Result<DeliveryStream, crate::Error> {
162 let token = self.cache.get().await?;
163 let interceptor = AuthInterceptor::new(BearerCredential::new(token));
164 let mut client =
165 ExternalMessageServiceClient::with_interceptor(self.channel.clone(), interceptor);
166
167 let req = StreamSendRequestEventsReq {
168 send_request_id: send_request_id.map(|id| id.0.clone()),
169 after_event_id: after_event_id.map(String::from),
170 };
171
172 let stream = client
173 .stream_send_request_events(tonic::Request::new(req))
174 .await
175 .map_err(|s| classify_status(&s))?
176 .into_inner();
177
178 use futures_util::StreamExt as _;
179 let mapped = stream
180 .map(|item| item.map(proto_event_to_delivery).map_err(|s| classify_status(&s)));
181
182 Ok(DeliveryStream::new(mapped))
183 }
184}
185
186fn proto_event_to_delivery(evt: PbSendRequestEvent) -> DeliveryEvent {
187 let kind = match PbEventType::try_from(evt.event_type).unwrap_or(PbEventType::Unspecified) {
189 PbEventType::RecipientDelivered => DeliveryEventKind::RecipientDelivered {
190 ppnum: evt.recipient.as_ref().map(|r| r.ppnum.clone()).unwrap_or_default(),
191 message_id: evt.recipient.as_ref().and_then(|r| r.message_id.clone()),
192 },
193 PbEventType::RecipientFailed => DeliveryEventKind::RecipientFailed {
194 ppnum: evt.recipient.as_ref().map(|r| r.ppnum.clone()).unwrap_or_default(),
195 error_code: evt.recipient.as_ref().and_then(|r| r.error_code.clone()),
196 },
197 PbEventType::RecipientPendingConsent => DeliveryEventKind::RecipientPendingConsent {
198 ppnum: evt.recipient.map(|r| r.ppnum).unwrap_or_default(),
199 },
200 PbEventType::ConsentGranted => DeliveryEventKind::ConsentGranted,
201 PbEventType::ConsentDenied => DeliveryEventKind::ConsentDenied,
202 PbEventType::RequestCompleted => DeliveryEventKind::RequestCompleted,
203 PbEventType::PollResponseReceived => DeliveryEventKind::PollResponseReceived,
204 PbEventType::Unspecified => DeliveryEventKind::Unknown,
205 };
206 DeliveryEvent {
207 event_id: evt.event_id,
208 send_request_id: SendRequestId::new(evt.send_request_id),
209 kind,
210 occurred_at: evt.occurred_at,
211 }
212}
213
214fn vars_to_pb_struct(vars: &std::collections::BTreeMap<String, String>) -> PbStruct {
215 let fields = vars
216 .iter()
217 .map(|(k, v)| {
218 (
219 k.clone(),
220 PbValue { kind: Some(PbValueKind::StringValue(v.clone())) },
221 )
222 })
223 .collect();
224 PbStruct { fields }
225}
226
227fn pb_status_to_state(s: i32) -> SendRequestState {
228 match PbSendStatus::try_from(s).unwrap_or(PbSendStatus::Unspecified) {
229 PbSendStatus::Queued => SendRequestState::Queued,
230 PbSendStatus::Processing => SendRequestState::Processing,
231 PbSendStatus::Completed => SendRequestState::Completed,
232 PbSendStatus::Failed => SendRequestState::Failed,
233 PbSendStatus::Unspecified => SendRequestState::Unknown,
234 }
235}
236
237fn i32_to_u32(n: i32) -> u32 {
238 n.max(0) as u32
241}
242
243#[cfg(test)]
244#[allow(clippy::unwrap_used, clippy::expect_used)]
245mod tests {
246 use super::*;
247 use std::assert_matches;
248
249 #[test]
250 fn pb_status_mapping_covers_all_variants() {
251 assert_eq!(pb_status_to_state(0), SendRequestState::Unknown);
252 assert_eq!(pb_status_to_state(1), SendRequestState::Queued);
253 assert_eq!(pb_status_to_state(2), SendRequestState::Processing);
254 assert_eq!(pb_status_to_state(3), SendRequestState::Completed);
255 assert_eq!(pb_status_to_state(4), SendRequestState::Failed);
256 assert_eq!(pb_status_to_state(99), SendRequestState::Unknown);
257 }
258
259 #[test]
260 fn i32_to_u32_saturates_negatives() {
261 assert_eq!(i32_to_u32(-5), 0);
262 assert_eq!(i32_to_u32(0), 0);
263 assert_eq!(i32_to_u32(42), 42);
264 }
265
266 #[test]
267 fn vars_translation_emits_string_values() {
268 let mut vars = std::collections::BTreeMap::new();
269 vars.insert("name".into(), "Daisy".into());
270 let s = vars_to_pb_struct(&vars);
271 assert_eq!(s.fields.len(), 1);
272 let name_v = s.fields.get("name").unwrap();
273 assert_matches!(&name_v.kind, Some(PbValueKind::StringValue(v)) if v == "Daisy");
274 }
275}