Skip to main content

cedros_data/store/
metered_reads.rs

1use std::time::{Duration, Instant};
2
3use chrono::Utc;
4use serde_json::json;
5use sqlx::{PgPool, Row};
6
7use crate::error::{CedrosDataError, Result};
8use crate::models::{EntryRecord, QueryEntriesRequest};
9
10use super::CedrosData;
11
12const FREE_READS_CACHE_TTL: Duration = Duration::from_secs(300);
13
14impl CedrosData {
15    /// Returns cached `freeReadsPerMonth` (5-min TTL).
16    ///
17    /// Returns 0 when the entry or field is missing (metering disabled).
18    pub(crate) async fn load_free_reads_per_month(&self) -> Result<i64> {
19        if let Some((ts, value)) = *self.free_reads_cache.read().await {
20            if ts.elapsed() < FREE_READS_CACHE_TTL {
21                return Ok(value);
22            }
23        }
24        let value = self.fetch_free_reads_per_month().await?;
25        *self.free_reads_cache.write().await = Some((Instant::now(), value));
26        Ok(value)
27    }
28
29    /// Fetches `freeReadsPerMonth` from the DB (uncached).
30    async fn fetch_free_reads_per_month(&self) -> Result<i64> {
31        let entries = self
32            .query_entries(QueryEntriesRequest {
33                collection_name: "site_settings".to_string(),
34                entry_keys: vec!["monetization".to_string()],
35                contains: None,
36                limit: 1,
37                offset: 0,
38                visitor_id: None,
39            })
40            .await?;
41
42        let value = entries
43            .into_iter()
44            .next()
45            .and_then(|e| e.payload.get("freeReadsPerMonth").cloned())
46            .and_then(|v| v.as_i64())
47            .unwrap_or(0);
48
49        Ok(value)
50    }
51
52    /// Applies transparent content gating to entries for a visitor.
53    ///
54    /// - If metering is disabled (freeReadsPerMonth == 0), no-op.
55    /// - If `record_reads` is true, records a read for each entry (deduplicated).
56    /// - Strips body fields and injects `_metering` status when reads exhausted.
57    pub async fn apply_content_gating(
58        &self,
59        entries: &mut [EntryRecord],
60        visitor_id: &str,
61        record_reads: bool,
62    ) -> Result<()> {
63        let free_reads_per_month = self.load_free_reads_per_month().await?;
64        if free_reads_per_month == 0 {
65            return Ok(());
66        }
67
68        let month = current_month_string();
69
70        if record_reads {
71            for entry in entries.iter() {
72                sqlx::query(
73                    "INSERT INTO metered_reads (visitor_id, post_slug, month)
74                     VALUES ($1, $2, $3)
75                     ON CONFLICT DO NOTHING",
76                )
77                .bind(visitor_id)
78                .bind(&entry.entry_key)
79                .bind(&month)
80                .execute(self.pool())
81                .await?;
82            }
83        }
84
85        let reads_used = count_reads_in_month(self.pool(), visitor_id, &month).await?;
86        let remaining = compute_remaining(free_reads_per_month, reads_used);
87        let exhausted = remaining == 0;
88
89        let metering = json!({
90            "remainingFreeReads": remaining,
91            "freeReadsPerMonth": free_reads_per_month,
92            "readsUsed": reads_used,
93        });
94
95        for entry in entries.iter_mut() {
96            if let Some(obj) = entry.payload.as_object_mut() {
97                if exhausted {
98                    obj.remove("body");
99                    obj.remove("bodyMarkdown");
100                    obj.remove("bodyHtml");
101                }
102                obj.insert("_metering".to_string(), metering.clone());
103            }
104        }
105
106        Ok(())
107    }
108}
109
110async fn count_reads_in_month(pool: &PgPool, visitor_id: &str, month: &str) -> Result<i64> {
111    let row = sqlx::query(
112        "SELECT COUNT(*) AS cnt
113         FROM metered_reads
114         WHERE visitor_id = $1 AND month = $2",
115    )
116    .bind(visitor_id)
117    .bind(month)
118    .fetch_one(pool)
119    .await?;
120
121    Ok(row.get::<i64, _>("cnt"))
122}
123
124fn compute_remaining(free_reads_per_month: i64, reads_used: i64) -> i64 {
125    (free_reads_per_month - reads_used).max(0)
126}
127
128fn current_month_string() -> String {
129    Utc::now().format("%Y-%m").to_string()
130}
131
132pub(crate) fn validate_visitor_id(visitor_id: &str) -> Result<()> {
133    if visitor_id.is_empty() || visitor_id.len() > 256 {
134        return Err(CedrosDataError::InvalidRequest(
135            "visitor_id must be 1-256 characters".to_string(),
136        ));
137    }
138    Ok(())
139}
140
141#[cfg(test)]
142mod tests {
143    use super::compute_remaining;
144
145    #[test]
146    fn remaining_decrements_with_reads() {
147        assert_eq!(compute_remaining(5, 0), 5);
148        assert_eq!(compute_remaining(5, 3), 2);
149        assert_eq!(compute_remaining(5, 5), 0);
150    }
151
152    #[test]
153    fn remaining_floors_at_zero() {
154        assert_eq!(compute_remaining(5, 10), 0);
155        assert_eq!(compute_remaining(0, 3), 0);
156    }
157}