1use crate::application::dto::PageRequest;
2use crate::application::ports::{AuditLogFilters, AuditLogRepository};
3use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
4use crate::infrastructure::database::pool::DbPool;
5use async_trait::async_trait;
6use chrono::{DateTime, Utc};
7use sqlx::Row;
8use uuid::Uuid;
9
10pub struct PostgresAuditLogRepository {
11 pool: DbPool,
12}
13
14impl PostgresAuditLogRepository {
15 pub fn new(pool: DbPool) -> Self {
16 Self { pool }
17 }
18
19 fn event_type_to_string(event_type: &AuditEventType) -> String {
21 format!("{:?}", event_type)
22 }
23
24 fn string_to_event_type(s: &str) -> AuditEventType {
59 serde_json::from_value(serde_json::Value::String(s.to_string()))
60 .unwrap_or(AuditEventType::UnknownLegacyEvent)
61 }
62
63 fn row_to_entry(row: &sqlx::postgres::PgRow) -> AuditLogEntry {
65 let event_type_str: String = row.get("event_type");
66 let metadata_json: Option<serde_json::Value> = row.get("metadata");
67
68 AuditLogEntry {
69 id: row.get("id"),
70 timestamp: row.get("timestamp"),
71 event_type: Self::string_to_event_type(&event_type_str),
72 user_id: row.get("user_id"),
73 organization_id: row.get("organization_id"),
74 resource_type: row.get("resource_type"),
75 resource_id: row.get("resource_id"),
76 ip_address: row.get("ip_address"),
77 user_agent: row.get("user_agent"),
78 metadata: metadata_json,
79 success: row.get("success"),
80 error_message: row.get("error_message"),
81 }
82 }
83}
84
85#[async_trait]
86impl AuditLogRepository for PostgresAuditLogRepository {
87 async fn create(&self, entry: &AuditLogEntry) -> Result<AuditLogEntry, String> {
88 let event_type_str = Self::event_type_to_string(&entry.event_type);
89
90 sqlx::query(
91 r#"
92 INSERT INTO audit_logs (
93 id, timestamp, event_type, user_id, organization_id,
94 resource_type, resource_id, ip_address, user_agent,
95 metadata, success, error_message, created_at
96 )
97 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
98 "#,
99 )
100 .bind(entry.id)
101 .bind(entry.timestamp)
102 .bind(event_type_str)
103 .bind(entry.user_id)
104 .bind(entry.organization_id)
105 .bind(&entry.resource_type)
106 .bind(entry.resource_id)
107 .bind(&entry.ip_address)
108 .bind(&entry.user_agent)
109 .bind(&entry.metadata)
110 .bind(entry.success)
111 .bind(&entry.error_message)
112 .bind(Utc::now())
113 .execute(&self.pool)
114 .await
115 .map_err(|e| format!("Database error: {}", e))?;
116
117 Ok(entry.clone())
118 }
119
120 async fn find_by_id(&self, id: Uuid) -> Result<Option<AuditLogEntry>, String> {
121 let row = sqlx::query(
122 r#"
123 SELECT id, timestamp, event_type, user_id, organization_id,
124 resource_type, resource_id, ip_address, user_agent,
125 metadata, success, error_message
126 FROM audit_logs
127 WHERE id = $1
128 "#,
129 )
130 .bind(id)
131 .fetch_optional(&self.pool)
132 .await
133 .map_err(|e| format!("Database error: {}", e))?;
134
135 Ok(row.map(|r| Self::row_to_entry(&r)))
136 }
137
138 async fn find_all_paginated(
139 &self,
140 page_request: &PageRequest,
141 filters: &AuditLogFilters,
142 ) -> Result<(Vec<AuditLogEntry>, i64), String> {
143 let limit = page_request.per_page.min(100);
144 let offset = (page_request.page - 1) * limit;
145
146 let mut where_clauses = Vec::new();
148 let mut param_index = 1;
149
150 if filters.user_id.is_some() {
151 where_clauses.push(format!("user_id = ${}", param_index));
152 param_index += 1;
153 }
154 if filters.organization_id.is_some() {
155 where_clauses.push(format!("organization_id = ${}", param_index));
156 param_index += 1;
157 }
158 if filters.event_type.is_some() {
159 where_clauses.push(format!("event_type = ${}", param_index));
160 param_index += 1;
161 }
162 if filters.success.is_some() {
163 where_clauses.push(format!("success = ${}", param_index));
164 param_index += 1;
165 }
166 if filters.start_date.is_some() {
167 where_clauses.push(format!("timestamp >= ${}", param_index));
168 param_index += 1;
169 }
170 if filters.end_date.is_some() {
171 where_clauses.push(format!("timestamp <= ${}", param_index));
172 param_index += 1;
173 }
174 if filters.resource_type.is_some() {
175 where_clauses.push(format!("resource_type = ${}", param_index));
176 param_index += 1;
177 }
178 if filters.resource_id.is_some() {
179 where_clauses.push(format!("resource_id = ${}", param_index));
180 param_index += 1;
181 }
182
183 let where_clause = if where_clauses.is_empty() {
184 String::new()
185 } else {
186 format!("WHERE {}", where_clauses.join(" AND "))
187 };
188
189 let count_query = format!("SELECT COUNT(*) as count FROM audit_logs {}", where_clause);
191 let mut count_query_builder = sqlx::query(&count_query);
192
193 if let Some(user_id) = filters.user_id {
195 count_query_builder = count_query_builder.bind(user_id);
196 }
197 if let Some(org_id) = filters.organization_id {
198 count_query_builder = count_query_builder.bind(org_id);
199 }
200 if let Some(ref event_type) = filters.event_type {
201 count_query_builder = count_query_builder.bind(Self::event_type_to_string(event_type));
202 }
203 if let Some(success) = filters.success {
204 count_query_builder = count_query_builder.bind(success);
205 }
206 if let Some(start_date) = filters.start_date {
207 count_query_builder = count_query_builder.bind(start_date);
208 }
209 if let Some(end_date) = filters.end_date {
210 count_query_builder = count_query_builder.bind(end_date);
211 }
212 if let Some(ref resource_type) = filters.resource_type {
213 count_query_builder = count_query_builder.bind(resource_type);
214 }
215 if let Some(resource_id) = filters.resource_id {
216 count_query_builder = count_query_builder.bind(resource_id);
217 }
218
219 let count_row = count_query_builder
220 .fetch_one(&self.pool)
221 .await
222 .map_err(|e| format!("Database error: {}", e))?;
223 let total: i64 = count_row.get("count");
224
225 let data_query = format!(
227 r#"
228 SELECT id, timestamp, event_type, user_id, organization_id,
229 resource_type, resource_id, ip_address, user_agent,
230 metadata, success, error_message
231 FROM audit_logs
232 {}
233 ORDER BY timestamp DESC
234 LIMIT ${} OFFSET ${}
235 "#,
236 where_clause,
237 param_index,
238 param_index + 1
239 );
240
241 let mut data_query_builder = sqlx::query(&data_query);
242
243 if let Some(user_id) = filters.user_id {
245 data_query_builder = data_query_builder.bind(user_id);
246 }
247 if let Some(org_id) = filters.organization_id {
248 data_query_builder = data_query_builder.bind(org_id);
249 }
250 if let Some(ref event_type) = filters.event_type {
251 data_query_builder = data_query_builder.bind(Self::event_type_to_string(event_type));
252 }
253 if let Some(success) = filters.success {
254 data_query_builder = data_query_builder.bind(success);
255 }
256 if let Some(start_date) = filters.start_date {
257 data_query_builder = data_query_builder.bind(start_date);
258 }
259 if let Some(end_date) = filters.end_date {
260 data_query_builder = data_query_builder.bind(end_date);
261 }
262 if let Some(ref resource_type) = filters.resource_type {
263 data_query_builder = data_query_builder.bind(resource_type);
264 }
265 if let Some(resource_id) = filters.resource_id {
266 data_query_builder = data_query_builder.bind(resource_id);
267 }
268
269 data_query_builder = data_query_builder.bind(limit).bind(offset);
270
271 let rows = data_query_builder
272 .fetch_all(&self.pool)
273 .await
274 .map_err(|e| format!("Database error: {}", e))?;
275
276 let entries: Vec<AuditLogEntry> = rows.iter().map(Self::row_to_entry).collect();
277
278 Ok((entries, total))
279 }
280
281 async fn find_recent(&self, limit: i64) -> Result<Vec<AuditLogEntry>, String> {
282 let rows = sqlx::query(
283 r#"
284 SELECT id, timestamp, event_type, user_id, organization_id,
285 resource_type, resource_id, ip_address, user_agent,
286 metadata, success, error_message
287 FROM audit_logs
288 ORDER BY timestamp DESC
289 LIMIT $1
290 "#,
291 )
292 .bind(limit)
293 .fetch_all(&self.pool)
294 .await
295 .map_err(|e| format!("Database error: {}", e))?;
296
297 Ok(rows.iter().map(Self::row_to_entry).collect())
298 }
299
300 async fn find_failed_operations(
301 &self,
302 page_request: &PageRequest,
303 organization_id: Option<Uuid>,
304 ) -> Result<(Vec<AuditLogEntry>, i64), String> {
305 let limit = page_request.per_page.min(100);
306 let offset = (page_request.page - 1) * limit;
307
308 let where_clause = if organization_id.is_some() {
309 "WHERE success = false AND organization_id = $1"
310 } else {
311 "WHERE success = false"
312 };
313
314 let count_query = format!("SELECT COUNT(*) as count FROM audit_logs {}", where_clause);
316 let count_row = if let Some(org_id) = organization_id {
317 sqlx::query(&count_query)
318 .bind(org_id)
319 .fetch_one(&self.pool)
320 .await
321 .map_err(|e| format!("Database error: {}", e))?
322 } else {
323 sqlx::query(&count_query)
324 .fetch_one(&self.pool)
325 .await
326 .map_err(|e| format!("Database error: {}", e))?
327 };
328 let total: i64 = count_row.get("count");
329
330 let data_query = format!(
332 r#"
333 SELECT id, timestamp, event_type, user_id, organization_id,
334 resource_type, resource_id, ip_address, user_agent,
335 metadata, success, error_message
336 FROM audit_logs
337 {}
338 ORDER BY timestamp DESC
339 LIMIT $2 OFFSET $3
340 "#,
341 where_clause
342 );
343
344 let rows = if let Some(org_id) = organization_id {
345 sqlx::query(&data_query)
346 .bind(org_id)
347 .bind(limit)
348 .bind(offset)
349 .fetch_all(&self.pool)
350 .await
351 .map_err(|e| format!("Database error: {}", e))?
352 } else {
353 sqlx::query(&data_query)
354 .bind(limit)
355 .bind(offset)
356 .fetch_all(&self.pool)
357 .await
358 .map_err(|e| format!("Database error: {}", e))?
359 };
360
361 let entries: Vec<AuditLogEntry> = rows.iter().map(Self::row_to_entry).collect();
362
363 Ok((entries, total))
364 }
365
366 async fn delete_older_than(&self, timestamp: DateTime<Utc>) -> Result<i64, String> {
367 let result = sqlx::query(
368 r#"
369 DELETE FROM audit_logs
370 WHERE timestamp < $1
371 "#,
372 )
373 .bind(timestamp)
374 .execute(&self.pool)
375 .await
376 .map_err(|e| format!("Database error: {}", e))?;
377
378 Ok(result.rows_affected() as i64)
379 }
380
381 async fn count_by_filters(&self, filters: &AuditLogFilters) -> Result<i64, String> {
382 let mut where_clauses = Vec::new();
383 let mut param_index = 1;
384
385 if filters.user_id.is_some() {
386 where_clauses.push(format!("user_id = ${}", param_index));
387 param_index += 1;
388 }
389 if filters.organization_id.is_some() {
390 where_clauses.push(format!("organization_id = ${}", param_index));
391 param_index += 1;
392 }
393 if filters.event_type.is_some() {
394 where_clauses.push(format!("event_type = ${}", param_index));
395 param_index += 1;
396 }
397 if filters.success.is_some() {
398 where_clauses.push(format!("success = ${}", param_index));
399 param_index += 1;
400 }
401 if filters.start_date.is_some() {
402 where_clauses.push(format!("timestamp >= ${}", param_index));
403 param_index += 1;
404 }
405 if filters.end_date.is_some() {
406 where_clauses.push(format!("timestamp <= ${}", param_index));
407 param_index += 1;
408 }
409 if filters.resource_type.is_some() {
410 where_clauses.push(format!("resource_type = ${}", param_index));
411 param_index += 1;
412 }
413 if filters.resource_id.is_some() {
414 where_clauses.push(format!("resource_id = ${}", param_index));
415 }
416
417 let where_clause = if where_clauses.is_empty() {
418 String::new()
419 } else {
420 format!("WHERE {}", where_clauses.join(" AND "))
421 };
422
423 let count_query = format!("SELECT COUNT(*) as count FROM audit_logs {}", where_clause);
424 let mut query_builder = sqlx::query(&count_query);
425
426 if let Some(user_id) = filters.user_id {
428 query_builder = query_builder.bind(user_id);
429 }
430 if let Some(org_id) = filters.organization_id {
431 query_builder = query_builder.bind(org_id);
432 }
433 if let Some(ref event_type) = filters.event_type {
434 query_builder = query_builder.bind(Self::event_type_to_string(event_type));
435 }
436 if let Some(success) = filters.success {
437 query_builder = query_builder.bind(success);
438 }
439 if let Some(start_date) = filters.start_date {
440 query_builder = query_builder.bind(start_date);
441 }
442 if let Some(end_date) = filters.end_date {
443 query_builder = query_builder.bind(end_date);
444 }
445 if let Some(ref resource_type) = filters.resource_type {
446 query_builder = query_builder.bind(resource_type);
447 }
448 if let Some(resource_id) = filters.resource_id {
449 query_builder = query_builder.bind(resource_id);
450 }
451
452 let row = query_builder
453 .fetch_one(&self.pool)
454 .await
455 .map_err(|e| format!("Database error: {}", e))?;
456
457 Ok(row.get("count"))
458 }
459}
460
461#[cfg(test)]
462mod tests_relecture_du_type_devenement {
463 use super::*;
464
465 fn variantes_declarees() -> Vec<String> {
472 let source = include_str!("../../audit.rs");
473 let debut = source
474 .find("pub enum AuditEventType")
475 .expect("énumération AuditEventType introuvable dans audit.rs");
476 let bloc = &source[debut..];
477 let fin = bloc.find("\n}").expect("fin de l'énumération introuvable");
478 bloc[..fin]
479 .lines()
480 .map(str::trim)
481 .filter(|l| {
482 l.ends_with(',')
483 && !l.starts_with("//")
484 && !l.contains(' ')
485 && l.chars().next().is_some_and(char::is_uppercase)
486 })
487 .map(|l| l.trim_end_matches(',').to_string())
488 .collect()
489 }
490
491 #[test]
492 fn happy_le_registre_relit_toutes_les_variantes_pour_ce_quelles_sont() {
493 let variantes = variantes_declarees();
494 assert!(
495 variantes.len() > 200,
496 "seulement {} variantes extraites : l'extraction ne lit plus \
497 l'énumération. Vérifiez avant de vous réjouir.",
498 variantes.len()
499 );
500
501 let mut travesties = Vec::new();
502 for nom in &variantes {
503 let relu = PostgresAuditLogRepository::string_to_event_type(nom);
504 let ecrit = PostgresAuditLogRepository::event_type_to_string(&relu);
505 if &ecrit != nom {
506 travesties.push(format!("{nom} se relit {ecrit}"));
507 }
508 }
509
510 assert!(
511 travesties.is_empty(),
512 "{} types d'évènements sur {} ne se relisent pas pour ce qu'ils \
513 sont.\n\n\
514 Le registre d'audit est un document de la copropriété \
515 (Art. 3.89 § 5 7°). Un type travesti à la relecture y invente \
516 des évènements qui n'ont pas eu lieu.\n\n{}",
517 travesties.len(),
518 variantes.len(),
519 travesties.join("\n")
520 );
521 }
522
523 #[test]
525 fn security_un_type_inconnu_ne_se_deguise_pas_en_acces_non_autorise() {
526 let relu = PostgresAuditLogRepository::string_to_event_type("EvenementDunFuturLointain");
527 assert_eq!(
528 relu,
529 AuditEventType::UnknownLegacyEvent,
530 "un type inconnu doit se lire inconnu. Le repli était \
531 `UnauthorizedAccess` : 194 types sur 223 ressortaient du \
532 registre en alertes de sécurité, et une élection au conseil de \
533 copropriété se lisait comme une intrusion."
534 );
535 assert_ne!(relu, AuditEventType::UnauthorizedAccess);
536 }
537
538 #[test]
540 fn happy_une_variante_recente_se_relit_sans_table_a_tenir() {
541 for nom in [
542 "MeetingCancelled",
543 "MeetingRescheduled",
544 "PortfolioShared",
545 "ExpenseMarkedOverdue",
546 "ExpenseCancelled",
547 "ExpenseReactivated",
548 "ExpenseUnpaid",
549 ] {
550 let relu = PostgresAuditLogRepository::string_to_event_type(nom);
551 assert_eq!(PostgresAuditLogRepository::event_type_to_string(&relu), nom);
552 }
553 }
554}