Skip to main content

heldar_kernel/routes/
snapshot_schedules.rs

1//! Snapshot-schedule CRUD + a query over captured snapshots.
2//!
3//! A schedule fires a live-frame capture for its camera every `interval_seconds`; the background
4//! scheduler writes the frame and records a row in `snapshots`. Schedules are managed by manager+;
5//! any authenticated principal can list schedules and captured snapshots.
6
7use axum::extract::{Path, Query, State};
8use axum::http::StatusCode;
9use axum::routing::get;
10use axum::{Json, Router};
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use serde_json::json;
14use uuid::Uuid;
15
16use crate::auth::{self, Principal};
17use crate::error::{AppError, AppResult};
18use crate::models::{
19    PersistedSnapshot, SnapshotSchedule, SnapshotScheduleCreate, SnapshotScheduleUpdate,
20};
21use crate::routes::cameras::load_camera;
22use crate::state::AppState;
23use crate::util;
24
25pub fn router() -> Router<AppState> {
26    Router::new()
27        .route(
28            "/api/v1/cameras/{id}/snapshot-schedules",
29            get(list_schedules).post(create_schedule),
30        )
31        .route(
32            "/api/v1/snapshot-schedules/{schedule_id}",
33            axum::routing::patch(update_schedule).delete(delete_schedule),
34        )
35        .route("/api/v1/cameras/{id}/snapshots", get(list_snapshots))
36}
37
38/// Clamp an interval into a sane range (>= 5s avoids hammering the camera; cap at ~24h).
39fn clamp_interval(seconds: i64) -> i64 {
40    seconds.clamp(5, 86_400)
41}
42
43async fn list_schedules(
44    State(st): State<AppState>,
45    principal: Principal,
46    Path(id): Path<String>,
47) -> AppResult<Json<Vec<SnapshotSchedule>>> {
48    principal.require(principal.can_view(), "list snapshot schedules")?;
49    let _ = load_camera(&st.pool, &id).await?;
50    let rows = sqlx::query_as::<_, SnapshotSchedule>(
51        "SELECT * FROM snapshot_schedules WHERE camera_id = ? ORDER BY created_at ASC",
52    )
53    .bind(&id)
54    .fetch_all(&st.pool)
55    .await?;
56    Ok(Json(rows))
57}
58
59async fn create_schedule(
60    State(st): State<AppState>,
61    Path(id): Path<String>,
62    principal: Principal,
63    Json(body): Json<SnapshotScheduleCreate>,
64) -> AppResult<(StatusCode, Json<SnapshotSchedule>)> {
65    principal.require(principal.can_manage_registry(), "create snapshot schedules")?;
66    let _ = load_camera(&st.pool, &id).await?;
67
68    let interval = clamp_interval(body.interval_seconds.unwrap_or(300));
69    let enabled = body.enabled.unwrap_or(true);
70    let now = Utc::now();
71    let schedule_id = format!("snsch_{}", Uuid::new_v4().simple());
72
73    sqlx::query(
74        "INSERT INTO snapshot_schedules
75           (id, camera_id, interval_seconds, enabled, last_fired_at, created_at, updated_at)
76         VALUES (?, ?, ?, ?, NULL, ?, ?)",
77    )
78    .bind(&schedule_id)
79    .bind(&id)
80    .bind(interval)
81    .bind(enabled)
82    .bind(now)
83    .bind(now)
84    .execute(&st.pool)
85    .await?;
86
87    let schedule =
88        sqlx::query_as::<_, SnapshotSchedule>("SELECT * FROM snapshot_schedules WHERE id = ?")
89            .bind(&schedule_id)
90            .fetch_one(&st.pool)
91            .await?;
92    auth::audit(
93        &st.pool,
94        &principal,
95        "create_snapshot_schedule",
96        "snapshot_schedule",
97        &schedule_id,
98        json!({ "camera_id": &id, "interval_seconds": interval, "enabled": enabled }),
99    )
100    .await;
101    Ok((StatusCode::CREATED, Json(schedule)))
102}
103
104async fn update_schedule(
105    State(st): State<AppState>,
106    Path(schedule_id): Path<String>,
107    principal: Principal,
108    Json(body): Json<SnapshotScheduleUpdate>,
109) -> AppResult<Json<SnapshotSchedule>> {
110    principal.require(principal.can_manage_registry(), "update snapshot schedules")?;
111    let cur =
112        sqlx::query_as::<_, SnapshotSchedule>("SELECT * FROM snapshot_schedules WHERE id = ?")
113            .bind(&schedule_id)
114            .fetch_optional(&st.pool)
115            .await?
116            .ok_or_else(|| {
117                AppError::NotFound(format!("snapshot schedule {schedule_id} not found"))
118            })?;
119
120    let interval = clamp_interval(body.interval_seconds.unwrap_or(cur.interval_seconds));
121    let enabled = body.enabled.unwrap_or(cur.enabled);
122
123    sqlx::query(
124        "UPDATE snapshot_schedules SET interval_seconds = ?, enabled = ?, updated_at = ? WHERE id = ?",
125    )
126    .bind(interval)
127    .bind(enabled)
128    .bind(Utc::now())
129    .bind(&schedule_id)
130    .execute(&st.pool)
131    .await?;
132
133    let schedule =
134        sqlx::query_as::<_, SnapshotSchedule>("SELECT * FROM snapshot_schedules WHERE id = ?")
135            .bind(&schedule_id)
136            .fetch_one(&st.pool)
137            .await?;
138    auth::audit(
139        &st.pool,
140        &principal,
141        "update_snapshot_schedule",
142        "snapshot_schedule",
143        &schedule_id,
144        json!({ "interval_seconds": interval, "enabled": enabled }),
145    )
146    .await;
147    Ok(Json(schedule))
148}
149
150async fn delete_schedule(
151    State(st): State<AppState>,
152    Path(schedule_id): Path<String>,
153    principal: Principal,
154) -> AppResult<StatusCode> {
155    principal.require(principal.can_manage_registry(), "delete snapshot schedules")?;
156    let res = sqlx::query("DELETE FROM snapshot_schedules WHERE id = ?")
157        .bind(&schedule_id)
158        .execute(&st.pool)
159        .await?;
160    if res.rows_affected() == 0 {
161        return Err(AppError::NotFound(format!(
162            "snapshot schedule {schedule_id} not found"
163        )));
164    }
165    auth::audit(
166        &st.pool,
167        &principal,
168        "delete_snapshot_schedule",
169        "snapshot_schedule",
170        &schedule_id,
171        json!({}),
172    )
173    .await;
174    Ok(StatusCode::NO_CONTENT)
175}
176
177#[derive(Debug, Deserialize)]
178struct SnapshotRangeQuery {
179    from: Option<String>,
180    to: Option<String>,
181    limit: Option<i64>,
182}
183
184/// A captured snapshot row plus its browser-fetchable media URL. Flattens [`PersistedSnapshot`]
185/// (new model fields flow through), mirroring how [`crate::routes::recordings::SegmentView`] wraps a
186/// segment with its served URL.
187#[derive(Debug, Serialize)]
188pub struct SnapshotView {
189    #[serde(flatten)]
190    snap: PersistedSnapshot,
191    /// Browser-fetchable URL for the snapshot file (under /media/snapshots/...).
192    url: String,
193}
194
195impl SnapshotView {
196    fn new(snap: PersistedSnapshot) -> Self {
197        let file = std::path::Path::new(&snap.path)
198            .file_name()
199            .and_then(|s| s.to_str())
200            .unwrap_or("");
201        let url = format!("/media/snapshots/{}/{}", snap.camera_id, file);
202        SnapshotView { snap, url }
203    }
204}
205
206async fn list_snapshots(
207    State(st): State<AppState>,
208    principal: Principal,
209    Path(id): Path<String>,
210    Query(q): Query<SnapshotRangeQuery>,
211) -> AppResult<Json<Vec<SnapshotView>>> {
212    principal.require(principal.can_view(), "list snapshots")?;
213    let _ = load_camera(&st.pool, &id).await?;
214    let limit = q.limit.unwrap_or(500).clamp(1, 5000);
215    let parse = |s: &Option<String>, field: &str| -> AppResult<Option<DateTime<Utc>>> {
216        match s {
217            Some(v) => util::parse_rfc3339(v)
218                .map(Some)
219                .ok_or_else(|| AppError::BadRequest(format!("invalid `{field}` timestamp"))),
220            None => Ok(None),
221        }
222    };
223    let from = parse(&q.from, "from")?;
224    let to = parse(&q.to, "to")?;
225    if let (Some(f), Some(t)) = (from, to) {
226        if f > t {
227            return Err(AppError::BadRequest("`from` must be <= `to`".into()));
228        }
229    }
230
231    let rows = sqlx::query_as::<_, PersistedSnapshot>(
232        "SELECT * FROM snapshots
233         WHERE camera_id = ?
234           AND (? IS NULL OR taken_at >= ?)
235           AND (? IS NULL OR taken_at <= ?)
236         ORDER BY taken_at DESC LIMIT ?",
237    )
238    .bind(&id)
239    .bind(from)
240    .bind(from)
241    .bind(to)
242    .bind(to)
243    .bind(limit)
244    .fetch_all(&st.pool)
245    .await?;
246
247    let views = rows.into_iter().map(SnapshotView::new).collect();
248    Ok(Json(views))
249}