1use crate::entry::{Builder, Entry};
4use crate::tables::TABLE;
5use rustlavel_core::{Json, Result};
6use rustlavel_db::{Database, Value};
7
8#[derive(Clone)]
12pub struct Trail {
13 db: Database,
14}
15
16#[derive(Debug, Clone, Default)]
19pub struct Filter {
20 pub user_id: Option<i64>,
21 pub event: Option<String>,
22 pub model_type: Option<String>,
23 pub from: Option<String>,
25 pub to: Option<String>,
27 pub ip_address: Option<String>,
28 pub search: Option<String>,
30}
31
32#[derive(Debug, Clone)]
34pub struct Page {
35 pub entries: Vec<Entry>,
36 pub total: i64,
37 pub page: i64,
38 pub per_page: i64,
39}
40
41impl Page {
42 pub fn pages(&self) -> i64 {
43 if self.per_page <= 0 { 1 } else { ((self.total + self.per_page - 1) / self.per_page).max(1) }
44 }
45
46 pub fn first(&self) -> i64 {
48 if self.total == 0 { 0 } else { (self.page - 1) * self.per_page + 1 }
49 }
50
51 pub fn last(&self) -> i64 {
52 ((self.page - 1) * self.per_page + self.entries.len() as i64).max(0)
53 }
54}
55
56impl Trail {
57 pub fn new(db: Database) -> Trail {
58 Trail { db }
59 }
60
61 pub fn database(&self) -> &Database {
62 &self.db
63 }
64
65 pub fn event(&self, event: impl Into<String>) -> Builder {
67 Builder { trail: self.clone(), entry: Entry::new(event) }
68 }
69
70 pub async fn write(&self, entry: Entry) -> Result<i64> {
72 let properties = match &entry.properties {
73 Json::Null => None,
74 value => Some(value.to_string()),
75 };
76 let now = now();
77
78 self.db
79 .table(TABLE)
80 .insert(
81 &self.db,
82 &[
83 ("user_id", entry.user_id.map_or(Value::Null, Value::from)),
84 ("user_name", optional(entry.user_name)),
85 ("event", Value::from(entry.event)),
86 ("model_type", optional(entry.model_type)),
87 ("model_id", optional(entry.model_id)),
88 ("description", optional(entry.description)),
89 ("properties", optional(properties)),
90 ("ip_address", optional(entry.ip_address)),
91 ("user_agent", optional(entry.user_agent.map(|a| a.chars().take(255).collect::<String>()))),
94 ("created_at", Value::from(now.clone())),
95 ("updated_at", Value::from(now)),
96 ],
97 )
98 .await
99 }
100
101 pub async fn page(&self, filter: &Filter, page: i64, per_page: i64) -> Result<Page> {
103 let page = page.max(1);
104 let per_page = per_page.clamp(1, 500);
105
106 let total = self.query(filter).count(&self.db).await?;
107 let rows = self
108 .query(filter)
109 .latest("id")
110 .limit(per_page)
111 .offset((page - 1) * per_page)
112 .get(&self.db)
113 .await?;
114
115 Ok(Page { entries: rows.iter().map(read).collect(), total, page, per_page })
116 }
117
118 pub async fn all(&self, filter: &Filter, limit: i64) -> Result<Vec<Entry>> {
120 let rows =
121 self.query(filter).latest("id").limit(limit.clamp(1, 100_000)).get(&self.db).await?;
122 Ok(rows.iter().map(read).collect())
123 }
124
125 pub async fn find(&self, id: i64) -> Result<Option<Entry>> {
126 let rows = self.db.table(TABLE).filter("id", id).limit(1).get(&self.db).await?;
127 Ok(rows.first().map(read))
128 }
129
130 pub async fn events(&self) -> Result<Vec<String>> {
134 let rows = self.db.table(TABLE).group_by(&["event"]).select(&["event"]).get(&self.db).await?;
135 let mut names: Vec<String> =
136 rows.iter().filter_map(|row| row.get::<String>("event").ok()).collect();
137 names.sort();
138 Ok(names)
139 }
140
141 pub async fn model_types(&self) -> Result<Vec<String>> {
143 let rows = self
144 .db
145 .table(TABLE)
146 .filter_not_null("model_type")
147 .group_by(&["model_type"])
148 .select(&["model_type"])
149 .get(&self.db)
150 .await?;
151 let mut names: Vec<String> =
152 rows.iter().filter_map(|row| row.get::<String>("model_type").ok()).filter(|n| !n.is_empty()).collect();
153 names.sort();
154 Ok(names)
155 }
156
157 pub async fn count(&self, filter: &Filter) -> Result<i64> {
158 self.query(filter).count(&self.db).await
159 }
160
161 pub async fn count_since(&self, since: &str) -> Result<i64> {
163 self.db.table(TABLE).filter_op("created_at", ">=", since).count(&self.db).await
164 }
165
166 pub async fn distinct_users(&self) -> Result<i64> {
168 let rows = self
169 .db
170 .table(TABLE)
171 .filter_not_null("user_id")
172 .group_by(&["user_id"])
173 .select(&["user_id"])
174 .get(&self.db)
175 .await?;
176 Ok(rows.len() as i64)
177 }
178
179 pub async fn latest_at(&self) -> Result<Option<String>> {
181 let rows = self.db.table(TABLE).latest("id").limit(1).get(&self.db).await?;
182 Ok(rows.first().and_then(|row| row.get::<String>("created_at").ok()))
183 }
184
185 pub async fn prune_before(&self, cutoff: &str) -> Result<u64> {
189 self.db.table(TABLE).filter_op("created_at", "<", cutoff).delete(&self.db).await
190 }
191
192 fn query(&self, filter: &Filter) -> rustlavel_db::QueryBuilder {
193 let mut query = self.db.table(TABLE);
194
195 if let Some(user_id) = filter.user_id {
196 query = query.filter("user_id", user_id);
197 }
198 if let Some(event) = filter.event.as_deref().filter(|e| !e.is_empty()) {
199 query = query.filter("event", event);
200 }
201 if let Some(kind) = filter.model_type.as_deref().filter(|m| !m.is_empty()) {
202 query = query.filter("model_type", kind);
203 }
204 if let Some(from) = filter.from.as_deref().filter(|d| !d.is_empty()) {
205 query = query.filter_op("created_at", ">=", format!("{from} 00:00:00"));
206 }
207 if let Some(to) = filter.to.as_deref().filter(|d| !d.is_empty()) {
208 query = query.filter_op("created_at", "<=", format!("{to} 23:59:59"));
212 }
213 if let Some(ip) = filter.ip_address.as_deref().filter(|i| !i.is_empty()) {
214 query = query.filter("ip_address", ip);
215 }
216 if let Some(search) = filter.search.as_deref().filter(|s| !s.is_empty()) {
217 let pattern = format!("%{}%", escape_like(search));
218 query = query.group_filter(|group| {
221 group
222 .filter_like("description", pattern.clone())
223 .or_filter_like("event", pattern.clone())
224 .or_filter_like("user_name", pattern.clone())
225 });
226 }
227
228 query
229 }
230}
231
232fn optional(value: Option<String>) -> Value {
233 value.filter(|v| !v.is_empty()).map_or(Value::Null, Value::from)
234}
235
236fn escape_like(text: &str) -> String {
239 text.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_")
240}
241
242fn read(row: &rustlavel_db::Row) -> Entry {
243 Entry {
244 id: row.get("id").unwrap_or_default(),
245 user_id: row.get::<i64>("user_id").ok(),
246 user_name: row.get::<String>("user_name").ok().filter(|n| !n.is_empty()),
247 event: row.get("event").unwrap_or_default(),
248 model_type: row.get::<String>("model_type").ok().filter(|n| !n.is_empty()),
249 model_id: row.get::<String>("model_id").ok().filter(|n| !n.is_empty()),
250 description: row.get::<String>("description").ok().filter(|n| !n.is_empty()),
251 properties: row
252 .get::<String>("properties")
253 .ok()
254 .filter(|p| !p.is_empty())
255 .and_then(|p| Json::parse(&p).ok())
256 .unwrap_or(Json::Null),
257 ip_address: row.get::<String>("ip_address").ok().filter(|n| !n.is_empty()),
258 user_agent: row.get::<String>("user_agent").ok().filter(|n| !n.is_empty()),
259 created_at: row.get("created_at").unwrap_or_default(),
260 }
261}
262
263pub fn now() -> String {
265 format_utc(
266 std::time::SystemTime::now()
267 .duration_since(std::time::UNIX_EPOCH)
268 .map_or(0, |d| d.as_secs() as i64),
269 )
270}
271
272pub fn format_utc(unix: i64) -> String {
274 let days = unix.div_euclid(86_400);
275 let seconds = unix.rem_euclid(86_400);
276
277 let z = days + 719_468;
278 let era = z.div_euclid(146_097);
279 let doe = z.rem_euclid(146_097);
280 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
281 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
282 let mp = (5 * doy + 2) / 153;
283 let day = doy - (153 * mp + 2) / 5 + 1;
284 let month = if mp < 10 { mp + 3 } else { mp - 9 };
285 let year = yoe + era * 400 + i64::from(month <= 2);
286
287 format!(
288 "{year:04}-{month:02}-{day:02} {:02}:{:02}:{:02}",
289 seconds / 3600,
290 (seconds % 3600) / 60,
291 seconds % 60
292 )
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 #[test]
300 fn a_page_reports_its_own_bounds() {
301 let page = Page { entries: vec![Entry::new("a"); 50], total: 305, page: 1, per_page: 50 };
302
303 assert_eq!(page.pages(), 7);
304 assert_eq!(page.first(), 1);
305 assert_eq!(page.last(), 50);
306
307 let page = Page { entries: vec![Entry::new("a"); 5], total: 305, page: 7, per_page: 50 };
308 assert_eq!(page.first(), 301);
309 assert_eq!(page.last(), 305);
310
311 let page = Page { entries: vec![], total: 0, page: 1, per_page: 50 };
313 assert_eq!(page.first(), 0);
314 assert_eq!(page.last(), 0);
315 assert_eq!(page.pages(), 1);
316 }
317
318 #[test]
319 fn a_search_cannot_smuggle_wildcards_in() {
320 assert_eq!(escape_like("50%"), "50\\%");
321 assert_eq!(escape_like("a_b"), "a\\_b");
322 assert_eq!(escape_like("100\\%"), "100\\\\\\%");
323 }
324
325 #[test]
326 fn an_entry_without_a_description_still_says_something() {
327 let mut entry = Entry::new("users.deleted");
328 assert_eq!(entry.summary(), "Somebody performed users.deleted");
329
330 entry.user_name = Some("Ada Lovelace".into());
331 entry.model_type = Some("User".into());
332 entry.model_id = Some("7".into());
333 assert_eq!(entry.summary(), "Ada Lovelace performed users.deleted on User #7");
334
335 entry.description = Some("Ada Lovelace deleted Alan Turing".into());
336 assert_eq!(entry.summary(), "Ada Lovelace deleted Alan Turing");
337 }
338
339 #[test]
340 fn the_epoch_formats_the_way_the_rest_of_the_framework_stores_a_time() {
341 assert_eq!(format_utc(0), "1970-01-01 00:00:00");
342 assert_eq!(format_utc(1_788_400_000), "2026-09-03 01:46:40");
343 }
344}