1use crate::application::dto::{
2 CreateUnitDto, PageRequest, PageResponse, UnitResponseDto, UpdateUnitDto,
3};
4use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
5use crate::infrastructure::web::middleware::scope_guard::{
6 verify_acp_org_access, verify_building_org_access,
7};
8use crate::infrastructure::web::{AppState, AuthenticatedUser};
9use actix_web::{delete, get, post, put, web, HttpResponse, Responder, ResponseError};
10use uuid::Uuid;
11use validator::Validate;
12
13#[utoipa::path(
14 post,
15 path = "/units",
16 tag = "Units",
17 summary = "Create a unit (lot) inside a building",
18 request_body = CreateUnitDto,
19 responses(
20 (status = 201, description = "Unit created", body = UnitResponseDto),
21 (status = 400, description = "Validation error, or unknown field in the body"),
22 (status = 403, description = "Forbidden (superadmin only — structural data)"),
23 (status = 404, description = "Building not found"),
24 ),
25 security(("bearer_auth" = []))
26)]
27#[post("/units")]
28pub async fn create_unit(
29 state: web::Data<AppState>,
30 user: AuthenticatedUser, dto: web::Json<CreateUnitDto>,
32) -> impl Responder {
33 if !matches!(user.role.as_str(), "superadmin" | "syndic") {
38 return HttpResponse::Forbidden().json(serde_json::json!({
39 "error": "Seuls le syndic et le SuperAdmin créent des lots"
40 }));
41 }
42
43 if dto.building_id.is_empty() {
44 return HttpResponse::BadRequest().json(serde_json::json!({
45 "error": "Le lot doit désigner l'immeuble dont il relève (building_id)"
46 }));
47 }
48
49 let building_uuid = match Uuid::parse_str(&dto.building_id) {
50 Ok(id) => id,
51 Err(_) => {
52 return HttpResponse::BadRequest().json(serde_json::json!({
53 "error": "Invalid building ID format"
54 }));
55 }
56 };
57
58 if let Err(e) = crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
61 &user,
62 building_uuid,
63 &state.building_use_cases,
64 &state.acp_use_cases,
65 )
66 .await
67 {
68 return e.error_response();
69 }
70
71 let acp_id = match dto
76 .acp_id
77 .as_deref()
78 .map(str::trim)
79 .filter(|s| !s.is_empty())
80 {
81 Some(raw) => match Uuid::parse_str(raw) {
82 Ok(id) => id,
83 Err(_) => {
84 return HttpResponse::BadRequest().json(serde_json::json!({
85 "error": "Invalid acp_id format"
86 }));
87 }
88 },
89 None => match state.building_use_cases.get_building(building_uuid).await {
90 Ok(Some(building)) => match Uuid::parse_str(&building.acp_id) {
91 Ok(id) => id,
92 Err(_) => {
93 return HttpResponse::InternalServerError().json(serde_json::json!({
94 "error": "Invalid building.acp_id format"
95 }));
96 }
97 },
98 Ok(None) => {
99 return HttpResponse::NotFound().json(serde_json::json!({
100 "error": "Building not found"
101 }));
102 }
103 Err(err) => {
104 return HttpResponse::InternalServerError().json(serde_json::json!({
105 "error": format!("Failed to resolve building ACP: {}", err)
106 }));
107 }
108 },
109 };
110
111 let organization_id = user.organization_id;
114
115 if let Err(errors) = dto.validate() {
116 return HttpResponse::BadRequest().json(serde_json::json!({
117 "error": "Validation failed",
118 "details": errors.to_string()
119 }));
120 }
121
122 let mut dto = dto.into_inner();
125 dto.acp_id = Some(acp_id.to_string());
126
127 match state.unit_use_cases.create_unit(dto).await {
128 Ok(unit) => {
129 AuditLogEntry::new(
131 AuditEventType::UnitCreated,
132 Some(user.user_id),
133 organization_id,
134 )
135 .with_resource("Unit", Uuid::parse_str(&unit.id).unwrap())
136 .log();
137
138 HttpResponse::Created().json(unit)
139 }
140 Err(err) => {
141 AuditLogEntry::new(
143 AuditEventType::UnitCreated,
144 Some(user.user_id),
145 organization_id,
146 )
147 .with_error(err.clone())
148 .log();
149
150 HttpResponse::BadRequest().json(serde_json::json!({
151 "error": err
152 }))
153 }
154 }
155}
156
157#[utoipa::path(
158 get,
159 path = "/units/{id}",
160 tag = "Units",
161 summary = "Get a single unit",
162 params(("id" = Uuid, Path, description = "Unit identifier")),
163 responses(
164 (status = 200, description = "Unit", body = UnitResponseDto),
165 (status = 404, description = "Unit not found"),
166 ),
167 security(("bearer_auth" = []))
168)]
169#[get("/units/{id}")]
170pub async fn get_unit(
171 state: web::Data<AppState>,
172 user: AuthenticatedUser,
173 id: web::Path<Uuid>,
174) -> impl Responder {
175 match state.unit_use_cases.get_unit(*id).await {
176 Ok(Some(unit)) => {
177 if let Ok(building_id) = Uuid::parse_str(&unit.building_id) {
179 if let Ok(Some(building)) = state.building_use_cases.get_building(building_id).await
180 {
181 let acp_id = match Uuid::parse_str(&building.acp_id) {
182 Ok(id) => id,
183 Err(_) => {
184 return HttpResponse::InternalServerError().json(serde_json::json!({
185 "error": "Invalid building.acp_id format"
186 }));
187 }
188 };
189 if let Err(err) =
190 verify_acp_org_access(&user, acp_id, &state.acp_use_cases).await
191 {
192 return err.error_response();
193 }
194 }
195 }
196 HttpResponse::Ok().json(unit)
197 }
198 Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
199 "error": "Unit not found"
200 })),
201 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
202 "error": err
203 })),
204 }
205}
206
207#[utoipa::path(
208 get,
209 path = "/units",
210 tag = "Units",
211 summary = "List units visible to the authenticated user (paginated)",
212 responses(
213 (status = 200, description = "Units page"),
214 (status = 401, description = "User does not belong to an organization"),
215 ),
216 security(("bearer_auth" = []))
217)]
218#[get("/units")]
219pub async fn list_units(
220 state: web::Data<AppState>,
221 user: AuthenticatedUser,
222 page_request: web::Query<PageRequest>,
223) -> impl Responder {
224 let organization_id = user.organization_id;
225
226 match state
227 .unit_use_cases
228 .list_units_paginated(&page_request, organization_id)
229 .await
230 {
231 Ok((units, total)) => {
232 let response =
233 PageResponse::new(units, page_request.page, page_request.per_page, total);
234 HttpResponse::Ok().json(response)
235 }
236 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
237 "error": err
238 })),
239 }
240}
241
242#[utoipa::path(
243 get,
244 path = "/buildings/{building_id}/units",
245 tag = "Units",
246 summary = "List the units of a building",
247 params(("building_id" = Uuid, Path, description = "Building identifier")),
248 responses(
249 (status = 200, description = "Units", body = Vec<UnitResponseDto>),
250 (status = 403, description = "Forbidden (building outside the user scope)"),
251 ),
252 security(("bearer_auth" = []))
253)]
254#[get("/buildings/{building_id}/units")]
255pub async fn list_units_by_building(
256 state: web::Data<AppState>,
257 user: AuthenticatedUser,
258 building_id: web::Path<Uuid>,
259) -> impl Responder {
260 match state.building_use_cases.get_building(*building_id).await {
262 Ok(Some(building)) => {
263 let acp_id = match Uuid::parse_str(&building.acp_id) {
264 Ok(id) => id,
265 Err(_) => {
266 return HttpResponse::InternalServerError().json(serde_json::json!({
267 "error": "Invalid building.acp_id format"
268 }));
269 }
270 };
271 if let Err(err) = verify_acp_org_access(&user, acp_id, &state.acp_use_cases).await {
272 return err.error_response();
273 }
274 }
275 Ok(None) => {
276 return HttpResponse::NotFound().json(serde_json::json!({
277 "error": "Building not found"
278 }));
279 }
280 Err(err) => {
281 return HttpResponse::InternalServerError().json(serde_json::json!({
282 "error": err
283 }));
284 }
285 }
286
287 match state
288 .unit_use_cases
289 .list_units_by_building(*building_id)
290 .await
291 {
292 Ok(units) => HttpResponse::Ok().json(units),
293 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
294 "error": err
295 })),
296 }
297}
298
299#[utoipa::path(
300 put,
301 path = "/units/{id}",
302 tag = "Units",
303 summary = "Update a unit",
304 description = "N'accepte PAS `owner_id` : la relation lot/proprietaire vit dans \
305`unit_owners` (routes `/unit-owners`), qui porte les quotites et les dates de \
306detention. `units.owner_id` est deprecie depuis la migration \
307`20250127000000_refactor_owners_multitenancy`. Un corps portant `owner_id` \
308recevait auparavant un 200 en jetant le champ ; il recoit desormais un 400.",
309 params(("id" = Uuid, Path, description = "Unit identifier")),
310 request_body = UpdateUnitDto,
311 responses(
312 (status = 200, description = "Unit updated", body = UnitResponseDto),
313 (status = 400, description = "Validation error, or unknown field (e.g. `owner_id`)"),
314 (status = 403, description = "Forbidden (superadmin only — quotites are structural)"),
315 (status = 404, description = "Unit not found"),
316 ),
317 security(("bearer_auth" = []))
318)]
319#[put("/units/{id}")]
320pub async fn update_unit(
321 state: web::Data<AppState>,
322 user: AuthenticatedUser,
323 id: web::Path<Uuid>,
324 dto: web::Json<UpdateUnitDto>,
325) -> impl Responder {
326 if !user.is_superadmin() {
328 return HttpResponse::Forbidden().json(serde_json::json!({
329 "error": "Only SuperAdmin can update units (structural data including quotités)"
330 }));
331 }
332
333 if let Err(errors) = dto.validate() {
334 return HttpResponse::BadRequest().json(serde_json::json!({
335 "error": "Validation failed",
336 "details": errors.to_string()
337 }));
338 }
339
340 if !user.is_superadmin() {
342 match state.unit_use_cases.get_unit(*id).await {
343 Ok(Some(unit)) => {
344 let building_id = match Uuid::parse_str(&unit.building_id) {
346 Ok(id) => id,
347 Err(_) => {
348 return HttpResponse::InternalServerError().json(serde_json::json!({
349 "error": "Invalid building_id"
350 }));
351 }
352 };
353
354 match state.building_use_cases.get_building(building_id).await {
355 Ok(Some(building)) => {
356 let acp_id = match Uuid::parse_str(&building.acp_id) {
360 Ok(id) => id,
361 Err(_) => {
362 return HttpResponse::InternalServerError().json(
363 serde_json::json!({
364 "error": "Invalid building.acp_id format"
365 }),
366 );
367 }
368 };
369 if let Err(err) =
370 verify_acp_org_access(&user, acp_id, &state.acp_use_cases).await
371 {
372 return err.error_response();
373 }
374 }
375 Ok(None) => {
376 return HttpResponse::NotFound().json(serde_json::json!({
377 "error": "Building not found"
378 }));
379 }
380 Err(err) => {
381 return HttpResponse::InternalServerError().json(serde_json::json!({
382 "error": err
383 }));
384 }
385 }
386 }
387 Ok(None) => {
388 return HttpResponse::NotFound().json(serde_json::json!({
389 "error": "Unit not found"
390 }));
391 }
392 Err(err) => {
393 return HttpResponse::InternalServerError().json(serde_json::json!({
394 "error": err
395 }));
396 }
397 }
398 }
399
400 match state
401 .unit_use_cases
402 .update_unit(*id, dto.into_inner())
403 .await
404 {
405 Ok(unit) => {
406 AuditLogEntry::new(
408 AuditEventType::UnitUpdated,
409 Some(user.user_id),
410 user.organization_id,
411 )
412 .with_resource("Unit", *id)
413 .log();
414
415 HttpResponse::Ok().json(unit)
416 }
417 Err(err) => {
418 AuditLogEntry::new(
420 AuditEventType::UnitUpdated,
421 Some(user.user_id),
422 user.organization_id,
423 )
424 .with_resource("Unit", *id)
425 .with_error(err.clone())
426 .log();
427
428 HttpResponse::BadRequest().json(serde_json::json!({
429 "error": err
430 }))
431 }
432 }
433}
434
435#[utoipa::path(
436 delete,
437 path = "/units/{id}",
438 tag = "Units",
439 summary = "Delete a unit",
440 params(("id" = Uuid, Path, description = "Unit identifier")),
441 responses(
442 (status = 204, description = "Unit deleted"),
443 (status = 403, description = "Forbidden (superadmin only)"),
444 (status = 404, description = "Unit not found"),
445 ),
446 security(("bearer_auth" = []))
447)]
448#[delete("/units/{id}")]
449pub async fn delete_unit(
450 state: web::Data<AppState>,
451 user: AuthenticatedUser,
452 id: web::Path<Uuid>,
453) -> impl Responder {
454 if !user.is_superadmin() {
456 return HttpResponse::Forbidden().json(serde_json::json!({
457 "error": "Only SuperAdmin can delete units (structural data)"
458 }));
459 }
460
461 match state.unit_use_cases.delete_unit(*id).await {
462 Ok(true) => {
463 AuditLogEntry::new(
465 AuditEventType::UnitDeleted,
466 Some(user.user_id),
467 user.organization_id,
468 )
469 .with_resource("Unit", *id)
470 .log();
471
472 HttpResponse::Ok().json(serde_json::json!({
473 "message": "Unit deleted successfully"
474 }))
475 }
476 Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
477 "error": "Unit not found"
478 })),
479 Err(err) => {
480 AuditLogEntry::new(
482 AuditEventType::UnitDeleted,
483 Some(user.user_id),
484 user.organization_id,
485 )
486 .with_resource("Unit", *id)
487 .with_error(err.clone())
488 .log();
489
490 HttpResponse::BadRequest().json(serde_json::json!({
491 "error": err
492 }))
493 }
494 }
495}
496
497#[utoipa::path(
498 put,
499 path = "/units/{unit_id}/assign-owner/{owner_id}",
500 tag = "Units",
501 summary = "Assign an owner to a unit",
502 params(
503 ("unit_id" = Uuid, Path, description = "Unit identifier"),
504 ("owner_id" = Uuid, Path, description = "Owner identifier"),
505 ),
506 responses(
507 (status = 200, description = "Owner assigned", body = UnitResponseDto),
508 (status = 400, description = "Assignment refused by the domain"),
509 ),
510 security(("bearer_auth" = []))
511)]
512#[put("/units/{unit_id}/assign-owner/{owner_id}")]
513pub async fn assign_owner(
514 state: web::Data<AppState>,
515 user: AuthenticatedUser,
516 path: web::Path<(Uuid, Uuid)>,
517) -> impl Responder {
518 let (unit_id, owner_id) = path.into_inner();
519
520 match state.unit_use_cases.get_unit(unit_id).await {
528 Ok(Some(unite)) => {
529 let building_id = match Uuid::parse_str(&unite.building_id) {
537 Ok(id) => id,
538 Err(_) => {
539 return HttpResponse::Forbidden().json(serde_json::json!({
540 "error": "Impossible de rattacher ce lot à un immeuble"
541 }))
542 }
543 };
544 if let Err(err) = verify_building_org_access(
545 &user,
546 building_id,
547 &state.building_use_cases,
548 &state.acp_use_cases,
549 )
550 .await
551 {
552 return err.error_response();
553 }
554 }
555 Ok(None) => {
556 return HttpResponse::NotFound().json(serde_json::json!({
557 "error": "Unit not found"
558 }))
559 }
560 Err(err) => {
561 return HttpResponse::InternalServerError().json(serde_json::json!({
562 "error": err.to_string()
563 }))
564 }
565 }
566
567 match state.unit_use_cases.assign_owner(unit_id, owner_id).await {
568 Ok(unit) => {
569 AuditLogEntry::new(
571 AuditEventType::UnitAssignedToOwner,
572 Some(user.user_id),
573 user.organization_id,
574 )
575 .with_resource("Unit", unit_id)
576 .log();
577
578 HttpResponse::Ok().json(unit)
579 }
580 Err(err) => {
581 AuditLogEntry::new(
583 AuditEventType::UnitAssignedToOwner,
584 Some(user.user_id),
585 user.organization_id,
586 )
587 .with_resource("Unit", unit_id)
588 .with_error(err.clone())
589 .log();
590
591 HttpResponse::BadRequest().json(serde_json::json!({
592 "error": err
593 }))
594 }
595 }
596}