Skip to main content

heldar_kernel/
models.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use sqlx::types::Json;
5use sqlx::FromRow;
6
7use crate::camera_url;
8
9/// Camera row as stored. `password` is never serialized to clients; use [`CameraView`] for output.
10#[derive(Debug, Clone, FromRow)]
11pub struct Camera {
12    pub id: String,
13    pub site_id: Option<String>,
14    pub name: String,
15    pub vendor: String,
16    pub model: Option<String>,
17    pub address: Option<String>,
18    pub rtsp_port: i64,
19    pub username: Option<String>,
20    pub password: Option<String>,
21    pub main_stream_url: Option<String>,
22    pub sub_stream_url: Option<String>,
23    pub record_stream: String,
24    pub codec: Option<String>,
25    pub resolution_main: Option<String>,
26    pub resolution_sub: Option<String>,
27    pub fps_main: Option<i64>,
28    pub fps_sub: Option<i64>,
29    pub capabilities: Json<Value>,
30    pub record_enabled: bool,
31    pub segment_seconds: i64,
32    pub retention_hours: i64,
33    /// Per-camera storage quota in bytes; NULL means no per-camera cap.
34    pub storage_quota_bytes: Option<i64>,
35    /// Record the camera's audio stream (pass-through) instead of dropping it.
36    pub record_audio: bool,
37    /// When the recorder runs: `continuous` | `scheduled` | `event` | `scheduled_event`.
38    pub record_mode: String,
39    /// Event recording: footage desired BEFORE a trigger (best-effort, see recorder service).
40    pub pre_roll_seconds: i64,
41    /// Event recording: how long the recorder keeps writing after a trigger (the trigger window).
42    pub post_roll_seconds: i64,
43    /// Run a SECOND ffmpeg pipeline writing identical segments to HELDAR_MIRROR_RECORDINGS_DIR
44    /// (redundant DVR copy). No-op unless the mirror dir is configured.
45    pub mirror_enabled: bool,
46    /// Let the ANR loop re-fetch missed footage from the camera's onboard storage to fill gaps.
47    pub anr_enabled: bool,
48    /// Optional replay URL template for ANR ({start}/{end} placeholders); NULL = default Hikvision
49    /// RTSP playback built from address+credentials.
50    pub anr_replay_url_template: Option<String>,
51    pub enabled: bool,
52    pub created_at: DateTime<Utc>,
53    pub updated_at: DateTime<Utc>,
54}
55
56impl Camera {
57    /// Whether the recorder should be running a process for this camera.
58    pub fn should_record(&self) -> bool {
59        self.enabled && self.record_enabled
60    }
61}
62
63/// Client-facing camera representation: credentials stripped, stream URLs masked.
64#[derive(Debug, Clone, Serialize)]
65pub struct CameraView {
66    pub id: String,
67    pub site_id: Option<String>,
68    pub name: String,
69    pub vendor: String,
70    pub model: Option<String>,
71    pub address: Option<String>,
72    pub rtsp_port: i64,
73    pub username: Option<String>,
74    pub has_password: bool,
75    pub record_stream: String,
76    /// Effective RTSP URL for the recorded stream, with credentials masked.
77    pub record_url_masked: Option<String>,
78    pub codec: Option<String>,
79    pub resolution_main: Option<String>,
80    pub resolution_sub: Option<String>,
81    pub fps_main: Option<i64>,
82    pub fps_sub: Option<i64>,
83    pub capabilities: Value,
84    pub record_enabled: bool,
85    pub segment_seconds: i64,
86    pub retention_hours: i64,
87    pub storage_quota_bytes: Option<i64>,
88    pub record_audio: bool,
89    pub record_mode: String,
90    pub pre_roll_seconds: i64,
91    pub post_roll_seconds: i64,
92    pub mirror_enabled: bool,
93    pub anr_enabled: bool,
94    pub anr_replay_url_template: Option<String>,
95    pub enabled: bool,
96    pub created_at: DateTime<Utc>,
97    pub updated_at: DateTime<Utc>,
98}
99
100impl From<Camera> for CameraView {
101    fn from(c: Camera) -> Self {
102        let record_url_masked = camera_url::record_url(&c).map(|u| camera_url::mask_url(&u));
103        CameraView {
104            id: c.id,
105            site_id: c.site_id,
106            name: c.name,
107            vendor: c.vendor,
108            model: c.model,
109            address: c.address,
110            rtsp_port: c.rtsp_port,
111            username: c.username,
112            has_password: c
113                .password
114                .as_deref()
115                .map(|p| !p.is_empty())
116                .unwrap_or(false),
117            record_stream: c.record_stream,
118            record_url_masked,
119            codec: c.codec,
120            resolution_main: c.resolution_main,
121            resolution_sub: c.resolution_sub,
122            fps_main: c.fps_main,
123            fps_sub: c.fps_sub,
124            capabilities: c.capabilities.0,
125            record_enabled: c.record_enabled,
126            segment_seconds: c.segment_seconds,
127            retention_hours: c.retention_hours,
128            storage_quota_bytes: c.storage_quota_bytes,
129            record_audio: c.record_audio,
130            record_mode: c.record_mode,
131            pre_roll_seconds: c.pre_roll_seconds,
132            post_roll_seconds: c.post_roll_seconds,
133            mirror_enabled: c.mirror_enabled,
134            anr_enabled: c.anr_enabled,
135            anr_replay_url_template: c.anr_replay_url_template,
136            enabled: c.enabled,
137            created_at: c.created_at,
138            updated_at: c.updated_at,
139        }
140    }
141}
142
143/// Payload to create a camera. `id` may be omitted (slug auto-derived from name).
144#[derive(Debug, Deserialize)]
145pub struct CameraCreate {
146    pub id: Option<String>,
147    pub name: String,
148    pub site_id: Option<String>,
149    #[serde(default = "default_vendor")]
150    pub vendor: String,
151    pub model: Option<String>,
152    pub address: Option<String>,
153    pub rtsp_port: Option<i64>,
154    pub username: Option<String>,
155    pub password: Option<String>,
156    pub main_stream_url: Option<String>,
157    pub sub_stream_url: Option<String>,
158    pub record_stream: Option<String>,
159    pub capabilities: Option<Value>,
160    pub record_enabled: Option<bool>,
161    pub segment_seconds: Option<i64>,
162    pub retention_hours: Option<i64>,
163    pub storage_quota_bytes: Option<i64>,
164    pub record_audio: Option<bool>,
165    pub record_mode: Option<String>,
166    pub pre_roll_seconds: Option<i64>,
167    pub post_roll_seconds: Option<i64>,
168    pub mirror_enabled: Option<bool>,
169    pub anr_enabled: Option<bool>,
170    pub anr_replay_url_template: Option<String>,
171    pub enabled: Option<bool>,
172}
173
174fn default_vendor() -> String {
175    "generic".to_string()
176}
177
178/// Partial update; only present fields are changed.
179#[derive(Debug, Deserialize, Default)]
180pub struct CameraUpdate {
181    pub name: Option<String>,
182    pub site_id: Option<String>,
183    pub vendor: Option<String>,
184    pub model: Option<String>,
185    pub address: Option<String>,
186    pub rtsp_port: Option<i64>,
187    pub username: Option<String>,
188    pub password: Option<String>,
189    pub main_stream_url: Option<String>,
190    pub sub_stream_url: Option<String>,
191    pub record_stream: Option<String>,
192    pub capabilities: Option<Value>,
193    pub record_enabled: Option<bool>,
194    pub segment_seconds: Option<i64>,
195    pub retention_hours: Option<i64>,
196    pub storage_quota_bytes: Option<i64>,
197    pub record_audio: Option<bool>,
198    pub record_mode: Option<String>,
199    pub pre_roll_seconds: Option<i64>,
200    pub post_roll_seconds: Option<i64>,
201    pub mirror_enabled: Option<bool>,
202    pub anr_enabled: Option<bool>,
203    pub anr_replay_url_template: Option<String>,
204    pub enabled: Option<bool>,
205}
206
207#[derive(Debug, Clone, Serialize, FromRow)]
208pub struct Segment {
209    pub id: String,
210    pub camera_id: String,
211    pub path: String,
212    pub start_time: DateTime<Utc>,
213    pub end_time: DateTime<Utc>,
214    pub duration_s: f64,
215    pub codec: Option<String>,
216    pub width: Option<i64>,
217    pub height: Option<i64>,
218    pub size_bytes: i64,
219    pub container: String,
220    /// Transient read-lock held by clip/snapshot export; cleared at startup. Not durable.
221    pub locked: bool,
222    /// Durable evidence hold: when true the segment is never pruned by retention. Set via the
223    /// incident API; survives restarts (unlike `locked`).
224    pub evidence_locked: bool,
225    pub incident_id: Option<String>,
226    pub created_at: DateTime<Utc>,
227}
228
229/// A recording gap detected by the indexer (a hole > 3s between consecutive segments). The ANR loop
230/// (services/anr.rs) tries to re-fill pending gaps from the camera's onboard storage. `fill_state` is
231/// `pending` | `filled` | `failed`.
232#[derive(Debug, Clone, Serialize, FromRow)]
233pub struct RecordingGap {
234    pub id: String,
235    pub camera_id: String,
236    pub gap_start: DateTime<Utc>,
237    pub gap_end: DateTime<Utc>,
238    pub gap_seconds: i64,
239    pub fill_state: String,
240    pub fill_attempts: i64,
241    pub last_attempt_at: Option<DateTime<Utc>>,
242    pub filled_at: Option<DateTime<Utc>>,
243    pub created_at: DateTime<Utc>,
244}
245
246#[derive(Debug, Clone, Serialize, FromRow)]
247pub struct CameraStatus {
248    pub camera_id: String,
249    pub state: String,
250    pub last_segment_at: Option<DateTime<Utc>>,
251    pub last_started_at: Option<DateTime<Utc>>,
252    pub reconnect_count: i64,
253    pub segments_written: i64,
254    pub fps_observed: Option<f64>,
255    pub bitrate_kbps: Option<f64>,
256    pub last_error: Option<String>,
257    pub recorder_pid: Option<i64>,
258    pub updated_at: DateTime<Utc>,
259}
260
261#[derive(Debug, Clone, Serialize, FromRow)]
262pub struct Event {
263    pub id: String,
264    pub camera_id: Option<String>,
265    pub site_id: Option<String>,
266    pub event_type: String,
267    pub severity: String,
268    pub timestamp: DateTime<Utc>,
269    pub payload: Json<Value>,
270    pub created_at: DateTime<Utc>,
271}
272
273// ---- Stage 2: AI frame sampling ----
274
275/// A perception task to run on a camera (consumed by AI workers).
276#[derive(Debug, Clone, Serialize, FromRow)]
277pub struct AiTask {
278    pub id: String,
279    pub camera_id: String,
280    pub task_type: String,
281    pub enabled: bool,
282    pub stream_profile: String,
283    pub fps: f64,
284    pub width: i64,
285    pub config: Json<Value>,
286    pub created_at: DateTime<Utc>,
287    pub updated_at: DateTime<Utc>,
288}
289
290#[derive(Debug, Deserialize)]
291pub struct AiTaskCreate {
292    pub task_type: String,
293    pub stream_profile: Option<String>,
294    pub fps: Option<f64>,
295    pub width: Option<i64>,
296    pub config: Option<Value>,
297    pub enabled: Option<bool>,
298}
299
300#[derive(Debug, Deserialize, Default)]
301pub struct AiTaskUpdate {
302    pub task_type: Option<String>,
303    pub stream_profile: Option<String>,
304    pub fps: Option<f64>,
305    pub width: Option<i64>,
306    pub config: Option<Value>,
307    pub enabled: Option<bool>,
308}
309
310/// A detection result posted by an AI worker.
311#[derive(Debug, Clone, Serialize, FromRow)]
312pub struct Detection {
313    pub id: String,
314    pub camera_id: String,
315    pub task_type: String,
316    pub timestamp: DateTime<Utc>,
317    pub label: Option<String>,
318    pub confidence: Option<f64>,
319    pub bbox: Option<Json<Value>>,
320    pub track_id: Option<String>,
321    pub attributes: Json<Value>,
322    /// Worker-supplied per-camera frame id this detection belongs to (idempotency / batch grouping).
323    pub frame_id: Option<String>,
324    pub created_at: DateTime<Utc>,
325}
326
327/// One detection inside an ingest request.
328// `Serialize` so the Wasm plugin host (heldar-wasm) can marshal a batch to JSON for a sandboxed guest.
329#[derive(Debug, Clone, Deserialize, Serialize)]
330pub struct DetectionIngest {
331    pub label: Option<String>,
332    pub confidence: Option<f64>,
333    pub bbox: Option<Value>,
334    pub track_id: Option<String>,
335    pub attributes: Option<Value>,
336}
337
338/// Optional event an AI worker can raise alongside its detections.
339#[derive(Debug, Deserialize)]
340pub struct IngestEvent {
341    pub event_type: String,
342    pub severity: Option<String>,
343    pub payload: Option<Value>,
344}
345
346/// Payload an AI worker POSTs to ingest detections (and optionally an event) for a camera.
347#[derive(Debug, Deserialize)]
348pub struct AiIngest {
349    pub camera_id: String,
350    pub task_type: String,
351    pub timestamp: Option<String>,
352    /// Optional per-camera monotonic frame id. When present, ingest is idempotent on
353    /// (camera_id, frame_id): a duplicate redelivery is a no-op (no double-insert, no re-fire of
354    /// consumer side effects). Omit it (e.g. the dependency-light client) to accept every batch.
355    pub frame_id: Option<String>,
356    #[serde(default)]
357    pub detections: Vec<DetectionIngest>,
358    pub event: Option<IngestEvent>,
359}
360
361// ---- Stage 3: zones + zone events ----
362
363/// A polygon region on a camera; tracked detections crossing it raise enter/exit/dwell events.
364#[derive(Debug, Clone, Serialize, FromRow)]
365pub struct Zone {
366    pub id: String,
367    pub camera_id: String,
368    pub name: String,
369    pub kind: String,
370    /// JSON array of [x, y] vertices, normalized 0..1.
371    pub polygon: Json<Value>,
372    pub dwell_seconds: f64,
373    /// JSON array of detection labels that count toward this zone (empty = all labels).
374    pub labels: Json<Value>,
375    pub severity: String,
376    pub config: Json<Value>,
377    pub enabled: bool,
378    pub created_at: DateTime<Utc>,
379    pub updated_at: DateTime<Utc>,
380}
381
382#[derive(Debug, Deserialize)]
383pub struct ZoneCreate {
384    pub name: String,
385    pub kind: Option<String>,
386    pub polygon: Value,
387    pub dwell_seconds: Option<f64>,
388    pub labels: Option<Value>,
389    pub severity: Option<String>,
390    pub config: Option<Value>,
391    pub enabled: Option<bool>,
392}
393
394#[derive(Debug, Deserialize, Default)]
395pub struct ZoneUpdate {
396    pub name: Option<String>,
397    pub kind: Option<String>,
398    pub polygon: Option<Value>,
399    pub dwell_seconds: Option<f64>,
400    pub labels: Option<Value>,
401    pub severity: Option<String>,
402    pub config: Option<Value>,
403    pub enabled: Option<bool>,
404}
405
406#[derive(Debug, Clone, Serialize, FromRow)]
407pub struct ZoneEvent {
408    pub id: String,
409    pub camera_id: String,
410    pub zone_id: String,
411    pub zone_name: String,
412    pub track_id: Option<String>,
413    pub event_type: String,
414    pub label: Option<String>,
415    pub timestamp: DateTime<Utc>,
416    pub dwell_seconds: Option<f64>,
417    pub evidence_path: Option<String>,
418    pub created_at: DateTime<Utc>,
419}
420
421// ---- Stage 4: Access control — RBAC ----
422
423/// Operator account. `password_hash` is never serialized; use [`UserView`] for output.
424#[derive(Debug, Clone, FromRow)]
425pub struct User {
426    pub id: String,
427    pub username: String,
428    pub password_hash: String,
429    pub role: String,
430    pub display_name: Option<String>,
431    pub active: bool,
432    pub created_at: DateTime<Utc>,
433    pub updated_at: DateTime<Utc>,
434}
435
436#[derive(Debug, Clone, Serialize)]
437pub struct UserView {
438    pub id: String,
439    pub username: String,
440    pub role: String,
441    pub display_name: Option<String>,
442    pub active: bool,
443    pub created_at: DateTime<Utc>,
444    pub updated_at: DateTime<Utc>,
445}
446
447impl From<User> for UserView {
448    fn from(u: User) -> Self {
449        UserView {
450            id: u.id,
451            username: u.username,
452            role: u.role,
453            display_name: u.display_name,
454            active: u.active,
455            created_at: u.created_at,
456            updated_at: u.updated_at,
457        }
458    }
459}
460
461#[derive(Debug, Deserialize)]
462pub struct UserCreate {
463    pub username: String,
464    pub password: String,
465    pub role: Option<String>,
466    pub display_name: Option<String>,
467    pub active: Option<bool>,
468}
469
470#[derive(Debug, Deserialize, Default)]
471pub struct UserUpdate {
472    pub password: Option<String>,
473    pub role: Option<String>,
474    pub display_name: Option<String>,
475    pub active: Option<bool>,
476}
477
478#[derive(Debug, Deserialize)]
479pub struct LoginRequest {
480    pub username: String,
481    pub password: String,
482}
483
484#[derive(Debug, Clone, FromRow)]
485pub struct ApiKey {
486    pub id: String,
487    pub name: String,
488    /// Mapped from the row for completeness; never exposed (see [`ApiKeyView`]).
489    pub key_hash: String,
490    pub key_prefix: String,
491    pub role: String,
492    pub active: bool,
493    pub last_used_at: Option<DateTime<Utc>>,
494    pub created_at: DateTime<Utc>,
495}
496
497#[derive(Debug, Clone, Serialize)]
498pub struct ApiKeyView {
499    pub id: String,
500    pub name: String,
501    pub key_prefix: String,
502    pub role: String,
503    pub active: bool,
504    pub last_used_at: Option<DateTime<Utc>>,
505    pub created_at: DateTime<Utc>,
506}
507
508impl From<ApiKey> for ApiKeyView {
509    fn from(k: ApiKey) -> Self {
510        ApiKeyView {
511            id: k.id,
512            name: k.name,
513            key_prefix: k.key_prefix,
514            role: k.role,
515            active: k.active,
516            last_used_at: k.last_used_at,
517            created_at: k.created_at,
518        }
519    }
520}
521
522#[derive(Debug, Deserialize)]
523pub struct ApiKeyCreate {
524    pub name: String,
525    pub role: Option<String>,
526}
527
528// ---- Scheduled interval snapshots ----
529
530/// A per-camera schedule that captures a live JPEG every `interval_seconds`.
531#[derive(Debug, Clone, Serialize, FromRow)]
532pub struct SnapshotSchedule {
533    pub id: String,
534    pub camera_id: String,
535    pub interval_seconds: i64,
536    pub enabled: bool,
537    pub last_fired_at: Option<DateTime<Utc>>,
538    pub created_at: DateTime<Utc>,
539    pub updated_at: DateTime<Utc>,
540}
541
542#[derive(Debug, Deserialize)]
543pub struct SnapshotScheduleCreate {
544    pub interval_seconds: Option<i64>,
545    pub enabled: Option<bool>,
546}
547
548#[derive(Debug, Deserialize, Default)]
549pub struct SnapshotScheduleUpdate {
550    pub interval_seconds: Option<i64>,
551    pub enabled: Option<bool>,
552}
553
554/// A captured snapshot frame on disk (one file under snapshots_dir/{camera_id}/).
555#[derive(Debug, Clone, Serialize, FromRow)]
556pub struct PersistedSnapshot {
557    pub id: String,
558    pub camera_id: String,
559    pub schedule_id: Option<String>,
560    pub path: String,
561    pub taken_at: DateTime<Utc>,
562    pub size_bytes: i64,
563    pub created_at: DateTime<Utc>,
564}
565
566// ---- Per-camera recording schedule (time-of-day windows) ----
567
568/// A recurring per-camera recording window, applied when the camera's `record_mode` is `scheduled`
569/// or `scheduled_event`. `days` is a JSON array of weekday ints (0=Mon..6=Sun); `time_start` /
570/// `time_end` are "HH:MM" 24h in the SERVER's LOCAL timezone (chrono::Local). When `time_start` >
571/// `time_end` the window wraps past midnight (its early-morning portion is attributed to the day it
572/// started on).
573#[derive(Debug, Clone, Serialize, FromRow)]
574pub struct RecordSchedule {
575    pub id: String,
576    pub camera_id: String,
577    pub days: Json<Value>,
578    pub time_start: String,
579    pub time_end: String,
580    pub enabled: bool,
581    pub created_at: DateTime<Utc>,
582    pub updated_at: DateTime<Utc>,
583}
584
585#[derive(Debug, Deserialize)]
586pub struct RecordScheduleCreate {
587    /// JSON array of weekday ints (0=Mon..6=Sun).
588    pub days: Value,
589    /// "HH:MM" 24h, server local time.
590    pub time_start: String,
591    /// "HH:MM" 24h, server local time (start > end means an overnight window).
592    pub time_end: String,
593    pub enabled: Option<bool>,
594}
595
596#[derive(Debug, Deserialize, Default)]
597pub struct RecordScheduleUpdate {
598    pub days: Option<Value>,
599    pub time_start: Option<String>,
600    pub time_end: Option<String>,
601    pub enabled: Option<bool>,
602}
603
604// ---- Backup subsystem: destinations, policies, jobs, archive export ----
605
606/// Config keys that hold a secret. Masked in [`BackupDestinationView`] (and preserved across an
607/// update when the client round-trips the `***` placeholder back).
608pub const BACKUP_SECRET_KEYS: &[&str] = &["pass", "password", "secret_key", "secret"];
609
610/// A backup transfer target. `config` is a kind-specific JSON blob (credentials live here and are
611/// never serialized raw — use [`BackupDestinationView`]). Not `Serialize` for exactly that reason.
612#[derive(Debug, Clone, FromRow)]
613pub struct BackupDestination {
614    pub id: String,
615    pub name: String,
616    /// `local` | `sftp` | `ftp` | `s3`.
617    pub kind: String,
618    pub config: Json<Value>,
619    pub enabled: bool,
620    pub created_at: DateTime<Utc>,
621    pub updated_at: DateTime<Utc>,
622}
623
624/// Client-facing destination: secret config values are replaced with `***`.
625#[derive(Debug, Clone, Serialize)]
626pub struct BackupDestinationView {
627    pub id: String,
628    pub name: String,
629    pub kind: String,
630    /// The config blob with any secret values masked to `***`.
631    pub config: Value,
632    /// Whether at least one secret credential is configured (so the UI can show "set" without the value).
633    pub has_credentials: bool,
634    pub enabled: bool,
635    pub created_at: DateTime<Utc>,
636    pub updated_at: DateTime<Utc>,
637}
638
639/// Mask the secret values in a config blob, returning the masked blob and whether any secret was set.
640pub fn mask_backup_config(mut config: Value) -> (Value, bool) {
641    let mut has_credentials = false;
642    if let Some(obj) = config.as_object_mut() {
643        for key in BACKUP_SECRET_KEYS {
644            if let Some(v) = obj.get_mut(*key) {
645                if v.as_str().map(|s| !s.is_empty()).unwrap_or(false) {
646                    has_credentials = true;
647                    *v = Value::String("***".to_string());
648                }
649            }
650        }
651    }
652    (config, has_credentials)
653}
654
655impl From<BackupDestination> for BackupDestinationView {
656    fn from(d: BackupDestination) -> Self {
657        let (config, has_credentials) = mask_backup_config(d.config.0);
658        BackupDestinationView {
659            id: d.id,
660            name: d.name,
661            kind: d.kind,
662            config,
663            has_credentials,
664            enabled: d.enabled,
665            created_at: d.created_at,
666            updated_at: d.updated_at,
667        }
668    }
669}
670
671#[derive(Debug, Deserialize)]
672pub struct BackupDestinationCreate {
673    pub name: String,
674    /// `local` | `sftp` | `ftp` | `s3`.
675    pub kind: String,
676    pub config: Option<Value>,
677    pub enabled: Option<bool>,
678}
679
680#[derive(Debug, Deserialize, Default)]
681pub struct BackupDestinationUpdate {
682    pub name: Option<String>,
683    pub kind: Option<String>,
684    pub config: Option<Value>,
685    pub enabled: Option<bool>,
686}
687
688/// Result of POST /api/v1/backup/destinations/{id}/test (a connectivity / writability probe).
689#[derive(Debug, Clone, Serialize)]
690pub struct BackupTestResult {
691    pub ok: bool,
692    pub error: Option<String>,
693    pub latency_ms: i64,
694}
695
696/// A scheduled backup policy: ship a camera selection's recent footage to a destination on an interval.
697#[derive(Debug, Clone, Serialize, FromRow)]
698pub struct BackupPolicy {
699    pub id: String,
700    pub name: String,
701    pub destination_id: String,
702    /// JSON array of camera ids; empty array means all cameras.
703    pub camera_ids: Json<Value>,
704    pub incident_lock_only: bool,
705    pub schedule_interval_s: i64,
706    pub lookback_hours: i64,
707    pub last_run_at: Option<DateTime<Utc>>,
708    pub last_job_id: Option<String>,
709    pub enabled: bool,
710    pub created_at: DateTime<Utc>,
711    pub updated_at: DateTime<Utc>,
712}
713
714#[derive(Debug, Deserialize)]
715pub struct BackupPolicyCreate {
716    pub name: String,
717    pub destination_id: String,
718    pub camera_ids: Option<Value>,
719    pub incident_lock_only: Option<bool>,
720    pub schedule_interval_s: Option<i64>,
721    pub lookback_hours: Option<i64>,
722    pub enabled: Option<bool>,
723}
724
725#[derive(Debug, Deserialize, Default)]
726pub struct BackupPolicyUpdate {
727    pub name: Option<String>,
728    pub destination_id: Option<String>,
729    pub camera_ids: Option<Value>,
730    pub incident_lock_only: Option<bool>,
731    pub schedule_interval_s: Option<i64>,
732    pub lookback_hours: Option<i64>,
733    pub enabled: Option<bool>,
734}
735
736/// A single backup run (policy-scheduled, manually triggered, or an on-demand archive export).
737#[derive(Debug, Clone, Serialize, FromRow)]
738pub struct BackupJob {
739    pub id: String,
740    pub policy_id: Option<String>,
741    pub destination_id: Option<String>,
742    /// `policy` | `on_demand_archive`.
743    pub kind: String,
744    pub camera_ids: Json<Value>,
745    pub from_time: Option<DateTime<Utc>>,
746    pub to_time: Option<DateTime<Utc>>,
747    pub incident_lock_only: bool,
748    /// `pending` | `running` | `completed` | `error`.
749    pub status: String,
750    pub files_total: i64,
751    pub files_copied: i64,
752    pub bytes_copied: i64,
753    pub error: Option<String>,
754    /// Filesystem path of the produced artifact (archive .zip), if any.
755    pub output_path: Option<String>,
756    /// Browser-fetchable URL of the produced artifact (under /media/archives/...), if any.
757    pub output_url: Option<String>,
758    pub started_at: Option<DateTime<Utc>>,
759    pub finished_at: Option<DateTime<Utc>>,
760    pub created_at: DateTime<Utc>,
761}
762
763// ---- ONVIF (Profile S MVP): device profile + PTZ presets ----
764
765/// Per-camera ONVIF device profile, populated by [`crate::services::onvif::probe`]. `scopes` is a
766/// JSON array of ONVIF scope URIs. `ptz_enabled` is true when the device exposes a PTZ service and
767/// the chosen media profile carries a PTZConfiguration.
768#[derive(Debug, Clone, Serialize, FromRow)]
769pub struct CameraOnvif {
770    pub camera_id: String,
771    pub device_url: String,
772    pub manufacturer: Option<String>,
773    pub model: Option<String>,
774    pub firmware_version: Option<String>,
775    pub serial_number: Option<String>,
776    pub hardware_id: Option<String>,
777    pub scopes: Json<Value>,
778    pub media_url: Option<String>,
779    pub ptz_url: Option<String>,
780    pub profile_token: Option<String>,
781    pub ptz_node_token: Option<String>,
782    pub ptz_enabled: bool,
783    pub probed_at: DateTime<Utc>,
784}
785
786/// A PTZ preset fetched from a camera's ONVIF PTZ service (GetPresets). One row per (camera, token).
787#[derive(Debug, Clone, Serialize, FromRow)]
788pub struct PtzPreset {
789    pub id: String,
790    pub camera_id: String,
791    pub token: String,
792    pub name: Option<String>,
793    pub fetched_at: DateTime<Utc>,
794}
795
796// ---- Camera configuration (HikVision ISAPI): device + integration state ----
797
798/// Per-camera HikVision ISAPI configuration state, populated by the camera-config service. Mirrors
799/// `GET /ISAPI/System/deviceInfo` (identity), `/System/Network/Integrate` (`onvif_enabled`), the
800/// kernel-provisioned ONVIF user (`onvif_user_created`), and `/System/time` (`time_mode`/`ntp_server`).
801#[derive(Debug, Clone, FromRow, Serialize)]
802pub struct CameraIsapi {
803    pub camera_id: String,
804    pub device_name: Option<String>,
805    pub model: Option<String>,
806    pub firmware_version: Option<String>,
807    pub serial_number: Option<String>,
808    pub onvif_enabled: bool,
809    pub onvif_user_created: bool,
810    pub time_mode: Option<String>,
811    pub ntp_server: Option<String>,
812    pub fetched_at: DateTime<Utc>,
813}
814
815// ---- Webhook helpers (URL masking + three-state field deserialization) ----
816
817/// Deserialize a PRESENT field into `Some(inner)`. Combined with `#[serde(default)]` (which leaves a
818/// missing field as `None`), this yields three states: omitted = `None`, null = `Some(None)`,
819/// value = `Some(Some(v))`.
820fn de_field_present<'de, D>(deserializer: D) -> Result<Option<Option<String>>, D::Error>
821where
822    D: serde::Deserializer<'de>,
823{
824    Ok(Some(Option::<String>::deserialize(deserializer)?))
825}
826
827/// Mask a webhook URL for display: keep only `scheme://host[:port]` and append `/…` so the path/token
828/// is never revealed. Returns None for an empty url; a url without a scheme is masked to `…` (it may
829/// be a bare token).
830pub fn mask_webhook_url(url: &str) -> Option<String> {
831    let url = url.trim();
832    if url.is_empty() {
833        return None;
834    }
835    match url.split_once("://") {
836        Some((scheme, rest)) => {
837            let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
838            let authority = &rest[..authority_end];
839            if authority_end < rest.len() {
840                Some(format!("{scheme}://{authority}/…"))
841            } else {
842                Some(format!("{scheme}://{authority}"))
843            }
844        }
845        None => Some("…".to_string()),
846    }
847}
848
849// ---- Webhook subscriptions (the generic event-delivery substrate; supersedes single-URL alerting) ----
850
851/// A webhook subscription row as stored. `secret` (the HMAC signing key) is never serialized; use
852/// [`WebhookSubscriptionView`] for output. `event_types` is a JSON array of type names; the sentinel
853/// `["*"]` matches every event type, otherwise it is an exact-membership set. `cursor_at` is the
854/// per-subscription delivery cursor (an `events.created_at`); NULL means "start at now" (no backlog).
855#[derive(Debug, Clone, FromRow)]
856pub struct WebhookSubscription {
857    pub id: String,
858    pub name: String,
859    pub url: String,
860    pub event_types: Json<Vec<String>>,
861    pub min_severity: String,
862    pub secret: Option<String>,
863    pub enabled: bool,
864    pub cursor_at: Option<DateTime<Utc>>,
865    pub created_at: DateTime<Utc>,
866    pub updated_at: DateTime<Utc>,
867}
868
869/// Client-facing subscription view: the `secret` is replaced by a `has_secret` flag and never echoed.
870#[derive(Debug, Clone, Serialize)]
871pub struct WebhookSubscriptionView {
872    pub id: String,
873    pub name: String,
874    pub url: String,
875    pub event_types: Vec<String>,
876    pub min_severity: String,
877    /// Whether an HMAC signing secret is configured (the value itself is never returned).
878    pub has_secret: bool,
879    pub enabled: bool,
880    pub cursor_at: Option<DateTime<Utc>>,
881    pub created_at: DateTime<Utc>,
882    pub updated_at: DateTime<Utc>,
883}
884
885impl From<WebhookSubscription> for WebhookSubscriptionView {
886    fn from(s: WebhookSubscription) -> Self {
887        WebhookSubscriptionView {
888            id: s.id,
889            name: s.name,
890            url: s.url,
891            event_types: s.event_types.0,
892            min_severity: s.min_severity,
893            has_secret: s.secret.as_deref().map(|v| !v.is_empty()).unwrap_or(false),
894            enabled: s.enabled,
895            cursor_at: s.cursor_at,
896            created_at: s.created_at,
897            updated_at: s.updated_at,
898        }
899    }
900}
901
902#[derive(Debug, Deserialize)]
903pub struct WebhookSubscriptionCreate {
904    pub name: String,
905    pub url: String,
906    /// Omitted/empty = all types (`["*"]`).
907    pub event_types: Option<Vec<String>>,
908    /// `info` | `warning` | `critical` (default `info`).
909    pub min_severity: Option<String>,
910    /// Optional HMAC-SHA256 signing secret.
911    pub secret: Option<String>,
912    pub enabled: Option<bool>,
913}
914
915/// Partial update; an ABSENT field is left unchanged. `secret` is three-state: omitted = unchanged,
916/// null = clear the secret, a value = set it (the outer `Option` distinguishes "field omitted" from
917/// an explicit null — see [`de_field_present`]).
918#[derive(Debug, Deserialize, Default)]
919pub struct WebhookSubscriptionUpdate {
920    pub name: Option<String>,
921    pub url: Option<String>,
922    pub event_types: Option<Vec<String>>,
923    pub min_severity: Option<String>,
924    #[serde(default, deserialize_with = "de_field_present")]
925    pub secret: Option<Option<String>>,
926    pub enabled: Option<bool>,
927}
928
929/// One webhook delivery attempt (the at-least-once retry ledger). `status` is `delivered` | `failed`.
930#[derive(Debug, Clone, Serialize, FromRow)]
931pub struct WebhookDelivery {
932    pub id: String,
933    pub subscription_id: String,
934    pub event_id: Option<String>,
935    pub event_type: Option<String>,
936    pub status: String,
937    pub attempts: i64,
938    pub response_code: Option<i64>,
939    pub error: Option<String>,
940    pub created_at: DateTime<Utc>,
941    pub delivered_at: Option<DateTime<Utc>>,
942}
943
944/// Request body for POST /api/v1/archive/export — zip a selection of recorded footage on demand.
945#[derive(Debug, Deserialize)]
946pub struct ArchiveExportRequest {
947    /// Camera ids to include; empty/omitted means all cameras.
948    #[serde(default)]
949    pub camera_ids: Vec<String>,
950    pub from: Option<String>,
951    pub to: Option<String>,
952    pub incident_lock_only: Option<bool>,
953    /// Trim each segment to the [from, to] window (re-mux with -c copy); requires both bounds.
954    pub trim: Option<bool>,
955}
956
957#[cfg(test)]
958mod tests {
959    use super::*;
960
961    #[test]
962    fn mask_webhook_url_hides_path_and_token() {
963        // Path/query/fragment are dropped behind an ellipsis; scheme + host (and port) are kept.
964        assert_eq!(
965            mask_webhook_url("https://hooks.slack.com/services/T000/B000/XXXXSECRET"),
966            Some("https://hooks.slack.com/…".to_string())
967        );
968        assert_eq!(
969            mask_webhook_url("https://example.com:8443/alert?token=abc"),
970            Some("https://example.com:8443/…".to_string())
971        );
972        // Host-only urls keep just scheme://host.
973        assert_eq!(
974            mask_webhook_url("https://example.com"),
975            Some("https://example.com".to_string())
976        );
977        // Empty/whitespace => None; schemeless => fully masked (may be a bare token).
978        assert_eq!(mask_webhook_url("   "), None);
979        assert_eq!(mask_webhook_url("not-a-url"), Some("…".to_string()));
980    }
981
982    #[test]
983    fn webhook_update_secret_is_three_state() {
984        // Omitted => None (leave the signing secret unchanged).
985        let u: WebhookSubscriptionUpdate = serde_json::from_str(r#"{"enabled": true}"#).unwrap();
986        assert!(u.secret.is_none());
987        assert_eq!(u.enabled, Some(true));
988        // Explicit null => Some(None) (clear the secret).
989        let u: WebhookSubscriptionUpdate = serde_json::from_str(r#"{"secret": null}"#).unwrap();
990        assert_eq!(u.secret, Some(None));
991        // A value => Some(Some(v)) (set the secret).
992        let u: WebhookSubscriptionUpdate = serde_json::from_str(r#"{"secret": "s3cr3t"}"#).unwrap();
993        assert_eq!(u.secret, Some(Some("s3cr3t".to_string())));
994    }
995}