Skip to main content

heldar_kernel/routes/
zones.rs

1//! Zone CRUD + zone-events query (Stage 3).
2
3use 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}