1use 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
38fn 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#[derive(Debug, Serialize)]
186pub struct SnapshotView {
187 #[serde(flatten)]
188 snap: PersistedSnapshot,
189 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}