Skip to main content

pcs_external/
types.rs

1//! Domain types for the PCS External API client hot path.
2//!
3//! No proto types appear here — [`crate::PcsExternalClient`] translates at
4//! the wire boundary. These newtypes encode invariants the proto schema cannot
5//! (ppnum format, recipient list size + dedup) so violations are caught at
6//! construction, not at the wire.
7
8use std::collections::{BTreeMap, HashSet};
9
10/// Identifier for a previously-issued send request, returned from
11/// [`crate::PcsExternalClient::send_alert`] and consumed by
12/// [`crate::PcsExternalClient::get_send_status`].
13///
14/// Opaque from the SDK's perspective — PCS owns the format. Currently
15/// a ULID, but the port does not require that shape.
16#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub struct SendRequestId(pub String);
18
19impl SendRequestId {
20    #[must_use]
21    pub fn new(id: impl Into<String>) -> Self {
22        Self(id.into())
23    }
24}
25
26/// Identifier for a registered template on the PCS External platform.
27///
28/// PCS External API is template-driven: every `send_alert` call cites
29/// a template that the calling app pre-registered through
30/// `ExternalTemplateService::CreateTemplate` (today on the escape-hatch
31/// path). Per-recipient `vars` substitute placeholders in the template.
32#[derive(Debug, Clone, PartialEq, Eq, Hash)]
33pub struct TemplateId(pub String);
34
35impl TemplateId {
36    #[must_use]
37    pub fn new(id: impl Into<String>) -> Self {
38        Self(id.into())
39    }
40}
41
42/// A validated Ppoppo number — the canonical recipient identifier.
43///
44/// Per workspace contract (`CLAUDE.md` Architecture Terms): ≥11 digits,
45/// `^[0-9]{11,}$`. Validated once at construction so adapters never
46/// re-validate and the wire never sees a malformed value.
47#[derive(Debug, Clone, PartialEq, Eq, Hash)]
48pub struct Ppnum(String);
49
50/// Construction-time error for a [`Ppnum`].
51#[derive(Debug, Clone, thiserror::Error)]
52pub enum PpnumError {
53    /// Empty string.
54    #[error("ppnum is empty")]
55    Empty,
56    /// Length below the 11-digit minimum.
57    #[error("ppnum too short ({len} digits, minimum 11)")]
58    TooShort { len: usize },
59    /// Contains a non-digit character at the given byte index.
60    #[error("ppnum contains non-digit character at position {pos}")]
61    NonDigit { pos: usize },
62}
63
64impl Ppnum {
65    /// Validate and wrap a candidate ppnum string.
66    ///
67    /// # Errors
68    ///
69    /// Returns [`PpnumError`] if the string is empty, shorter than 11
70    /// characters, or contains non-digit bytes.
71    pub fn try_new(s: impl Into<String>) -> Result<Self, PpnumError> {
72        let s = s.into();
73        if s.is_empty() {
74            return Err(PpnumError::Empty);
75        }
76        for (i, b) in s.bytes().enumerate() {
77            if !b.is_ascii_digit() {
78                return Err(PpnumError::NonDigit { pos: i });
79            }
80        }
81        if s.len() < 11 {
82            return Err(PpnumError::TooShort { len: s.len() });
83        }
84        Ok(Self(s))
85    }
86
87    /// Borrow the underlying digit string (no formatting hyphens).
88    #[must_use]
89    pub fn as_str(&self) -> &str {
90        &self.0
91    }
92
93    /// Consume into the owned `String`.
94    #[must_use]
95    pub fn into_inner(self) -> String {
96        self.0
97    }
98}
99
100impl AsRef<str> for Ppnum {
101    fn as_ref(&self) -> &str {
102        &self.0
103    }
104}
105
106/// A single recipient entry: ppnum plus per-recipient template variables.
107///
108/// `vars` are substituted into the template referenced by the surrounding
109/// [`crate::PcsExternalClient::send_alert`] call. Hot-path sticks to
110/// `BTreeMap<String, String>` (95% of templating uses string substitution
111/// like `{{name}} → "John"`); richer payloads are out of scope and
112/// reachable only via the the raw channel escape hatch.
113#[derive(Debug, Clone)]
114pub struct Recipient {
115    pub ppnum: Ppnum,
116    pub vars: BTreeMap<String, String>,
117}
118
119impl Recipient {
120    /// Recipient with no template variables.
121    #[must_use]
122    pub fn bare(ppnum: Ppnum) -> Self {
123        Self { ppnum, vars: BTreeMap::new() }
124    }
125}
126
127/// A validated batch of recipients enforcing both PCS quotas and SDK
128/// invariants:
129///
130/// - **Non-empty**: at least one recipient (PCS rejects empty batches).
131/// - **At most 1,000**: PCS External API limit per send request.
132/// - **Deduplicated by ppnum**: avoids paying for two deliveries to the
133///   same recipient if the caller's caller passed dupes.
134///
135/// Construct via [`Self::try_new`]; the only unchecked path is
136/// `From<Vec<Recipient>>` which is intentionally not provided.
137#[derive(Debug, Clone)]
138pub struct RecipientList(Vec<Recipient>);
139
140/// Construction-time error for a [`RecipientList`].
141#[derive(Debug, Clone, thiserror::Error)]
142pub enum RecipientListError {
143    /// Zero recipients passed in.
144    #[error("recipient list is empty")]
145    Empty,
146    /// More than 1,000 recipients (PCS hard limit).
147    #[error("recipient list exceeds 1000 (got {count})")]
148    TooLarge { count: usize },
149}
150
151const RECIPIENT_LIST_MAX: usize = 1000;
152
153impl RecipientList {
154    /// Validate and dedupe a recipient batch.
155    ///
156    /// Dedup is by [`Ppnum`] equality, preserving first-occurrence order.
157    /// The deduped count must be in `1..=1000`.
158    ///
159    /// # Errors
160    ///
161    /// Returns [`RecipientListError::Empty`] when the input is empty,
162    /// [`RecipientListError::TooLarge`] when the *deduped* result still
163    /// exceeds 1,000 entries.
164    pub fn try_new(recipients: Vec<Recipient>) -> Result<Self, RecipientListError> {
165        if recipients.is_empty() {
166            return Err(RecipientListError::Empty);
167        }
168        let mut seen: HashSet<String> = HashSet::with_capacity(recipients.len());
169        let mut deduped = Vec::with_capacity(recipients.len());
170        for r in recipients {
171            if seen.insert(r.ppnum.as_str().to_string()) {
172                deduped.push(r);
173            }
174        }
175        if deduped.len() > RECIPIENT_LIST_MAX {
176            return Err(RecipientListError::TooLarge { count: deduped.len() });
177        }
178        Ok(Self(deduped))
179    }
180
181    /// Convenience constructor for the common case where only ppnums
182    /// are needed and templates require no per-recipient variables.
183    ///
184    /// # Errors
185    ///
186    /// Same as [`Self::try_new`].
187    pub fn from_ppnums(ppnums: Vec<Ppnum>) -> Result<Self, RecipientListError> {
188        Self::try_new(ppnums.into_iter().map(Recipient::bare).collect())
189    }
190
191    #[must_use]
192    pub fn len(&self) -> usize {
193        self.0.len()
194    }
195
196    #[must_use]
197    pub fn is_empty(&self) -> bool {
198        self.0.is_empty()
199    }
200
201    /// Iterate over the underlying recipients (read-only — invariants
202    /// stay locked).
203    pub fn iter(&self) -> impl Iterator<Item = &Recipient> {
204        self.0.iter()
205    }
206}
207
208impl<'a> IntoIterator for &'a RecipientList {
209    type Item = &'a Recipient;
210    type IntoIter = std::slice::Iter<'a, Recipient>;
211    fn into_iter(self) -> Self::IntoIter {
212        self.0.iter()
213    }
214}
215
216/// Optional poll configuration when the cited template includes a poll.
217///
218/// Maps to proto `PollConfig`. Default is "no poll attached".
219#[derive(Debug, Clone, Default)]
220pub struct PollConfig {
221    /// Hours until the poll closes. `None` = template default.
222    pub expires_in_hours: Option<i32>,
223    /// Whether a recipient may submit multiple responses. Default `false`.
224    pub allow_multiple: bool,
225}
226
227/// Outcome of a successful [`crate::PcsExternalClient::send_alert`] call.
228#[derive(Debug, Clone)]
229pub struct SendOutcome {
230    pub id: SendRequestId,
231    pub state: SendRequestState,
232    pub total_recipients: u32,
233}
234
235/// Domain mirror of proto `SendRequestStatus`. Catch-all `Unknown` for
236/// forward compatibility — proto enum addition won't break consumers.
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238pub enum SendRequestState {
239    Queued,
240    Processing,
241    Completed,
242    Failed,
243    /// Reserved value not understood by this SDK release.
244    Unknown,
245}
246
247/// Aggregate status returned by
248/// [`crate::PcsExternalClient::get_send_status`]. Per-recipient detail is
249/// out of scope for the hot path and reachable via the escape hatch
250/// when needed.
251#[derive(Debug, Clone)]
252pub struct SendStatus {
253    pub id: SendRequestId,
254    pub state: SendRequestState,
255    pub totals: SendStatusTotals,
256}
257
258/// Delivery counters within a [`SendStatus`].
259#[derive(Debug, Clone, Copy, Default)]
260pub struct SendStatusTotals {
261    pub total: u32,
262    pub delivered: u32,
263    pub pending_consent: u32,
264    pub failed: u32,
265}
266
267// =============================================================================
268// Streaming event types
269// =============================================================================
270
271/// Wrapper stream returned by
272/// [`crate::PcsExternalClient::stream_send_request_events`].
273///
274/// Call `.message().await` in a loop to receive events. Returns `Ok(None)`
275/// when the server closes the stream (normal end — reconnect to resume).
276pub struct DeliveryStream(
277    Box<dyn futures_core::Stream<Item = Result<DeliveryEvent, crate::Error>> + Send + Unpin>,
278);
279
280impl DeliveryStream {
281    pub(crate) fn new(
282        inner: impl futures_core::Stream<Item = Result<DeliveryEvent, crate::Error>>
283            + Send
284            + Unpin
285            + 'static,
286    ) -> Self {
287        Self(Box::new(inner))
288    }
289
290    /// Receive the next delivery event.
291    ///
292    /// Returns `Ok(None)` when the stream ends normally. Returns `Err(…)` on
293    /// transport, token, or proto mapping failure.
294    pub async fn message(&mut self) -> Result<Option<DeliveryEvent>, crate::Error> {
295        use futures_util::StreamExt as _;
296        match self.0.next().await {
297            None => Ok(None),
298            Some(Ok(evt)) => Ok(Some(evt)),
299            Some(Err(e)) => Err(e),
300        }
301    }
302}
303
304impl std::fmt::Debug for DeliveryStream {
305    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306        f.debug_struct("DeliveryStream").finish_non_exhaustive()
307    }
308}
309
310/// A single lifecycle event from
311/// [`crate::PcsExternalClient::stream_send_request_events`].
312#[derive(Debug, Clone)]
313pub struct DeliveryEvent {
314    /// ULID cursor — pass as `after_event_id` on reconnect to resume without
315    /// replay.
316    pub event_id: String,
317    pub send_request_id: SendRequestId,
318    pub kind: DeliveryEventKind,
319    /// Server-reported event time. `None` if the server omitted the field.
320    pub occurred_at: Option<prost_types::Timestamp>,
321}
322
323/// Discriminated payload of a [`DeliveryEvent`].
324///
325/// `ppnum` fields carry the raw digit string as received from PCS — validated
326/// at send time, trusted on the receive path.
327#[derive(Debug, Clone)]
328pub enum DeliveryEventKind {
329    RecipientDelivered { ppnum: String, message_id: Option<String> },
330    RecipientFailed    { ppnum: String, error_code: Option<String> },
331    RecipientPendingConsent { ppnum: String },
332    ConsentGranted,
333    ConsentDenied,
334    RequestCompleted,
335    PollResponseReceived,
336    /// Unknown event type — forward-compat catch-all for future proto variants.
337    Unknown,
338}
339
340#[cfg(test)]
341#[allow(clippy::unwrap_used, clippy::expect_used)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn ppnum_accepts_11_digits() {
347        let p = Ppnum::try_new("12345678901").unwrap();
348        assert_eq!(p.as_str(), "12345678901");
349    }
350
351    #[test]
352    fn ppnum_accepts_longer_than_11() {
353        let p = Ppnum::try_new("123456789012345").unwrap();
354        assert_eq!(p.as_str().len(), 15);
355    }
356
357    #[test]
358    fn ppnum_rejects_short() {
359        let err = Ppnum::try_new("1234567890").unwrap_err();
360        assert!(matches!(err, PpnumError::TooShort { len: 10 }));
361    }
362
363    #[test]
364    fn ppnum_rejects_empty() {
365        let err = Ppnum::try_new("").unwrap_err();
366        assert!(matches!(err, PpnumError::Empty));
367    }
368
369    #[test]
370    fn ppnum_rejects_non_digit() {
371        let err = Ppnum::try_new("1234567890a").unwrap_err();
372        assert!(matches!(err, PpnumError::NonDigit { pos: 10 }));
373    }
374
375    #[test]
376    fn ppnum_rejects_hyphens() {
377        // Display format is `123-1234-5678` but storage is digits-only.
378        let err = Ppnum::try_new("123-1234-5678").unwrap_err();
379        assert!(matches!(err, PpnumError::NonDigit { .. }));
380    }
381
382    fn p(s: &str) -> Ppnum {
383        Ppnum::try_new(s).unwrap()
384    }
385
386    #[test]
387    fn recipient_list_rejects_empty() {
388        let err = RecipientList::try_new(vec![]).unwrap_err();
389        assert!(matches!(err, RecipientListError::Empty));
390    }
391
392    #[test]
393    fn recipient_list_dedupes_by_ppnum() {
394        let list = RecipientList::from_ppnums(vec![
395            p("12345678901"),
396            p("12345678901"),
397            p("12345678902"),
398        ])
399        .unwrap();
400        assert_eq!(list.len(), 2);
401    }
402
403    #[test]
404    fn recipient_list_dedup_keeps_first_occurrence() {
405        let mut a = Recipient::bare(p("12345678901"));
406        a.vars.insert("name".into(), "first".into());
407        let mut b = Recipient::bare(p("12345678901"));
408        b.vars.insert("name".into(), "second".into());
409        let c = Recipient::bare(p("12345678902"));
410        let list = RecipientList::try_new(vec![a, b, c]).unwrap();
411        assert_eq!(list.len(), 2);
412        let first = list.iter().next().unwrap();
413        assert_eq!(first.vars.get("name").map(String::as_str), Some("first"));
414    }
415
416    #[test]
417    fn recipient_list_accepts_max_1000() {
418        let ppnums: Vec<Ppnum> =
419            (0..1000).map(|i| p(&format!("100000{:05}", i))).collect();
420        let list = RecipientList::from_ppnums(ppnums).unwrap();
421        assert_eq!(list.len(), 1000);
422    }
423
424    #[test]
425    fn recipient_list_rejects_over_1000_after_dedup() {
426        let ppnums: Vec<Ppnum> =
427            (0..1001).map(|i| p(&format!("100000{:05}", i))).collect();
428        let err = RecipientList::from_ppnums(ppnums).unwrap_err();
429        assert!(matches!(err, RecipientListError::TooLarge { count: 1001 }));
430    }
431
432    #[test]
433    fn recipient_list_dedup_can_rescue_oversize_input() {
434        // 1500 entries collapse to 750 unique → valid.
435        let mut ppnums: Vec<Ppnum> =
436            (0..750).map(|i| p(&format!("100000{:05}", i))).collect();
437        ppnums.extend(ppnums.clone());
438        let list = RecipientList::from_ppnums(ppnums).unwrap();
439        assert_eq!(list.len(), 750);
440    }
441}