koprogo_api/infrastructure/web/handlers/
security_incident_handlers.rs

1use crate::infrastructure::web::{AppState, AuthenticatedUser};
2use actix_web::{get, post, put, web, HttpRequest, HttpResponse, Responder};
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6/// Extract client IP address from request
7fn extract_ip_address(req: &HttpRequest) -> Option<String> {
8    req.headers()
9        .get("X-Forwarded-For")
10        .and_then(|h| h.to_str().ok())
11        .map(|s| s.split(',').next().unwrap_or("").trim().to_string())
12        .filter(|s| !s.is_empty())
13        .or_else(|| {
14            req.headers()
15                .get("X-Real-IP")
16                .and_then(|h| h.to_str().ok())
17                .map(|s| s.to_string())
18        })
19        .or_else(|| req.peer_addr().map(|addr| addr.ip().to_string()))
20}
21
22/// Extract user agent from request
23fn extract_user_agent(req: &HttpRequest) -> Option<String> {
24    req.headers()
25        .get("User-Agent")
26        .and_then(|h| h.to_str().ok())
27        .map(|s| s.to_string())
28}
29
30/// Request body for creating a security incident
31#[derive(Debug, Deserialize)]
32pub struct CreateSecurityIncidentRequest {
33    pub severity: String,      // "critical", "high", "medium", "low"
34    pub incident_type: String, // "data_breach", "unauthorized_access", "malware", etc.
35    pub title: String,
36    pub description: String,
37    pub data_categories_affected: Vec<String>,
38    pub affected_subjects_count: Option<i32>,
39}
40
41/// Request body for reporting incident to APD
42#[derive(Debug, Deserialize)]
43pub struct ReportToApdRequest {
44    pub apd_reference_number: Option<String>,
45    pub investigation_notes: Option<String>,
46}
47
48/// Response DTO for security incident
49#[derive(Debug, Serialize)]
50pub struct SecurityIncidentDto {
51    pub id: String,
52    pub organization_id: String,
53    pub severity: String,
54    pub incident_type: String,
55    pub title: String,
56    pub description: String,
57    pub data_categories_affected: Vec<String>,
58    pub affected_subjects_count: Option<i32>,
59    pub discovery_at: String,
60    pub notification_at: Option<String>,
61    pub apd_reference_number: Option<String>,
62    pub status: String,
63    pub reported_by: String,
64    pub investigation_notes: Option<String>,
65    pub root_cause: Option<String>,
66    pub remediation_steps: Option<String>,
67    pub created_at: String,
68    pub updated_at: String,
69    pub hours_since_discovery: f64,
70}
71
72/// Response for listing incidents
73#[derive(Debug, Serialize)]
74pub struct SecurityIncidentsResponse {
75    pub incidents: Vec<SecurityIncidentDto>,
76    pub total: i64,
77}
78
79/// Query parameters for listing incidents
80#[derive(Debug, Deserialize)]
81pub struct SecurityIncidentsQuery {
82    pub page: Option<i64>,
83    pub per_page: Option<i64>,
84    pub severity: Option<String>,
85    pub status: Option<String>,
86}
87
88/// POST /api/v1/admin/security-incidents
89/// Create a new security incident (SuperAdmin only)
90///
91/// # Returns
92/// * `201 Created` - Security incident created successfully
93/// * `400 Bad Request` - Invalid input parameters
94/// * `401 Unauthorized` - Missing or invalid authentication
95/// * `403 Forbidden` - User is not SuperAdmin
96/// * `500 Internal Server Error` - Database error
97#[post("/admin/security-incidents")]
98pub async fn create_security_incident(
99    req: HttpRequest,
100    data: web::Data<AppState>,
101    auth: AuthenticatedUser,
102    body: web::Json<CreateSecurityIncidentRequest>,
103) -> impl Responder {
104    // Only SuperAdmin can create incidents
105    if auth.role != "superadmin" {
106        return HttpResponse::Forbidden().json(serde_json::json!({
107            "error": "Access denied. SuperAdmin role required."
108        }));
109    }
110
111    // Validate input
112    if body.title.is_empty() || body.description.is_empty() {
113        return HttpResponse::BadRequest().json(serde_json::json!({
114            "error": "title and description are required"
115        }));
116    }
117
118    let valid_severities = ["critical", "high", "medium", "low"];
119    if !valid_severities.contains(&body.severity.as_str()) {
120        return HttpResponse::BadRequest().json(serde_json::json!({
121            "error": "Invalid severity. Must be: critical, high, medium, or low"
122        }));
123    }
124
125    let _valid_statuses = ["detected", "investigating"];
126    let status = "detected".to_string();
127
128    // Extract client info for audit
129    let ip_address = extract_ip_address(&req);
130    let user_agent = extract_user_agent(&req);
131
132    // Insert incident into database
133    match data.pool.acquire().await {
134        Ok(mut conn) => {
135            match sqlx::query_as!(
136                SecurityIncidentRow,
137                r#"
138                INSERT INTO security_incidents (
139                    organization_id,
140                    severity,
141                    incident_type,
142                    title,
143                    description,
144                    data_categories_affected,
145                    affected_subjects_count,
146                    discovery_at,
147                    status,
148                    reported_by
149                )
150                VALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8, $9)
151                RETURNING
152                    id, organization_id, severity, incident_type, title, description,
153                    data_categories_affected, affected_subjects_count, discovery_at,
154                    notification_at, apd_reference_number, status, reported_by,
155                    investigation_notes, root_cause, remediation_steps, created_at, updated_at
156                "#,
157                auth.organization_id,
158                body.severity,
159                body.incident_type,
160                body.title,
161                body.description,
162                &body.data_categories_affected,
163                body.affected_subjects_count,
164                status,
165                auth.user_id
166            )
167            .fetch_one(&mut *conn)
168            .await
169            {
170                Ok(row) => {
171                    let hours_since = calculate_hours_since(&row.discovery_at);
172                    let incident_dto = SecurityIncidentDto {
173                        id: row.id.to_string(),
174                        organization_id: row.organization_id.to_string(),
175                        severity: row.severity,
176                        incident_type: row.incident_type,
177                        title: row.title,
178                        description: row.description,
179                        data_categories_affected: row.data_categories_affected.unwrap_or_default(),
180                        affected_subjects_count: row.affected_subjects_count,
181                        discovery_at: row.discovery_at.to_rfc3339(),
182                        notification_at: row.notification_at.map(|dt| dt.to_rfc3339()),
183                        apd_reference_number: row.apd_reference_number,
184                        status: row.status,
185                        reported_by: row.reported_by.to_string(),
186                        investigation_notes: row.investigation_notes,
187                        root_cause: row.root_cause,
188                        remediation_steps: row.remediation_steps,
189                        created_at: row.created_at.to_rfc3339(),
190                        updated_at: row.updated_at.to_rfc3339(),
191                        hours_since_discovery: hours_since,
192                    };
193
194                    // Audit log (async)
195                    let audit_entry = crate::infrastructure::audit::AuditLogEntry::new(
196                        crate::infrastructure::audit::AuditEventType::SecurityIncidentReported,
197                        Some(auth.user_id),
198                        auth.organization_id,
199                    )
200                    .with_resource("SecurityIncident", row.id)
201                    .with_client_info(ip_address, user_agent)
202                    .with_metadata(serde_json::json!({
203                        "severity": body.severity,
204                        "incident_type": body.incident_type,
205                        "affected_subjects": body.affected_subjects_count
206                    }));
207
208                    let audit_logger = data.audit_logger.clone();
209                    tokio::spawn(async move {
210                        audit_logger.log(&audit_entry).await;
211                    });
212
213                    HttpResponse::Created().json(incident_dto)
214                }
215                Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
216                    "error": format!("Failed to create incident: {}", e)
217                })),
218            }
219        }
220        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
221            "error": format!("Database connection error: {}", e)
222        })),
223    }
224}
225
226/// GET /api/v1/admin/security-incidents
227/// List security incidents with pagination and filters (SuperAdmin only)
228///
229/// # Query Parameters
230/// - page: Page number (default: 1)
231/// - per_page: Items per page (default: 20)
232/// - severity: Filter by severity (critical, high, medium, low)
233/// - status: Filter by status (detected, investigating, contained, reported, closed)
234///
235/// # Returns
236/// * `200 OK` - Paginated list of incidents
237/// * `401 Unauthorized` - Missing or invalid authentication
238/// * `403 Forbidden` - User is not SuperAdmin
239/// * `500 Internal Server Error` - Database error
240#[get("/admin/security-incidents")]
241pub async fn list_security_incidents(
242    data: web::Data<AppState>,
243    auth: AuthenticatedUser,
244    query: web::Query<SecurityIncidentsQuery>,
245) -> impl Responder {
246    // Only SuperAdmin can view incidents
247    if auth.role != "superadmin" {
248        return HttpResponse::Forbidden().json(serde_json::json!({
249            "error": "Access denied. SuperAdmin role required."
250        }));
251    }
252
253    let page = query.page.unwrap_or(1).max(1);
254    let per_page = query.per_page.unwrap_or(20).clamp(1, 100);
255    let offset = (page - 1) * per_page;
256
257    match data.pool.acquire().await {
258        Ok(mut conn) => {
259            // Build base query with optional filters
260            let base_query = if let Some(ref severity) = query.severity {
261                if let Some(ref status) = query.status {
262                    sqlx::query_as!(
263                        SecurityIncidentRow,
264                        r#"
265                        SELECT
266                            id, organization_id, severity, incident_type, title, description,
267                            data_categories_affected, affected_subjects_count, discovery_at,
268                            notification_at, apd_reference_number, status, reported_by,
269                            investigation_notes, root_cause, remediation_steps, created_at, updated_at
270                        FROM security_incidents
271                        WHERE organization_id = $1 AND severity = $2 AND status = $3
272                        ORDER BY discovery_at DESC
273                        LIMIT $4 OFFSET $5
274                        "#,
275                        auth.organization_id,
276                        severity,
277                        status,
278                        per_page,
279                        offset
280                    )
281                    .fetch_all(&mut *conn)
282                    .await
283                } else {
284                    sqlx::query_as!(
285                        SecurityIncidentRow,
286                        r#"
287                        SELECT
288                            id, organization_id, severity, incident_type, title, description,
289                            data_categories_affected, affected_subjects_count, discovery_at,
290                            notification_at, apd_reference_number, status, reported_by,
291                            investigation_notes, root_cause, remediation_steps, created_at, updated_at
292                        FROM security_incidents
293                        WHERE organization_id = $1 AND severity = $2
294                        ORDER BY discovery_at DESC
295                        LIMIT $3 OFFSET $4
296                        "#,
297                        auth.organization_id,
298                        severity,
299                        per_page,
300                        offset
301                    )
302                    .fetch_all(&mut *conn)
303                    .await
304                }
305            } else if let Some(ref status) = query.status {
306                sqlx::query_as!(
307                    SecurityIncidentRow,
308                    r#"
309                    SELECT
310                        id, organization_id, severity, incident_type, title, description,
311                        data_categories_affected, affected_subjects_count, discovery_at,
312                        notification_at, apd_reference_number, status, reported_by,
313                        investigation_notes, root_cause, remediation_steps, created_at, updated_at
314                    FROM security_incidents
315                    WHERE organization_id = $1 AND status = $2
316                    ORDER BY discovery_at DESC
317                    LIMIT $3 OFFSET $4
318                    "#,
319                    auth.organization_id,
320                    status,
321                    per_page,
322                    offset
323                )
324                .fetch_all(&mut *conn)
325                .await
326            } else {
327                sqlx::query_as!(
328                    SecurityIncidentRow,
329                    r#"
330                    SELECT
331                        id, organization_id, severity, incident_type, title, description,
332                        data_categories_affected, affected_subjects_count, discovery_at,
333                        notification_at, apd_reference_number, status, reported_by,
334                        investigation_notes, root_cause, remediation_steps, created_at, updated_at
335                    FROM security_incidents
336                    WHERE organization_id = $1
337                    ORDER BY discovery_at DESC
338                    LIMIT $2 OFFSET $3
339                    "#,
340                    auth.organization_id,
341                    per_page,
342                    offset
343                )
344                .fetch_all(&mut *conn)
345                .await
346            };
347
348            match base_query {
349                Ok(rows) => {
350                    // Get total count
351                    let count_result = sqlx::query_scalar!(
352                        "SELECT COUNT(*) as count FROM security_incidents WHERE organization_id = $1",
353                        auth.organization_id
354                    )
355                    .fetch_one(&mut *conn)
356                    .await;
357
358                    let total = count_result.unwrap_or(Some(0)).unwrap_or(0);
359
360                    let incidents: Vec<SecurityIncidentDto> = rows
361                        .into_iter()
362                        .map(|row| {
363                            let hours_since = calculate_hours_since(&row.discovery_at);
364                            SecurityIncidentDto {
365                                id: row.id.to_string(),
366                                organization_id: row.organization_id.to_string(),
367                                severity: row.severity,
368                                incident_type: row.incident_type,
369                                title: row.title,
370                                description: row.description,
371                                data_categories_affected: row
372                                    .data_categories_affected
373                                    .unwrap_or_default(),
374                                affected_subjects_count: row.affected_subjects_count,
375                                discovery_at: row.discovery_at.to_rfc3339(),
376                                notification_at: row.notification_at.map(|dt| dt.to_rfc3339()),
377                                apd_reference_number: row.apd_reference_number,
378                                status: row.status,
379                                reported_by: row.reported_by.to_string(),
380                                investigation_notes: row.investigation_notes,
381                                root_cause: row.root_cause,
382                                remediation_steps: row.remediation_steps,
383                                created_at: row.created_at.to_rfc3339(),
384                                updated_at: row.updated_at.to_rfc3339(),
385                                hours_since_discovery: hours_since,
386                            }
387                        })
388                        .collect();
389
390                    HttpResponse::Ok().json(SecurityIncidentsResponse { incidents, total })
391                }
392                Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
393                    "error": format!("Failed to fetch incidents: {}", e)
394                })),
395            }
396        }
397        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
398            "error": format!("Database connection error: {}", e)
399        })),
400    }
401}
402
403/// GET /api/v1/admin/security-incidents/:id
404/// Get a specific security incident (SuperAdmin only)
405///
406/// # Returns
407/// * `200 OK` - Incident details
408/// * `401 Unauthorized` - Missing or invalid authentication
409/// * `403 Forbidden` - User is not SuperAdmin
410/// * `404 Not Found` - Incident not found
411/// * `500 Internal Server Error` - Database error
412#[get("/admin/security-incidents/{incident_id}")]
413pub async fn get_security_incident(
414    data: web::Data<AppState>,
415    auth: AuthenticatedUser,
416    path: web::Path<Uuid>,
417) -> impl Responder {
418    // Only SuperAdmin can view incidents
419    if auth.role != "superadmin" {
420        return HttpResponse::Forbidden().json(serde_json::json!({
421            "error": "Access denied. SuperAdmin role required."
422        }));
423    }
424
425    let incident_id = path.into_inner();
426
427    match data.pool.acquire().await {
428        Ok(mut conn) => {
429            match sqlx::query_as!(
430                SecurityIncidentRow,
431                r#"
432                SELECT
433                    id, organization_id, severity, incident_type, title, description,
434                    data_categories_affected, affected_subjects_count, discovery_at,
435                    notification_at, apd_reference_number, status, reported_by,
436                    investigation_notes, root_cause, remediation_steps, created_at, updated_at
437                FROM security_incidents
438                WHERE id = $1 AND organization_id = $2
439                "#,
440                incident_id,
441                auth.organization_id
442            )
443            .fetch_optional(&mut *conn)
444            .await
445            {
446                Ok(Some(row)) => {
447                    let hours_since = calculate_hours_since(&row.discovery_at);
448                    let incident_dto = SecurityIncidentDto {
449                        id: row.id.to_string(),
450                        organization_id: row.organization_id.to_string(),
451                        severity: row.severity,
452                        incident_type: row.incident_type,
453                        title: row.title,
454                        description: row.description,
455                        data_categories_affected: row.data_categories_affected.unwrap_or_default(),
456                        affected_subjects_count: row.affected_subjects_count,
457                        discovery_at: row.discovery_at.to_rfc3339(),
458                        notification_at: row.notification_at.map(|dt| dt.to_rfc3339()),
459                        apd_reference_number: row.apd_reference_number,
460                        status: row.status,
461                        reported_by: row.reported_by.to_string(),
462                        investigation_notes: row.investigation_notes,
463                        root_cause: row.root_cause,
464                        remediation_steps: row.remediation_steps,
465                        created_at: row.created_at.to_rfc3339(),
466                        updated_at: row.updated_at.to_rfc3339(),
467                        hours_since_discovery: hours_since,
468                    };
469
470                    HttpResponse::Ok().json(incident_dto)
471                }
472                Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
473                    "error": "Security incident not found"
474                })),
475                Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
476                    "error": format!("Failed to fetch incident: {}", e)
477                })),
478            }
479        }
480        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
481            "error": format!("Database connection error: {}", e)
482        })),
483    }
484}
485
486/// PUT /api/v1/admin/security-incidents/:id/report-apd
487/// Mark incident as reported to APD with reference number (SuperAdmin only)
488///
489/// # Returns
490/// * `200 OK` - Incident marked as reported
491/// * `400 Bad Request` - APD reference number required
492/// * `401 Unauthorized` - Missing or invalid authentication
493/// * `403 Forbidden` - User is not SuperAdmin
494/// * `404 Not Found` - Incident not found
495/// * `409 Conflict` - Incident already reported
496/// * `500 Internal Server Error` - Database error
497#[put("/admin/security-incidents/{incident_id}/report-apd")]
498pub async fn report_incident_to_apd(
499    data: web::Data<AppState>,
500    auth: AuthenticatedUser,
501    path: web::Path<Uuid>,
502    body: web::Json<ReportToApdRequest>,
503) -> impl Responder {
504    // Only SuperAdmin can report incidents
505    if auth.role != "superadmin" {
506        return HttpResponse::Forbidden().json(serde_json::json!({
507            "error": "Access denied. SuperAdmin role required."
508        }));
509    }
510
511    let incident_id = path.into_inner();
512
513    if body.apd_reference_number.is_none() || body.apd_reference_number.as_ref().unwrap().is_empty()
514    {
515        return HttpResponse::BadRequest().json(serde_json::json!({
516            "error": "apd_reference_number is required"
517        }));
518    }
519
520    match data.pool.acquire().await {
521        Ok(mut conn) => {
522            // First, check if already reported
523            match sqlx::query_scalar!(
524                "SELECT notification_at FROM security_incidents WHERE id = $1 AND organization_id = $2",
525                incident_id,
526                auth.organization_id
527            )
528            .fetch_optional(&mut *conn)
529            .await
530            {
531                Ok(Some(Some(_))) => {
532                    HttpResponse::Conflict().json(serde_json::json!({
533                        "error": "Incident already reported to APD"
534                    }))
535                }
536                Ok(Some(None)) => {
537                    // Update incident as reported
538                    match sqlx::query_as!(
539                        SecurityIncidentRow,
540                        r#"
541                        UPDATE security_incidents
542                        SET notification_at = now(),
543                            apd_reference_number = $1,
544                            status = 'reported',
545                            investigation_notes = $2
546                        WHERE id = $3 AND organization_id = $4
547                        RETURNING
548                            id, organization_id, severity, incident_type, title, description,
549                            data_categories_affected, affected_subjects_count, discovery_at,
550                            notification_at, apd_reference_number, status, reported_by,
551                            investigation_notes, root_cause, remediation_steps, created_at, updated_at
552                        "#,
553                        body.apd_reference_number,
554                        body.investigation_notes,
555                        incident_id,
556                        auth.organization_id
557                    )
558                    .fetch_one(&mut *conn)
559                    .await
560                    {
561                        Ok(row) => {
562                            let hours_since = calculate_hours_since(&row.discovery_at);
563                            let incident_dto = SecurityIncidentDto {
564                                id: row.id.to_string(),
565                                organization_id: row.organization_id.to_string(),
566                                severity: row.severity,
567                                incident_type: row.incident_type,
568                                title: row.title,
569                                description: row.description,
570                                data_categories_affected: row.data_categories_affected.unwrap_or_default(),
571                                affected_subjects_count: row.affected_subjects_count,
572                                discovery_at: row.discovery_at.to_rfc3339(),
573                                notification_at: row.notification_at.map(|dt| dt.to_rfc3339()),
574                                apd_reference_number: row.apd_reference_number,
575                                status: row.status,
576                                reported_by: row.reported_by.to_string(),
577                                investigation_notes: row.investigation_notes,
578                                root_cause: row.root_cause,
579                                remediation_steps: row.remediation_steps,
580                                created_at: row.created_at.to_rfc3339(),
581                                updated_at: row.updated_at.to_rfc3339(),
582                                hours_since_discovery: hours_since,
583                            };
584
585                            HttpResponse::Ok().json(incident_dto)
586                        }
587                        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
588                            "error": format!("Failed to update incident: {}", e)
589                        })),
590                    }
591                }
592                Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
593                    "error": "Security incident not found"
594                })),
595                Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
596                    "error": format!("Database query failed: {}", e)
597                })),
598            }
599        }
600        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
601            "error": format!("Database connection error: {}", e)
602        })),
603    }
604}
605
606/// GET /api/v1/admin/security-incidents/overdue
607/// List incidents overdue for APD notification (>72 hours old, not yet reported)
608///
609/// # Returns
610/// * `200 OK` - List of overdue incidents requiring APD notification
611/// * `401 Unauthorized` - Missing or invalid authentication
612/// * `403 Forbidden` - User is not SuperAdmin
613/// * `500 Internal Server Error` - Database error
614#[get("/admin/security-incidents/overdue")]
615pub async fn list_overdue_incidents(
616    data: web::Data<AppState>,
617    auth: AuthenticatedUser,
618) -> impl Responder {
619    // Only SuperAdmin can view overdue incidents
620    if auth.role != "superadmin" {
621        return HttpResponse::Forbidden().json(serde_json::json!({
622            "error": "Access denied. SuperAdmin role required."
623        }));
624    }
625
626    match data.pool.acquire().await {
627        Ok(mut conn) => {
628            match sqlx::query_as!(
629                SecurityIncidentRow,
630                r#"
631                SELECT
632                    id, organization_id, severity, incident_type, title, description,
633                    data_categories_affected, affected_subjects_count, discovery_at,
634                    notification_at, apd_reference_number, status, reported_by,
635                    investigation_notes, root_cause, remediation_steps, created_at, updated_at
636                FROM security_incidents
637                WHERE organization_id = $1
638                  AND notification_at IS NULL
639                  AND status IN ('detected', 'investigating', 'contained')
640                  AND discovery_at < (NOW() - INTERVAL '72 hours')
641                ORDER BY discovery_at ASC
642                "#,
643                auth.organization_id
644            )
645            .fetch_all(&mut *conn)
646            .await
647            {
648                Ok(rows) => {
649                    let total = rows.len() as i64;
650                    let incidents: Vec<SecurityIncidentDto> = rows
651                        .into_iter()
652                        .map(|row| {
653                            let hours_since = calculate_hours_since(&row.discovery_at);
654                            SecurityIncidentDto {
655                                id: row.id.to_string(),
656                                organization_id: row.organization_id.to_string(),
657                                severity: row.severity,
658                                incident_type: row.incident_type,
659                                title: row.title,
660                                description: row.description,
661                                data_categories_affected: row
662                                    .data_categories_affected
663                                    .unwrap_or_default(),
664                                affected_subjects_count: row.affected_subjects_count,
665                                discovery_at: row.discovery_at.to_rfc3339(),
666                                notification_at: row.notification_at.map(|dt| dt.to_rfc3339()),
667                                apd_reference_number: row.apd_reference_number,
668                                status: row.status,
669                                reported_by: row.reported_by.to_string(),
670                                investigation_notes: row.investigation_notes,
671                                root_cause: row.root_cause,
672                                remediation_steps: row.remediation_steps,
673                                created_at: row.created_at.to_rfc3339(),
674                                updated_at: row.updated_at.to_rfc3339(),
675                                hours_since_discovery: hours_since,
676                            }
677                        })
678                        .collect();
679
680                    HttpResponse::Ok().json(SecurityIncidentsResponse { incidents, total })
681                }
682                Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
683                    "error": format!("Failed to fetch overdue incidents: {}", e)
684                })),
685            }
686        }
687        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
688            "error": format!("Database connection error: {}", e)
689        })),
690    }
691}
692
693// Helper function to calculate hours since discovery
694fn calculate_hours_since(discovery_at: &chrono::DateTime<chrono::Utc>) -> f64 {
695    let now = chrono::Utc::now();
696    let duration = now.signed_duration_since(*discovery_at);
697    duration.num_seconds() as f64 / 3600.0
698}
699
700// Internal struct for sqlx query results
701struct SecurityIncidentRow {
702    id: uuid::Uuid,
703    organization_id: uuid::Uuid,
704    severity: String,
705    incident_type: String,
706    title: String,
707    description: String,
708    data_categories_affected: Option<Vec<String>>,
709    affected_subjects_count: Option<i32>,
710    discovery_at: chrono::DateTime<chrono::Utc>,
711    notification_at: Option<chrono::DateTime<chrono::Utc>>,
712    apd_reference_number: Option<String>,
713    status: String,
714    reported_by: uuid::Uuid,
715    investigation_notes: Option<String>,
716    root_cause: Option<String>,
717    remediation_steps: Option<String>,
718    created_at: chrono::DateTime<chrono::Utc>,
719    updated_at: chrono::DateTime<chrono::Utc>,
720}
721
722#[cfg(test)]
723mod tests {
724    #[test]
725    fn test_handler_structure_create_incident() {
726        // Structural test - actual testing in E2E
727    }
728
729    #[test]
730    fn test_handler_structure_list_incidents() {
731        // Structural test - actual testing in E2E
732    }
733
734    #[test]
735    fn test_handler_structure_get_incident() {
736        // Structural test - actual testing in E2E
737    }
738
739    #[test]
740    fn test_handler_structure_report_to_apd() {
741        // Structural test - actual testing in E2E
742    }
743
744    #[test]
745    fn test_handler_structure_overdue_incidents() {
746        // Structural test - actual testing in E2E
747    }
748}