Skip to main content

koprogo_api/infrastructure/database/repositories/
meeting_repository_impl.rs

1use crate::application::ports::MeetingRepository;
2use crate::domain::entities::{Meeting, MeetingMode, MeetingStatus, MeetingType};
3use crate::infrastructure::database::pool::DbPool;
4use async_trait::async_trait;
5use sqlx::Row;
6use uuid::Uuid;
7
8pub struct PostgresMeetingRepository {
9    pool: DbPool,
10}
11
12impl PostgresMeetingRepository {
13    pub fn new(pool: DbPool) -> Self {
14        Self { pool }
15    }
16}
17
18/// Helper: map a DB row to a Meeting entity (includes quorum fields)
19fn row_to_meeting(row: &sqlx::postgres::PgRow) -> Meeting {
20    let meeting_type_str: String = row.get("meeting_type");
21    let meeting_type = match meeting_type_str.as_str() {
22        "extraordinary" => MeetingType::Extraordinary,
23        _ => MeetingType::Ordinary,
24    };
25
26    let status_str: String = row.get("status");
27    let status = match status_str.as_str() {
28        "completed" => MeetingStatus::Completed,
29        "cancelled" => MeetingStatus::Cancelled,
30        _ => MeetingStatus::Scheduled,
31    };
32
33    let agenda_json: serde_json::Value = row.get("agenda");
34    let agenda: Vec<String> = serde_json::from_value(agenda_json).unwrap_or_default();
35
36    let mode = row
37        .try_get::<String, _>("mode")
38        .ok()
39        .and_then(|s| MeetingMode::from_db_string(&s).ok())
40        .unwrap_or(MeetingMode::InPerson);
41
42    Meeting {
43        id: row.get("id"),
44        acp_id: row.get("acp_id"),
45        organization_id: row.get("organization_id"),
46        building_id: row.get("building_id"),
47        meeting_type,
48        title: row.get("title"),
49        description: row.get("description"),
50        scheduled_date: row.get("scheduled_date"),
51        location: row.get("location"),
52        status,
53        agenda,
54        attendees_count: row.get("attendees_count"),
55        quorum_validated: row.try_get("quorum_validated").unwrap_or(false),
56        quorum_percentage: row.try_get("quorum_percentage").unwrap_or(None),
57        total_quotas: row.try_get("total_quotas").unwrap_or(None),
58        present_quotas: row.try_get("present_quotas").unwrap_or(None),
59        created_at: row.get("created_at"),
60        updated_at: row.get("updated_at"),
61        is_second_convocation: row.try_get("is_second_convocation").unwrap_or(false),
62        minutes_document_id: row.try_get("minutes_document_id").unwrap_or(None),
63        minutes_sent_at: row.try_get("minutes_sent_at").unwrap_or(None),
64        mode,
65        videoconf_url: row.try_get("videoconf_url").unwrap_or(None),
66    }
67}
68
69const SELECT_COLUMNS: &str = "id, acp_id, organization_id, building_id, \
70    meeting_type::text AS meeting_type, title, description, scheduled_date, \
71    location, status::text AS status, agenda, attendees_count, \
72    quorum_validated, quorum_percentage, total_quotas, present_quotas, \
73    is_second_convocation, mode, videoconf_url, created_at, updated_at";
74
75#[async_trait]
76impl MeetingRepository for PostgresMeetingRepository {
77    async fn create(&self, meeting: &Meeting) -> Result<Meeting, String> {
78        let meeting_type_str = match meeting.meeting_type {
79            MeetingType::Ordinary => "ordinary",
80            MeetingType::Extraordinary => "extraordinary",
81        };
82
83        let status_str = match meeting.status {
84            MeetingStatus::Scheduled => "scheduled",
85            MeetingStatus::Completed => "completed",
86            MeetingStatus::Cancelled => "cancelled",
87        };
88
89        let agenda_json = serde_json::to_value(&meeting.agenda)
90            .map_err(|e| format!("JSON serialization error: {}", e))?;
91
92        let mode_str = meeting.mode.to_db_str();
93
94        sqlx::query(
95            r#"
96            INSERT INTO meetings (
97                id, acp_id, organization_id, building_id, meeting_type, title, description,
98                scheduled_date, location, status, agenda, attendees_count,
99                quorum_validated, quorum_percentage, total_quotas, present_quotas,
100                is_second_convocation, mode, videoconf_url, created_at, updated_at
101            )
102            VALUES ($1, $2, $3, $4, CAST($5 AS meeting_type), $6, $7, $8, $9, CAST($10 AS meeting_status),
103                    $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
104            "#,
105        )
106        .bind(meeting.id)
107        .bind(meeting.acp_id)
108        .bind(meeting.organization_id)
109        .bind(meeting.building_id)
110        .bind(meeting_type_str)
111        .bind(&meeting.title)
112        .bind(&meeting.description)
113        .bind(meeting.scheduled_date)
114        .bind(&meeting.location)
115        .bind(status_str)
116        .bind(agenda_json)
117        .bind(meeting.attendees_count)
118        .bind(meeting.quorum_validated)
119        .bind(meeting.quorum_percentage)
120        .bind(meeting.total_quotas)
121        .bind(meeting.present_quotas)
122        .bind(meeting.is_second_convocation)
123        .bind(mode_str)
124        .bind(&meeting.videoconf_url)
125        .bind(meeting.created_at)
126        .bind(meeting.updated_at)
127        .execute(&self.pool)
128        .await
129        .map_err(|e| format!("Database error: {}", e))?;
130
131        Ok(meeting.clone())
132    }
133
134    async fn find_by_id(&self, id: Uuid) -> Result<Option<Meeting>, String> {
135        let query = format!("SELECT {} FROM meetings WHERE id = $1", SELECT_COLUMNS);
136        let row = sqlx::query(&query)
137            .bind(id)
138            .fetch_optional(&self.pool)
139            .await
140            .map_err(|e| format!("Database error: {}", e))?;
141
142        Ok(row.as_ref().map(row_to_meeting))
143    }
144
145    async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Meeting>, String> {
146        let query = format!(
147            "SELECT {} FROM meetings WHERE building_id = $1 ORDER BY scheduled_date DESC",
148            SELECT_COLUMNS
149        );
150        let rows = sqlx::query(&query)
151            .bind(building_id)
152            .fetch_all(&self.pool)
153            .await
154            .map_err(|e| format!("Database error: {}", e))?;
155
156        Ok(rows.iter().map(row_to_meeting).collect())
157    }
158
159    async fn update(&self, meeting: &Meeting) -> Result<Meeting, String> {
160        let status_str = match meeting.status {
161            MeetingStatus::Scheduled => "scheduled",
162            MeetingStatus::Completed => "completed",
163            MeetingStatus::Cancelled => "cancelled",
164        };
165
166        let agenda_json = serde_json::to_value(&meeting.agenda)
167            .map_err(|e| format!("JSON serialization error: {}", e))?;
168
169        let mode_str = meeting.mode.to_db_str();
170
171        sqlx::query(
172            r#"
173            UPDATE meetings
174            SET title = $2,
175                description = $3,
176                scheduled_date = $4,
177                location = $5,
178                status = CAST($6 AS meeting_status),
179                agenda = $7,
180                attendees_count = $8,
181                quorum_validated = $9,
182                quorum_percentage = $10,
183                total_quotas = $11,
184                present_quotas = $12,
185                is_second_convocation = $13,
186                mode = $14,
187                videoconf_url = $15,
188                updated_at = $16
189            WHERE id = $1
190            "#,
191        )
192        .bind(meeting.id)
193        .bind(&meeting.title)
194        .bind(&meeting.description)
195        .bind(meeting.scheduled_date)
196        .bind(&meeting.location)
197        .bind(status_str)
198        .bind(agenda_json)
199        .bind(meeting.attendees_count)
200        .bind(meeting.quorum_validated)
201        .bind(meeting.quorum_percentage)
202        .bind(meeting.total_quotas)
203        .bind(meeting.present_quotas)
204        .bind(meeting.is_second_convocation)
205        .bind(mode_str)
206        .bind(&meeting.videoconf_url)
207        .bind(meeting.updated_at)
208        .execute(&self.pool)
209        .await
210        .map_err(|e| format!("Database error: {}", e))?;
211
212        Ok(meeting.clone())
213    }
214
215    async fn delete(&self, id: Uuid) -> Result<bool, String> {
216        let result = sqlx::query("DELETE FROM meetings WHERE id = $1")
217            .bind(id)
218            .execute(&self.pool)
219            .await
220            .map_err(|e| format!("Database error: {}", e))?;
221
222        Ok(result.rows_affected() > 0)
223    }
224
225    async fn find_all_paginated(
226        &self,
227        page_request: &crate::application::dto::PageRequest,
228        organization_id: Option<Uuid>,
229    ) -> Result<(Vec<Meeting>, i64), String> {
230        page_request.validate()?;
231
232        let where_clause = if let Some(org_id) = organization_id {
233            // Le périmètre se dérive du mandat, pas d'une colonne gravée dans
234            // l'assemblée (ADR-0045). L'interpolation reste sûre — `org_id` est
235            // un `Uuid` typé, pas une chaîne venue de l'extérieur — mais elle
236            // gagnerait à devenir un paramètre lié comme partout ailleurs.
237            format!(
238                "WHERE acp_id IN (SELECT id FROM acps WHERE organization_id = '{}')",
239                org_id
240            )
241        } else {
242            String::new()
243        };
244
245        let count_query = format!("SELECT COUNT(*) FROM meetings {}", where_clause);
246        let total_items = sqlx::query_scalar::<_, i64>(&count_query)
247            .fetch_one(&self.pool)
248            .await
249            .map_err(|e| format!("Database error: {}", e))?;
250
251        let data_query = format!(
252            "SELECT {} FROM meetings {} ORDER BY scheduled_date DESC LIMIT {} OFFSET {}",
253            SELECT_COLUMNS,
254            where_clause,
255            page_request.limit(),
256            page_request.offset()
257        );
258
259        let rows = sqlx::query(&data_query)
260            .fetch_all(&self.pool)
261            .await
262            .map_err(|e| format!("Database error: {}", e))?;
263
264        Ok((rows.iter().map(row_to_meeting).collect(), total_items))
265    }
266}