Skip to main content

koprogo_api/infrastructure/web/handlers/
magic_link_handlers.rs

1//! HTTP handlers for the MagicLink feature (Story 3.2).
2//!
3//! Two endpoints:
4//! - `POST /magic-links` — syndic / superadmin issues a link for a recipient.
5//! - `GET  /c/{token}`   — PUBLIC: validate, consume, resolve scope. The route
6//!   is intentionally outside `/api/v1` so the public-facing URL stays short
7//!   (`/c/<token>`). IP-based rate-limiting is enforced by Traefik for all
8//!   routes — no extra guard needed here.
9
10use 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// ---------------------------------------------------------------------------
21// DTOs
22// ---------------------------------------------------------------------------
23
24#[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
49// ---------------------------------------------------------------------------
50// Guards
51// ---------------------------------------------------------------------------
52
53fn 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// ---------------------------------------------------------------------------
63// POST /magic-links — syndic / superadmin only
64// ---------------------------------------------------------------------------
65
66#[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// ---------------------------------------------------------------------------
109// GET /c/{token} — PUBLIC (no auth) — validate, consume, resolve.
110// ---------------------------------------------------------------------------
111
112#[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    // Resolve the underlying resource. For scopes that don't yet have a
134    // public-friendly DTO we return a minimal placeholder — the front-end
135    // page will render the scope_kind specific UI and may call additional
136    // public endpoints if needed (follow-up).
137    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            // Follow-up: wire dedicated public DTOs for these scopes.
153            // For now return the scope identifier so the front-end can
154            // render a minimal "received" view.
155            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            // #835 — le rapport d'intervention rejoint l'écran unifié : même
168            // page, même paramètre `t`, plus de second système parallèle.
169            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// ---------------------------------------------------------------------------
185// POST /c/{token}/respond — PUBLIC (no auth) — write action bound to the same
186// token as the GET above. #835.
187// ---------------------------------------------------------------------------
188
189/// Only `ContractorReport` has a real "respond" action today: view the ticket
190/// via the other four scopes is already implemented (`GET /c/{token}`), but
191/// writing back for them has no use case yet — that's an existing gap this
192/// route doesn't attempt to close, only to name explicitly rather than 404.
193#[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    // Non-consuming lookup — the initial `GET /c/{token}` already consumed
213    // the token as an audit marker; this write may happen much later
214    // (offline draft, cf. #835 @edge), so it must not be re-gated on
215    // `consumed_at`. Cloisonnement is enforced right below via `ensure_scope`.
216    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}