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