1use 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
36fn 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
54fn 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 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}