1use 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#[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#[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}