koprogo_api/infrastructure/database/repositories/
convocation_repository_impl.rs

1use crate::application::ports::ConvocationRepository;
2use crate::domain::entities::{Convocation, ConvocationStatus, ConvocationType};
3use async_trait::async_trait;
4use chrono::{DateTime, Duration, Utc};
5use sqlx::PgPool;
6use uuid::Uuid;
7
8/// PostgreSQL implementation of ConvocationRepository
9pub struct PostgresConvocationRepository {
10    pool: PgPool,
11}
12
13impl PostgresConvocationRepository {
14    pub fn new(pool: PgPool) -> Self {
15        Self { pool }
16    }
17
18    /// Convert ConvocationType enum to database string
19    fn convocation_type_to_db(meeting_type: &ConvocationType) -> &'static str {
20        meeting_type.to_db_string()
21    }
22
23    /// Convert database string to ConvocationType enum
24    fn convocation_type_from_db(s: &str) -> Result<ConvocationType, String> {
25        ConvocationType::from_db_string(s)
26    }
27
28    /// Convert ConvocationStatus enum to database string
29    fn status_to_db(status: &ConvocationStatus) -> &'static str {
30        status.to_db_string()
31    }
32
33    /// Convert database string to ConvocationStatus enum
34    fn status_from_db(s: &str) -> Result<ConvocationStatus, String> {
35        ConvocationStatus::from_db_string(s)
36    }
37}
38
39#[async_trait]
40impl ConvocationRepository for PostgresConvocationRepository {
41    async fn create(&self, convocation: &Convocation) -> Result<Convocation, String> {
42        let meeting_type_str = Self::convocation_type_to_db(&convocation.meeting_type);
43        let status_str = Self::status_to_db(&convocation.status);
44
45        let row = sqlx::query!(
46            r#"
47            INSERT INTO convocations (
48                id, organization_id, building_id, meeting_id, meeting_type, meeting_date,
49                status, minimum_send_date, actual_send_date, scheduled_send_date,
50                pdf_file_path, language, total_recipients, opened_count,
51                will_attend_count, will_not_attend_count, reminder_sent_at,
52                created_at, updated_at, created_by
53            )
54            VALUES ($1, $2, $3, $4, $5::TEXT::convocation_type, $6, $7::TEXT::convocation_status, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
55            RETURNING id, organization_id, building_id, meeting_id, meeting_type::text AS "meeting_type!", meeting_date,
56                      status::text AS "status!", minimum_send_date, actual_send_date, scheduled_send_date,
57                      pdf_file_path, language, total_recipients, opened_count,
58                      will_attend_count, will_not_attend_count, reminder_sent_at,
59                      created_at, updated_at, created_by
60            "#,
61            convocation.id,
62            convocation.organization_id,
63            convocation.building_id,
64            convocation.meeting_id,
65            meeting_type_str,
66            convocation.meeting_date,
67            status_str,
68            convocation.minimum_send_date,
69            convocation.actual_send_date,
70            convocation.scheduled_send_date,
71            convocation.pdf_file_path,
72            convocation.language,
73            convocation.total_recipients,
74            convocation.opened_count,
75            convocation.will_attend_count,
76            convocation.will_not_attend_count,
77            convocation.reminder_sent_at,
78            convocation.created_at,
79            convocation.updated_at,
80            convocation.created_by,
81        )
82        .fetch_one(&self.pool)
83        .await
84        .map_err(|e| format!("Failed to create convocation: {}", e))?;
85
86        Ok(Convocation {
87            id: row.id,
88            organization_id: row.organization_id,
89            building_id: row.building_id,
90            meeting_id: row.meeting_id,
91            meeting_type: Self::convocation_type_from_db(&row.meeting_type)?,
92            meeting_date: row.meeting_date,
93            status: Self::status_from_db(&row.status)?,
94            minimum_send_date: row.minimum_send_date,
95            actual_send_date: row.actual_send_date,
96            scheduled_send_date: row.scheduled_send_date,
97            pdf_file_path: row.pdf_file_path,
98            language: row.language,
99            total_recipients: row.total_recipients,
100            opened_count: row.opened_count,
101            will_attend_count: row.will_attend_count,
102            will_not_attend_count: row.will_not_attend_count,
103            reminder_sent_at: row.reminder_sent_at,
104            created_at: row.created_at,
105            updated_at: row.updated_at,
106            created_by: row.created_by,
107        })
108    }
109
110    async fn find_by_id(&self, id: Uuid) -> Result<Option<Convocation>, String> {
111        let row = sqlx::query!(
112            r#"
113            SELECT id, organization_id, building_id, meeting_id, meeting_type::text AS "meeting_type!", meeting_date,
114                   status::text AS "status!", minimum_send_date, actual_send_date, scheduled_send_date,
115                   pdf_file_path, language, total_recipients, opened_count,
116                   will_attend_count, will_not_attend_count, reminder_sent_at,
117                   created_at, updated_at, created_by
118            FROM convocations
119            WHERE id = $1
120            "#,
121            id
122        )
123        .fetch_optional(&self.pool)
124        .await
125        .map_err(|e| format!("Failed to find convocation by id: {}", e))?;
126
127        match row {
128            Some(row) => Ok(Some(Convocation {
129                id: row.id,
130                organization_id: row.organization_id,
131                building_id: row.building_id,
132                meeting_id: row.meeting_id,
133                meeting_type: Self::convocation_type_from_db(&row.meeting_type)?,
134                meeting_date: row.meeting_date,
135                status: Self::status_from_db(&row.status)?,
136                minimum_send_date: row.minimum_send_date,
137                actual_send_date: row.actual_send_date,
138                scheduled_send_date: row.scheduled_send_date,
139                pdf_file_path: row.pdf_file_path,
140                language: row.language,
141                total_recipients: row.total_recipients,
142                opened_count: row.opened_count,
143                will_attend_count: row.will_attend_count,
144                will_not_attend_count: row.will_not_attend_count,
145                reminder_sent_at: row.reminder_sent_at,
146                created_at: row.created_at,
147                updated_at: row.updated_at,
148                created_by: row.created_by,
149            })),
150            None => Ok(None),
151        }
152    }
153
154    async fn find_by_meeting_id(&self, meeting_id: Uuid) -> Result<Option<Convocation>, String> {
155        let row = sqlx::query!(
156            r#"
157            SELECT id, organization_id, building_id, meeting_id, meeting_type::text AS "meeting_type!", meeting_date,
158                   status::text AS "status!", minimum_send_date, actual_send_date, scheduled_send_date,
159                   pdf_file_path, language, total_recipients, opened_count,
160                   will_attend_count, will_not_attend_count, reminder_sent_at,
161                   created_at, updated_at, created_by
162            FROM convocations
163            WHERE meeting_id = $1
164            "#,
165            meeting_id
166        )
167        .fetch_optional(&self.pool)
168        .await
169        .map_err(|e| format!("Failed to find convocation by meeting_id: {}", e))?;
170
171        match row {
172            Some(row) => Ok(Some(Convocation {
173                id: row.id,
174                organization_id: row.organization_id,
175                building_id: row.building_id,
176                meeting_id: row.meeting_id,
177                meeting_type: Self::convocation_type_from_db(&row.meeting_type)?,
178                meeting_date: row.meeting_date,
179                status: Self::status_from_db(&row.status)?,
180                minimum_send_date: row.minimum_send_date,
181                actual_send_date: row.actual_send_date,
182                scheduled_send_date: row.scheduled_send_date,
183                pdf_file_path: row.pdf_file_path,
184                language: row.language,
185                total_recipients: row.total_recipients,
186                opened_count: row.opened_count,
187                will_attend_count: row.will_attend_count,
188                will_not_attend_count: row.will_not_attend_count,
189                reminder_sent_at: row.reminder_sent_at,
190                created_at: row.created_at,
191                updated_at: row.updated_at,
192                created_by: row.created_by,
193            })),
194            None => Ok(None),
195        }
196    }
197
198    async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Convocation>, String> {
199        let rows = sqlx::query!(
200            r#"
201            SELECT id, organization_id, building_id, meeting_id, meeting_type::text AS "meeting_type!", meeting_date,
202                   status::text AS "status!", minimum_send_date, actual_send_date, scheduled_send_date,
203                   pdf_file_path, language, total_recipients, opened_count,
204                   will_attend_count, will_not_attend_count, reminder_sent_at,
205                   created_at, updated_at, created_by
206            FROM convocations
207            WHERE building_id = $1
208            ORDER BY meeting_date DESC
209            "#,
210            building_id
211        )
212        .fetch_all(&self.pool)
213        .await
214        .map_err(|e| format!("Failed to find convocations by building: {}", e))?;
215
216        rows.into_iter()
217            .map(|row| {
218                Ok(Convocation {
219                    id: row.id,
220                    organization_id: row.organization_id,
221                    building_id: row.building_id,
222                    meeting_id: row.meeting_id,
223                    meeting_type: Self::convocation_type_from_db(&row.meeting_type)?,
224                    meeting_date: row.meeting_date,
225                    status: Self::status_from_db(&row.status)?,
226                    minimum_send_date: row.minimum_send_date,
227                    actual_send_date: row.actual_send_date,
228                    scheduled_send_date: row.scheduled_send_date,
229                    pdf_file_path: row.pdf_file_path,
230                    language: row.language,
231                    total_recipients: row.total_recipients,
232                    opened_count: row.opened_count,
233                    will_attend_count: row.will_attend_count,
234                    will_not_attend_count: row.will_not_attend_count,
235                    reminder_sent_at: row.reminder_sent_at,
236                    created_at: row.created_at,
237                    updated_at: row.updated_at,
238                    created_by: row.created_by,
239                })
240            })
241            .collect()
242    }
243
244    async fn find_by_organization(
245        &self,
246        organization_id: Uuid,
247    ) -> Result<Vec<Convocation>, String> {
248        let rows = sqlx::query!(
249            r#"
250            SELECT id, organization_id, building_id, meeting_id, meeting_type::text AS "meeting_type!", meeting_date,
251                   status::text AS "status!", minimum_send_date, actual_send_date, scheduled_send_date,
252                   pdf_file_path, language, total_recipients, opened_count,
253                   will_attend_count, will_not_attend_count, reminder_sent_at,
254                   created_at, updated_at, created_by
255            FROM convocations
256            WHERE organization_id = $1
257            ORDER BY meeting_date DESC
258            "#,
259            organization_id
260        )
261        .fetch_all(&self.pool)
262        .await
263        .map_err(|e| format!("Failed to find convocations by organization: {}", e))?;
264
265        rows.into_iter()
266            .map(|row| {
267                Ok(Convocation {
268                    id: row.id,
269                    organization_id: row.organization_id,
270                    building_id: row.building_id,
271                    meeting_id: row.meeting_id,
272                    meeting_type: Self::convocation_type_from_db(&row.meeting_type)?,
273                    meeting_date: row.meeting_date,
274                    status: Self::status_from_db(&row.status)?,
275                    minimum_send_date: row.minimum_send_date,
276                    actual_send_date: row.actual_send_date,
277                    scheduled_send_date: row.scheduled_send_date,
278                    pdf_file_path: row.pdf_file_path,
279                    language: row.language,
280                    total_recipients: row.total_recipients,
281                    opened_count: row.opened_count,
282                    will_attend_count: row.will_attend_count,
283                    will_not_attend_count: row.will_not_attend_count,
284                    reminder_sent_at: row.reminder_sent_at,
285                    created_at: row.created_at,
286                    updated_at: row.updated_at,
287                    created_by: row.created_by,
288                })
289            })
290            .collect()
291    }
292
293    async fn find_by_status(
294        &self,
295        organization_id: Uuid,
296        status: ConvocationStatus,
297    ) -> Result<Vec<Convocation>, String> {
298        let status_str = Self::status_to_db(&status);
299
300        let rows = sqlx::query!(
301            r#"
302            SELECT id, organization_id, building_id, meeting_id, meeting_type::text AS "meeting_type!", meeting_date,
303                   status::text AS "status!", minimum_send_date, actual_send_date, scheduled_send_date,
304                   pdf_file_path, language, total_recipients, opened_count,
305                   will_attend_count, will_not_attend_count, reminder_sent_at,
306                   created_at, updated_at, created_by
307            FROM convocations
308            WHERE organization_id = $1 AND status = $2::TEXT::convocation_status
309            ORDER BY meeting_date DESC
310            "#,
311            organization_id,
312            status_str
313        )
314        .fetch_all(&self.pool)
315        .await
316        .map_err(|e| format!("Failed to find convocations by status: {}", e))?;
317
318        rows.into_iter()
319            .map(|row| {
320                Ok(Convocation {
321                    id: row.id,
322                    organization_id: row.organization_id,
323                    building_id: row.building_id,
324                    meeting_id: row.meeting_id,
325                    meeting_type: Self::convocation_type_from_db(&row.meeting_type)?,
326                    meeting_date: row.meeting_date,
327                    status: Self::status_from_db(&row.status)?,
328                    minimum_send_date: row.minimum_send_date,
329                    actual_send_date: row.actual_send_date,
330                    scheduled_send_date: row.scheduled_send_date,
331                    pdf_file_path: row.pdf_file_path,
332                    language: row.language,
333                    total_recipients: row.total_recipients,
334                    opened_count: row.opened_count,
335                    will_attend_count: row.will_attend_count,
336                    will_not_attend_count: row.will_not_attend_count,
337                    reminder_sent_at: row.reminder_sent_at,
338                    created_at: row.created_at,
339                    updated_at: row.updated_at,
340                    created_by: row.created_by,
341                })
342            })
343            .collect()
344    }
345
346    async fn find_pending_scheduled(&self, now: DateTime<Utc>) -> Result<Vec<Convocation>, String> {
347        let rows = sqlx::query!(
348            r#"
349            SELECT id, organization_id, building_id, meeting_id, meeting_type::text AS "meeting_type!", meeting_date,
350                   status::text AS "status!", minimum_send_date, actual_send_date, scheduled_send_date,
351                   pdf_file_path, language, total_recipients, opened_count,
352                   will_attend_count, will_not_attend_count, reminder_sent_at,
353                   created_at, updated_at, created_by
354            FROM convocations
355            WHERE status = 'scheduled'
356              AND scheduled_send_date IS NOT NULL
357              AND scheduled_send_date <= $1
358            ORDER BY scheduled_send_date ASC
359            "#,
360            now
361        )
362        .fetch_all(&self.pool)
363        .await
364        .map_err(|e| format!("Failed to find pending scheduled convocations: {}", e))?;
365
366        rows.into_iter()
367            .map(|row| {
368                Ok(Convocation {
369                    id: row.id,
370                    organization_id: row.organization_id,
371                    building_id: row.building_id,
372                    meeting_id: row.meeting_id,
373                    meeting_type: Self::convocation_type_from_db(&row.meeting_type)?,
374                    meeting_date: row.meeting_date,
375                    status: Self::status_from_db(&row.status)?,
376                    minimum_send_date: row.minimum_send_date,
377                    actual_send_date: row.actual_send_date,
378                    scheduled_send_date: row.scheduled_send_date,
379                    pdf_file_path: row.pdf_file_path,
380                    language: row.language,
381                    total_recipients: row.total_recipients,
382                    opened_count: row.opened_count,
383                    will_attend_count: row.will_attend_count,
384                    will_not_attend_count: row.will_not_attend_count,
385                    reminder_sent_at: row.reminder_sent_at,
386                    created_at: row.created_at,
387                    updated_at: row.updated_at,
388                    created_by: row.created_by,
389                })
390            })
391            .collect()
392    }
393
394    async fn find_needing_reminder(&self, now: DateTime<Utc>) -> Result<Vec<Convocation>, String> {
395        // Find convocations that:
396        // - Status = sent
397        // - Meeting date is in 0-3 days
398        // - Reminder not sent yet (reminder_sent_at IS NULL)
399        let three_days_from_now = now + Duration::days(3);
400
401        let rows = sqlx::query!(
402            r#"
403            SELECT id, organization_id, building_id, meeting_id, meeting_type::text AS "meeting_type!", meeting_date,
404                   status::text AS "status!", minimum_send_date, actual_send_date, scheduled_send_date,
405                   pdf_file_path, language, total_recipients, opened_count,
406                   will_attend_count, will_not_attend_count, reminder_sent_at,
407                   created_at, updated_at, created_by
408            FROM convocations
409            WHERE status = 'sent'
410              AND meeting_date >= $1
411              AND meeting_date <= $2
412              AND reminder_sent_at IS NULL
413            ORDER BY meeting_date ASC
414            "#,
415            now,
416            three_days_from_now
417        )
418        .fetch_all(&self.pool)
419        .await
420        .map_err(|e| format!("Failed to find convocations needing reminder: {}", e))?;
421
422        rows.into_iter()
423            .map(|row| {
424                Ok(Convocation {
425                    id: row.id,
426                    organization_id: row.organization_id,
427                    building_id: row.building_id,
428                    meeting_id: row.meeting_id,
429                    meeting_type: Self::convocation_type_from_db(&row.meeting_type)?,
430                    meeting_date: row.meeting_date,
431                    status: Self::status_from_db(&row.status)?,
432                    minimum_send_date: row.minimum_send_date,
433                    actual_send_date: row.actual_send_date,
434                    scheduled_send_date: row.scheduled_send_date,
435                    pdf_file_path: row.pdf_file_path,
436                    language: row.language,
437                    total_recipients: row.total_recipients,
438                    opened_count: row.opened_count,
439                    will_attend_count: row.will_attend_count,
440                    will_not_attend_count: row.will_not_attend_count,
441                    reminder_sent_at: row.reminder_sent_at,
442                    created_at: row.created_at,
443                    updated_at: row.updated_at,
444                    created_by: row.created_by,
445                })
446            })
447            .collect()
448    }
449
450    async fn update(&self, convocation: &Convocation) -> Result<Convocation, String> {
451        let meeting_type_str = Self::convocation_type_to_db(&convocation.meeting_type);
452        let status_str = Self::status_to_db(&convocation.status);
453
454        let row = sqlx::query!(
455            r#"
456            UPDATE convocations
457            SET organization_id = $2, building_id = $3, meeting_id = $4, meeting_type = $5::TEXT::convocation_type, meeting_date = $6,
458                status = $7::TEXT::convocation_status, minimum_send_date = $8, actual_send_date = $9, scheduled_send_date = $10,
459                pdf_file_path = $11, language = $12, total_recipients = $13, opened_count = $14,
460                will_attend_count = $15, will_not_attend_count = $16, reminder_sent_at = $17,
461                updated_at = $18
462            WHERE id = $1
463            RETURNING id, organization_id, building_id, meeting_id, meeting_type::text AS "meeting_type!", meeting_date,
464                      status::text AS "status!", minimum_send_date, actual_send_date, scheduled_send_date,
465                      pdf_file_path, language, total_recipients, opened_count,
466                      will_attend_count, will_not_attend_count, reminder_sent_at,
467                      created_at, updated_at, created_by
468            "#,
469            convocation.id,
470            convocation.organization_id,
471            convocation.building_id,
472            convocation.meeting_id,
473            meeting_type_str,
474            convocation.meeting_date,
475            status_str,
476            convocation.minimum_send_date,
477            convocation.actual_send_date,
478            convocation.scheduled_send_date,
479            convocation.pdf_file_path,
480            convocation.language,
481            convocation.total_recipients,
482            convocation.opened_count,
483            convocation.will_attend_count,
484            convocation.will_not_attend_count,
485            convocation.reminder_sent_at,
486            convocation.updated_at,
487        )
488        .fetch_one(&self.pool)
489        .await
490        .map_err(|e| format!("Failed to update convocation: {}", e))?;
491
492        Ok(Convocation {
493            id: row.id,
494            organization_id: row.organization_id,
495            building_id: row.building_id,
496            meeting_id: row.meeting_id,
497            meeting_type: Self::convocation_type_from_db(&row.meeting_type)?,
498            meeting_date: row.meeting_date,
499            status: Self::status_from_db(&row.status)?,
500            minimum_send_date: row.minimum_send_date,
501            actual_send_date: row.actual_send_date,
502            scheduled_send_date: row.scheduled_send_date,
503            pdf_file_path: row.pdf_file_path,
504            language: row.language,
505            total_recipients: row.total_recipients,
506            opened_count: row.opened_count,
507            will_attend_count: row.will_attend_count,
508            will_not_attend_count: row.will_not_attend_count,
509            reminder_sent_at: row.reminder_sent_at,
510            created_at: row.created_at,
511            updated_at: row.updated_at,
512            created_by: row.created_by,
513        })
514    }
515
516    async fn delete(&self, id: Uuid) -> Result<bool, String> {
517        let result = sqlx::query!(
518            r#"
519            DELETE FROM convocations
520            WHERE id = $1
521            "#,
522            id
523        )
524        .execute(&self.pool)
525        .await
526        .map_err(|e| format!("Failed to delete convocation: {}", e))?;
527
528        Ok(result.rows_affected() > 0)
529    }
530
531    async fn count_by_building(&self, building_id: Uuid) -> Result<i64, String> {
532        let row = sqlx::query!(
533            r#"
534            SELECT COUNT(*) AS "count!"
535            FROM convocations
536            WHERE building_id = $1
537            "#,
538            building_id
539        )
540        .fetch_one(&self.pool)
541        .await
542        .map_err(|e| format!("Failed to count convocations by building: {}", e))?;
543
544        Ok(row.count)
545    }
546
547    async fn count_by_status(
548        &self,
549        organization_id: Uuid,
550        status: ConvocationStatus,
551    ) -> Result<i64, String> {
552        let status_str = Self::status_to_db(&status);
553
554        let row = sqlx::query!(
555            r#"
556            SELECT COUNT(*) AS "count!"
557            FROM convocations
558            WHERE organization_id = $1 AND status = $2::TEXT::convocation_status
559            "#,
560            organization_id,
561            status_str
562        )
563        .fetch_one(&self.pool)
564        .await
565        .map_err(|e| format!("Failed to count convocations by status: {}", e))?;
566
567        Ok(row.count)
568    }
569}