koprogo_api/infrastructure/web/handlers/
syndic_response_handlers.rs1use 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#[derive(Debug, Deserialize, utoipa::ToSchema)]
28pub struct CreateSyndicResponseRequest {
29 pub body: String,
31 #[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
60fn 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#[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 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#[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 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}