diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApiExceptionHandler.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApiExceptionHandler.java new file mode 100644 index 0000000..57137e1 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApiExceptionHandler.java @@ -0,0 +1,44 @@ +package fr.nessar.lmnporganizer.backend.controller; + +import fr.nessar.lmnporganizer.backend.exception.AttachmentTooLargeException; +import fr.nessar.lmnporganizer.backend.exception.InvalidPhoneNumberException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; + +@RestControllerAdvice +public class ApiExceptionHandler { + @ExceptionHandler(AttachmentTooLargeException.class) + public ResponseEntity handleAttachmentTooLarge(AttachmentTooLargeException exception) { + return error(HttpStatus.PAYLOAD_TOO_LARGE, exception.getMessage()); + } + + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity handleMultipartTooLarge() { + return error(HttpStatus.PAYLOAD_TOO_LARGE, "Une pièce jointe ne peut pas dépasser 250 Mo."); + } + + @ExceptionHandler(InvalidPhoneNumberException.class) + public ResponseEntity handleInvalidPhone(InvalidPhoneNumberException exception) { + return error(HttpStatus.BAD_REQUEST, exception.getMessage()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleInvalidRequest(MethodArgumentNotValidException exception) { + String message = exception.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(error -> error.getDefaultMessage()) + .orElse("La requête est invalide."); + return error(HttpStatus.BAD_REQUEST, message); + } + + private ResponseEntity error(HttpStatus status, String message) { + return ResponseEntity.status(status).body(new ApiError(message)); + } + + private record ApiError(String message) { + } +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/LeaseController.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/LeaseController.java index 56dc50c..82efdea 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/LeaseController.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/LeaseController.java @@ -7,6 +7,8 @@ import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository; import fr.nessar.lmnporganizer.backend.repository.LeaseRepository; import fr.nessar.lmnporganizer.backend.repository.TenantRepository; import fr.nessar.lmnporganizer.backend.service.LeasePdfService; +import fr.nessar.lmnporganizer.backend.service.PhoneNumberNormalizer; +import jakarta.validation.Valid; import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; @@ -25,13 +27,16 @@ public class LeaseController { private final ApartmentRepository apartmentRepository; private final TenantRepository tenantRepository; private final LeasePdfService leasePdfService; + private final PhoneNumberNormalizer phoneNumberNormalizer; public LeaseController(LeaseRepository leaseRepository, ApartmentRepository apartmentRepository, - TenantRepository tenantRepository, LeasePdfService leasePdfService) { + TenantRepository tenantRepository, LeasePdfService leasePdfService, + PhoneNumberNormalizer phoneNumberNormalizer) { this.leaseRepository = leaseRepository; this.apartmentRepository = apartmentRepository; this.tenantRepository = tenantRepository; this.leasePdfService = leasePdfService; + this.phoneNumberNormalizer = phoneNumberNormalizer; } @GetMapping @@ -45,17 +50,18 @@ public class LeaseController { } @PostMapping - public ResponseEntity createLease(@RequestBody Lease lease) { + public ResponseEntity createLease(@Valid @RequestBody Lease lease) { if (!referencesExist(lease)) { return ResponseEntity.badRequest().build(); } lease.setId(null); lease.setLandlordId("default-landlord"); + normalizeGuarantorPhones(lease); return ResponseEntity.status(HttpStatus.CREATED).body(leaseRepository.save(lease)); } @PutMapping("/{id}") - public ResponseEntity updateLease(@PathVariable String id, @RequestBody Lease details) { + public ResponseEntity updateLease(@PathVariable String id, @Valid @RequestBody Lease details) { if (!referencesExist(details)) { return ResponseEntity.badRequest().build(); } @@ -111,6 +117,7 @@ public class LeaseController { target.setApartmentId(source.getApartmentId()); target.setTenantIds(source.getTenantIds()); target.setGuarantors(source.getGuarantors()); + normalizeGuarantorPhones(target); target.setStartDate(source.getStartDate()); target.setEndDate(source.getEndDate()); target.setMonthlyRent(source.getMonthlyRent()); @@ -118,4 +125,8 @@ public class LeaseController { target.setSecurityDeposit(source.getSecurityDeposit()); target.setStatus(source.getStatus()); } + + private void normalizeGuarantorPhones(Lease lease) { + lease.getGuarantors().forEach(guarantor -> guarantor.setPhone(phoneNumberNormalizer.normalize(guarantor.getPhone()))); + } } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/TenantController.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/TenantController.java index 015c897..7fa448c 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/TenantController.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/TenantController.java @@ -4,6 +4,9 @@ import fr.nessar.lmnporganizer.backend.model.Tenant; import fr.nessar.lmnporganizer.backend.model.TenantAttachment; import fr.nessar.lmnporganizer.backend.repository.TenantRepository; import fr.nessar.lmnporganizer.backend.service.TenantAttachmentStorage; +import fr.nessar.lmnporganizer.backend.service.AttachmentUploadPolicy; +import fr.nessar.lmnporganizer.backend.service.PhoneNumberNormalizer; +import jakarta.validation.Valid; import org.springframework.core.io.Resource; import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; @@ -21,10 +24,15 @@ import java.util.List; public class TenantController { private final TenantRepository tenantRepository; private final TenantAttachmentStorage attachmentStorage; + private final AttachmentUploadPolicy attachmentUploadPolicy; + private final PhoneNumberNormalizer phoneNumberNormalizer; - public TenantController(TenantRepository tenantRepository, TenantAttachmentStorage attachmentStorage) { + public TenantController(TenantRepository tenantRepository, TenantAttachmentStorage attachmentStorage, + AttachmentUploadPolicy attachmentUploadPolicy, PhoneNumberNormalizer phoneNumberNormalizer) { this.tenantRepository = tenantRepository; this.attachmentStorage = attachmentStorage; + this.attachmentUploadPolicy = attachmentUploadPolicy; + this.phoneNumberNormalizer = phoneNumberNormalizer; } @GetMapping @@ -38,19 +46,20 @@ public class TenantController { } @PostMapping - public ResponseEntity createTenant(@RequestBody Tenant tenant) { + public ResponseEntity createTenant(@Valid @RequestBody Tenant tenant) { tenant.setId(null); tenant.setLandlordId("default-landlord"); + tenant.setPhone(phoneNumberNormalizer.normalize(tenant.getPhone())); return ResponseEntity.status(HttpStatus.CREATED).body(tenantRepository.save(tenant)); } @PutMapping("/{id}") - public ResponseEntity updateTenant(@PathVariable String id, @RequestBody Tenant details) { + public ResponseEntity updateTenant(@PathVariable String id, @Valid @RequestBody Tenant details) { return tenantRepository.findById(id).map(tenant -> { tenant.setFirstName(details.getFirstName()); tenant.setLastName(details.getLastName()); tenant.setEmail(details.getEmail()); - tenant.setPhone(details.getPhone()); + tenant.setPhone(phoneNumberNormalizer.normalize(details.getPhone())); tenant.setAddress(details.getAddress()); tenant.setLandlordId("default-landlord"); return ResponseEntity.ok(tenantRepository.save(tenant)); @@ -73,6 +82,7 @@ public class TenantController { if (tenant == null) { return ResponseEntity.notFound().build(); } + attachmentUploadPolicy.validate(file); TenantAttachment attachment = attachmentStorage.store(tenantId, file); tenant.getAttachments().add(attachment); tenantRepository.save(tenant); diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/exception/AttachmentTooLargeException.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/exception/AttachmentTooLargeException.java new file mode 100644 index 0000000..66baea0 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/exception/AttachmentTooLargeException.java @@ -0,0 +1,7 @@ +package fr.nessar.lmnporganizer.backend.exception; + +public class AttachmentTooLargeException extends RuntimeException { + public AttachmentTooLargeException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/exception/InvalidPhoneNumberException.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/exception/InvalidPhoneNumberException.java new file mode 100644 index 0000000..f266bea --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/exception/InvalidPhoneNumberException.java @@ -0,0 +1,7 @@ +package fr.nessar.lmnporganizer.backend.exception; + +public class InvalidPhoneNumberException extends RuntimeException { + public InvalidPhoneNumberException() { + super("Le numéro de téléphone doit être français ou international."); + } +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Guarantor.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Guarantor.java index 333ad70..54305ed 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Guarantor.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Guarantor.java @@ -1,9 +1,18 @@ package fr.nessar.lmnporganizer.backend.model; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + public class Guarantor { private String id; + @NotBlank(message = "Le prénom du garant est obligatoire.") private String firstName; + + @NotBlank(message = "Le nom du garant est obligatoire.") private String lastName; + + @NotBlank(message = "L'adresse e-mail du garant est obligatoire.") + @Email(message = "L'adresse e-mail du garant est invalide.") private String email; private String phone; private String address; diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Lease.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Lease.java index cce4160..6c0da71 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Lease.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Lease.java @@ -1,5 +1,6 @@ package fr.nessar.lmnporganizer.backend.model; +import jakarta.validation.Valid; import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.index.Indexed; import org.springframework.data.mongodb.core.mapping.Document; @@ -23,7 +24,7 @@ public class Lease { @Indexed private List tenantIds = new ArrayList<>(); - private List guarantors = new ArrayList<>(); + private List<@Valid Guarantor> guarantors = new ArrayList<>(); private LocalDate startDate; private LocalDate endDate; private BigDecimal monthlyRent; diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Tenant.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Tenant.java index 442d659..88262b9 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Tenant.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Tenant.java @@ -1,5 +1,7 @@ package fr.nessar.lmnporganizer.backend.model; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.index.Indexed; import org.springframework.data.mongodb.core.mapping.Document; @@ -15,8 +17,14 @@ public class Tenant { @Indexed private String landlordId; + @NotBlank(message = "Le prénom est obligatoire.") private String firstName; + + @NotBlank(message = "Le nom est obligatoire.") private String lastName; + + @NotBlank(message = "L'adresse e-mail est obligatoire.") + @Email(message = "L'adresse e-mail est invalide.") private String email; private String phone; private String address; diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/AttachmentUploadPolicy.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/AttachmentUploadPolicy.java new file mode 100644 index 0000000..590d2fd --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/AttachmentUploadPolicy.java @@ -0,0 +1,22 @@ +package fr.nessar.lmnporganizer.backend.service; + +import fr.nessar.lmnporganizer.backend.exception.AttachmentTooLargeException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.unit.DataSize; +import org.springframework.web.multipart.MultipartFile; + +@Service +public class AttachmentUploadPolicy { + private final DataSize maximumSize; + + public AttachmentUploadPolicy(@Value("${lmnp.attachments.max-size:250MB}") String maximumSize) { + this.maximumSize = DataSize.parse(maximumSize); + } + + public void validate(MultipartFile attachment) { + if (attachment.getSize() > maximumSize.toBytes()) { + throw new AttachmentTooLargeException("Une pièce jointe ne peut pas dépasser 250 Mo."); + } + } +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/PhoneNumberNormalizer.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/PhoneNumberNormalizer.java new file mode 100644 index 0000000..e6796d9 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/PhoneNumberNormalizer.java @@ -0,0 +1,25 @@ +package fr.nessar.lmnporganizer.backend.service; + +import fr.nessar.lmnporganizer.backend.exception.InvalidPhoneNumberException; +import org.springframework.stereotype.Service; + +@Service +public class PhoneNumberNormalizer { + public String normalize(String phone) { + if (phone == null || phone.isBlank()) { + return ""; + } + + String compact = phone.trim().replaceAll("[\\s()./-]", ""); + if (compact.startsWith("00")) { + compact = "+" + compact.substring(2); + } + if (compact.matches("0\\d{9}")) { + return "+33" + compact.substring(1); + } + if (compact.matches("\\+\\d{8,15}")) { + return compact; + } + throw new InvalidPhoneNumberException(); + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 0ab3fcf..7141afd 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1,6 +1,9 @@ spring.application.name=backend spring.mongodb.uri=${SPRING_DATA_MONGODB_URI:mongodb://localhost:27017/lmnp} lmnp.storage.attachments=${LMNP_ATTACHMENT_STORAGE_PATH:${user.home}/.lmnp-organizer/attachments} +lmnp.attachments.max-size=${LMNP_ATTACHMENT_MAX_SIZE:250MB} +spring.servlet.multipart.max-file-size=${lmnp.attachments.max-size} +spring.servlet.multipart.max-request-size=251MB # OAuth2 Client registration spring.security.oauth2.client.registration.oidc.client-id=${OIDC_CLIENT_ID:lmnp-client} diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LeaseControllerTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LeaseControllerTest.java index c288e61..f6fd09c 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LeaseControllerTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LeaseControllerTest.java @@ -61,7 +61,18 @@ class LeaseControllerTest { .andExpect(jsonPath("$.landlordId").value("default-landlord")) .andExpect(jsonPath("$.apartmentId").value("apartment-1")) .andExpect(jsonPath("$.tenantIds[0]").value("tenant-1")) - .andExpect(jsonPath("$.guarantors[0].lastName").value("Durand")); + .andExpect(jsonPath("$.guarantors[0].lastName").value("Durand")) + .andExpect(jsonPath("$.guarantors[0].phone").value("+33600000000")); + } + + @Test + void whenCreateLeaseWithInvalidGuarantorEmail_thenBadRequest() throws Exception { + mockMvc.perform(post("/api/leases") + .with(oidcLogin()) + .contentType(MediaType.APPLICATION_JSON) + .content(leaseJson().replace("alex@example.com", "alex-at-example.com"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("L'adresse e-mail du garant est invalide.")); } @Test @@ -161,7 +172,7 @@ class LeaseControllerTest { "firstName": "Alex", "lastName": "Durand", "email": "alex@example.com", - "phone": "+33600000000", + "phone": "06 00 00 00 00", "address": "5 rue Victor Hugo" }], "startDate": "2026-09-01", diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/TenantControllerTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/TenantControllerTest.java index b799685..bf86fc7 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/TenantControllerTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/TenantControllerTest.java @@ -61,6 +61,48 @@ class TenantControllerTest { .andExpect(jsonPath("$.landlordId").value("default-landlord")); } + @Test + void whenCreateTenantWithFrenchPhone_thenPhoneIsNormalized() throws Exception { + when(tenantRepository.save(any(Tenant.class))).thenAnswer(invocation -> { + Tenant tenant = invocation.getArgument(0); + tenant.setId("tenant-1"); + return tenant; + }); + + mockMvc.perform(post("/api/tenants") + .with(oidcLogin()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "firstName": "Camille", + "lastName": "Martin", + "email": "camille@example.com", + "phone": "06 12 34 56 78", + "address": "Lyon" + } + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.phone").value("+33612345678")); + } + + @Test + void whenCreateTenantWithInvalidEmail_thenBadRequest() throws Exception { + mockMvc.perform(post("/api/tenants") + .with(oidcLogin()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "firstName": "Camille", + "lastName": "Martin", + "email": "not-an-email", + "phone": "", + "address": "Lyon" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("L'adresse e-mail est invalide.")); + } + @Test void whenGetTenantsWithoutAuthentication_thenUnauthorized() throws Exception { mockMvc.perform(get("/api/tenants")) diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/AttachmentUploadPolicyTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/AttachmentUploadPolicyTest.java new file mode 100644 index 0000000..4740df6 --- /dev/null +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/AttachmentUploadPolicyTest.java @@ -0,0 +1,31 @@ +package fr.nessar.lmnporganizer.backend.service; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import fr.nessar.lmnporganizer.backend.exception.AttachmentTooLargeException; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +class AttachmentUploadPolicyTest { + + @Test + void rejectsAttachmentsLargerThanConfiguredLimit() { + AttachmentUploadPolicy policy = new AttachmentUploadPolicy("250MB"); + MultipartFile attachment = mock(MultipartFile.class); + when(attachment.getSize()).thenReturn(250L * 1024 * 1024 + 1); + + assertThatThrownBy(() -> policy.validate(attachment)) + .isInstanceOf(AttachmentTooLargeException.class); + } + + @Test + void acceptsAttachmentsAtConfiguredLimit() { + AttachmentUploadPolicy policy = new AttachmentUploadPolicy("250MB"); + MockMultipartFile attachment = new MockMultipartFile("attachment", "lease.pdf", "application/pdf", new byte[0]); + + policy.validate(attachment); + } +} diff --git a/backend/src/test/resources/application.properties b/backend/src/test/resources/application.properties index c38bf99..20fdfc5 100644 --- a/backend/src/test/resources/application.properties +++ b/backend/src/test/resources/application.properties @@ -11,3 +11,4 @@ spring.security.oauth2.client.provider.oidc.user-info-uri=http://localhost:8090/ spring.security.oauth2.client.provider.oidc.jwk-set-uri=http://localhost:8090/realms/test/protocol/openid-connect/certs spring.security.oauth2.client.provider.oidc.user-name-attribute=preferred_username lmnp.storage.attachments=${java.io.tmpdir}/lmnp-organizer-test/attachments +lmnp.attachments.max-size=250MB diff --git a/frontend/e2e/app.spec.ts b/frontend/e2e/app.spec.ts index 0bf3e66..038a586 100644 --- a/frontend/e2e/app.spec.ts +++ b/frontend/e2e/app.spec.ts @@ -277,8 +277,11 @@ test('creates a tenant with an attachment and a lease with a guarantor', async ( await page.getByTestId('primary-navigation').getByRole('button', { name: 'Baux' }).click(); await page.getByRole('button', { name: 'Créer un bail' }).first().click(); - await page.getByLabel('Appartement').selectOption('apartment-1'); - await page.getByLabel('Locataire').selectOption('tenant-1'); + await page.getByRole('combobox', { name: 'Appartement' }).click(); + await page.getByRole('option', { name: 'Studio République' }).click(); + await page.getByRole('combobox', { name: 'Locataires' }).click(); + await page.getByRole('option', { name: 'Camille Martin' }).click(); + await page.keyboard.press('Escape'); await page.getByLabel('Date de début').fill('2026-09-01'); await page.getByLabel('Date de fin').fill('2027-08-31'); await page.getByLabel('Loyer mensuel').fill('850'); diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index 79c6e82..a27b931 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -6,15 +6,26 @@ import { } from '@angular/core'; import { provideRouter } from '@angular/router'; import { provideHttpClient } from '@angular/common/http'; +import { MAT_NATIVE_DATE_FORMATS, MAT_DATE_LOCALE, provideNativeDateAdapter } from '@angular/material/core'; import { routes } from './app.routes'; import { ConfigService } from './services/config.service'; +const frenchDateFormats = { + ...MAT_NATIVE_DATE_FORMATS, + display: { + ...MAT_NATIVE_DATE_FORMATS.display, + dateInput: { year: 'numeric', month: '2-digit', day: '2-digit' }, + }, +}; + export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideRouter(routes), provideHttpClient(), + provideNativeDateAdapter(frenchDateFormats), + { provide: MAT_DATE_LOCALE, useValue: 'fr-FR' }, provideAppInitializer(() => { const configService = inject(ConfigService); return configService.loadConfig(); diff --git a/frontend/src/app/app.html b/frontend/src/app/app.html index 0eb02e7..a2bd37b 100644 --- a/frontend/src/app/app.html +++ b/frontend/src/app/app.html @@ -42,6 +42,7 @@ @if (activeTab() === 'tenants') { ([]); readonly tenants = signal([]); readonly leases = signal([]); + readonly tenantAttachmentError = signal(''); private readonly _isLoggedIn = signal(false); private readonly _username = signal(''); private readonly _email = signal(''); @@ -179,9 +180,13 @@ export class App implements OnInit { } uploadTenantAttachment(request: { tenantId: string; file: File }): void { + this.tenantAttachmentError.set(''); this.tenantService.uploadAttachment(request.tenantId, request.file).subscribe({ next: () => this.loadTenants(), - error: (err) => console.error('Error uploading tenant attachment', err), + error: (error: unknown) => { + console.error('Error uploading tenant attachment', error); + this.tenantAttachmentError.set(this.errorMessage(error, 'Impossible d’envoyer cette pièce jointe.')); + }, }); } @@ -234,4 +239,15 @@ export class App implements OnInit { link.click(); URL.revokeObjectURL(url); } + + private errorMessage(error: unknown, fallback: string): string { + if (typeof error !== 'object' || error === null || !('error' in error)) { + return fallback; + } + const response = error.error; + if (typeof response !== 'object' || response === null || !('message' in response)) { + return fallback; + } + return typeof response.message === 'string' ? response.message : fallback; + } } diff --git a/frontend/src/app/components/leases-page/leases-page.html b/frontend/src/app/components/leases-page/leases-page.html index 0b4d73f..061647c 100644 --- a/frontend/src/app/components/leases-page/leases-page.html +++ b/frontend/src/app/components/leases-page/leases-page.html @@ -88,39 +88,37 @@ 01

Parties du bail

-

Choisissez le logement et le locataire.

+

Choisissez le logement et les locataires.

Appartement Locataire
@@ -137,22 +135,24 @@ >Date de début + /> + Date de fin + /> + Loyer mensuelE-mail du garant Téléphone du garant Adresse du garant { expect(component.guarantors()).toHaveLength(1); component.apartmentId.set('apartment-1'); - component.tenantId.set('tenant-1'); - component.startDate.set('2026-09-01'); - component.endDate.set('2027-08-31'); + component.tenantIds.set(['tenant-1']); + component.startDate.set(new Date(2026, 8, 1)); + component.endDate.set(new Date(2027, 7, 31)); component.monthlyRent.set(850); component.save(); expect(savedLease?.tenantIds).toEqual(['tenant-1']); expect(savedLease?.guarantors[0].lastName).toBe('Durand'); + expect(savedLease?.startDate).toBe('2026-09-01'); }); it('rejects incomplete lease and guarantor drafts', () => { @@ -53,6 +54,21 @@ describe('LeasesPage', () => { expect(component.error()).toContain('Sélectionnez'); }); + it('rejects malformed guarantor contact details', () => { + const component = createComponent(); + component.guarantorFirstName.set('Alex'); + component.guarantorLastName.set('Durand'); + component.guarantorEmail.set('alex-at-example.com'); + + component.addGuarantor(); + expect(component.error()).toContain('e-mail valide'); + + component.guarantorEmail.set('alex@example.com'); + component.guarantorPhone.set('not-a-phone'); + component.addGuarantor(); + expect(component.error()).toContain('numéro français'); + }); + it('resets the form and resolves display labels', () => { const component = createComponent(); component.monthlyRent.set(900); diff --git a/frontend/src/app/components/leases-page/leases-page.ts b/frontend/src/app/components/leases-page/leases-page.ts index 330ee2b..ed1c0b7 100644 --- a/frontend/src/app/components/leases-page/leases-page.ts +++ b/frontend/src/app/components/leases-page/leases-page.ts @@ -3,13 +3,23 @@ import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatDatepickerModule } from '@angular/material/datepicker'; import { Apartment } from '../../services/apartment.service'; import { Guarantor, Lease } from '../../services/lease.service'; import { Tenant } from '../../services/tenant.service'; +import { isValidEmail, isValidPhone } from '../../shared/contact-validation'; @Component({ selector: 'app-leases-page', - imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule], + imports: [ + FormsModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatDatepickerModule, + ], templateUrl: './leases-page.html', styleUrl: './leases-page.css', }) @@ -23,9 +33,9 @@ export class LeasesPage { readonly showForm = signal(false); readonly apartmentId = signal(''); - readonly tenantId = signal(''); - readonly startDate = signal(''); - readonly endDate = signal(''); + readonly tenantIds = signal([]); + readonly startDate = signal(null); + readonly endDate = signal(null); readonly monthlyRent = signal(0); readonly monthlyCharges = signal(0); readonly securityDeposit = signal(0); @@ -55,6 +65,14 @@ export class LeasesPage { this.error.set('Le prénom, le nom et l’e-mail du garant sont obligatoires.'); return; } + if (!isValidEmail(this.guarantorEmail())) { + this.error.set('Saisissez une adresse e-mail valide pour le garant.'); + return; + } + if (!isValidPhone(this.guarantorPhone())) { + this.error.set('Saisissez un numéro français ou international valide pour le garant.'); + return; + } this.guarantors.update((guarantors) => [ ...guarantors, { @@ -82,7 +100,7 @@ export class LeasesPage { save(): void { if ( !this.apartmentId() || - !this.tenantId() || + this.tenantIds().length === 0 || !this.startDate() || !this.endDate() || this.monthlyRent() <= 0 @@ -90,12 +108,16 @@ export class LeasesPage { this.error.set('Sélectionnez un bien et un locataire, puis complétez les dates et le loyer.'); return; } + if (this.endDate()! < this.startDate()!) { + this.error.set('La date de fin doit être postérieure à la date de début.'); + return; + } this.leaseSaved.emit({ apartmentId: this.apartmentId(), - tenantIds: [this.tenantId()], + tenantIds: this.tenantIds(), guarantors: this.guarantors(), - startDate: this.startDate(), - endDate: this.endDate(), + startDate: this.toIsoDate(this.startDate()!), + endDate: this.toIsoDate(this.endDate()!), monthlyRent: this.monthlyRent(), monthlyCharges: this.monthlyCharges(), securityDeposit: this.securityDeposit(), @@ -125,9 +147,9 @@ export class LeasesPage { private reset(): void { this.apartmentId.set(''); - this.tenantId.set(''); - this.startDate.set(''); - this.endDate.set(''); + this.tenantIds.set([]); + this.startDate.set(null); + this.endDate.set(null); this.monthlyRent.set(0); this.monthlyCharges.set(0); this.securityDeposit.set(0); @@ -139,4 +161,10 @@ export class LeasesPage { this.guarantorAddress.set(''); this.error.set(''); } + + private toIsoDate(date: Date): string { + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${date.getFullYear()}-${month}-${day}`; + } } diff --git a/frontend/src/app/components/tenants-page/tenants-page.css b/frontend/src/app/components/tenants-page/tenants-page.css index 3703fb4..08a5a31 100644 --- a/frontend/src/app/components/tenants-page/tenants-page.css +++ b/frontend/src/app/components/tenants-page/tenants-page.css @@ -127,6 +127,15 @@ overflow: hidden; clip: rect(0 0 0 0); } +.upload-action small { + color: var(--text-muted); + font-weight: 500; +} +.attachment-error { + margin: 8px 0 0; + color: var(--danger); + font-size: 0.78rem; +} .card-actions { display: flex; justify-content: flex-end; diff --git a/frontend/src/app/components/tenants-page/tenants-page.html b/frontend/src/app/components/tenants-page/tenants-page.html index 20842b6..e78ad80 100644 --- a/frontend/src/app/components/tenants-page/tenants-page.html +++ b/frontend/src/app/components/tenants-page/tenants-page.html @@ -63,16 +63,20 @@ } @if (tenant.id) { + @if (attachmentUploadError() || attachmentError()) { + + } } @@ -136,6 +140,7 @@ [ngModel]="email()" (ngModelChange)="email.set($event)" required + autocomplete="email" /> Téléphone Adresse complète { expect(component.showForm()).toBe(false); }); + it('rejects malformed tenant contact details', () => { + const fixture = TestBed.createComponent(TenantsPage); + fixture.componentRef.setInput('tenants', []); + const component = fixture.componentInstance; + component.firstName.set('Camille'); + component.lastName.set('Martin'); + component.email.set('camille-at-example.com'); + + component.save(); + expect(component.error()).toContain('e-mail valide'); + + component.email.set('camille@example.com'); + component.phone.set('invalid'); + component.save(); + expect(component.error()).toContain('numéro français'); + }); + it('emits a selected attachment for an existing tenant', () => { const fixture = TestBed.createComponent(TenantsPage); fixture.componentRef.setInput('tenants', []); @@ -46,6 +63,23 @@ describe('TenantsPage', () => { expect(uploadedFile).toBe(file); }); + it('rejects an attachment larger than 250 Mo before upload', () => { + const fixture = TestBed.createComponent(TenantsPage); + fixture.componentRef.setInput('tenants', []); + const component = fixture.componentInstance; + const file = new File(['identity'], 'archive.zip'); + Object.defineProperty(file, 'size', { value: 250 * 1024 * 1024 + 1 }); + const input = document.createElement('input'); + Object.defineProperty(input, 'files', { value: { item: () => file } }); + + component.selectFile( + { id: 'tenant-1', firstName: 'Camille', lastName: 'Martin', email: 'camille@example.com', phone: '', address: '' }, + { target: input } as unknown as Event, + ); + + expect(component.attachmentUploadError()).toContain('250 Mo'); + }); + it('opens, fills, and closes the edit form', () => { const fixture = TestBed.createComponent(TenantsPage); fixture.componentRef.setInput('tenants', []); diff --git a/frontend/src/app/components/tenants-page/tenants-page.ts b/frontend/src/app/components/tenants-page/tenants-page.ts index 8f26175..6ca99c9 100644 --- a/frontend/src/app/components/tenants-page/tenants-page.ts +++ b/frontend/src/app/components/tenants-page/tenants-page.ts @@ -3,6 +3,7 @@ import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; +import { isValidEmail, isValidPhone } from '../../shared/contact-validation'; import { Tenant, TenantAttachment } from '../../services/tenant.service'; export interface TenantSaveRequest { @@ -23,6 +24,7 @@ export interface TenantAttachmentRequest { }) export class TenantsPage { readonly tenants = input.required(); + readonly attachmentError = input(''); readonly tenantSaved = output(); readonly deleteRequested = output(); readonly attachmentUploadRequested = output<{ tenantId: string; file: File }>(); @@ -36,6 +38,7 @@ export class TenantsPage { readonly phone = signal(''); readonly address = signal(''); readonly error = signal(''); + readonly attachmentUploadError = signal(''); constructor() { effect(() => this.fillForm(this.editingTenant())); @@ -62,6 +65,14 @@ export class TenantsPage { this.error.set('Le prénom, le nom et l’e-mail sont obligatoires.'); return; } + if (!isValidEmail(this.email())) { + this.error.set('Saisissez une adresse e-mail valide.'); + return; + } + if (!isValidPhone(this.phone())) { + this.error.set('Saisissez un numéro français ou international valide.'); + return; + } this.tenantSaved.emit({ tenant: { firstName: this.firstName().trim(), @@ -77,7 +88,10 @@ export class TenantsPage { selectFile(tenant: Tenant, event: Event): void { const file = (event.target as HTMLInputElement).files?.item(0); - if (tenant.id && file) { + if (file && file.size > 250 * 1024 * 1024) { + this.attachmentUploadError.set('Une pièce jointe ne peut pas dépasser 250 Mo.'); + } else if (tenant.id && file) { + this.attachmentUploadError.set(''); this.attachmentUploadRequested.emit({ tenantId: tenant.id, file }); } (event.target as HTMLInputElement).value = ''; diff --git a/frontend/src/app/shared/contact-validation.ts b/frontend/src/app/shared/contact-validation.ts new file mode 100644 index 0000000..5d34472 --- /dev/null +++ b/frontend/src/app/shared/contact-validation.ts @@ -0,0 +1,17 @@ +const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export function isValidEmail(email: string): boolean { + return emailPattern.test(email.trim()); +} + +export function isValidPhone(phone: string): boolean { + const compact = phone.trim().replace(/[\s()./-]/g, ''); + if (!compact) { + return true; + } + + const international = compact.startsWith('00') ? `+${compact.slice(2)}` : compact; + return international.startsWith('+') + ? /^\+\d{8,15}$/.test(international) + : /^0\d{9}$/.test(international); +}