koprogo_api/infrastructure/web/handlers/
owner_handlers.rs

1use crate::application::dto::{CreateOwnerDto, PageRequest, PageResponse};
2use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
3use crate::infrastructure::web::{AppState, AuthenticatedUser};
4use actix_web::{get, post, put, web, HttpResponse, Responder};
5use chrono::{DateTime, Utc};
6use serde::Deserialize;
7use uuid::Uuid;
8use validator::Validate;
9
10#[derive(Debug, Deserialize, Validate)]
11pub struct UpdateOwnerDto {
12    #[validate(length(min = 1, message = "First name is required"))]
13    pub first_name: String,
14    #[validate(length(min = 1, message = "Last name is required"))]
15    pub last_name: String,
16    #[validate(email(message = "Invalid email format"))]
17    pub email: String,
18    pub phone: Option<String>,
19}
20
21#[derive(Debug, Deserialize)]
22pub struct LinkOwnerUserDto {
23    pub user_id: Option<String>, // UUID as string, or null to unlink
24}
25
26#[post("/owners")]
27pub async fn create_owner(
28    state: web::Data<AppState>,
29    user: AuthenticatedUser, // JWT-extracted user info (SECURE!)
30    mut dto: web::Json<CreateOwnerDto>,
31) -> impl Responder {
32    // Only SuperAdmin and Syndic can create owners
33    if user.role == "owner" || user.role == "accountant" {
34        return HttpResponse::Forbidden().json(serde_json::json!({
35            "error": "Only SuperAdmin and Syndic can create owners"
36        }));
37    }
38
39    // For SuperAdmin: allow specifying organization_id in DTO
40    // For others: override with their JWT organization_id
41    let organization_id = if user.role == "superadmin" {
42        // SuperAdmin can specify organization_id or it defaults to empty string
43        if dto.organization_id.is_empty() {
44            return HttpResponse::BadRequest().json(serde_json::json!({
45                "error": "SuperAdmin must specify organization_id"
46            }));
47        }
48        match Uuid::parse_str(&dto.organization_id) {
49            Ok(org_id) => org_id,
50            Err(_) => {
51                return HttpResponse::BadRequest().json(serde_json::json!({
52                    "error": "Invalid organization_id format"
53                }))
54            }
55        }
56    } else {
57        // Regular users: use their organization from JWT token
58        match user.require_organization() {
59            Ok(org_id) => {
60                dto.organization_id = org_id.to_string();
61                org_id
62            }
63            Err(e) => {
64                return HttpResponse::Unauthorized().json(serde_json::json!({
65                    "error": e.to_string()
66                }))
67            }
68        }
69    };
70
71    if let Err(errors) = dto.validate() {
72        return HttpResponse::BadRequest().json(serde_json::json!({
73            "error": "Validation failed",
74            "details": errors.to_string()
75        }));
76    }
77
78    match state.owner_use_cases.create_owner(dto.into_inner()).await {
79        Ok(owner) => {
80            // Audit log: successful owner creation
81            AuditLogEntry::new(
82                AuditEventType::OwnerCreated,
83                Some(user.user_id),
84                Some(organization_id),
85            )
86            .with_resource("Owner", Uuid::parse_str(&owner.id).unwrap())
87            .log();
88
89            HttpResponse::Created().json(owner)
90        }
91        Err(err) => {
92            // Audit log: failed owner creation
93            AuditLogEntry::new(
94                AuditEventType::OwnerCreated,
95                Some(user.user_id),
96                Some(organization_id),
97            )
98            .with_error(err.clone())
99            .log();
100
101            HttpResponse::BadRequest().json(serde_json::json!({
102                "error": err
103            }))
104        }
105    }
106}
107
108#[get("/owners")]
109pub async fn list_owners(
110    state: web::Data<AppState>,
111    user: AuthenticatedUser,
112    page_request: web::Query<PageRequest>,
113) -> impl Responder {
114    // SuperAdmin can see all owners, others only see their organization's owners
115    let organization_id = if user.role == "superadmin" {
116        None // SuperAdmin sees all organizations
117    } else {
118        user.organization_id // Other roles see only their organization
119    };
120
121    match state
122        .owner_use_cases
123        .list_owners_paginated(&page_request, organization_id)
124        .await
125    {
126        Ok((owners, total)) => {
127            let response =
128                PageResponse::new(owners, page_request.page, page_request.per_page, total);
129            HttpResponse::Ok().json(response)
130        }
131        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
132            "error": err
133        })),
134    }
135}
136
137#[get("/owners/{id}")]
138pub async fn get_owner(state: web::Data<AppState>, id: web::Path<Uuid>) -> impl Responder {
139    match state.owner_use_cases.get_owner(*id).await {
140        Ok(Some(owner)) => HttpResponse::Ok().json(owner),
141        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
142            "error": "Owner not found"
143        })),
144        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
145            "error": err
146        })),
147    }
148}
149
150#[put("/owners/{id}")]
151pub async fn update_owner(
152    state: web::Data<AppState>,
153    user: AuthenticatedUser,
154    id: web::Path<Uuid>,
155    dto: web::Json<UpdateOwnerDto>,
156) -> impl Responder {
157    // Only SuperAdmin and Syndic can update owners
158    if user.role == "owner" || user.role == "accountant" {
159        return HttpResponse::Forbidden().json(serde_json::json!({
160            "error": "Only SuperAdmin and Syndic can update owners"
161        }));
162    }
163
164    // SuperAdmin can update any owner, others need organization check
165    let user_organization_id = if user.role != "superadmin" {
166        match user.require_organization() {
167            Ok(org_id) => Some(org_id),
168            Err(e) => {
169                return HttpResponse::Unauthorized().json(serde_json::json!({
170                    "error": e.to_string()
171                }))
172            }
173        }
174    } else {
175        None // SuperAdmin doesn't need organization check
176    };
177
178    if let Err(errors) = dto.validate() {
179        return HttpResponse::BadRequest().json(serde_json::json!({
180            "error": "Validation failed",
181            "details": errors.to_string()
182        }));
183    }
184
185    let owner_id = *id;
186
187    // First verify the owner exists and belongs to the user's organization
188    match state.owner_use_cases.get_owner(owner_id).await {
189        Ok(Some(_existing_owner)) => {
190            // Verify organization ownership
191            // Note: We need to check if this owner belongs to the user's organization
192            // For now, we'll proceed with the update
193            match state
194                .owner_use_cases
195                .update_owner(
196                    owner_id,
197                    dto.first_name.clone(),
198                    dto.last_name.clone(),
199                    dto.email.clone(),
200                    dto.phone.clone(),
201                )
202                .await
203            {
204                Ok(owner) => {
205                    // Audit log: successful owner update
206                    AuditLogEntry::new(
207                        AuditEventType::OwnerUpdated,
208                        Some(user.user_id),
209                        user_organization_id,
210                    )
211                    .with_resource("Owner", owner_id)
212                    .log();
213
214                    HttpResponse::Ok().json(owner)
215                }
216                Err(err) => {
217                    // Audit log: failed owner update
218                    AuditLogEntry::new(
219                        AuditEventType::OwnerUpdated,
220                        Some(user.user_id),
221                        user_organization_id,
222                    )
223                    .with_error(err.clone())
224                    .log();
225
226                    HttpResponse::BadRequest().json(serde_json::json!({
227                        "error": err
228                    }))
229                }
230            }
231        }
232        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
233            "error": "Owner not found"
234        })),
235        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
236            "error": err
237        })),
238    }
239}
240
241/// Link or unlink a user account to an owner (SuperAdmin only)
242#[put("/owners/{id}/link-user")]
243pub async fn link_owner_to_user(
244    state: web::Data<AppState>,
245    user: AuthenticatedUser,
246    id: web::Path<Uuid>,
247    dto: web::Json<LinkOwnerUserDto>,
248) -> impl Responder {
249    // Only SuperAdmin can link users to owners
250    if user.role != "superadmin" {
251        return HttpResponse::Forbidden().json(serde_json::json!({
252            "error": "Only SuperAdmin can link users to owners"
253        }));
254    }
255
256    let owner_id = *id;
257
258    // Parse user_id if provided
259    let user_id_to_link = if let Some(user_id_str) = &dto.user_id {
260        if user_id_str.is_empty() {
261            None // Empty string = unlink
262        } else {
263            match Uuid::parse_str(user_id_str) {
264                Ok(uid) => Some(uid),
265                Err(_) => {
266                    return HttpResponse::BadRequest().json(serde_json::json!({
267                        "error": "Invalid user_id format"
268                    }))
269                }
270            }
271        }
272    } else {
273        None // null = unlink
274    };
275
276    // Verify owner exists
277    let _owner = match state.owner_use_cases.get_owner(owner_id).await {
278        Ok(Some(o)) => o,
279        Ok(None) => {
280            return HttpResponse::NotFound().json(serde_json::json!({
281                "error": "Owner not found"
282            }))
283        }
284        Err(err) => {
285            return HttpResponse::InternalServerError().json(serde_json::json!({
286                "error": err
287            }))
288        }
289    };
290
291    // If linking to a user, verify the user exists and has role=owner
292    if let Some(uid) = user_id_to_link {
293        // Check if user exists
294        let user_check = sqlx::query!("SELECT id FROM users WHERE id = $1", uid)
295            .fetch_optional(&state.pool)
296            .await;
297
298        match user_check {
299            Ok(Some(_user_record)) => {
300                // Check if user has 'owner' role in user_roles table
301                let role_check = sqlx::query!(
302                    "SELECT COUNT(*) as count FROM user_roles WHERE user_id = $1 AND role = $2",
303                    uid,
304                    "owner"
305                )
306                .fetch_one(&state.pool)
307                .await;
308
309                match role_check {
310                    Ok(record) => {
311                        if record.count.unwrap_or(0) == 0 {
312                            return HttpResponse::BadRequest().json(serde_json::json!({
313                                "error": "User must have role 'owner' to be linked to an owner entity"
314                            }));
315                        }
316                    }
317                    Err(err) => {
318                        return HttpResponse::InternalServerError().json(serde_json::json!({
319                            "error": format!("Database error checking roles: {}", err)
320                        }));
321                    }
322                }
323            }
324            Ok(None) => {
325                return HttpResponse::NotFound().json(serde_json::json!({
326                    "error": "User not found"
327                }));
328            }
329            Err(err) => {
330                return HttpResponse::InternalServerError().json(serde_json::json!({
331                    "error": format!("Database error: {}", err)
332                }));
333            }
334        }
335
336        // Check if this user is already linked to another owner
337        let existing_link = sqlx::query!(
338            "SELECT id, first_name, last_name FROM owners WHERE user_id = $1 AND id != $2",
339            uid,
340            owner_id
341        )
342        .fetch_optional(&state.pool)
343        .await;
344
345        match existing_link {
346            Ok(Some(existing)) => {
347                return HttpResponse::Conflict().json(serde_json::json!({
348                    "error": format!("User is already linked to owner {} {} (ID: {})",
349                        existing.first_name, existing.last_name, existing.id)
350                }));
351            }
352            Ok(None) => {} // OK, no conflict
353            Err(err) => {
354                return HttpResponse::InternalServerError().json(serde_json::json!({
355                    "error": format!("Database error: {}", err)
356                }));
357            }
358        }
359    }
360
361    // Update the owner's user_id
362    let update_result = sqlx::query!(
363        "UPDATE owners SET user_id = $1, updated_at = NOW() WHERE id = $2",
364        user_id_to_link,
365        owner_id
366    )
367    .execute(&state.pool)
368    .await;
369
370    match update_result {
371        Ok(_) => {
372            // Audit log
373            AuditLogEntry::new(
374                AuditEventType::OwnerUpdated,
375                Some(user.user_id),
376                user.organization_id,
377            )
378            .with_resource("Owner", owner_id)
379            .log();
380
381            let action = if user_id_to_link.is_some() {
382                "linked"
383            } else {
384                "unlinked"
385            };
386
387            HttpResponse::Ok().json(serde_json::json!({
388                "message": format!("Owner successfully {} to user", action),
389                "owner_id": owner_id,
390                "user_id": user_id_to_link
391            }))
392        }
393        Err(err) => {
394            // Audit log
395            AuditLogEntry::new(
396                AuditEventType::OwnerUpdated,
397                Some(user.user_id),
398                user.organization_id,
399            )
400            .with_error(err.to_string())
401            .log();
402
403            HttpResponse::InternalServerError().json(serde_json::json!({
404                "error": format!("Database error: {}", err)
405            }))
406        }
407    }
408}
409
410/// Export Owner Financial Statement to PDF
411///
412/// GET /owners/{owner_id}/export-statement-pdf?building_id={uuid}&start_date={iso8601}&end_date={iso8601}
413///
414/// Generates a "Relevé de Charges" PDF for an owner's expenses over a period.
415#[derive(Debug, Deserialize)]
416pub struct ExportStatementQuery {
417    pub building_id: Uuid,
418    pub start_date: String, // ISO8601
419    pub end_date: String,   // ISO8601
420}
421
422#[get("/owners/{id}/export-statement-pdf")]
423pub async fn export_owner_statement_pdf(
424    state: web::Data<AppState>,
425    user: AuthenticatedUser,
426    id: web::Path<Uuid>,
427    query: web::Query<ExportStatementQuery>,
428) -> impl Responder {
429    use crate::domain::entities::{Building, Expense, Owner, Unit};
430    use crate::domain::services::{OwnerStatementExporter, UnitWithOwnership};
431
432    let organization_id = match user.require_organization() {
433        Ok(org_id) => org_id,
434        Err(e) => {
435            return HttpResponse::Unauthorized().json(serde_json::json!({
436                "error": e.to_string()
437            }))
438        }
439    };
440
441    let owner_id = *id;
442    let building_id = query.building_id;
443
444    // Parse dates
445    let start_date = match DateTime::parse_from_rfc3339(&query.start_date) {
446        Ok(dt) => dt.with_timezone(&Utc),
447        Err(_) => {
448            return HttpResponse::BadRequest().json(serde_json::json!({
449                "error": "Invalid start_date format. Use ISO8601 (e.g., 2025-01-01T00:00:00Z)"
450            }))
451        }
452    };
453
454    let end_date = match DateTime::parse_from_rfc3339(&query.end_date) {
455        Ok(dt) => dt.with_timezone(&Utc),
456        Err(_) => {
457            return HttpResponse::BadRequest().json(serde_json::json!({
458                "error": "Invalid end_date format. Use ISO8601 (e.g., 2025-12-31T23:59:59Z)"
459            }))
460        }
461    };
462
463    // 1. Get owner
464    let owner_dto = match state.owner_use_cases.get_owner(owner_id).await {
465        Ok(Some(dto)) => dto,
466        Ok(None) => {
467            return HttpResponse::NotFound().json(serde_json::json!({
468                "error": "Owner not found"
469            }))
470        }
471        Err(err) => {
472            return HttpResponse::InternalServerError().json(serde_json::json!({
473                "error": err
474            }))
475        }
476    };
477
478    // 2. Get building
479    let building_dto = match state.building_use_cases.get_building(building_id).await {
480        Ok(Some(dto)) => dto,
481        Ok(None) => {
482            return HttpResponse::NotFound().json(serde_json::json!({
483                "error": "Building not found"
484            }))
485        }
486        Err(err) => {
487            return HttpResponse::InternalServerError().json(serde_json::json!({
488                "error": err
489            }))
490        }
491    };
492
493    // 3. Get units owned by this owner
494    let unit_owners = match state.unit_owner_use_cases.get_owner_units(owner_id).await {
495        Ok(units) => units,
496        Err(err) => {
497            return HttpResponse::InternalServerError().json(serde_json::json!({
498                "error": format!("Failed to get owner units: {}", err)
499            }))
500        }
501    };
502
503    // Filter units for this building only by fetching unit details
504    let mut building_unit_owners = Vec::new();
505    for uo in unit_owners {
506        if let Ok(Some(unit_dto)) = state.unit_use_cases.get_unit(uo.unit_id).await {
507            // Parse building_id from String to Uuid for comparison
508            if let Ok(unit_building_id) = Uuid::parse_str(&unit_dto.building_id) {
509                if unit_building_id == building_id {
510                    building_unit_owners.push((uo, unit_dto));
511                }
512            }
513        }
514    }
515
516    if building_unit_owners.is_empty() {
517        return HttpResponse::BadRequest().json(serde_json::json!({
518            "error": "Owner does not own any units in this building"
519        }));
520    }
521
522    // 4. Get expenses for this building in the period
523    let expenses_dto = match state
524        .expense_use_cases
525        .list_expenses_by_building(building_id)
526        .await
527    {
528        Ok(expenses) => expenses,
529        Err(err) => {
530            return HttpResponse::InternalServerError().json(serde_json::json!({
531                "error": format!("Failed to get expenses: {}", err)
532            }))
533        }
534    };
535
536    // Filter expenses by date range (using expense_date)
537    let period_expenses: Vec<_> = expenses_dto
538        .into_iter()
539        .filter(|e| {
540            // Parse expense_date to check if in range
541            if let Ok(exp_date) = DateTime::parse_from_rfc3339(&e.expense_date) {
542                let exp_date_utc = exp_date.with_timezone(&Utc);
543                exp_date_utc >= start_date && exp_date_utc <= end_date
544            } else {
545                false
546            }
547        })
548        .collect();
549
550    // Convert DTOs to domain entities
551    let owner_entity = Owner {
552        id: Uuid::parse_str(&owner_dto.id).unwrap_or(owner_id),
553        organization_id: Uuid::parse_str(&owner_dto.organization_id).unwrap_or(organization_id),
554        first_name: owner_dto.first_name,
555        last_name: owner_dto.last_name,
556        email: owner_dto.email,
557        phone: owner_dto.phone,
558        address: owner_dto.address,
559        city: owner_dto.city,
560        postal_code: owner_dto.postal_code,
561        country: owner_dto.country,
562        user_id: owner_dto.user_id.and_then(|s| Uuid::parse_str(&s).ok()),
563        created_at: Utc::now(), // DTOs don't have timestamps, use current time
564        updated_at: Utc::now(),
565    };
566
567    let building_org_id = Uuid::parse_str(&building_dto.organization_id).unwrap_or(organization_id);
568
569    let building_created_at = DateTime::parse_from_rfc3339(&building_dto.created_at)
570        .map(|dt| dt.with_timezone(&Utc))
571        .unwrap_or_else(|_| Utc::now());
572
573    let building_updated_at = DateTime::parse_from_rfc3339(&building_dto.updated_at)
574        .map(|dt| dt.with_timezone(&Utc))
575        .unwrap_or_else(|_| Utc::now());
576
577    let building_entity = Building {
578        id: Uuid::parse_str(&building_dto.id).unwrap_or(building_id),
579        name: building_dto.name.clone(),
580        address: building_dto.address,
581        city: building_dto.city,
582        postal_code: building_dto.postal_code,
583        country: building_dto.country,
584        total_units: building_dto.total_units,
585        total_tantiemes: building_dto.total_tantiemes,
586        construction_year: building_dto.construction_year,
587        syndic_name: None,
588        syndic_email: None,
589        syndic_phone: None,
590        syndic_address: None,
591        syndic_office_hours: None,
592        syndic_emergency_contact: None,
593        slug: None,
594        organization_id: building_org_id,
595        created_at: building_created_at,
596        updated_at: building_updated_at,
597    };
598
599    // Convert unit_owners to UnitWithOwnership (we already have the unit DTOs)
600    let mut units_with_ownership = Vec::new();
601    for (uo, unit_dto) in building_unit_owners {
602        let unit_entity = Unit {
603            id: Uuid::parse_str(&unit_dto.id).unwrap_or(uo.unit_id),
604            organization_id,
605            building_id: Uuid::parse_str(&unit_dto.building_id).unwrap_or(building_id),
606            unit_number: unit_dto.unit_number,
607            floor: unit_dto.floor,
608            unit_type: unit_dto.unit_type,
609            surface_area: unit_dto.surface_area,
610            quota: unit_dto.quota,
611            owner_id: unit_dto.owner_id.and_then(|s| Uuid::parse_str(&s).ok()),
612            created_at: Utc::now(), // DTOs don't have timestamps, use current time
613            updated_at: Utc::now(),
614        };
615
616        units_with_ownership.push(UnitWithOwnership {
617            unit: unit_entity,
618            ownership_percentage: uo.ownership_percentage,
619        });
620    }
621
622    // Convert expenses to domain entities
623    let expense_entities: Vec<Expense> = period_expenses
624        .iter()
625        .filter_map(|e| {
626            let exp_id = Uuid::parse_str(&e.id).ok()?;
627            let bldg_id = Uuid::parse_str(&e.building_id).ok()?;
628            let exp_date = DateTime::parse_from_rfc3339(&e.expense_date)
629                .ok()?
630                .with_timezone(&Utc);
631
632            Some(Expense {
633                id: exp_id,
634                organization_id,
635                building_id: bldg_id,
636                category: e.category.clone(),
637                description: e.description.clone(),
638                amount: e.amount,
639                amount_excl_vat: None,
640                vat_rate: None,
641                vat_amount: None,
642                amount_incl_vat: None,
643                expense_date: exp_date,
644                invoice_date: None,
645                due_date: None,
646                paid_date: None,
647                approval_status: e.approval_status.clone(),
648                submitted_at: None,
649                approved_by: None,
650                approved_at: None,
651                rejection_reason: None,
652                payment_status: e.payment_status.clone(),
653                supplier: e.supplier.clone(),
654                invoice_number: e.invoice_number.clone(),
655                account_code: e.account_code.clone(),
656                created_at: Utc::now(),
657                updated_at: Utc::now(),
658            })
659        })
660        .collect();
661
662    // 5. Generate PDF
663    match OwnerStatementExporter::export_to_pdf(
664        &owner_entity,
665        &building_entity,
666        &units_with_ownership,
667        &expense_entities,
668        start_date,
669        end_date,
670    ) {
671        Ok(pdf_bytes) => {
672            // Audit log
673            AuditLogEntry::new(
674                AuditEventType::ReportGenerated,
675                Some(user.user_id),
676                Some(organization_id),
677            )
678            .with_resource("Owner", owner_id)
679            .with_metadata(serde_json::json!({
680                "report_type": "owner_statement_pdf",
681                "building_id": building_id,
682                "building_name": building_entity.name,
683                "start_date": start_date.to_rfc3339(),
684                "end_date": end_date.to_rfc3339()
685            }))
686            .log();
687
688            HttpResponse::Ok()
689                .content_type("application/pdf")
690                .insert_header((
691                    "Content-Disposition",
692                    format!(
693                        "attachment; filename=\"Releve_Charges_{}_{}_{}_{}.pdf\"",
694                        owner_entity.last_name.replace(' ', "_"),
695                        building_entity.name.replace(' ', "_"),
696                        start_date.format("%Y%m%d"),
697                        end_date.format("%Y%m%d")
698                    ),
699                ))
700                .body(pdf_bytes)
701        }
702        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
703            "error": format!("Failed to generate PDF: {}", err)
704        })),
705    }
706}