koprogo_api/infrastructure/database/repositories/
stats_repository_impl.rs

1use crate::application::dto::{
2    AdminDashboardStats, NextMeetingInfo, SeedDataStats, SyndicDashboardStats, UrgentTask,
3};
4use crate::application::ports::StatsRepository;
5use crate::infrastructure::pool::DbPool;
6use async_trait::async_trait;
7use chrono::Utc;
8use sqlx::Row;
9use uuid::Uuid;
10
11pub struct PostgresStatsRepository {
12    pool: DbPool,
13}
14
15impl PostgresStatsRepository {
16    pub fn new(pool: DbPool) -> Self {
17        Self { pool }
18    }
19}
20
21#[async_trait]
22impl StatsRepository for PostgresStatsRepository {
23    async fn get_admin_dashboard_stats(&self) -> Result<AdminDashboardStats, String> {
24        let total_organizations =
25            sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM organizations")
26                .fetch_one(&self.pool)
27                .await
28                .map_err(|e| e.to_string())?;
29
30        let total_users = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users")
31            .fetch_one(&self.pool)
32            .await
33            .map_err(|e| e.to_string())?;
34
35        let total_buildings = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM buildings")
36            .fetch_one(&self.pool)
37            .await
38            .map_err(|e| e.to_string())?;
39
40        let active_subscriptions = sqlx::query_scalar::<_, i64>(
41            "SELECT COUNT(*) FROM organizations WHERE is_active = true",
42        )
43        .fetch_one(&self.pool)
44        .await
45        .map_err(|e| e.to_string())?;
46
47        let total_owners = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owners")
48            .fetch_one(&self.pool)
49            .await
50            .map_err(|e| e.to_string())?;
51
52        let total_units = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM units")
53            .fetch_one(&self.pool)
54            .await
55            .map_err(|e| e.to_string())?;
56
57        let total_expenses = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM expenses")
58            .fetch_one(&self.pool)
59            .await
60            .map_err(|e| e.to_string())?;
61
62        let total_meetings = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM meetings")
63            .fetch_one(&self.pool)
64            .await
65            .map_err(|e| e.to_string())?;
66
67        Ok(AdminDashboardStats {
68            total_organizations,
69            total_users,
70            total_buildings,
71            active_subscriptions,
72            total_owners,
73            total_units,
74            total_expenses,
75            total_meetings,
76        })
77    }
78
79    async fn get_seed_data_stats(&self) -> Result<SeedDataStats, String> {
80        let seed_organizations = sqlx::query_scalar::<_, i64>(
81            "SELECT COUNT(*) FROM organizations WHERE is_seed_data = true",
82        )
83        .fetch_one(&self.pool)
84        .await
85        .map_err(|e| e.to_string())?;
86
87        let production_organizations = sqlx::query_scalar::<_, i64>(
88            "SELECT COUNT(*) FROM organizations WHERE is_seed_data = false",
89        )
90        .fetch_one(&self.pool)
91        .await
92        .map_err(|e| e.to_string())?;
93
94        let seed_buildings = sqlx::query_scalar::<_, i64>(
95            "SELECT COUNT(*) FROM buildings b
96             INNER JOIN organizations o ON b.organization_id = o.id
97             WHERE o.is_seed_data = true",
98        )
99        .fetch_one(&self.pool)
100        .await
101        .map_err(|e| e.to_string())?;
102
103        let seed_units = sqlx::query_scalar::<_, i64>(
104            "SELECT COUNT(*) FROM units u
105             INNER JOIN buildings b ON u.building_id = b.id
106             INNER JOIN organizations o ON b.organization_id = o.id
107             WHERE o.is_seed_data = true",
108        )
109        .fetch_one(&self.pool)
110        .await
111        .map_err(|e| e.to_string())?;
112
113        let seed_owners = sqlx::query_scalar::<_, i64>(
114            "SELECT COUNT(DISTINCT o.id) FROM owners o
115             INNER JOIN unit_owners uo ON o.id = uo.owner_id
116             INNER JOIN units u ON uo.unit_id = u.id
117             INNER JOIN buildings b ON u.building_id = b.id
118             INNER JOIN organizations org ON b.organization_id = org.id
119             WHERE org.is_seed_data = true",
120        )
121        .fetch_one(&self.pool)
122        .await
123        .map_err(|e| e.to_string())?;
124
125        let seed_unit_owners = sqlx::query_scalar::<_, i64>(
126            "SELECT COUNT(*) FROM unit_owners uo
127             INNER JOIN units u ON uo.unit_id = u.id
128             INNER JOIN buildings b ON u.building_id = b.id
129             INNER JOIN organizations o ON b.organization_id = o.id
130             WHERE o.is_seed_data = true",
131        )
132        .fetch_one(&self.pool)
133        .await
134        .map_err(|e| e.to_string())?;
135
136        let seed_expenses = sqlx::query_scalar::<_, i64>(
137            "SELECT COUNT(*) FROM expenses e
138             INNER JOIN buildings b ON e.building_id = b.id
139             INNER JOIN organizations o ON b.organization_id = o.id
140             WHERE o.is_seed_data = true",
141        )
142        .fetch_one(&self.pool)
143        .await
144        .map_err(|e| e.to_string())?;
145
146        let seed_meetings = sqlx::query_scalar::<_, i64>(
147            "SELECT COUNT(*) FROM meetings m
148             INNER JOIN buildings b ON m.building_id = b.id
149             INNER JOIN organizations o ON b.organization_id = o.id
150             WHERE o.is_seed_data = true",
151        )
152        .fetch_one(&self.pool)
153        .await
154        .map_err(|e| e.to_string())?;
155
156        let seed_users = sqlx::query_scalar::<_, i64>(
157            "SELECT COUNT(*) FROM users u
158             INNER JOIN organizations o ON u.organization_id = o.id
159             WHERE o.is_seed_data = true",
160        )
161        .fetch_one(&self.pool)
162        .await
163        .map_err(|e| e.to_string())?;
164
165        Ok(SeedDataStats {
166            seed_organizations,
167            production_organizations,
168            seed_buildings,
169            seed_units,
170            seed_owners,
171            seed_unit_owners,
172            seed_expenses,
173            seed_meetings,
174            seed_users,
175        })
176    }
177
178    async fn get_syndic_stats(
179        &self,
180        organization_id: Uuid,
181    ) -> Result<SyndicDashboardStats, String> {
182        let total_buildings = sqlx::query_scalar::<_, i64>(
183            "SELECT COUNT(*) FROM buildings WHERE organization_id = $1",
184        )
185        .bind(organization_id)
186        .fetch_one(&self.pool)
187        .await
188        .map_err(|e| e.to_string())?;
189
190        let total_units = sqlx::query_scalar::<_, i64>(
191            "SELECT COUNT(*) FROM units u
192             INNER JOIN buildings b ON u.building_id = b.id
193             WHERE b.organization_id = $1",
194        )
195        .bind(organization_id)
196        .fetch_one(&self.pool)
197        .await
198        .map_err(|e| e.to_string())?;
199
200        let total_owners = sqlx::query_scalar::<_, i64>(
201            "SELECT COUNT(DISTINCT o.id) FROM owners o
202             INNER JOIN unit_owners uo ON o.id = uo.owner_id
203             INNER JOIN units u ON uo.unit_id = u.id
204             INNER JOIN buildings b ON u.building_id = b.id
205             WHERE b.organization_id = $1 AND uo.end_date IS NULL",
206        )
207        .bind(organization_id)
208        .fetch_one(&self.pool)
209        .await
210        .map_err(|e| e.to_string())?;
211
212        let row = sqlx::query(
213            "SELECT COUNT(*) as count, COALESCE(SUM(amount)::float8, 0::float8) as total
214             FROM expenses e
215             INNER JOIN buildings b ON e.building_id = b.id
216             WHERE b.organization_id = $1 AND e.payment_status = 'pending'",
217        )
218        .bind(organization_id)
219        .fetch_one(&self.pool)
220        .await
221        .map_err(|e| e.to_string())?;
222        let pending_count: i64 = row.try_get("count").unwrap_or(0);
223        let pending_total: f64 = row.try_get("total").unwrap_or(0.0);
224
225        let next_meeting_row = sqlx::query(
226            "SELECT m.id, m.scheduled_date, b.name as building_name
227             FROM meetings m
228             INNER JOIN buildings b ON m.building_id = b.id
229             WHERE b.organization_id = $1 AND m.scheduled_date > NOW() AND m.status = 'scheduled'
230             ORDER BY m.scheduled_date ASC
231             LIMIT 1",
232        )
233        .bind(organization_id)
234        .fetch_optional(&self.pool)
235        .await
236        .map_err(|e| e.to_string())?;
237
238        Ok(SyndicDashboardStats {
239            total_buildings,
240            total_units,
241            total_owners,
242            pending_expenses_count: pending_count,
243            pending_expenses_amount: pending_total,
244            next_meeting: next_meeting_row.map(|m| NextMeetingInfo {
245                id: m.get::<Uuid, _>("id").to_string(),
246                date: m.get("scheduled_date"),
247                building_name: m.get("building_name"),
248            }),
249        })
250    }
251
252    async fn get_owner_stats(&self, owner_id: Uuid) -> Result<SyndicDashboardStats, String> {
253        let total_buildings = sqlx::query_scalar::<_, i64>(
254            "SELECT COUNT(DISTINCT b.id) FROM buildings b
255             INNER JOIN units u ON b.id = u.building_id
256             INNER JOIN unit_owners uo ON u.id = uo.unit_id
257             WHERE uo.owner_id = $1 AND uo.end_date IS NULL",
258        )
259        .bind(owner_id)
260        .fetch_one(&self.pool)
261        .await
262        .map_err(|e| e.to_string())?;
263
264        let total_units = sqlx::query_scalar::<_, i64>(
265            "SELECT COUNT(*) FROM unit_owners uo WHERE uo.owner_id = $1 AND uo.end_date IS NULL",
266        )
267        .bind(owner_id)
268        .fetch_one(&self.pool)
269        .await
270        .map_err(|e| e.to_string())?;
271
272        let total_owners = sqlx::query_scalar::<_, i64>(
273            "SELECT COUNT(DISTINCT uo2.owner_id) FROM unit_owners uo2
274             INNER JOIN units u ON uo2.unit_id = u.id
275             WHERE u.building_id IN (
276                 SELECT DISTINCT u2.building_id FROM units u2
277                 INNER JOIN unit_owners uo ON u2.id = uo.unit_id
278                 WHERE uo.owner_id = $1 AND uo.end_date IS NULL
279             ) AND uo2.end_date IS NULL",
280        )
281        .bind(owner_id)
282        .fetch_one(&self.pool)
283        .await
284        .map_err(|e| e.to_string())?;
285
286        let row = sqlx::query(
287            "SELECT COUNT(*) as count, COALESCE(SUM(amount)::float8, 0::float8) as total
288             FROM expenses e
289             WHERE e.building_id IN (
290                 SELECT DISTINCT u.building_id FROM units u
291                 INNER JOIN unit_owners uo ON u.id = uo.unit_id
292                 WHERE uo.owner_id = $1 AND uo.end_date IS NULL
293             ) AND e.payment_status = 'pending'",
294        )
295        .bind(owner_id)
296        .fetch_one(&self.pool)
297        .await
298        .map_err(|e| e.to_string())?;
299        let pending_count: i64 = row.try_get("count").unwrap_or(0);
300        let pending_total: f64 = row.try_get("total").unwrap_or(0.0);
301
302        let next_meeting_row = sqlx::query(
303            "SELECT m.id, m.scheduled_date, b.name as building_name
304             FROM meetings m
305             INNER JOIN buildings b ON m.building_id = b.id
306             WHERE b.id IN (
307                 SELECT DISTINCT u.building_id FROM units u
308                 INNER JOIN unit_owners uo ON u.id = uo.unit_id
309                 WHERE uo.owner_id = $1 AND uo.end_date IS NULL
310             )
311             AND m.scheduled_date > NOW() AND m.status = 'scheduled'
312             ORDER BY m.scheduled_date ASC
313             LIMIT 1",
314        )
315        .bind(owner_id)
316        .fetch_optional(&self.pool)
317        .await
318        .map_err(|e| e.to_string())?;
319
320        Ok(SyndicDashboardStats {
321            total_buildings,
322            total_units,
323            total_owners,
324            pending_expenses_count: pending_count,
325            pending_expenses_amount: pending_total,
326            next_meeting: next_meeting_row.map(|m| NextMeetingInfo {
327                id: m.get::<Uuid, _>("id").to_string(),
328                date: m.get("scheduled_date"),
329                building_name: m.get("building_name"),
330            }),
331        })
332    }
333
334    async fn find_owner_id_by_user_id(&self, user_id: Uuid) -> Result<Option<Uuid>, String> {
335        let row = sqlx::query("SELECT id FROM owners WHERE user_id = $1")
336            .bind(user_id)
337            .fetch_optional(&self.pool)
338            .await
339            .map_err(|e| e.to_string())?;
340        Ok(row.map(|r| r.get("id")))
341    }
342
343    async fn get_syndic_urgent_tasks(
344        &self,
345        organization_id: Uuid,
346    ) -> Result<Vec<UrgentTask>, String> {
347        let mut tasks: Vec<UrgentTask> = Vec::new();
348
349        let overdue_expenses = sqlx::query(
350            "SELECT e.id, e.description, e.amount, b.name as building_name, e.expense_date
351             FROM expenses e
352             INNER JOIN buildings b ON e.building_id = b.id
353             WHERE b.organization_id = $1
354             AND e.payment_status = 'overdue'
355             ORDER BY e.expense_date ASC
356             LIMIT 5",
357        )
358        .bind(organization_id)
359        .fetch_all(&self.pool)
360        .await
361        .map_err(|e| e.to_string())?;
362
363        for expense in overdue_expenses {
364            let amount: f64 = expense.get("amount");
365            let id: Uuid = expense.get("id");
366            tasks.push(UrgentTask {
367                task_type: "expense".to_string(),
368                title: format!("Charge en retard - {:.2}€", amount),
369                description: expense.get("description"),
370                priority: "urgent".to_string(),
371                building_name: Some(expense.get("building_name")),
372                entity_id: Some(id.to_string()),
373                due_date: Some(expense.get("expense_date")),
374            });
375        }
376
377        let upcoming_meetings = sqlx::query(
378            "SELECT m.id, m.title, m.scheduled_date, b.name as building_name
379             FROM meetings m
380             INNER JOIN buildings b ON m.building_id = b.id
381             WHERE b.organization_id = $1
382             AND m.status = 'scheduled'
383             AND m.scheduled_date BETWEEN NOW() AND NOW() + INTERVAL '7 days'
384             ORDER BY m.scheduled_date ASC
385             LIMIT 3",
386        )
387        .bind(organization_id)
388        .fetch_all(&self.pool)
389        .await
390        .map_err(|e| e.to_string())?;
391
392        for meeting in upcoming_meetings {
393            let scheduled_date: chrono::DateTime<Utc> = meeting.get("scheduled_date");
394            let days_until = (scheduled_date - Utc::now()).num_days();
395            let priority = if days_until <= 3 { "urgent" } else { "high" };
396            let id: Uuid = meeting.get("id");
397            tasks.push(UrgentTask {
398                task_type: "meeting".to_string(),
399                title: meeting.get("title"),
400                description: format!("AG dans {} jours", days_until),
401                priority: priority.to_string(),
402                building_name: Some(meeting.get("building_name")),
403                entity_id: Some(id.to_string()),
404                due_date: Some(scheduled_date),
405            });
406        }
407
408        let pending_overdue_count = sqlx::query_scalar::<_, i64>(
409            "SELECT COUNT(*)
410             FROM expenses e
411             INNER JOIN buildings b ON e.building_id = b.id
412             WHERE b.organization_id = $1
413             AND e.payment_status = 'pending'
414             AND e.expense_date < NOW() - INTERVAL '30 days'",
415        )
416        .bind(organization_id)
417        .fetch_one(&self.pool)
418        .await
419        .map_err(|e| e.to_string())?;
420
421        if pending_overdue_count > 0 {
422            tasks.push(UrgentTask {
423                task_type: "expense".to_string(),
424                title: "Relance paiements".to_string(),
425                description: format!(
426                    "{} charges en attente depuis plus de 30 jours",
427                    pending_overdue_count
428                ),
429                priority: "high".to_string(),
430                building_name: None,
431                entity_id: None,
432                due_date: None,
433            });
434        }
435
436        tasks.sort_by(|a, b| {
437            let priority_order = |p: &str| match p {
438                "urgent" => 0,
439                "high" => 1,
440                _ => 2,
441            };
442            priority_order(&a.priority).cmp(&priority_order(&b.priority))
443        });
444
445        Ok(tasks)
446    }
447}