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