koprogo_api/infrastructure/web/handlers/
magic_link_handlers.rs1use crate::application::dto::contractor_report_dto::UpdateContractorReportDto;
11use crate::application::error::AppError;
12use crate::application::use_cases::MagicLinkUseCases;
13use crate::domain::entities::MagicLinkScopeKind;
14use crate::infrastructure::web::{AppState, AuthenticatedUser};
15use actix_web::{get, post, web, HttpResponse};
16use serde::{Deserialize, Serialize};
17use std::str::FromStr;
18use uuid::Uuid;
19
20#[derive(Debug, Deserialize, utoipa::ToSchema)]
25pub struct IssueMagicLinkRequest {
26 pub subject_user_id: Uuid,
27 pub scope_kind: String,
28 pub scope_id: Uuid,
29 pub expires_in_seconds: i64,
30}
31
32#[derive(Debug, Serialize, utoipa::ToSchema)]
33pub struct IssueMagicLinkResponse {
34 pub id: Uuid,
35 pub token: String,
36 pub expires_at: chrono::DateTime<chrono::Utc>,
37 pub scope_kind: String,
38 pub scope_id: Uuid,
39}
40
41#[derive(Debug, Serialize, utoipa::ToSchema)]
42pub struct PublicScopePayload {
43 pub scope_kind: String,
44 pub scope_id: Uuid,
45 #[schema(value_type = serde_json::Value)]
46 pub scope: serde_json::Value,
47}
48
49fn require_syndic_or_superadmin(user: &AuthenticatedUser) -> Result<(), AppError> {
54 match user.role.as_str() {
55 "syndic" | "superadmin" => Ok(()),
56 _ => Err(AppError::Forbidden(
57 "Only syndic or superadmin can issue magic links".to_string(),
58 )),
59 }
60}
61
62#[utoipa::path(
67 post,
68 path = "/magic-links",
69 tag = "MagicLink",
70 summary = "Issue a magic link (syndic / superadmin only)",
71 responses(
72 (status = 201, description = "MagicLink issued"),
73 (status = 400, description = "Validation error"),
74 (status = 403, description = "Forbidden — only syndic or superadmin"),
75 ),
76)]
77#[post("/magic-links")]
78pub async fn issue_magic_link(
79 state: web::Data<AppState>,
80 user: AuthenticatedUser,
81 body: web::Json<IssueMagicLinkRequest>,
82) -> Result<HttpResponse, AppError> {
83 require_syndic_or_superadmin(&user)?;
84
85 let req = body.into_inner();
86 let scope_kind = MagicLinkScopeKind::from_str(&req.scope_kind)?;
87
88 let issued = state
89 .magic_link_use_cases
90 .issue(
91 req.subject_user_id,
92 scope_kind,
93 req.scope_id,
94 user.user_id,
95 req.expires_in_seconds,
96 )
97 .await?;
98
99 Ok(HttpResponse::Created().json(IssueMagicLinkResponse {
100 id: issued.id,
101 token: issued.token,
102 expires_at: issued.expires_at,
103 scope_kind: issued.scope_kind.to_string(),
104 scope_id: issued.scope_id,
105 }))
106}
107
108#[utoipa::path(
113 get,
114 path = "/c/{token}",
115 tag = "MagicLink",
116 summary = "Public access via magic link",
117 responses(
118 (status = 200, description = "Scope payload"),
119 (status = 403, description = "Invalid / expired / already consumed"),
120 ),
121)]
122#[get("/c/{token}")]
123pub async fn consume_magic_link(
124 state: web::Data<AppState>,
125 path: web::Path<String>,
126) -> Result<HttpResponse, AppError> {
127 let token = path.into_inner();
128 let link = state
129 .magic_link_use_cases
130 .validate_and_consume(&token)
131 .await?;
132
133 let scope_json = match link.scope_kind {
138 MagicLinkScopeKind::Ticket => {
139 match state
140 .ticket_use_cases
141 .get_ticket(link.scope_id)
142 .await
143 .map_err(AppError::Internal)?
144 {
145 Some(ticket) => {
146 serde_json::to_value(&ticket).map_err(|e| AppError::Internal(e.to_string()))?
147 }
148 None => return Err(AppError::NotFound(format!("ticket {}", link.scope_id))),
149 }
150 }
151 MagicLinkScopeKind::Quote | MagicLinkScopeKind::Invoice => {
152 serde_json::json!({
156 "scope_id": link.scope_id,
157 "note": "Scope payload resolution pending follow-up",
158 })
159 }
160 MagicLinkScopeKind::ContractorEvaluation => {
161 serde_json::json!({
162 "scope_id": link.scope_id,
163 "note": "Scope payload resolution pending follow-up",
164 })
165 }
166 MagicLinkScopeKind::ContractorReport => {
167 let dto = state
170 .contractor_report_use_cases
171 .get_via_magic_link(link.scope_id)
172 .await?;
173 serde_json::to_value(&dto).map_err(|e| AppError::Internal(e.to_string()))?
174 }
175 };
176
177 Ok(HttpResponse::Ok().json(PublicScopePayload {
178 scope_kind: link.scope_kind.to_string(),
179 scope_id: link.scope_id,
180 scope: scope_json,
181 }))
182}
183
184#[utoipa::path(
194 post,
195 path = "/c/{token}/respond",
196 tag = "MagicLink",
197 summary = "Public write action for a magic link (currently: ContractorReport submit)",
198 responses(
199 (status = 200, description = "Report updated and submitted"),
200 (status = 400, description = "Unsupported scope for this link, or validation error"),
201 (status = 403, description = "Invalid / expired token"),
202 ),
203)]
204#[post("/c/{token}/respond")]
205pub async fn respond_magic_link(
206 state: web::Data<AppState>,
207 path: web::Path<String>,
208 body: web::Json<UpdateContractorReportDto>,
209) -> Result<HttpResponse, AppError> {
210 let token = path.into_inner();
211
212 let link = state.magic_link_use_cases.peek(&token).await?;
217 MagicLinkUseCases::ensure_scope(&link, MagicLinkScopeKind::ContractorReport)?;
218
219 let updated = state
220 .contractor_report_use_cases
221 .respond_via_magic_link(link.scope_id, body.into_inner())
222 .await?;
223
224 Ok(HttpResponse::Ok().json(updated))
225}