koprogo_api/domain/services/
convocation_exporter.rs

1use crate::domain::entities::{Building, Convocation, ConvocationType, Meeting};
2use printpdf::*;
3use std::io::BufWriter;
4
5/// Convocation Exporter - Generates PDF for Convocations d'Assemblée Générale
6///
7/// Compliant with Belgian copropriété law requirements for meeting invitations:
8/// - Ordinary AG: 15 days minimum notice
9/// - Extraordinary AG: 8 days minimum notice
10/// - Second convocation: 8 days after quorum not reached
11pub struct ConvocationExporter;
12
13impl ConvocationExporter {
14    /// Export convocation to PDF bytes
15    ///
16    /// Generates a complete convocation including:
17    /// - Building information
18    /// - Meeting details (date, type, location, agenda)
19    /// - Legal compliance notice (minimum notice period)
20    /// - Attendance instructions
21    /// - Proxy information
22    /// - Syndic contact information
23    pub fn export_to_pdf(
24        building: &Building,
25        meeting: &Meeting,
26        convocation: &Convocation,
27    ) -> Result<Vec<u8>, String> {
28        // Create PDF document (A4: 210mm x 297mm)
29        let (doc, page1, layer1) = PdfDocument::new(
30            "Convocation Assemblée Générale",
31            Mm(210.0),
32            Mm(297.0),
33            "Layer 1",
34        );
35        let current_layer = doc.get_page(page1).get_layer(layer1);
36
37        // Load fonts
38        let font_bold = doc
39            .add_builtin_font(BuiltinFont::HelveticaBold)
40            .map_err(|e| format!("Failed to load bold font: {}", e))?;
41        let font_regular = doc
42            .add_builtin_font(BuiltinFont::Helvetica)
43            .map_err(|e| format!("Failed to load regular font: {}", e))?;
44
45        let mut y_position = 277.0; // Start from top (A4 = 297mm height, 20mm margin)
46
47        // Helper to add text line
48        let add_text = |layer: &PdfLayerReference,
49                        text: &str,
50                        font: &IndirectFontRef,
51                        size: f64,
52                        x: f64,
53                        y: &mut f64,
54                        _bold: bool| {
55            layer.use_text(text, size as f32, Mm(x as f32), Mm(*y as f32), font);
56            *y -= size * 0.5; // Line spacing (approx 1.5x font size)
57        };
58
59        // HEADER: Building name and type of meeting
60        add_text(
61            &current_layer,
62            &building.name,
63            &font_bold,
64            16.0,
65            20.0,
66            &mut y_position,
67            true,
68        );
69
70        // Building address
71        let address_line = format!(
72            "{}, {} {}",
73            building.address, building.postal_code, building.city
74        );
75        add_text(
76            &current_layer,
77            &address_line,
78            &font_regular,
79            10.0,
80            20.0,
81            &mut y_position,
82            false,
83        );
84
85        y_position -= 10.0; // Extra spacing
86
87        // TITLE: Convocation type
88        let meeting_type_label = match convocation.meeting_type {
89            ConvocationType::Ordinary => {
90                if convocation.language == "FR" {
91                    "CONVOCATION À L'ASSEMBLÉE GÉNÉRALE ORDINAIRE"
92                } else if convocation.language == "NL" {
93                    "OPROEP TOT GEWONE ALGEMENE VERGADERING"
94                } else if convocation.language == "DE" {
95                    "EINLADUNG ZUR ORDENTLICHEN GENERALVERSAMMLUNG"
96                } else {
97                    "CONVOCATION TO ORDINARY GENERAL ASSEMBLY"
98                }
99            }
100            ConvocationType::Extraordinary => {
101                if convocation.language == "FR" {
102                    "CONVOCATION À L'ASSEMBLÉE GÉNÉRALE EXTRAORDINAIRE"
103                } else if convocation.language == "NL" {
104                    "OPROEP TOT BUITENGEWONE ALGEMENE VERGADERING"
105                } else if convocation.language == "DE" {
106                    "EINLADUNG ZUR AUSSERORDENTLICHEN GENERALVERSAMMLUNG"
107                } else {
108                    "CONVOCATION TO EXTRAORDINARY GENERAL ASSEMBLY"
109                }
110            }
111            ConvocationType::SecondConvocation => {
112                if convocation.language == "FR" {
113                    "CONVOCATION À LA SECONDE ASSEMBLÉE GÉNÉRALE"
114                } else if convocation.language == "NL" {
115                    "OPROEP TOT TWEEDE ALGEMENE VERGADERING"
116                } else if convocation.language == "DE" {
117                    "EINLADUNG ZUR ZWEITEN GENERALVERSAMMLUNG"
118                } else {
119                    "CONVOCATION TO SECOND GENERAL ASSEMBLY"
120                }
121            }
122        };
123
124        add_text(
125            &current_layer,
126            meeting_type_label,
127            &font_bold,
128            14.0,
129            20.0,
130            &mut y_position,
131            true,
132        );
133
134        y_position -= 10.0;
135
136        // LEGAL NOTICE
137        let minimum_notice_days = convocation.meeting_type.minimum_notice_days();
138        let legal_notice = if convocation.language == "FR" {
139            format!(
140                "Conformément à la loi belge sur la copropriété, cette convocation respecte le délai légal minimum de {} jours.",
141                minimum_notice_days
142            )
143        } else if convocation.language == "NL" {
144            format!(
145                "In overeenstemming met de Belgische mede-eigenheidswet respecteert deze oproeping de wettelijke minimumtermijn van {} dagen.",
146                minimum_notice_days
147            )
148        } else if convocation.language == "DE" {
149            format!(
150                "Gemäß dem belgischen Wohnungseigentumsgesetz entspricht diese Einberufung der gesetzlichen Mindestfrist von {} Tagen.",
151                minimum_notice_days
152            )
153        } else {
154            format!(
155                "In accordance with Belgian copropriété law, this convocation respects the legal minimum notice period of {} days.",
156                minimum_notice_days
157            )
158        };
159
160        add_text(
161            &current_layer,
162            &legal_notice,
163            &font_regular,
164            9.0,
165            20.0,
166            &mut y_position,
167            false,
168        );
169
170        y_position -= 10.0;
171
172        // MEETING DETAILS
173        let details_label = if convocation.language == "FR" {
174            "DÉTAILS DE LA RÉUNION:"
175        } else if convocation.language == "NL" {
176            "VERGADERINGSDETAILS:"
177        } else if convocation.language == "DE" {
178            "VERSAMMLUNGSDETAILS:"
179        } else {
180            "MEETING DETAILS:"
181        };
182
183        add_text(
184            &current_layer,
185            details_label,
186            &font_bold,
187            12.0,
188            20.0,
189            &mut y_position,
190            true,
191        );
192
193        // Title
194        add_text(
195            &current_layer,
196            &format!("📋 {}", meeting.title),
197            &font_regular,
198            10.0,
199            20.0,
200            &mut y_position,
201            false,
202        );
203
204        // Date and time
205        let date_label = if convocation.language == "FR" {
206            "📅 Date"
207        } else {
208            "📅 Datum" // NL, DE, EN all use "Datum"
209        };
210        add_text(
211            &current_layer,
212            &format!(
213                "{}: {}",
214                date_label,
215                convocation.meeting_date.format("%d/%m/%Y à %H:%M")
216            ),
217            &font_regular,
218            10.0,
219            20.0,
220            &mut y_position,
221            false,
222        );
223
224        // Location
225        let location_label = if convocation.language == "FR" {
226            "📍 Lieu"
227        } else if convocation.language == "NL" {
228            "📍 Locatie"
229        } else if convocation.language == "DE" {
230            "📍 Ort"
231        } else {
232            "📍 Location"
233        };
234        add_text(
235            &current_layer,
236            &format!("{}: {}", location_label, meeting.location),
237            &font_regular,
238            10.0,
239            20.0,
240            &mut y_position,
241            false,
242        );
243
244        y_position -= 10.0;
245
246        // AGENDA
247        let agenda_label = if convocation.language == "FR" {
248            "ORDRE DU JOUR:"
249        } else if convocation.language == "NL" {
250            "AGENDA:"
251        } else if convocation.language == "DE" {
252            "TAGESORDNUNG:"
253        } else {
254            "AGENDA:"
255        };
256
257        add_text(
258            &current_layer,
259            agenda_label,
260            &font_bold,
261            12.0,
262            20.0,
263            &mut y_position,
264            true,
265        );
266
267        for (index, item) in meeting.agenda.iter().enumerate() {
268            add_text(
269                &current_layer,
270                &format!("{}. {}", index + 1, item),
271                &font_regular,
272                10.0,
273                25.0,
274                &mut y_position,
275                false,
276            );
277        }
278
279        y_position -= 10.0;
280
281        // ATTENDANCE INSTRUCTIONS
282        let attendance_label = if convocation.language == "FR" {
283            "MODALITÉS DE PARTICIPATION:"
284        } else if convocation.language == "NL" {
285            "DEELNAMEVOORWAARDEN:"
286        } else if convocation.language == "DE" {
287            "TEILNAHMEBEDINGUNGEN:"
288        } else {
289            "ATTENDANCE INSTRUCTIONS:"
290        };
291
292        add_text(
293            &current_layer,
294            attendance_label,
295            &font_bold,
296            12.0,
297            20.0,
298            &mut y_position,
299            true,
300        );
301
302        let attendance_text = if convocation.language == "FR" {
303            "• Vous pouvez participer en personne à l'assemblée générale\n\
304             • Si vous ne pouvez pas assister, vous pouvez donner procuration à un autre copropriétaire\n\
305             • Merci de confirmer votre présence via le lien de confirmation dans l'email"
306        } else if convocation.language == "NL" {
307            "• U kunt persoonlijk deelnemen aan de algemene vergadering\n\
308             • Als u niet kunt deelnemen, kunt u een volmacht geven aan een andere mede-eigenaar\n\
309             • Gelieve uw aanwezigheid te bevestigen via de bevestigingslink in de e-mail"
310        } else if convocation.language == "DE" {
311            "• Sie können persönlich an der Generalversammlung teilnehmen\n\
312             • Wenn Sie nicht teilnehmen können, können Sie einem anderen Miteigentümer eine Vollmacht erteilen\n\
313             • Bitte bestätigen Sie Ihre Anwesenheit über den Bestätigungslink in der E-Mail"
314        } else {
315            "• You can participate in person at the general assembly\n\
316             • If you cannot attend, you can give proxy to another co-owner\n\
317             • Please confirm your attendance via the confirmation link in the email"
318        };
319
320        for line in attendance_text.lines() {
321            add_text(
322                &current_layer,
323                line,
324                &font_regular,
325                9.0,
326                20.0,
327                &mut y_position,
328                false,
329            );
330        }
331
332        y_position -= 10.0;
333
334        // SYNDIC CONTACT INFORMATION
335        if let Some(syndic_name) = &building.syndic_name {
336            let contact_label = if convocation.language == "FR" {
337                "CONTACT DU SYNDIC:"
338            } else if convocation.language == "NL" {
339                "CONTACT SYNDICUS:"
340            } else if convocation.language == "DE" {
341                "KONTAKT VERWALTER:"
342            } else {
343                "SYNDIC CONTACT:"
344            };
345
346            add_text(
347                &current_layer,
348                contact_label,
349                &font_bold,
350                12.0,
351                20.0,
352                &mut y_position,
353                true,
354            );
355
356            add_text(
357                &current_layer,
358                syndic_name,
359                &font_regular,
360                10.0,
361                20.0,
362                &mut y_position,
363                false,
364            );
365
366            if let Some(email) = &building.syndic_email {
367                add_text(
368                    &current_layer,
369                    &format!("📧 {}", email),
370                    &font_regular,
371                    10.0,
372                    20.0,
373                    &mut y_position,
374                    false,
375                );
376            }
377
378            if let Some(phone) = &building.syndic_phone {
379                add_text(
380                    &current_layer,
381                    &format!("📞 {}", phone),
382                    &font_regular,
383                    10.0,
384                    20.0,
385                    &mut y_position,
386                    false,
387                );
388            }
389
390            if let Some(office_hours) = &building.syndic_office_hours {
391                let hours_label = if convocation.language == "FR" {
392                    "Heures d'ouverture"
393                } else if convocation.language == "NL" {
394                    "Openingsuren"
395                } else if convocation.language == "DE" {
396                    "Öffnungszeiten"
397                } else {
398                    "Office hours"
399                };
400                add_text(
401                    &current_layer,
402                    &format!("🕒 {}: {}", hours_label, office_hours),
403                    &font_regular,
404                    10.0,
405                    20.0,
406                    &mut y_position,
407                    false,
408                );
409            }
410        }
411
412        y_position -= 15.0;
413
414        // FOOTER
415        let footer_text = if convocation.language == "FR" {
416            "Cette convocation a été générée automatiquement par KoproGo."
417        } else if convocation.language == "NL" {
418            "Deze oproeping werd automatisch gegenereerd door KoproGo."
419        } else if convocation.language == "DE" {
420            "Diese Einladung wurde automatisch von KoproGo generiert."
421        } else {
422            "This convocation was automatically generated by KoproGo."
423        };
424
425        add_text(
426            &current_layer,
427            footer_text,
428            &font_regular,
429            8.0,
430            20.0,
431            &mut y_position,
432            false,
433        );
434
435        // Save PDF to bytes
436        let mut buffer = BufWriter::new(Vec::new());
437        doc.save(&mut buffer)
438            .map_err(|e| format!("Failed to save PDF: {}", e))?;
439
440        buffer.into_inner().map_err(|e| e.to_string())
441    }
442
443    /// Save PDF bytes to file
444    ///
445    /// # Arguments
446    /// * `pdf_bytes` - PDF content as bytes
447    /// * `file_path` - Destination file path
448    ///
449    /// # Returns
450    /// Result with file path or error
451    pub fn save_to_file(pdf_bytes: &[u8], file_path: &str) -> Result<String, String> {
452        use std::fs;
453        use std::path::Path;
454
455        // Create parent directory if it doesn't exist
456        if let Some(parent) = Path::new(file_path).parent() {
457            fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {}", e))?;
458        }
459
460        // Write PDF bytes to file
461        fs::write(file_path, pdf_bytes).map_err(|e| format!("Failed to write PDF file: {}", e))?;
462
463        Ok(file_path.to_string())
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use chrono::Utc;
471    use uuid::Uuid;
472
473    fn create_test_building() -> Building {
474        Building {
475            id: Uuid::new_v4(),
476            organization_id: Uuid::new_v4(),
477            name: "Résidence Les Lilas".to_string(),
478            address: "Avenue Louise 123".to_string(),
479            city: "Bruxelles".to_string(),
480            postal_code: "1050".to_string(),
481            country: "Belgium".to_string(),
482            total_units: 20,
483            total_tantiemes: 1000,
484            construction_year: Some(1995),
485            slug: Some("residence-les-lilas-bruxelles".to_string()),
486            syndic_name: Some("Syndic Pro SPRL".to_string()),
487            syndic_email: Some("contact@syndicpro.be".to_string()),
488            syndic_phone: Some("+32 2 123 45 67".to_string()),
489            syndic_address: Some("Rue du Commerce 45, 1000 Bruxelles".to_string()),
490            syndic_office_hours: Some("Lun-Ven 9h-17h".to_string()),
491            syndic_emergency_contact: Some("+32 475 12 34 56".to_string()),
492            created_at: Utc::now(),
493            updated_at: Utc::now(),
494        }
495    }
496
497    fn create_test_meeting() -> Meeting {
498        let mut meeting = Meeting::new(
499            Uuid::new_v4(),
500            Uuid::new_v4(),
501            crate::domain::entities::MeetingType::Ordinary,
502            "Assemblée Générale Ordinaire 2025".to_string(),
503            Some("Discussion du budget annuel et travaux de rénovation".to_string()),
504            Utc::now() + chrono::Duration::days(20),
505            "Salle de réunion, Rez-de-chaussée".to_string(),
506        )
507        .unwrap();
508
509        meeting
510            .add_agenda_item("Approbation du procès-verbal de la dernière AG".to_string())
511            .unwrap();
512        meeting
513            .add_agenda_item("Présentation et vote du budget annuel 2025".to_string())
514            .unwrap();
515        meeting
516            .add_agenda_item("Travaux de rénovation de la toiture - Devis".to_string())
517            .unwrap();
518        meeting
519            .add_agenda_item("Questions diverses".to_string())
520            .unwrap();
521
522        meeting
523    }
524
525    fn create_test_convocation(building_id: Uuid, meeting_id: Uuid) -> Convocation {
526        Convocation::new(
527            Uuid::new_v4(),
528            building_id,
529            meeting_id,
530            ConvocationType::Ordinary,
531            Utc::now() + chrono::Duration::days(20),
532            "FR".to_string(),
533            Uuid::new_v4(),
534        )
535        .unwrap()
536    }
537
538    #[test]
539    fn test_convocation_pdf_generation() {
540        let building = create_test_building();
541        let meeting = create_test_meeting();
542        let convocation = create_test_convocation(building.id, meeting.id);
543
544        let pdf_bytes = ConvocationExporter::export_to_pdf(&building, &meeting, &convocation);
545
546        assert!(pdf_bytes.is_ok());
547        let bytes = pdf_bytes.unwrap();
548        assert!(bytes.len() > 1000); // PDF should be at least 1KB
549        assert!(bytes.starts_with(b"%PDF")); // Valid PDF header
550    }
551
552    #[test]
553    fn test_convocation_pdf_all_languages() {
554        let building = create_test_building();
555        let meeting = create_test_meeting();
556
557        for lang in &["FR", "NL", "DE", "EN"] {
558            let convocation = Convocation::new(
559                Uuid::new_v4(),
560                building.id,
561                meeting.id,
562                ConvocationType::Ordinary,
563                Utc::now() + chrono::Duration::days(20),
564                lang.to_string(),
565                Uuid::new_v4(),
566            )
567            .unwrap();
568
569            let pdf_bytes = ConvocationExporter::export_to_pdf(&building, &meeting, &convocation);
570
571            assert!(pdf_bytes.is_ok(), "Failed for language: {}", lang);
572            let bytes = pdf_bytes.unwrap();
573            assert!(bytes.len() > 1000, "PDF too small for language: {}", lang);
574            assert!(
575                bytes.starts_with(b"%PDF"),
576                "Invalid PDF for language: {}",
577                lang
578            );
579        }
580    }
581
582    #[test]
583    fn test_extraordinary_meeting_convocation() {
584        let building = create_test_building();
585        let meeting = create_test_meeting();
586        let convocation = Convocation::new(
587            Uuid::new_v4(),
588            building.id,
589            meeting.id,
590            ConvocationType::Extraordinary,
591            Utc::now() + chrono::Duration::days(10),
592            "FR".to_string(),
593            Uuid::new_v4(),
594        )
595        .unwrap();
596
597        let pdf_bytes = ConvocationExporter::export_to_pdf(&building, &meeting, &convocation);
598
599        assert!(pdf_bytes.is_ok());
600        let bytes = pdf_bytes.unwrap();
601        assert!(bytes.len() > 1000);
602    }
603}