1use crate::domain::entities::{Building, Convocation, ConvocationType, Meeting};
2use printpdf::*;
3use std::io::BufWriter;
4
5pub struct ConvocationExporter;
12
13impl ConvocationExporter {
14 pub fn export_to_pdf(
24 building: &Building,
25 meeting: &Meeting,
26 convocation: &Convocation,
27 ) -> Result<Vec<u8>, String> {
28 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 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; 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; };
58
59 add_text(
61 ¤t_layer,
62 &building.name,
63 &font_bold,
64 16.0,
65 20.0,
66 &mut y_position,
67 true,
68 );
69
70 let address_line = format!(
72 "{}, {} {}",
73 building.address, building.postal_code, building.city
74 );
75 add_text(
76 ¤t_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; 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 ¤t_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 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 ¤t_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 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 ¤t_layer,
185 details_label,
186 &font_bold,
187 12.0,
188 20.0,
189 &mut y_position,
190 true,
191 );
192
193 add_text(
195 ¤t_layer,
196 &format!("📋 {}", meeting.title),
197 &font_regular,
198 10.0,
199 20.0,
200 &mut y_position,
201 false,
202 );
203
204 let date_label = if convocation.language == "FR" {
206 "📅 Date"
207 } else {
208 "📅 Datum" };
210 add_text(
211 ¤t_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 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 ¤t_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 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 ¤t_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 ¤t_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 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 ¤t_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 ¤t_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 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 ¤t_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 ¤t_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 ¤t_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 ¤t_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 ¤t_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 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 ¤t_layer,
427 footer_text,
428 &font_regular,
429 8.0,
430 20.0,
431 &mut y_position,
432 false,
433 );
434
435 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 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 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 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); assert!(bytes.starts_with(b"%PDF")); }
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}