Skip to main content

koprogo_api/infrastructure/web/handlers/
portfolio_handlers.rs

1//! Handlers Actix pour `/portfolios` — Story 2.1.
2//!
3//! Endpoints :
4//! - POST   `/portfolios`                                : create
5//! - GET    `/portfolios`                                : list (owner + shared)
6//! - GET    `/portfolios/{id}`                           : get (scope-guarded)
7//! - PUT    `/portfolios/{id}`                           : update (owner OR shared can_edit)
8//! - DELETE `/portfolios/{id}`                           : delete (owner)
9//! - POST   `/portfolios/{id}/buildings`                 : add building (owner OR can_edit)
10//! - GET    `/portfolios/{id}/buildings`                 : list buildings (owner OR shared)
11//! - DELETE `/portfolios/{id}/buildings/{building_id}`   : remove building
12//! - POST   `/portfolios/{id}/shares`                    : share (owner)
13//! - GET    `/portfolios/{id}/shares`                    : list shares (owner)
14//! - DELETE `/portfolios/{id}/shares/{user_id}`          : unshare (owner)
15//!
16//! Audit : `infrastructure::audit::AuditLogEntry` consigne chaque mutation
17//! réussie ET chaque échec (traçabilité INV-24, pattern ACP Story 1.1).
18
19use crate::application::dto::{
20    AddBuildingDto, CreatePortfolioDto, SharePortfolioDto, UpdatePortfolioDto,
21};
22use crate::application::use_cases::PortfolioCaller;
23use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
24use crate::infrastructure::web::{AppState, AuthenticatedUser};
25use actix_web::{delete, get, post, put, web, HttpResponse, Responder, ResponseError};
26use uuid::Uuid;
27use validator::Validate;
28
29fn caller_from_user(user: &AuthenticatedUser) -> PortfolioCaller {
30    PortfolioCaller {
31        user_id: user.user_id,
32    }
33}
34
35#[utoipa::path(
36    post,
37    path = "/portfolios",
38    tag = "Portfolios",
39    summary = "Create a portfolio",
40    request_body = CreatePortfolioDto,
41    responses(
42        (status = 201, description = "Portfolio created", body = crate::application::dto::PortfolioResponseDto),
43        (status = 400, description = "Validation error"),
44        (status = 401, description = "Unauthorized"),
45    ),
46    security(("bearer_auth" = []))
47)]
48#[post("/portfolios")]
49pub async fn create_portfolio(
50    state: web::Data<AppState>,
51    user: AuthenticatedUser,
52    dto: web::Json<CreatePortfolioDto>,
53) -> impl Responder {
54    if let Err(errors) = dto.validate() {
55        return HttpResponse::BadRequest().json(serde_json::json!({
56            "error": "Validation failed",
57            "details": errors.to_string(),
58            "kind": "validation",
59        }));
60    }
61    let caller = caller_from_user(&user);
62    match state
63        .portfolio_use_cases
64        .create_portfolio(&caller, dto.into_inner())
65        .await
66    {
67        Ok(resp) => {
68            if let Ok(pid) = Uuid::parse_str(&resp.id) {
69                AuditLogEntry::new(
70                    AuditEventType::PortfolioCreated,
71                    Some(user.user_id),
72                    user.organization_id,
73                )
74                .with_resource("Portfolio", pid)
75                .log();
76            }
77            HttpResponse::Created().json(resp)
78        }
79        Err(err) => {
80            AuditLogEntry::new(
81                AuditEventType::PortfolioCreated,
82                Some(user.user_id),
83                user.organization_id,
84            )
85            .with_error(err.to_string())
86            .log();
87            err.error_response()
88        }
89    }
90}
91
92#[utoipa::path(
93    get,
94    path = "/portfolios",
95    tag = "Portfolios",
96    summary = "List portfolios visible to the authenticated user (owned + shared)",
97    responses(
98        (status = 200, description = "List of portfolios", body = Vec<crate::application::dto::PortfolioResponseDto>),
99        (status = 401, description = "Unauthorized"),
100    ),
101    security(("bearer_auth" = []))
102)]
103#[get("/portfolios")]
104pub async fn list_portfolios(
105    state: web::Data<AppState>,
106    user: AuthenticatedUser,
107) -> impl Responder {
108    let caller = caller_from_user(&user);
109    match state.portfolio_use_cases.list_portfolios(&caller).await {
110        Ok(list) => HttpResponse::Ok().json(list),
111        Err(err) => err.error_response(),
112    }
113}
114
115#[utoipa::path(
116    get,
117    path = "/portfolios/{id}",
118    tag = "Portfolios",
119    summary = "Get a portfolio by id (owner OR shared)",
120    params(("id" = Uuid, Path, description = "Portfolio UUID")),
121    responses(
122        (status = 200, description = "Portfolio found", body = crate::application::dto::PortfolioResponseDto),
123        (status = 403, description = "Forbidden"),
124        (status = 404, description = "Not found"),
125    ),
126    security(("bearer_auth" = []))
127)]
128#[get("/portfolios/{id}")]
129pub async fn get_portfolio(
130    state: web::Data<AppState>,
131    user: AuthenticatedUser,
132    id: web::Path<Uuid>,
133) -> impl Responder {
134    let caller = caller_from_user(&user);
135    match state.portfolio_use_cases.get_portfolio(&caller, *id).await {
136        Ok(resp) => HttpResponse::Ok().json(resp),
137        Err(err) => err.error_response(),
138    }
139}
140
141#[utoipa::path(
142    put,
143    path = "/portfolios/{id}",
144    tag = "Portfolios",
145    summary = "Update a portfolio (owner OR shared can_edit)",
146    params(("id" = Uuid, Path, description = "Portfolio UUID")),
147    request_body = UpdatePortfolioDto,
148    responses(
149        (status = 200, description = "Portfolio updated", body = crate::application::dto::PortfolioResponseDto),
150        (status = 400, description = "Validation error"),
151        (status = 403, description = "Forbidden"),
152        (status = 404, description = "Not found"),
153    ),
154    security(("bearer_auth" = []))
155)]
156#[put("/portfolios/{id}")]
157pub async fn update_portfolio(
158    state: web::Data<AppState>,
159    user: AuthenticatedUser,
160    id: web::Path<Uuid>,
161    dto: web::Json<UpdatePortfolioDto>,
162) -> impl Responder {
163    if let Err(errors) = dto.validate() {
164        return HttpResponse::BadRequest().json(serde_json::json!({
165            "error": "Validation failed",
166            "details": errors.to_string(),
167            "kind": "validation",
168        }));
169    }
170    let pid = *id;
171    let caller = caller_from_user(&user);
172    match state
173        .portfolio_use_cases
174        .update_portfolio(&caller, pid, dto.into_inner())
175        .await
176    {
177        Ok(resp) => {
178            AuditLogEntry::new(
179                AuditEventType::PortfolioUpdated,
180                Some(user.user_id),
181                user.organization_id,
182            )
183            .with_resource("Portfolio", pid)
184            .log();
185            HttpResponse::Ok().json(resp)
186        }
187        Err(err) => {
188            AuditLogEntry::new(
189                AuditEventType::PortfolioUpdated,
190                Some(user.user_id),
191                user.organization_id,
192            )
193            .with_resource("Portfolio", pid)
194            .with_error(err.to_string())
195            .log();
196            err.error_response()
197        }
198    }
199}
200
201#[utoipa::path(
202    delete,
203    path = "/portfolios/{id}",
204    tag = "Portfolios",
205    summary = "Delete a portfolio (owner only)",
206    params(("id" = Uuid, Path, description = "Portfolio UUID")),
207    responses(
208        (status = 204, description = "Portfolio deleted"),
209        (status = 403, description = "Forbidden"),
210        (status = 404, description = "Not found"),
211    ),
212    security(("bearer_auth" = []))
213)]
214#[delete("/portfolios/{id}")]
215pub async fn delete_portfolio(
216    state: web::Data<AppState>,
217    user: AuthenticatedUser,
218    id: web::Path<Uuid>,
219) -> impl Responder {
220    let pid = *id;
221    let caller = caller_from_user(&user);
222    match state
223        .portfolio_use_cases
224        .delete_portfolio(&caller, pid)
225        .await
226    {
227        Ok(()) => {
228            AuditLogEntry::new(
229                AuditEventType::PortfolioDeleted,
230                Some(user.user_id),
231                user.organization_id,
232            )
233            .with_resource("Portfolio", pid)
234            .log();
235            HttpResponse::NoContent().finish()
236        }
237        Err(err) => {
238            AuditLogEntry::new(
239                AuditEventType::PortfolioDeleted,
240                Some(user.user_id),
241                user.organization_id,
242            )
243            .with_resource("Portfolio", pid)
244            .with_error(err.to_string())
245            .log();
246            err.error_response()
247        }
248    }
249}
250
251// ============================================================================
252// Buildings
253// ============================================================================
254
255#[utoipa::path(
256    post,
257    path = "/portfolios/{id}/buildings",
258    tag = "Portfolios",
259    summary = "Add a building to a portfolio (owner OR shared can_edit)",
260    params(("id" = Uuid, Path, description = "Portfolio UUID")),
261    request_body = AddBuildingDto,
262    responses(
263        (status = 201, description = "Building added", body = crate::application::dto::PortfolioBuildingResponseDto),
264        (status = 400, description = "Validation error"),
265        (status = 403, description = "Forbidden"),
266        (status = 404, description = "Portfolio or Building not found"),
267    ),
268    security(("bearer_auth" = []))
269)]
270#[post("/portfolios/{id}/buildings")]
271pub async fn add_portfolio_building(
272    state: web::Data<AppState>,
273    user: AuthenticatedUser,
274    id: web::Path<Uuid>,
275    dto: web::Json<AddBuildingDto>,
276) -> impl Responder {
277    let pid = *id;
278    let caller = caller_from_user(&user);
279    match state
280        .portfolio_use_cases
281        .add_building(&caller, pid, dto.into_inner())
282        .await
283    {
284        Ok(resp) => {
285            AuditLogEntry::new(
286                AuditEventType::PortfolioBuildingAdded,
287                Some(user.user_id),
288                user.organization_id,
289            )
290            .with_resource("Portfolio", pid)
291            .log();
292            HttpResponse::Created().json(resp)
293        }
294        Err(err) => {
295            AuditLogEntry::new(
296                AuditEventType::PortfolioBuildingAdded,
297                Some(user.user_id),
298                user.organization_id,
299            )
300            .with_resource("Portfolio", pid)
301            .with_error(err.to_string())
302            .log();
303            err.error_response()
304        }
305    }
306}
307
308#[utoipa::path(
309    get,
310    path = "/portfolios/{id}/buildings",
311    tag = "Portfolios",
312    summary = "List buildings of a portfolio (favorites first)",
313    params(("id" = Uuid, Path, description = "Portfolio UUID")),
314    responses(
315        (status = 200, description = "List of buildings", body = Vec<crate::application::dto::PortfolioBuildingResponseDto>),
316        (status = 403, description = "Forbidden"),
317        (status = 404, description = "Portfolio not found"),
318    ),
319    security(("bearer_auth" = []))
320)]
321#[get("/portfolios/{id}/buildings")]
322pub async fn list_portfolio_buildings(
323    state: web::Data<AppState>,
324    user: AuthenticatedUser,
325    id: web::Path<Uuid>,
326) -> impl Responder {
327    let pid = *id;
328    let caller = caller_from_user(&user);
329    match state.portfolio_use_cases.list_buildings(&caller, pid).await {
330        Ok(list) => HttpResponse::Ok().json(list),
331        Err(err) => err.error_response(),
332    }
333}
334
335#[utoipa::path(
336    delete,
337    path = "/portfolios/{id}/buildings/{building_id}",
338    tag = "Portfolios",
339    summary = "Remove a building from a portfolio",
340    params(
341        ("id" = Uuid, Path, description = "Portfolio UUID"),
342        ("building_id" = Uuid, Path, description = "Building UUID"),
343    ),
344    responses(
345        (status = 204, description = "Building removed"),
346        (status = 403, description = "Forbidden"),
347        (status = 404, description = "Not found"),
348    ),
349    security(("bearer_auth" = []))
350)]
351#[delete("/portfolios/{id}/buildings/{building_id}")]
352pub async fn remove_portfolio_building(
353    state: web::Data<AppState>,
354    user: AuthenticatedUser,
355    path: web::Path<(Uuid, Uuid)>,
356) -> impl Responder {
357    let (pid, bid) = path.into_inner();
358    let caller = caller_from_user(&user);
359    match state
360        .portfolio_use_cases
361        .remove_building(&caller, pid, bid)
362        .await
363    {
364        Ok(()) => {
365            AuditLogEntry::new(
366                AuditEventType::PortfolioBuildingRemoved,
367                Some(user.user_id),
368                user.organization_id,
369            )
370            .with_resource("Portfolio", pid)
371            .log();
372            HttpResponse::NoContent().finish()
373        }
374        Err(err) => {
375            AuditLogEntry::new(
376                AuditEventType::PortfolioBuildingRemoved,
377                Some(user.user_id),
378                user.organization_id,
379            )
380            .with_resource("Portfolio", pid)
381            .with_error(err.to_string())
382            .log();
383            err.error_response()
384        }
385    }
386}
387
388// ============================================================================
389// Shares
390// ============================================================================
391
392#[utoipa::path(
393    post,
394    path = "/portfolios/{id}/shares",
395    tag = "Portfolios",
396    summary = "Share a portfolio with another user (owner only)",
397    params(("id" = Uuid, Path, description = "Portfolio UUID")),
398    request_body = SharePortfolioDto,
399    responses(
400        (status = 201, description = "Portfolio shared", body = crate::application::dto::PortfolioShareResponseDto),
401        (status = 400, description = "Validation error"),
402        (status = 403, description = "Forbidden"),
403        (status = 404, description = "Portfolio or User not found"),
404    ),
405    security(("bearer_auth" = []))
406)]
407#[post("/portfolios/{id}/shares")]
408pub async fn share_portfolio(
409    state: web::Data<AppState>,
410    user: AuthenticatedUser,
411    id: web::Path<Uuid>,
412    dto: web::Json<SharePortfolioDto>,
413) -> impl Responder {
414    let pid = *id;
415    let caller = caller_from_user(&user);
416    match state
417        .portfolio_use_cases
418        .share_with(&caller, pid, dto.into_inner())
419        .await
420    {
421        Ok(resp) => {
422            AuditLogEntry::new(
423                AuditEventType::PortfolioShared,
424                Some(user.user_id),
425                user.organization_id,
426            )
427            .with_resource("Portfolio", pid)
428            .log();
429            HttpResponse::Created().json(resp)
430        }
431        Err(err) => {
432            AuditLogEntry::new(
433                AuditEventType::PortfolioShared,
434                Some(user.user_id),
435                user.organization_id,
436            )
437            .with_resource("Portfolio", pid)
438            .with_error(err.to_string())
439            .log();
440            err.error_response()
441        }
442    }
443}
444
445#[utoipa::path(
446    get,
447    path = "/portfolios/{id}/shares",
448    tag = "Portfolios",
449    summary = "List shares of a portfolio (owner only)",
450    params(("id" = Uuid, Path, description = "Portfolio UUID")),
451    responses(
452        (status = 200, description = "List of shares", body = Vec<crate::application::dto::PortfolioShareResponseDto>),
453        (status = 403, description = "Forbidden"),
454        (status = 404, description = "Not found"),
455    ),
456    security(("bearer_auth" = []))
457)]
458#[get("/portfolios/{id}/shares")]
459pub async fn list_portfolio_shares(
460    state: web::Data<AppState>,
461    user: AuthenticatedUser,
462    id: web::Path<Uuid>,
463) -> impl Responder {
464    let pid = *id;
465    let caller = caller_from_user(&user);
466    match state.portfolio_use_cases.list_shares(&caller, pid).await {
467        Ok(list) => HttpResponse::Ok().json(list),
468        Err(err) => err.error_response(),
469    }
470}
471
472#[utoipa::path(
473    delete,
474    path = "/portfolios/{id}/shares/{user_id}",
475    tag = "Portfolios",
476    summary = "Unshare a portfolio (owner only)",
477    params(
478        ("id" = Uuid, Path, description = "Portfolio UUID"),
479        ("user_id" = Uuid, Path, description = "Shared user UUID"),
480    ),
481    responses(
482        (status = 204, description = "Unshared"),
483        (status = 403, description = "Forbidden"),
484        (status = 404, description = "Share not found"),
485    ),
486    security(("bearer_auth" = []))
487)]
488#[delete("/portfolios/{id}/shares/{user_id}")]
489pub async fn unshare_portfolio(
490    state: web::Data<AppState>,
491    user: AuthenticatedUser,
492    path: web::Path<(Uuid, Uuid)>,
493) -> impl Responder {
494    let (pid, uid) = path.into_inner();
495    let caller = caller_from_user(&user);
496    match state.portfolio_use_cases.unshare(&caller, pid, uid).await {
497        Ok(()) => {
498            AuditLogEntry::new(
499                AuditEventType::PortfolioUnshared,
500                Some(user.user_id),
501                user.organization_id,
502            )
503            .with_resource("Portfolio", pid)
504            .log();
505            HttpResponse::NoContent().finish()
506        }
507        Err(err) => {
508            AuditLogEntry::new(
509                AuditEventType::PortfolioUnshared,
510                Some(user.user_id),
511                user.organization_id,
512            )
513            .with_resource("Portfolio", pid)
514            .with_error(err.to_string())
515            .log();
516            err.error_response()
517        }
518    }
519}