Skip to main content

heldar_kernel/routes/
schedules.rs

1//! Per-camera recording-schedule CRUD (time-of-day windows).
2//!
3//! A `camera_schedules` row defines a recurring daily recording window; it takes effect only when
4//! the camera's `record_mode` is `scheduled` or `scheduled_event`. `days` is a JSON array of weekday
5//! ints (0=Mon..6=Sun); `time_start`/`time_end` are "HH:MM" 24h in the SERVER's LOCAL timezone
6//! (start > end means an overnight window). Schedules are managed by manager+; any authenticated
7//! principal can list them. The schedule watcher opens/closes windows in the background.
8
9use axum::extract::{Path, State};
10use axum::http::StatusCode;
11use axum::routing::get;
12use axum::{Json, Router};
13use chrono::Utc;
14use serde_json::{json, Value};
15use sqlx::types::Json as SqlxJson;
16use uuid::Uuid;
17
18use crate::auth::{self, Principal};
19use crate::error::{AppError, AppResult};
20use crate::models::{RecordSchedule, RecordScheduleCreate, RecordScheduleUpdate};
21use crate::routes::cameras::load_camera;
22use crate::state::AppState;
23
24pub fn router() -> Router<AppState> {
25    Router::new()
26        .route(
27            "/api/v1/cameras/{id}/schedules",
28            get(list_schedules).post(create_schedule),
29        )
30        .route(
31            "/api/v1/schedules/{schedule_id}",
32            axum::routing::patch(update_schedule).delete(delete_schedule),
33        )
34}
35
36/// Validate a JSON `days` array: weekday ints 0..6 (0=Mon..6=Sun).
37fn validate_days(v: &Value) -> AppResult<()> {
38    let arr = v.as_array().ok_or_else(|| {
39        AppError::BadRequest("`days` must be an array of weekday ints (0=Mon..6=Sun)".into())
40    })?;
41    for d in arr {
42        match d.as_i64() {
43            Some(n) if (0..7).contains(&n) => {}
44            _ => {
45                return Err(AppError::BadRequest(
46                    "`days` entries must be integers 0..6 (0=Mon..6=Sun)".into(),
47                ))
48            }
49        }
50    }
51    Ok(())
52}
53
54/// Validate "HH:MM" 24h time and return its canonical zero-padded form.
55fn normalize_hhmm(s: &str, field: &str) -> AppResult<String> {
56    let (h, m) = s
57        .split_once(':')
58        .and_then(|(h, m)| Some((h.trim().parse::<u32>().ok()?, m.trim().parse::<u32>().ok()?)))
59        .filter(|(h, m)| *h < 24 && *m < 60)
60        .ok_or_else(|| AppError::BadRequest(format!("`{field}` must be HH:MM 24h time")))?;
61    Ok(format!("{h:02}:{m:02}"))
62}
63
64async fn list_schedules(
65    State(st): State<AppState>,
66    principal: Principal,
67    Path(id): Path<String>,
68) -> AppResult<Json<Vec<RecordSchedule>>> {
69    principal.require(principal.can_view(), "list recording schedules")?;
70    let _ = load_camera(&st.pool, &id).await?;
71    let rows = sqlx::query_as::<_, RecordSchedule>(
72        "SELECT * FROM camera_schedules WHERE camera_id = ? ORDER BY created_at ASC",
73    )
74    .bind(&id)
75    .fetch_all(&st.pool)
76    .await?;
77    Ok(Json(rows))
78}
79
80async fn create_schedule(
81    State(st): State<AppState>,
82    Path(id): Path<String>,
83    principal: Principal,
84    Json(body): Json<RecordScheduleCreate>,
85) -> AppResult<(StatusCode, Json<RecordSchedule>)> {
86    principal.require(
87        principal.can_manage_registry(),
88        "create recording schedules",
89    )?;
90    let _ = load_camera(&st.pool, &id).await?;
91    validate_days(&body.days)?;
92    let time_start = normalize_hhmm(&body.time_start, "time_start")?;
93    let time_end = normalize_hhmm(&body.time_end, "time_end")?;
94    let enabled = body.enabled.unwrap_or(true);
95    let now = Utc::now();
96    let schedule_id = format!("recsch_{}", Uuid::new_v4().simple());
97
98    sqlx::query(
99        "INSERT INTO camera_schedules
100           (id, camera_id, days, time_start, time_end, enabled, created_at, updated_at)
101         VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
102    )
103    .bind(&schedule_id)
104    .bind(&id)
105    .bind(SqlxJson(body.days))
106    .bind(&time_start)
107    .bind(&time_end)
108    .bind(enabled)
109    .bind(now)
110    .bind(now)
111    .execute(&st.pool)
112    .await?;
113
114    let schedule =
115        sqlx::query_as::<_, RecordSchedule>("SELECT * FROM camera_schedules WHERE id = ?")
116            .bind(&schedule_id)
117            .fetch_one(&st.pool)
118            .await?;
119    // Apply immediately (e.g. a window that is active right now should start the recorder).
120    st.recorder.reconcile(&id).await;
121    auth::audit(
122        &st.pool,
123        &principal,
124        "create_record_schedule",
125        "camera_schedule",
126        &schedule_id,
127        json!({ "camera_id": &id, "time_start": &time_start, "time_end": &time_end, "enabled": enabled }),
128    )
129    .await;
130    Ok((StatusCode::CREATED, Json(schedule)))
131}
132
133async fn update_schedule(
134    State(st): State<AppState>,
135    Path(schedule_id): Path<String>,
136    principal: Principal,
137    Json(body): Json<RecordScheduleUpdate>,
138) -> AppResult<Json<RecordSchedule>> {
139    principal.require(
140        principal.can_manage_registry(),
141        "update recording schedules",
142    )?;
143    let cur = sqlx::query_as::<_, RecordSchedule>("SELECT * FROM camera_schedules WHERE id = ?")
144        .bind(&schedule_id)
145        .fetch_optional(&st.pool)
146        .await?
147        .ok_or_else(|| AppError::NotFound(format!("recording schedule {schedule_id} not found")))?;
148
149    let days = match body.days {
150        Some(d) => {
151            validate_days(&d)?;
152            SqlxJson(d)
153        }
154        None => SqlxJson(cur.days.0.clone()),
155    };
156    let time_start = match body.time_start {
157        Some(s) => normalize_hhmm(&s, "time_start")?,
158        None => cur.time_start.clone(),
159    };
160    let time_end = match body.time_end {
161        Some(s) => normalize_hhmm(&s, "time_end")?,
162        None => cur.time_end.clone(),
163    };
164    let enabled = body.enabled.unwrap_or(cur.enabled);
165
166    sqlx::query(
167        "UPDATE camera_schedules SET days = ?, time_start = ?, time_end = ?, enabled = ?, updated_at = ?
168         WHERE id = ?",
169    )
170    .bind(days)
171    .bind(&time_start)
172    .bind(&time_end)
173    .bind(enabled)
174    .bind(Utc::now())
175    .bind(&schedule_id)
176    .execute(&st.pool)
177    .await?;
178
179    let schedule =
180        sqlx::query_as::<_, RecordSchedule>("SELECT * FROM camera_schedules WHERE id = ?")
181            .bind(&schedule_id)
182            .fetch_one(&st.pool)
183            .await?;
184    st.recorder.reconcile(&cur.camera_id).await;
185    auth::audit(
186        &st.pool,
187        &principal,
188        "update_record_schedule",
189        "camera_schedule",
190        &schedule_id,
191        json!({ "time_start": &time_start, "time_end": &time_end, "enabled": enabled }),
192    )
193    .await;
194    Ok(Json(schedule))
195}
196
197async fn delete_schedule(
198    State(st): State<AppState>,
199    Path(schedule_id): Path<String>,
200    principal: Principal,
201) -> AppResult<StatusCode> {
202    principal.require(
203        principal.can_manage_registry(),
204        "delete recording schedules",
205    )?;
206    let camera_id: Option<String> =
207        sqlx::query_scalar("SELECT camera_id FROM camera_schedules WHERE id = ?")
208            .bind(&schedule_id)
209            .fetch_optional(&st.pool)
210            .await?;
211    let Some(camera_id) = camera_id else {
212        return Err(AppError::NotFound(format!(
213            "recording schedule {schedule_id} not found"
214        )));
215    };
216    sqlx::query("DELETE FROM camera_schedules WHERE id = ?")
217        .bind(&schedule_id)
218        .execute(&st.pool)
219        .await?;
220    st.recorder.reconcile(&camera_id).await;
221    auth::audit(
222        &st.pool,
223        &principal,
224        "delete_record_schedule",
225        "camera_schedule",
226        &schedule_id,
227        json!({ "camera_id": &camera_id }),
228    )
229    .await;
230    Ok(StatusCode::NO_CONTENT)
231}