1use axum::extract::{Path, Query, State};
4use axum::http::StatusCode;
5use axum::routing::get;
6use axum::{Json, Router};
7use chrono::Utc;
8use serde::Deserialize;
9use serde_json::json;
10use sqlx::types::Json as SqlxJson;
11use uuid::Uuid;
12
13use crate::auth::{self, Principal};
14use crate::error::{AppError, AppResult};
15use crate::models::{Zone, ZoneCreate, ZoneEvent, ZoneUpdate};
16use crate::routes::cameras::load_camera;
17use crate::state::AppState;
18
19pub fn router() -> Router<AppState> {
20 Router::new()
21 .route(
22 "/api/v1/cameras/{id}/zones",
23 get(list_zones).post(create_zone),
24 )
25 .route(
26 "/api/v1/zones/{zone_id}",
27 axum::routing::patch(update_zone).delete(delete_zone),
28 )
29 .route("/api/v1/cameras/{id}/zone-events", get(list_zone_events))
30}
31
32const MAX_POLYGON_VERTICES: usize = 512;
33
34fn validate_polygon(v: &serde_json::Value) -> AppResult<()> {
35 let arr = v
36 .as_array()
37 .ok_or_else(|| AppError::BadRequest("`polygon` must be an array of [x,y] points".into()))?;
38 if arr.len() < 3 {
39 return Err(AppError::BadRequest(
40 "`polygon` must have at least 3 points".into(),
41 ));
42 }
43 if arr.len() > MAX_POLYGON_VERTICES {
44 return Err(AppError::BadRequest(format!(
45 "`polygon` has too many vertices (max {MAX_POLYGON_VERTICES})"
46 )));
47 }
48 for (i, pt) in arr.iter().enumerate() {
49 let p = pt
50 .as_array()
51 .filter(|a| a.len() == 2)
52 .ok_or_else(|| AppError::BadRequest(format!("polygon point {i} must be [x, y]")))?;
53 for c in p {
54 let n = c
55 .as_f64()
56 .filter(|n| n.is_finite())
57 .ok_or_else(|| AppError::BadRequest(format!("polygon point {i} is not numeric")))?;
58 if !(0.0..=1.0).contains(&n) {
59 return Err(AppError::BadRequest(format!(
60 "polygon coordinates must be normalized 0..1 (point {i})"
61 )));
62 }
63 }
64 }
65 Ok(())
66}
67
68fn validate_labels(v: &serde_json::Value) -> AppResult<()> {
69 let arr = v
70 .as_array()
71 .ok_or_else(|| AppError::BadRequest("`labels` must be an array of strings".into()))?;
72 for l in arr {
73 match l.as_str() {
74 Some(s) if !s.trim().is_empty() => {}
75 _ => {
76 return Err(AppError::BadRequest(
77 "`labels` must be non-empty strings".into(),
78 ))
79 }
80 }
81 }
82 Ok(())
83}
84
85fn validate_severity(s: &str) -> AppResult<()> {
86 if matches!(s, "info" | "warning" | "critical") {
87 Ok(())
88 } else {
89 Err(AppError::BadRequest(
90 "`severity` must be info|warning|critical".into(),
91 ))
92 }
93}
94
95async fn list_zones(
96 State(st): State<AppState>,
97 principal: Principal,
98 Path(id): Path<String>,
99) -> AppResult<Json<Vec<Zone>>> {
100 principal.require(principal.can_view(), "list zones")?;
101 let _ = load_camera(&st.pool, &id).await?;
102 let zones = sqlx::query_as::<_, Zone>(
103 "SELECT * FROM zones WHERE camera_id = ? ORDER BY created_at ASC",
104 )
105 .bind(&id)
106 .fetch_all(&st.pool)
107 .await?;
108 Ok(Json(zones))
109}
110
111async fn create_zone(
112 State(st): State<AppState>,
113 Path(id): Path<String>,
114 principal: Principal,
115 Json(body): Json<ZoneCreate>,
116) -> AppResult<(StatusCode, Json<Zone>)> {
117 principal.require(principal.can_manage_registry(), "create zones")?;
118 let _ = load_camera(&st.pool, &id).await?;
119 if body.name.trim().is_empty() {
120 return Err(AppError::BadRequest("`name` is required".into()));
121 }
122 validate_polygon(&body.polygon)?;
123 if let Some(l) = &body.labels {
124 validate_labels(l)?;
125 }
126 let severity = body.severity.unwrap_or_else(|| "info".into());
127 validate_severity(&severity)?;
128 let kind = body.kind.unwrap_or_else(|| "region".into());
129 let dwell = body.dwell_seconds.unwrap_or(0.0).max(0.0);
130 let labels = SqlxJson(body.labels.unwrap_or_else(|| json!([])));
131 let config = SqlxJson(body.config.unwrap_or_else(|| json!({})));
132 let polygon = SqlxJson(body.polygon);
133 let now = Utc::now();
134 let zone_id = format!("zone_{}", Uuid::new_v4().simple());
135
136 sqlx::query(
137 "INSERT INTO zones
138 (id, camera_id, name, kind, polygon, dwell_seconds, labels, severity, config, enabled, created_at, updated_at)
139 VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
140 )
141 .bind(&zone_id)
142 .bind(&id)
143 .bind(&body.name)
144 .bind(&kind)
145 .bind(polygon)
146 .bind(dwell)
147 .bind(labels)
148 .bind(&severity)
149 .bind(config)
150 .bind(body.enabled.unwrap_or(true))
151 .bind(now)
152 .bind(now)
153 .execute(&st.pool)
154 .await?;
155
156 let zone = sqlx::query_as::<_, Zone>("SELECT * FROM zones WHERE id = ?")
157 .bind(&zone_id)
158 .fetch_one(&st.pool)
159 .await?;
160 auth::audit(
161 &st.pool,
162 &principal,
163 "create_zone",
164 "zone",
165 &zone_id,
166 json!({ "camera_id": &id, "name": &zone.name, "kind": &zone.kind }),
167 )
168 .await;
169 Ok((StatusCode::CREATED, Json(zone)))
170}
171
172async fn update_zone(
173 State(st): State<AppState>,
174 Path(zone_id): Path<String>,
175 principal: Principal,
176 Json(body): Json<ZoneUpdate>,
177) -> AppResult<Json<Zone>> {
178 principal.require(principal.can_manage_registry(), "update zones")?;
179 let cur = sqlx::query_as::<_, Zone>("SELECT * FROM zones WHERE id = ?")
180 .bind(&zone_id)
181 .fetch_optional(&st.pool)
182 .await?
183 .ok_or_else(|| AppError::NotFound(format!("zone {zone_id} not found")))?;
184
185 let name = body.name.unwrap_or(cur.name);
186 let kind = body.kind.unwrap_or(cur.kind);
187 let severity = body.severity.unwrap_or(cur.severity);
188 validate_severity(&severity)?;
189 let polygon = match body.polygon {
190 Some(p) => {
191 validate_polygon(&p)?;
192 SqlxJson(p)
193 }
194 None => cur.polygon,
195 };
196 let dwell = body
197 .dwell_seconds
198 .map(|v| v.max(0.0))
199 .unwrap_or(cur.dwell_seconds);
200 if let Some(l) = &body.labels {
201 validate_labels(l)?;
202 }
203 let labels = SqlxJson(body.labels.unwrap_or(cur.labels.0));
204 let config = SqlxJson(body.config.unwrap_or(cur.config.0));
205 let enabled = body.enabled.unwrap_or(cur.enabled);
206
207 sqlx::query(
208 "UPDATE zones SET name=?, kind=?, polygon=?, dwell_seconds=?, labels=?, severity=?, config=?, enabled=?, updated_at=?
209 WHERE id=?",
210 )
211 .bind(&name)
212 .bind(&kind)
213 .bind(polygon)
214 .bind(dwell)
215 .bind(labels)
216 .bind(&severity)
217 .bind(config)
218 .bind(enabled)
219 .bind(Utc::now())
220 .bind(&zone_id)
221 .execute(&st.pool)
222 .await?;
223
224 let zone = sqlx::query_as::<_, Zone>("SELECT * FROM zones WHERE id = ?")
225 .bind(&zone_id)
226 .fetch_one(&st.pool)
227 .await?;
228 auth::audit(
229 &st.pool,
230 &principal,
231 "update_zone",
232 "zone",
233 &zone_id,
234 json!({}),
235 )
236 .await;
237 Ok(Json(zone))
238}
239
240async fn delete_zone(
241 State(st): State<AppState>,
242 Path(zone_id): Path<String>,
243 principal: Principal,
244) -> AppResult<StatusCode> {
245 principal.require(principal.can_manage_registry(), "delete zones")?;
246 let res = sqlx::query("DELETE FROM zones WHERE id = ?")
247 .bind(&zone_id)
248 .execute(&st.pool)
249 .await?;
250 if res.rows_affected() == 0 {
251 return Err(AppError::NotFound(format!("zone {zone_id} not found")));
252 }
253 auth::audit(
254 &st.pool,
255 &principal,
256 "delete_zone",
257 "zone",
258 &zone_id,
259 json!({}),
260 )
261 .await;
262 Ok(StatusCode::NO_CONTENT)
263}
264
265#[derive(Debug, Deserialize)]
266struct ZoneEventQuery {
267 from: Option<String>,
268 to: Option<String>,
269 zone_id: Option<String>,
270 event_type: Option<String>,
271 limit: Option<i64>,
272}
273
274async fn list_zone_events(
275 State(st): State<AppState>,
276 principal: Principal,
277 Path(id): Path<String>,
278 Query(q): Query<ZoneEventQuery>,
279) -> AppResult<Json<Vec<ZoneEvent>>> {
280 principal.require(principal.can_view(), "list zone events")?;
281 let _ = load_camera(&st.pool, &id).await?;
282 let limit = q.limit.unwrap_or(200).clamp(1, 5000);
283 let parse = |s: &Option<String>, field: &str| -> AppResult<Option<chrono::DateTime<Utc>>> {
284 match s {
285 Some(v) => crate::util::parse_rfc3339(v)
286 .map(Some)
287 .ok_or_else(|| AppError::BadRequest(format!("invalid `{field}` timestamp"))),
288 None => Ok(None),
289 }
290 };
291 let from = parse(&q.from, "from")?;
292 let to = parse(&q.to, "to")?;
293 let rows = sqlx::query_as::<_, ZoneEvent>(
294 "SELECT * FROM zone_events
295 WHERE camera_id = ?
296 AND (? IS NULL OR timestamp >= ?)
297 AND (? IS NULL OR timestamp <= ?)
298 AND (? IS NULL OR zone_id = ?)
299 AND (? IS NULL OR event_type = ?)
300 ORDER BY timestamp DESC LIMIT ?",
301 )
302 .bind(&id)
303 .bind(from)
304 .bind(from)
305 .bind(to)
306 .bind(to)
307 .bind(&q.zone_id)
308 .bind(&q.zone_id)
309 .bind(&q.event_type)
310 .bind(&q.event_type)
311 .bind(limit)
312 .fetch_all(&st.pool)
313 .await?;
314 Ok(Json(rows))
315}