cedros_data/store/
metered_reads.rs1use 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 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 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 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}