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
7pub struct ConvocationExporter;
14
15impl ConvocationExporter {
16 pub fn export_to_pdf(
26 building: &Building,
27 meeting: &Meeting,
28 convocation: &Convocation,
29 ) -> Result<Vec<u8>, String> {
30 let doc = new_document("Convocation Assemblée Générale");
32 let mut current_layer = PdfPageBuilder::new();
33
34 let font_bold = builtin_font(BuiltinFont::HelveticaBold);
36 let font_regular = builtin_font(BuiltinFont::Helvetica);
37
38 let mut y_position = 277.0; 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; };
51
52 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 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; 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 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 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 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 let date_label = if convocation.language == "FR" {
199 "📅 Date"
200 } else {
201 "📅 Datum" };
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 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 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 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 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 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 let page = current_layer.into_page(210.0, 297.0);
430 Ok(save_document(doc, page))
431 }
432
433 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 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 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(), 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(), 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); assert!(bytes.starts_with(b"%PDF")); }
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(), 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(), 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}