Skip to main content

baukit_push/
delivery_claim.rs

1//! At-most-once daily delivery claims for scheduled pushes.
2
3use chrono::{DateTime, NaiveDate, Utc};
4use uuid::Uuid;
5
6use crate::{PushStoreError, PushStoreFuture, PushValidationError};
7
8/// Longest delivery kind accepted, in bytes.
9pub const MAX_DELIVERY_KIND_LENGTH: usize = 64;
10
11/// A product-defined name for one kind of scheduled notification.
12///
13/// 1 to [`MAX_DELIVERY_KIND_LENGTH`] bytes of `[a-z0-9_.-]`, starting with a
14/// letter or digit, for example `training_reminder`.
15#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
16pub struct DeliveryKind(String);
17
18impl DeliveryKind {
19    /// Validates a kind name.
20    ///
21    /// # Errors
22    ///
23    /// Returns [`PushValidationError::DeliveryKind`] for any name outside the rule.
24    pub fn new(kind: impl Into<String>) -> Result<Self, PushValidationError> {
25        let kind = kind.into();
26        let starts_well = kind
27            .bytes()
28            .next()
29            .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit());
30        let valid = starts_well
31            && kind.len() <= MAX_DELIVERY_KIND_LENGTH
32            && kind.bytes().all(|byte| {
33                byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"_.-".contains(&byte)
34            });
35        if valid {
36            Ok(Self(kind))
37        } else {
38            Err(PushValidationError::DeliveryKind)
39        }
40    }
41
42    /// Returns the kind name.
43    #[must_use]
44    pub fn as_str(&self) -> &str {
45        &self.0
46    }
47}
48
49/// The right to send one kind of notification to one owner on one local date.
50#[derive(Clone, Debug, Eq, Hash, PartialEq)]
51pub struct DeliveryClaim {
52    /// The account the notification is for.
53    pub owner_id: Uuid,
54    /// The date in the owner's time zone the notification belongs to.
55    pub local_date: NaiveDate,
56    /// The kind of notification.
57    pub kind: DeliveryKind,
58}
59
60impl DeliveryClaim {
61    /// Creates a claim key.
62    #[must_use]
63    pub const fn new(owner_id: Uuid, local_date: NaiveDate, kind: DeliveryKind) -> Self {
64        Self {
65            owner_id,
66            local_date,
67            kind,
68        }
69    }
70}
71
72/// Storage port for daily delivery claims.
73///
74/// A scheduled sender claims before it sends, so two workers evaluating the
75/// same owner never both deliver. If the whole send fails, it releases the
76/// claim and a later run may try again. Event-driven senders do not need this
77/// port.
78pub trait DeliveryClaimStore: Send + Sync {
79    /// Records the claim; returns `false` when someone already holds it.
80    fn claim(
81        &self,
82        claim: DeliveryClaim,
83        claimed_at: DateTime<Utc>,
84    ) -> PushStoreFuture<'_, Result<bool, PushStoreError>>;
85
86    /// Drops a claim after a failed send; returns whether it existed.
87    fn release(&self, claim: DeliveryClaim) -> PushStoreFuture<'_, Result<bool, PushStoreError>>;
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn a_kind_is_a_short_lowercase_identifier() {
96        for valid in ["training_reminder", "weekly-summary", "daily.v2", "7day"] {
97            assert!(DeliveryKind::new(valid).is_ok(), "{valid}");
98        }
99        assert!(DeliveryKind::new("a".repeat(MAX_DELIVERY_KIND_LENGTH)).is_ok());
100        for invalid in [
101            String::new(),
102            "_leading".to_owned(),
103            "Upper".to_owned(),
104            "with space".to_owned(),
105            "a".repeat(MAX_DELIVERY_KIND_LENGTH + 1),
106        ] {
107            assert_eq!(
108                DeliveryKind::new(invalid),
109                Err(PushValidationError::DeliveryKind)
110            );
111        }
112    }
113}