Skip to main content

koprogo_api/infrastructure/web/handlers/
syndic_response_handlers.rs

1//! HTTP handlers for SyndicResponse (Story 3.7 — FR32 INV-23).
2//!
3//! Routes:
4//! - `POST /tickets/{id}/syndic-responses` — syndic / superadmin posts a
5//!   structured reply to a ticket.
6//! - `GET  /tickets/{id}/syndic-responses` — list responses for a ticket.
7//!
8//! Both routes are JWT-protected. Phase A keeps the scope checks minimal
9//! (syndic / superadmin can post; any authenticated user can read — the
10//! ticket detail page already enforces the ACP scope upstream). Tightening
11//! to "only members of the building's ACP can read" is a Phase B
12//! follow-up tracked in the Story 3.7 acceptance notes.
13
14use crate::application::error::AppError;
15use crate::domain::entities::SyndicResponse;
16use crate::infrastructure::web::middleware::scope_guard::verify_ticket_org_access;
17use crate::infrastructure::web::{AppState, AuthenticatedUser};
18use actix_web::{get, post, web, HttpResponse};
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21use uuid::Uuid;
22
23// ---------------------------------------------------------------------------
24// DTOs
25// ---------------------------------------------------------------------------
26
27#[derive(Debug, Deserialize, utoipa::ToSchema)]
28pub struct CreateSyndicResponseRequest {
29    /// Free text — 10..=5000 chars after trim.
30    pub body: String,
31    /// One of: `schedule_inspection`, `request_quote`, `closed_no_action`,
32    /// `escalated_board`, `other`. Optional.
33    #[serde(default)]
34    pub action_proposed: Option<String>,
35}
36
37#[derive(Debug, Serialize, utoipa::ToSchema)]
38pub struct SyndicResponseDto {
39    pub id: Uuid,
40    pub ticket_id: Uuid,
41    pub syndic_user_id: Uuid,
42    pub body: String,
43    pub action_proposed: Option<String>,
44    pub created_at: DateTime<Utc>,
45}
46
47impl From<SyndicResponse> for SyndicResponseDto {
48    fn from(r: SyndicResponse) -> Self {
49        Self {
50            id: r.id,
51            ticket_id: r.ticket_id,
52            syndic_user_id: r.syndic_user_id,
53            body: r.body,
54            action_proposed: r.action_proposed,
55            created_at: r.created_at,
56        }
57    }
58}
59
60// ---------------------------------------------------------------------------
61// Guards
62// ---------------------------------------------------------------------------
63
64fn require_syndic_or_superadmin(user: &AuthenticatedUser) -> Result<(), AppError> {
65    match user.role.as_str() {
66        "syndic" | "superadmin" => Ok(()),
67        _ => Err(AppError::Forbidden(
68            "Only syndic or superadmin can post a SyndicResponse".to_string(),
69        )),
70    }
71}
72
73// ---------------------------------------------------------------------------
74// POST /tickets/{id}/syndic-responses
75// ---------------------------------------------------------------------------
76
77#[utoipa::path(
78    post,
79    path = "/tickets/{id}/syndic-responses",
80    tag = "SyndicResponse",
81    summary = "Post a structured syndic response to a ticket (append-only)",
82    responses(
83        (status = 201, description = "Response saved", body = SyndicResponseDto),
84        (status = 400, description = "Validation error (body too short/long, invalid action)"),
85        (status = 403, description = "Forbidden — only syndic / superadmin"),
86        (status = 404, description = "Ticket not found"),
87    ),
88)]
89#[post("/tickets/{id}/syndic-responses")]
90pub async fn create_syndic_response(
91    state: web::Data<AppState>,
92    user: AuthenticatedUser,
93    path: web::Path<Uuid>,
94    body: web::Json<CreateSyndicResponseRequest>,
95) -> Result<HttpResponse, AppError> {
96    require_syndic_or_superadmin(&user)?;
97    let ticket_id = path.into_inner();
98
99    // Cloisonnement : ce ticket doit relever d'une ACP que cet utilisateur a le
100    // droit de voir. `require_syndic_or_superadmin` ci-dessus vérifie le RÔLE
101    // et rien d'autre — un syndic de l'organisation A y passait pour répondre
102    // au ticket d'un copropriétaire de l'organisation B (#772).
103    verify_ticket_org_access(
104        &user,
105        ticket_id,
106        &state.ticket_use_cases,
107        &state.building_use_cases,
108        &state.acp_use_cases,
109    )
110    .await?;
111    let payload = body.into_inner();
112
113    let response = state
114        .syndic_response_use_cases
115        .respond(
116            ticket_id,
117            user.user_id,
118            payload.body,
119            payload.action_proposed,
120        )
121        .await?;
122
123    Ok(HttpResponse::Created().json(SyndicResponseDto::from(response)))
124}
125
126// ---------------------------------------------------------------------------
127// GET /tickets/{id}/syndic-responses
128// ---------------------------------------------------------------------------
129
130#[utoipa::path(
131    get,
132    path = "/tickets/{id}/syndic-responses",
133    tag = "SyndicResponse",
134    summary = "List syndic responses for a ticket (oldest first)",
135    responses(
136        (status = 200, description = "Responses list", body = Vec<SyndicResponseDto>),
137    ),
138)]
139#[get("/tickets/{id}/syndic-responses")]
140pub async fn list_syndic_responses(
141    state: web::Data<AppState>,
142    user: AuthenticatedUser,
143    path: web::Path<Uuid>,
144) -> Result<HttpResponse, AppError> {
145    let ticket_id = path.into_inner();
146
147    // Cloisonnement : ce ticket doit relever d'une ACP que cet utilisateur a le
148    // droit de voir. `require_syndic_or_superadmin` ci-dessus vérifie le RÔLE
149    // et rien d'autre — un syndic de l'organisation A y passait pour répondre
150    // au ticket d'un copropriétaire de l'organisation B (#772).
151    verify_ticket_org_access(
152        &user,
153        ticket_id,
154        &state.ticket_use_cases,
155        &state.building_use_cases,
156        &state.acp_use_cases,
157    )
158    .await?;
159    let responses = state
160        .syndic_response_use_cases
161        .list_for_ticket(ticket_id)
162        .await?;
163    let dtos: Vec<SyndicResponseDto> = responses.into_iter().map(SyndicResponseDto::from).collect();
164    Ok(HttpResponse::Ok().json(dtos))
165}