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    Path(id): Path<String>,
46) -> AppResult<Json<Vec<SnapshotSchedule>>> {
47    let _ = load_camera(&st.pool, &id).await?;
48    let rows = sqlx::query_as::<_, SnapshotSchedule>(
49        "SELECT * FROM snapshot_schedules WHERE camera_id = ? ORDER BY created_at ASC",
50    )
51    .bind(&id)
52    .fetch_all(&st.pool)
53    .await?;
54    Ok(Json(rows))
55}
56
57async fn create_schedule(
58    State(st): State<AppState>,
59    Path(id): Path<String>,
60    principal: Principal,
61    Json(body): Json<SnapshotScheduleCreate>,
62) -> AppResult<(StatusCode, Json<SnapshotSchedule>)> {
63    principal.require(principal.can_manage_registry(), "create snapshot schedules")?;
64    let _ = load_camera(&st.pool, &id).await?;
65
66    let interval = clamp_interval(body.interval_seconds.unwrap_or(300));
67    let enabled = body.enabled.unwrap_or(true);
68    let now = Utc::now();
69    let schedule_id = format!("snsch_{}", Uuid::new_v4().simple());
70
71    sqlx::query(
72        "INSERT INTO snapshot_schedules
73           (id, camera_id, interval_seconds, enabled, last_fired_at, created_at, updated_at)
74         VALUES (?, ?, ?, ?, NULL, ?, ?)",
75    )
76    .bind(&schedule_id)
77    .bind(&id)
78    .bind(interval)
79    .bind(enabled)
80    .bind(now)
81    .bind(now)
82    .execute(&st.pool)
83    .await?;
84
85    let schedule =
86        sqlx::query_as::<_, SnapshotSchedule>("SELECT * FROM snapshot_schedules WHERE id = ?")
87            .bind(&schedule_id)
88            .fetch_one(&st.pool)
89            .await?;
90    auth::audit(
91        &st.pool,
92        &principal,
93        "create_snapshot_schedule",
94        "snapshot_schedule",
95        &schedule_id,
96        json!({ "camera_id": &id, "interval_seconds": interval, "enabled": enabled }),
97    )
98    .await;
99    Ok((StatusCode::CREATED, Json(schedule)))
100}
101
102async fn update_schedule(
103    State(st): State<AppState>,
104    Path(schedule_id): Path<String>,
105    principal: Principal,
106    Json(body): Json<SnapshotScheduleUpdate>,
107) -> AppResult<Json<SnapshotSchedule>> {
108    principal.require(principal.can_manage_registry(), "update snapshot schedules")?;
109    let cur =
110        sqlx::query_as::<_, SnapshotSchedule>("SELECT * FROM snapshot_schedules WHERE id = ?")
111            .bind(&schedule_id)
112            .fetch_optional(&st.pool)
113            .await?
114            .ok_or_else(|| {
115                AppError::NotFound(format!("snapshot schedule {schedule_id} not found"))
116            })?;
117
118    let interval = clamp_interval(body.interval_seconds.unwrap_or(cur.interval_seconds));
119    let enabled = body.enabled.unwrap_or(cur.enabled);
120
121    sqlx::query(
122        "UPDATE snapshot_schedules SET interval_seconds = ?, enabled = ?, updated_at = ? WHERE id = ?",
123    )
124    .bind(interval)
125    .bind(enabled)
126    .bind(Utc::now())
127    .bind(&schedule_id)
128    .execute(&st.pool)
129    .await?;
130
131    let schedule =
132        sqlx::query_as::<_, SnapshotSchedule>("SELECT * FROM snapshot_schedules WHERE id = ?")
133            .bind(&schedule_id)
134            .fetch_one(&st.pool)
135            .await?;
136    auth::audit(
137        &st.pool,
138        &principal,
139        "update_snapshot_schedule",
140        "snapshot_schedule",
141        &schedule_id,
142        json!({ "interval_seconds": interval, "enabled": enabled }),
143    )
144    .await;
145    Ok(Json(schedule))
146}
147
148async fn delete_schedule(
149    State(st): State<AppState>,
150    Path(schedule_id): Path<String>,
151    principal: Principal,
152) -> AppResult<StatusCode> {
153    principal.require(principal.can_manage_registry(), "delete snapshot schedules")?;
154    let res = sqlx::query("DELETE FROM snapshot_schedules WHERE id = ?")
155        .bind(&schedule_id)
156        .execute(&st.pool)
157        .await?;
158    if res.rows_affected() == 0 {
159        return Err(AppError::NotFound(format!(
160            "snapshot schedule {schedule_id} not found"
161        )));
162    }
163    auth::audit(
164        &st.pool,
165        &principal,
166        "delete_snapshot_schedule",
167        "snapshot_schedule",
168        &schedule_id,
169        json!({}),
170    )
171    .await;
172    Ok(StatusCode::NO_CONTENT)
173}
174
175#[derive(Debug, Deserialize)]
176struct SnapshotRangeQuery {
177    from: Option<String>,
178    to: Option<String>,
179    limit: Option<i64>,
180}
181
182/// A captured snapshot row plus its browser-fetchable media URL. Flattens [`PersistedSnapshot`]
183/// (new model fields flow through), mirroring how [`crate::routes::recordings::SegmentView`] wraps a
184/// segment with its served URL.
185#[derive(Debug, Serialize)]
186pub struct SnapshotView {
187    #[serde(flatten)]
188    snap: PersistedSnapshot,
189    /// Browser-fetchable URL for the snapshot file (under /media/snapshots/...).
190    url: String,
191}
192
193impl SnapshotView {
194    fn new(snap: PersistedSnapshot) -> Self {
195        let file = std::path::Path::new(&snap.path)
196            .file_name()
197            .and_then(|s| s.to_str())
198            .unwrap_or("");
199        let url = format!("/media/snapshots/{}/{}", snap.camera_id, file);
200        SnapshotView { snap, url }
201    }
202}
203
204async fn list_snapshots(
205    State(st): State<AppState>,
206    Path(id): Path<String>,
207    Query(q): Query<SnapshotRangeQuery>,
208) -> AppResult<Json<Vec<SnapshotView>>> {
209    let _ = load_camera(&st.pool, &id).await?;
210    let limit = q.limit.unwrap_or(500).clamp(1, 5000);
211    let parse = |s: &Option<String>, field: &str| -> AppResult<Option<DateTime<Utc>>> {
212        match s {
213            Some(v) => util::parse_rfc3339(v)
214                .map(Some)
215                .ok_or_else(|| AppError::BadRequest(format!("invalid `{field}` timestamp"))),
216            None => Ok(None),
217        }
218    };
219    let from = parse(&q.from, "from")?;
220    let to = parse(&q.to, "to")?;
221    if let (Some(f), Some(t)) = (from, to) {
222        if f > t {
223            return Err(AppError::BadRequest("`from` must be <= `to`".into()));
224        }
225    }
226
227    let rows = sqlx::query_as::<_, PersistedSnapshot>(
228        "SELECT * FROM snapshots
229         WHERE camera_id = ?
230           AND (? IS NULL OR taken_at >= ?)
231           AND (? IS NULL OR taken_at <= ?)
232         ORDER BY taken_at DESC LIMIT ?",
233    )
234    .bind(&id)
235    .bind(from)
236    .bind(from)
237    .bind(to)
238    .bind(to)
239    .bind(limit)
240    .fetch_all(&st.pool)
241    .await?;
242
243    let views = rows.into_iter().map(SnapshotView::new).collect();
244    Ok(Json(views))
245}