koprogo_api/infrastructure/database/repositories/
stats_repository_impl.rs1use 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}