koprogo_api/infrastructure/web/handlers/
stats_handlers.rs

1use crate::infrastructure::web::{AppState, AuthenticatedUser};
2use actix_web::{get, web, HttpResponse, Responder};
3use chrono::{DateTime, Utc};
4use serde::Serialize;
5
6#[derive(Serialize)]
7pub struct DashboardStats {
8    pub total_organizations: i64,
9    pub total_users: i64,
10    pub total_buildings: i64,
11    pub active_subscriptions: i64,
12    pub total_owners: i64,
13    pub total_units: i64,
14    pub total_expenses: i64,
15    pub total_meetings: i64,
16}
17
18#[derive(Serialize)]
19pub struct SeedDataStats {
20    pub seed_organizations: i64,
21    pub production_organizations: i64,
22    pub seed_buildings: i64,
23    pub seed_units: i64,
24    pub seed_owners: i64,
25    pub seed_unit_owners: i64,
26    pub seed_expenses: i64,
27    pub seed_meetings: i64,
28    pub seed_users: i64,
29}
30
31#[derive(Serialize)]
32pub struct SyndicDashboardStats {
33    pub total_buildings: i64,
34    pub total_units: i64,
35    pub total_owners: i64,
36    pub pending_expenses_count: i64,
37    pub pending_expenses_amount: f64,
38    pub next_meeting: Option<NextMeetingInfo>,
39}
40
41#[derive(Serialize)]
42pub struct NextMeetingInfo {
43    pub id: String,
44    pub date: DateTime<Utc>,
45    pub building_name: String,
46}
47
48#[derive(Serialize)]
49pub struct UrgentTask {
50    pub task_type: String, // "expense" | "meeting" | "other"
51    pub title: String,
52    pub description: String,
53    pub priority: String, // "urgent" | "high" | "medium"
54    pub building_name: Option<String>,
55    pub entity_id: Option<String>,
56    pub due_date: Option<DateTime<Utc>>,
57}
58
59/// Get dashboard statistics (SuperAdmin only)
60#[get("/stats/dashboard")]
61pub async fn get_dashboard_stats(
62    state: web::Data<AppState>,
63    user: AuthenticatedUser,
64) -> impl Responder {
65    // Only SuperAdmin can access these stats
66    if user.role != "superadmin" {
67        return HttpResponse::Forbidden().json(serde_json::json!({
68            "error": "Only SuperAdmin can access dashboard statistics"
69        }));
70    }
71
72    // Count organizations
73    let orgs_result = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM organizations")
74        .fetch_one(&state.pool)
75        .await;
76
77    // Count users
78    let users_result = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users")
79        .fetch_one(&state.pool)
80        .await;
81
82    // Count buildings
83    let buildings_result = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM buildings")
84        .fetch_one(&state.pool)
85        .await;
86
87    // Count active subscriptions (organizations with is_active = true)
88    let active_subs_result =
89        sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM organizations WHERE is_active = true")
90            .fetch_one(&state.pool)
91            .await;
92
93    // Count owners
94    let owners_result = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owners")
95        .fetch_one(&state.pool)
96        .await;
97
98    // Count units
99    let units_result = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM units")
100        .fetch_one(&state.pool)
101        .await;
102
103    // Count expenses
104    let expenses_result = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM expenses")
105        .fetch_one(&state.pool)
106        .await;
107
108    // Count meetings
109    let meetings_result = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM meetings")
110        .fetch_one(&state.pool)
111        .await;
112
113    // Handle errors
114    match (
115        orgs_result,
116        users_result,
117        buildings_result,
118        active_subs_result,
119        owners_result,
120        units_result,
121        expenses_result,
122        meetings_result,
123    ) {
124        (
125            Ok(total_organizations),
126            Ok(total_users),
127            Ok(total_buildings),
128            Ok(active_subscriptions),
129            Ok(total_owners),
130            Ok(total_units),
131            Ok(total_expenses),
132            Ok(total_meetings),
133        ) => {
134            let stats = DashboardStats {
135                total_organizations,
136                total_users,
137                total_buildings,
138                active_subscriptions,
139                total_owners,
140                total_units,
141                total_expenses,
142                total_meetings,
143            };
144            HttpResponse::Ok().json(stats)
145        }
146        _ => HttpResponse::InternalServerError().json(serde_json::json!({
147            "error": "Failed to fetch dashboard statistics"
148        })),
149    }
150}
151
152/// Get Owner dashboard statistics (Owner role)
153/// Shows data only for buildings where this owner has units
154#[get("/stats/owner")]
155pub async fn get_owner_stats(
156    state: web::Data<AppState>,
157    user: AuthenticatedUser,
158) -> impl Responder {
159    // Only Owner role can access these stats (+ SuperAdmin for debugging)
160    if user.role != "owner" && user.role != "superadmin" {
161        return HttpResponse::Forbidden().json(serde_json::json!({
162            "error": "Only Owner can access these statistics"
163        }));
164    }
165
166    // Find the owner record linked to this user
167    let owner_record = sqlx::query!("SELECT id FROM owners WHERE user_id = $1", user.user_id)
168        .fetch_optional(&state.pool)
169        .await;
170
171    let owner_id = match owner_record {
172        Ok(Some(record)) => record.id,
173        Ok(None) => {
174            // User is not linked to any owner record
175            return HttpResponse::Ok().json(SyndicDashboardStats {
176                total_buildings: 0,
177                total_units: 0,
178                total_owners: 0,
179                pending_expenses_count: 0,
180                pending_expenses_amount: 0.0,
181                next_meeting: None,
182            });
183        }
184        Err(_) => {
185            return HttpResponse::InternalServerError().json(serde_json::json!({
186                "error": "Failed to fetch owner information"
187            }))
188        }
189    };
190
191    // Count DISTINCT buildings where this owner has units
192    let buildings_result = sqlx::query_scalar::<_, i64>(
193        "SELECT COUNT(DISTINCT b.id) FROM buildings b
194         INNER JOIN units u ON b.id = u.building_id
195         INNER JOIN unit_owners uo ON u.id = uo.unit_id
196         WHERE uo.owner_id = $1 AND uo.end_date IS NULL",
197    )
198    .bind(owner_id)
199    .fetch_one(&state.pool)
200    .await;
201
202    // Count units owned by this owner
203    let units_result = sqlx::query_scalar::<_, i64>(
204        "SELECT COUNT(*) FROM unit_owners uo
205         WHERE uo.owner_id = $1 AND uo.end_date IS NULL",
206    )
207    .bind(owner_id)
208    .fetch_one(&state.pool)
209    .await;
210
211    // Count other owners in the same buildings (for community info)
212    let owners_result = sqlx::query_scalar::<_, i64>(
213        "SELECT COUNT(DISTINCT uo2.owner_id) FROM unit_owners uo2
214         INNER JOIN units u ON uo2.unit_id = u.id
215         WHERE u.building_id IN (
216             SELECT DISTINCT u2.building_id FROM units u2
217             INNER JOIN unit_owners uo ON u2.id = uo.unit_id
218             WHERE uo.owner_id = $1 AND uo.end_date IS NULL
219         ) AND uo2.end_date IS NULL",
220    )
221    .bind(owner_id)
222    .fetch_one(&state.pool)
223    .await;
224
225    // Get pending expenses for buildings where owner has units
226    let pending_expenses = sqlx::query!(
227        "SELECT COUNT(*) as count, COALESCE(SUM(amount), 0) as total
228         FROM expenses e
229         WHERE e.building_id IN (
230             SELECT DISTINCT u.building_id FROM units u
231             INNER JOIN unit_owners uo ON u.id = uo.unit_id
232             WHERE uo.owner_id = $1 AND uo.end_date IS NULL
233         ) AND e.payment_status = 'pending'",
234        owner_id
235    )
236    .fetch_one(&state.pool)
237    .await;
238
239    // Get next meeting for owner's buildings
240    let next_meeting = sqlx::query!(
241        "SELECT m.id, m.scheduled_date, b.name as building_name
242         FROM meetings m
243         INNER JOIN buildings b ON m.building_id = b.id
244         WHERE b.id IN (
245             SELECT DISTINCT u.building_id FROM units u
246             INNER JOIN unit_owners uo ON u.id = uo.unit_id
247             WHERE uo.owner_id = $1 AND uo.end_date IS NULL
248         )
249         AND m.scheduled_date > NOW() AND m.status = 'scheduled'
250         ORDER BY m.scheduled_date ASC
251         LIMIT 1",
252        owner_id
253    )
254    .fetch_optional(&state.pool)
255    .await;
256
257    match (
258        buildings_result,
259        units_result,
260        owners_result,
261        pending_expenses,
262        next_meeting,
263    ) {
264        (
265            Ok(total_buildings),
266            Ok(total_units),
267            Ok(total_owners),
268            Ok(expenses_data),
269            Ok(meeting_data),
270        ) => {
271            let next_meeting_info = meeting_data.map(|m| NextMeetingInfo {
272                id: m.id.to_string(),
273                date: m.scheduled_date,
274                building_name: m.building_name,
275            });
276
277            let stats = SyndicDashboardStats {
278                total_buildings,
279                total_units,
280                total_owners,
281                pending_expenses_count: expenses_data.count.unwrap_or(0),
282                pending_expenses_amount: expenses_data.total.unwrap_or(0.0),
283                next_meeting: next_meeting_info,
284            };
285            HttpResponse::Ok().json(stats)
286        }
287        _ => HttpResponse::InternalServerError().json(serde_json::json!({
288            "error": "Failed to fetch owner dashboard statistics"
289        })),
290    }
291}
292
293/// Get Syndic dashboard statistics (Syndic and Accountant roles)
294#[get("/stats/syndic")]
295pub async fn get_syndic_stats(
296    state: web::Data<AppState>,
297    user: AuthenticatedUser,
298) -> impl Responder {
299    // Only Syndic and Accountant can access these stats
300    if user.role != "syndic" && user.role != "accountant" && user.role != "superadmin" {
301        return HttpResponse::Forbidden().json(serde_json::json!({
302            "error": "Only Syndic and Accountant can access these statistics"
303        }));
304    }
305
306    // Count buildings in user's organization
307    let buildings_result =
308        sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM buildings WHERE organization_id = $1")
309            .bind(user.organization_id)
310            .fetch_one(&state.pool)
311            .await;
312
313    // Count units in user's organization
314    let units_result = sqlx::query_scalar::<_, i64>(
315        "SELECT COUNT(*) FROM units u
316         INNER JOIN buildings b ON u.building_id = b.id
317         WHERE b.organization_id = $1",
318    )
319    .bind(user.organization_id)
320    .fetch_one(&state.pool)
321    .await;
322
323    // Count owners in user's organization (via unit_owners)
324    let owners_result = sqlx::query_scalar::<_, i64>(
325        "SELECT COUNT(DISTINCT o.id) FROM owners o
326         INNER JOIN unit_owners uo ON o.id = uo.owner_id
327         INNER JOIN units u ON uo.unit_id = u.id
328         INNER JOIN buildings b ON u.building_id = b.id
329         WHERE b.organization_id = $1 AND uo.end_date IS NULL",
330    )
331    .bind(user.organization_id)
332    .fetch_one(&state.pool)
333    .await;
334
335    // Count pending expenses and total amount
336    let pending_expenses = sqlx::query!(
337        "SELECT COUNT(*) as count, COALESCE(SUM(amount), 0) as total
338         FROM expenses e
339         INNER JOIN buildings b ON e.building_id = b.id
340         WHERE b.organization_id = $1 AND e.payment_status = 'pending'",
341        user.organization_id
342    )
343    .fetch_one(&state.pool)
344    .await;
345
346    // Get next meeting
347    let next_meeting = sqlx::query!(
348        "SELECT m.id, m.scheduled_date, b.name as building_name
349         FROM meetings m
350         INNER JOIN buildings b ON m.building_id = b.id
351         WHERE b.organization_id = $1 AND m.scheduled_date > NOW() AND m.status = 'scheduled'
352         ORDER BY m.scheduled_date ASC
353         LIMIT 1",
354        user.organization_id
355    )
356    .fetch_optional(&state.pool)
357    .await;
358
359    match (
360        buildings_result,
361        units_result,
362        owners_result,
363        pending_expenses,
364        next_meeting,
365    ) {
366        (
367            Ok(total_buildings),
368            Ok(total_units),
369            Ok(total_owners),
370            Ok(expenses_data),
371            Ok(meeting_data),
372        ) => {
373            let next_meeting_info = meeting_data.map(|m| NextMeetingInfo {
374                id: m.id.to_string(),
375                date: m.scheduled_date,
376                building_name: m.building_name,
377            });
378
379            let stats = SyndicDashboardStats {
380                total_buildings,
381                total_units,
382                total_owners,
383                pending_expenses_count: expenses_data.count.unwrap_or(0),
384                pending_expenses_amount: expenses_data.total.unwrap_or(0.0),
385                next_meeting: next_meeting_info,
386            };
387            HttpResponse::Ok().json(stats)
388        }
389        _ => HttpResponse::InternalServerError().json(serde_json::json!({
390            "error": "Failed to fetch syndic dashboard statistics"
391        })),
392    }
393}
394
395/// Get urgent tasks for Syndic dashboard (Syndic and Accountant roles)
396#[get("/stats/syndic/urgent-tasks")]
397pub async fn get_syndic_urgent_tasks(
398    state: web::Data<AppState>,
399    user: AuthenticatedUser,
400) -> impl Responder {
401    // Only Syndic and Accountant can access these tasks
402    if user.role != "syndic" && user.role != "accountant" && user.role != "superadmin" {
403        return HttpResponse::Forbidden().json(serde_json::json!({
404            "error": "Only Syndic and Accountant can access these tasks"
405        }));
406    }
407
408    let mut urgent_tasks: Vec<UrgentTask> = Vec::new();
409
410    // 1. Get overdue expenses (payment_status = 'overdue')
411    let overdue_expenses = sqlx::query!(
412        "SELECT e.id, e.description, e.amount, b.name as building_name, e.expense_date
413         FROM expenses e
414         INNER JOIN buildings b ON e.building_id = b.id
415         WHERE b.organization_id = $1
416         AND e.payment_status = 'overdue'
417         ORDER BY e.expense_date ASC
418         LIMIT 5",
419        user.organization_id
420    )
421    .fetch_all(&state.pool)
422    .await;
423
424    if let Ok(expenses) = overdue_expenses {
425        for expense in expenses {
426            urgent_tasks.push(UrgentTask {
427                task_type: "expense".to_string(),
428                title: format!("Charge en retard - {:.2}€", expense.amount),
429                description: expense.description,
430                priority: "urgent".to_string(),
431                building_name: Some(expense.building_name),
432                entity_id: Some(expense.id.to_string()),
433                due_date: Some(expense.expense_date),
434            });
435        }
436    }
437
438    // 2. Get upcoming meetings (within 7 days)
439    let upcoming_meetings = sqlx::query!(
440        "SELECT m.id, m.title, m.scheduled_date, b.name as building_name
441         FROM meetings m
442         INNER JOIN buildings b ON m.building_id = b.id
443         WHERE b.organization_id = $1
444         AND m.status = 'scheduled'
445         AND m.scheduled_date BETWEEN NOW() AND NOW() + INTERVAL '7 days'
446         ORDER BY m.scheduled_date ASC
447         LIMIT 3",
448        user.organization_id
449    )
450    .fetch_all(&state.pool)
451    .await;
452
453    if let Ok(meetings) = upcoming_meetings {
454        for meeting in meetings {
455            let days_until = (meeting.scheduled_date - Utc::now()).num_days();
456            let priority = if days_until <= 3 { "urgent" } else { "high" };
457
458            urgent_tasks.push(UrgentTask {
459                task_type: "meeting".to_string(),
460                title: meeting.title,
461                description: format!("AG dans {} jours", days_until),
462                priority: priority.to_string(),
463                building_name: Some(meeting.building_name),
464                entity_id: Some(meeting.id.to_string()),
465                due_date: Some(meeting.scheduled_date),
466            });
467        }
468    }
469
470    // 3. Get pending expenses (payment_status = 'pending' and overdue)
471    let pending_overdue = sqlx::query!(
472        "SELECT COUNT(*) as count
473         FROM expenses e
474         INNER JOIN buildings b ON e.building_id = b.id
475         WHERE b.organization_id = $1
476         AND e.payment_status = 'pending'
477         AND e.expense_date < NOW() - INTERVAL '30 days'",
478        user.organization_id
479    )
480    .fetch_one(&state.pool)
481    .await;
482
483    if let Ok(data) = pending_overdue {
484        if let Some(count) = data.count {
485            if count > 0 {
486                urgent_tasks.push(UrgentTask {
487                    task_type: "expense".to_string(),
488                    title: "Relance paiements".to_string(),
489                    description: format!("{} charges en attente depuis plus de 30 jours", count),
490                    priority: "high".to_string(),
491                    building_name: None,
492                    entity_id: None,
493                    due_date: None,
494                });
495            }
496        }
497    }
498
499    // Sort by priority and due date
500    urgent_tasks.sort_by(|a, b| {
501        let priority_order = |p: &str| match p {
502            "urgent" => 0,
503            "high" => 1,
504            _ => 2,
505        };
506        priority_order(&a.priority).cmp(&priority_order(&b.priority))
507    });
508
509    HttpResponse::Ok().json(urgent_tasks)
510}
511
512/// Get seed data statistics (SuperAdmin only) - shows breakdown of seed vs production data
513#[get("/stats/seed-data")]
514pub async fn get_seed_data_stats(
515    state: web::Data<AppState>,
516    user: AuthenticatedUser,
517) -> impl Responder {
518    // Only SuperAdmin can access these stats
519    if user.role != "superadmin" {
520        return HttpResponse::Forbidden().json(serde_json::json!({
521            "error": "Only SuperAdmin can access seed data statistics"
522        }));
523    }
524
525    // Count seed organizations
526    let seed_orgs_result = sqlx::query_scalar::<_, i64>(
527        "SELECT COUNT(*) FROM organizations WHERE is_seed_data = true",
528    )
529    .fetch_one(&state.pool)
530    .await;
531
532    // Count production organizations
533    let prod_orgs_result = sqlx::query_scalar::<_, i64>(
534        "SELECT COUNT(*) FROM organizations WHERE is_seed_data = false",
535    )
536    .fetch_one(&state.pool)
537    .await;
538
539    // Count buildings in seed organizations
540    let seed_buildings_result = sqlx::query_scalar::<_, i64>(
541        "SELECT COUNT(*) FROM buildings b
542         INNER JOIN organizations o ON b.organization_id = o.id
543         WHERE o.is_seed_data = true",
544    )
545    .fetch_one(&state.pool)
546    .await;
547
548    // Count units in seed organizations
549    let seed_units_result = sqlx::query_scalar::<_, i64>(
550        "SELECT COUNT(*) FROM units u
551         INNER JOIN buildings b ON u.building_id = b.id
552         INNER JOIN organizations o ON b.organization_id = o.id
553         WHERE o.is_seed_data = true",
554    )
555    .fetch_one(&state.pool)
556    .await;
557
558    // Count owners in seed organizations (via unit_owners)
559    let seed_owners_result = sqlx::query_scalar::<_, i64>(
560        "SELECT COUNT(DISTINCT o.id) FROM owners o
561         INNER JOIN unit_owners uo ON o.id = uo.owner_id
562         INNER JOIN units u ON uo.unit_id = u.id
563         INNER JOIN buildings b ON u.building_id = b.id
564         INNER JOIN organizations org ON b.organization_id = org.id
565         WHERE org.is_seed_data = true",
566    )
567    .fetch_one(&state.pool)
568    .await;
569
570    // Count unit_owners relationships in seed organizations
571    let seed_unit_owners_result = sqlx::query_scalar::<_, i64>(
572        "SELECT COUNT(*) FROM unit_owners uo
573         INNER JOIN units u ON uo.unit_id = u.id
574         INNER JOIN buildings b ON u.building_id = b.id
575         INNER JOIN organizations o ON b.organization_id = o.id
576         WHERE o.is_seed_data = true",
577    )
578    .fetch_one(&state.pool)
579    .await;
580
581    // Count expenses in seed organizations
582    let seed_expenses_result = sqlx::query_scalar::<_, i64>(
583        "SELECT COUNT(*) FROM expenses e
584         INNER JOIN buildings b ON e.building_id = b.id
585         INNER JOIN organizations o ON b.organization_id = o.id
586         WHERE o.is_seed_data = true",
587    )
588    .fetch_one(&state.pool)
589    .await;
590
591    // Count meetings in seed organizations
592    let seed_meetings_result = sqlx::query_scalar::<_, i64>(
593        "SELECT COUNT(*) FROM meetings m
594         INNER JOIN buildings b ON m.building_id = b.id
595         INNER JOIN organizations o ON b.organization_id = o.id
596         WHERE o.is_seed_data = true",
597    )
598    .fetch_one(&state.pool)
599    .await;
600
601    // Count users in seed organizations
602    let seed_users_result = sqlx::query_scalar::<_, i64>(
603        "SELECT COUNT(*) FROM users u
604         INNER JOIN organizations o ON u.organization_id = o.id
605         WHERE o.is_seed_data = true",
606    )
607    .fetch_one(&state.pool)
608    .await;
609
610    // Handle results
611    match (
612        seed_orgs_result,
613        prod_orgs_result,
614        seed_buildings_result,
615        seed_units_result,
616        seed_owners_result,
617        seed_unit_owners_result,
618        seed_expenses_result,
619        seed_meetings_result,
620        seed_users_result,
621    ) {
622        (
623            Ok(seed_organizations),
624            Ok(production_organizations),
625            Ok(seed_buildings),
626            Ok(seed_units),
627            Ok(seed_owners),
628            Ok(seed_unit_owners),
629            Ok(seed_expenses),
630            Ok(seed_meetings),
631            Ok(seed_users),
632        ) => {
633            let stats = SeedDataStats {
634                seed_organizations,
635                production_organizations,
636                seed_buildings,
637                seed_units,
638                seed_owners,
639                seed_unit_owners,
640                seed_expenses,
641                seed_meetings,
642                seed_users,
643            };
644            HttpResponse::Ok().json(stats)
645        }
646        _ => HttpResponse::InternalServerError().json(serde_json::json!({
647            "error": "Failed to fetch seed data statistics"
648        })),
649    }
650}