Skip to main content

koprogo_api/domain/services/
convocation_exporter.rs

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