diff --git a/backend/pom.xml b/backend/pom.xml
index ed63a83..b288d8f 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -28,6 +28,7 @@
25
+ 1.6.3
@@ -59,6 +60,11 @@
openhtmltopdf-pdfbox
1.1.40
+
+ org.mapstruct
+ mapstruct
+ ${mapstruct.version}
+
org.springframework.boot
@@ -83,6 +89,13 @@
org.apache.maven.plugins
maven-compiler-plugin
+
+
+ org.mapstruct
+ mapstruct-processor
+ ${mapstruct.version}
+
+
-Xlint:deprecation
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/HelloController.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/HelloController.java
index f0caa0c..8394346 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/HelloController.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/HelloController.java
@@ -1,5 +1,6 @@
package fr.nessar.lmnporganizer.backend;
+import fr.nessar.lmnporganizer.backend.service.GreetingService;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.web.bind.annotation.GetMapping;
@@ -7,16 +8,14 @@ import org.springframework.web.bind.annotation.RestController;
@RestController
public class HelloController {
+ private final GreetingService greetingService;
+
+ public HelloController(GreetingService greetingService) {
+ this.greetingService = greetingService;
+ }
@GetMapping("/api/hello")
public String hello(@AuthenticationPrincipal OAuth2User user) {
- String name = user.getAttribute("preferred_username");
- if (name == null) {
- name = user.getAttribute("name");
- }
- if (name == null) {
- name = user.getName();
- }
- return "Hello, " + name + "!";
+ return greetingService.greetingFor(user);
}
}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApartmentController.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApartmentController.java
index 425d38f..1eaa425 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApartmentController.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApartmentController.java
@@ -1,8 +1,8 @@
package fr.nessar.lmnporganizer.backend.controller;
-import fr.nessar.lmnporganizer.backend.model.Apartment;
-import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository;
-import org.springframework.http.HttpStatus;
+import fr.nessar.lmnporganizer.backend.dto.ApartmentRequest;
+import fr.nessar.lmnporganizer.backend.dto.ApartmentResponse;
+import fr.nessar.lmnporganizer.backend.service.ApartmentService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -11,56 +11,35 @@ import java.util.List;
@RestController
@RequestMapping("/api/apartments")
public class ApartmentController {
+ private final ApartmentService apartmentService;
- private final ApartmentRepository apartmentRepository;
+ public ApartmentController(ApartmentService apartmentService) {
+ this.apartmentService = apartmentService;
+ }
- public ApartmentController(ApartmentRepository apartmentRepository) {
- this.apartmentRepository = apartmentRepository;
- }
+ @GetMapping
+ public List getAllApartments() {
+ return apartmentService.getAll();
+ }
- @GetMapping
- public List getAllApartments() {
- return apartmentRepository.findAll();
- }
+ @GetMapping("/{id}")
+ public ResponseEntity getApartmentById(@PathVariable String id) {
+ return apartmentService.getById(id);
+ }
- @GetMapping("/{id}")
- public ResponseEntity getApartmentById(@PathVariable String id) {
- return apartmentRepository.findById(id)
- .map(ResponseEntity::ok)
- .orElse(ResponseEntity.notFound().build());
- }
+ @PostMapping
+ public ResponseEntity createApartment(@RequestBody ApartmentRequest request) {
+ return apartmentService.create(request);
+ }
- @PostMapping
- public ResponseEntity createApartment(@RequestBody Apartment apartment) {
- apartment.setId(null); // Ensure a new entity is created
- apartment.setBailleurId("default-landlord"); // Associate with default landlord profile
- Apartment savedApartment = apartmentRepository.save(apartment);
- return ResponseEntity.status(HttpStatus.CREATED).body(savedApartment);
- }
+ @PutMapping("/{id}")
+ public ResponseEntity updateApartment(@PathVariable String id,
+ @RequestBody ApartmentRequest request) {
+ return apartmentService.update(id, request);
+ }
- @PutMapping("/{id}")
- public ResponseEntity updateApartment(@PathVariable String id, @RequestBody Apartment apartmentDetails) {
- return apartmentRepository.findById(id)
- .map(existingApartment -> {
- existingApartment.setName(apartmentDetails.getName());
- existingApartment.setAddress(apartmentDetails.getAddress());
- existingApartment.setPurchasePrice(apartmentDetails.getPurchasePrice());
- existingApartment.setDescription(apartmentDetails.getDescription());
- // Keep bailleurId fixed to default-landlord in single-portfolio mode
- existingApartment.setBailleurId("default-landlord");
- Apartment updatedApartment = apartmentRepository.save(existingApartment);
- return ResponseEntity.ok(updatedApartment);
- })
- .orElse(ResponseEntity.notFound().build());
- }
-
- @DeleteMapping("/{id}")
- public ResponseEntity deleteApartment(@PathVariable String id) {
- if (apartmentRepository.existsById(id)) {
- apartmentRepository.deleteById(id);
- return ResponseEntity.noContent().build();
- } else {
- return ResponseEntity.notFound().build();
- }
- }
+ @DeleteMapping("/{id}")
+ public ResponseEntity deleteApartment(@PathVariable String id) {
+ return apartmentService.delete(id);
+ }
}
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
index ad15b99..09e24ab 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApiExceptionHandler.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApiExceptionHandler.java
@@ -11,34 +11,34 @@ import org.springframework.web.multipart.MaxUploadSizeExceededException;
@RestControllerAdvice
public class ApiExceptionHandler {
- @ExceptionHandler(AttachmentTooLargeException.class)
- public ResponseEntity handleAttachmentTooLarge(AttachmentTooLargeException exception) {
- return error(HttpStatus.CONTENT_TOO_LARGE, exception.getMessage());
- }
+ @ExceptionHandler(AttachmentTooLargeException.class)
+ public ResponseEntity handleAttachmentTooLarge(AttachmentTooLargeException exception) {
+ return error(HttpStatus.CONTENT_TOO_LARGE, exception.getMessage());
+ }
- @ExceptionHandler(MaxUploadSizeExceededException.class)
- public ResponseEntity handleMultipartTooLarge() {
- return error(HttpStatus.CONTENT_TOO_LARGE, "Une pièce jointe ne peut pas dépasser 250 Mo.");
- }
+ @ExceptionHandler(MaxUploadSizeExceededException.class)
+ public ResponseEntity handleMultipartTooLarge() {
+ return error(HttpStatus.CONTENT_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(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);
- }
+ @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 ResponseEntity error(HttpStatus status, String message) {
+ return ResponseEntity.status(status).body(new ApiError(message));
+ }
- private record ApiError(String 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 82efdea..8f5851a 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
@@ -1,132 +1,51 @@
package fr.nessar.lmnporganizer.backend.controller;
-import fr.nessar.lmnporganizer.backend.model.Apartment;
-import fr.nessar.lmnporganizer.backend.model.Lease;
-import fr.nessar.lmnporganizer.backend.model.Tenant;
-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 fr.nessar.lmnporganizer.backend.dto.LeaseRequest;
+import fr.nessar.lmnporganizer.backend.dto.LeaseResponse;
+import fr.nessar.lmnporganizer.backend.service.LeaseService;
import jakarta.validation.Valid;
-import org.springframework.http.ContentDisposition;
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
-import java.util.ArrayList;
-import java.util.HashSet;
import java.util.List;
@RestController
@RequestMapping("/api/leases")
public class LeaseController {
- private final LeaseRepository leaseRepository;
- private final ApartmentRepository apartmentRepository;
- private final TenantRepository tenantRepository;
- private final LeasePdfService leasePdfService;
- private final PhoneNumberNormalizer phoneNumberNormalizer;
+ private final LeaseService leaseService;
- public LeaseController(LeaseRepository leaseRepository, ApartmentRepository apartmentRepository,
- TenantRepository tenantRepository, LeasePdfService leasePdfService,
- PhoneNumberNormalizer phoneNumberNormalizer) {
- this.leaseRepository = leaseRepository;
- this.apartmentRepository = apartmentRepository;
- this.tenantRepository = tenantRepository;
- this.leasePdfService = leasePdfService;
- this.phoneNumberNormalizer = phoneNumberNormalizer;
- }
+ public LeaseController(LeaseService leaseService) {
+ this.leaseService = leaseService;
+ }
- @GetMapping
- public List getAllLeases() {
- return leaseRepository.findAll();
- }
+ @GetMapping
+ public List getAllLeases() {
+ return leaseService.getAll();
+ }
- @GetMapping("/{id}")
- public ResponseEntity getLease(@PathVariable String id) {
- return leaseRepository.findById(id).map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());
- }
+ @GetMapping("/{id}")
+ public ResponseEntity getLease(@PathVariable String id) {
+ return leaseService.getById(id);
+ }
- @PostMapping
- 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));
- }
+ @PostMapping
+ public ResponseEntity createLease(@Valid @RequestBody LeaseRequest request) {
+ return leaseService.create(request);
+ }
- @PutMapping("/{id}")
- public ResponseEntity updateLease(@PathVariable String id, @Valid @RequestBody Lease details) {
- if (!referencesExist(details)) {
- return ResponseEntity.badRequest().build();
- }
- return leaseRepository.findById(id).map(lease -> {
- copyTerms(details, lease);
- return ResponseEntity.ok(leaseRepository.save(lease));
- }).orElse(ResponseEntity.notFound().build());
- }
+ @PutMapping("/{id}")
+ public ResponseEntity updateLease(@PathVariable String id,
+ @Valid @RequestBody LeaseRequest request) {
+ return leaseService.update(id, request);
+ }
- @DeleteMapping("/{id}")
- public ResponseEntity deleteLease(@PathVariable String id) {
- if (!leaseRepository.existsById(id)) {
- return ResponseEntity.notFound().build();
- }
- leaseRepository.deleteById(id);
- return ResponseEntity.noContent().build();
- }
+ @DeleteMapping("/{id}")
+ public ResponseEntity deleteLease(@PathVariable String id) {
+ return leaseService.delete(id);
+ }
- @GetMapping("/{id}/pdf")
- public ResponseEntity downloadPdf(@PathVariable String id) {
- Lease lease = leaseRepository.findById(id).orElse(null);
- if (lease == null) {
- return ResponseEntity.notFound().build();
- }
- Apartment apartment = apartmentRepository.findById(lease.getApartmentId()).orElse(null);
- List tenants = tenantsFor(lease.getTenantIds());
- if (apartment == null || tenants.size() != new HashSet<>(lease.getTenantIds()).size()) {
- return ResponseEntity.notFound().build();
- }
- return ResponseEntity.ok()
- .contentType(MediaType.APPLICATION_PDF)
- .header(HttpHeaders.CONTENT_DISPOSITION,
- ContentDisposition.attachment().filename("bail-" + id + ".pdf").build().toString())
- .body(leasePdfService.generate(lease, apartment, tenants));
- }
-
- private boolean referencesExist(Lease lease) {
- if (lease.getApartmentId() == null || !apartmentRepository.existsById(lease.getApartmentId())
- || lease.getTenantIds().isEmpty()) {
- return false;
- }
- return tenantsFor(lease.getTenantIds()).size() == new HashSet<>(lease.getTenantIds()).size();
- }
-
- private List tenantsFor(List ids) {
- List tenants = new ArrayList<>();
- tenantRepository.findAllById(ids).forEach(tenants::add);
- return tenants;
- }
-
- private void copyTerms(Lease source, Lease target) {
- target.setLandlordId("default-landlord");
- 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());
- target.setMonthlyCharges(source.getMonthlyCharges());
- target.setSecurityDeposit(source.getSecurityDeposit());
- target.setStatus(source.getStatus());
- }
-
- private void normalizeGuarantorPhones(Lease lease) {
- lease.getGuarantors().forEach(guarantor -> guarantor.setPhone(phoneNumberNormalizer.normalize(guarantor.getPhone())));
- }
+ @GetMapping("/{id}/pdf")
+ public ResponseEntity downloadPdf(@PathVariable String id) {
+ return leaseService.downloadPdf(id);
+ }
}
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 7fa448c..0f73294 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
@@ -1,16 +1,11 @@
package fr.nessar.lmnporganizer.backend.controller;
-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 fr.nessar.lmnporganizer.backend.dto.TenantAttachmentResponse;
+import fr.nessar.lmnporganizer.backend.dto.TenantRequest;
+import fr.nessar.lmnporganizer.backend.dto.TenantResponse;
+import fr.nessar.lmnporganizer.backend.service.TenantService;
import jakarta.validation.Valid;
import org.springframework.core.io.Resource;
-import org.springframework.http.ContentDisposition;
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -22,110 +17,53 @@ import java.util.List;
@RestController
@RequestMapping("/api/tenants")
public class TenantController {
- private final TenantRepository tenantRepository;
- private final TenantAttachmentStorage attachmentStorage;
- private final AttachmentUploadPolicy attachmentUploadPolicy;
- private final PhoneNumberNormalizer phoneNumberNormalizer;
+ private final TenantService tenantService;
- public TenantController(TenantRepository tenantRepository, TenantAttachmentStorage attachmentStorage,
- AttachmentUploadPolicy attachmentUploadPolicy, PhoneNumberNormalizer phoneNumberNormalizer) {
- this.tenantRepository = tenantRepository;
- this.attachmentStorage = attachmentStorage;
- this.attachmentUploadPolicy = attachmentUploadPolicy;
- this.phoneNumberNormalizer = phoneNumberNormalizer;
- }
+ public TenantController(TenantService tenantService) {
+ this.tenantService = tenantService;
+ }
- @GetMapping
- public List getAllTenants() {
- return tenantRepository.findAll();
- }
+ @GetMapping
+ public List getAllTenants() {
+ return tenantService.getAll();
+ }
- @GetMapping("/{id}")
- public ResponseEntity getTenant(@PathVariable String id) {
- return tenantRepository.findById(id).map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());
- }
+ @GetMapping("/{id}")
+ public ResponseEntity getTenant(@PathVariable String id) {
+ return tenantService.getById(id);
+ }
- @PostMapping
- 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));
- }
+ @PostMapping
+ public ResponseEntity createTenant(@Valid @RequestBody TenantRequest request) {
+ return tenantService.create(request);
+ }
- @PutMapping("/{id}")
- 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(phoneNumberNormalizer.normalize(details.getPhone()));
- tenant.setAddress(details.getAddress());
- tenant.setLandlordId("default-landlord");
- return ResponseEntity.ok(tenantRepository.save(tenant));
- }).orElse(ResponseEntity.notFound().build());
- }
+ @PutMapping("/{id}")
+ public ResponseEntity updateTenant(@PathVariable String id,
+ @Valid @RequestBody TenantRequest request) {
+ return tenantService.update(id, request);
+ }
- @DeleteMapping("/{id}")
- public ResponseEntity deleteTenant(@PathVariable String id) {
- if (!tenantRepository.existsById(id)) {
- return ResponseEntity.notFound().build();
- }
- tenantRepository.deleteById(id);
- return ResponseEntity.noContent().build();
- }
+ @DeleteMapping("/{id}")
+ public ResponseEntity deleteTenant(@PathVariable String id) {
+ return tenantService.delete(id);
+ }
- @PostMapping(path = "/{tenantId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
- public ResponseEntity uploadAttachment(@PathVariable String tenantId,
- @RequestPart("file") MultipartFile file) throws IOException {
- Tenant tenant = tenantRepository.findById(tenantId).orElse(null);
- if (tenant == null) {
- return ResponseEntity.notFound().build();
- }
- attachmentUploadPolicy.validate(file);
- TenantAttachment attachment = attachmentStorage.store(tenantId, file);
- tenant.getAttachments().add(attachment);
- tenantRepository.save(tenant);
- return ResponseEntity.status(HttpStatus.CREATED).body(attachment);
- }
+ @PostMapping(path = "/{tenantId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ public ResponseEntity uploadAttachment(@PathVariable String tenantId,
+ @RequestPart("file") MultipartFile file) throws IOException {
+ return tenantService.uploadAttachment(tenantId, file);
+ }
- @GetMapping("/{tenantId}/attachments/{attachmentId}")
- public ResponseEntity downloadAttachment(@PathVariable String tenantId, @PathVariable String attachmentId)
- throws IOException {
- TenantAttachment attachment = tenantRepository.findById(tenantId)
- .flatMap(tenant -> tenant.getAttachments().stream().filter(item -> item.getId().equals(attachmentId)).findFirst())
- .orElse(null);
- if (attachment == null) {
- return ResponseEntity.notFound().build();
- }
- MediaType mediaType;
- try {
- mediaType = MediaType.parseMediaType(attachment.getContentType());
- } catch (IllegalArgumentException exception) {
- mediaType = MediaType.APPLICATION_OCTET_STREAM;
- }
- return ResponseEntity.ok()
- .contentType(mediaType)
- .header(HttpHeaders.CONTENT_DISPOSITION,
- ContentDisposition.attachment().filename(attachment.getFileName()).build().toString())
- .body(attachmentStorage.read(attachment));
- }
+ @GetMapping("/{tenantId}/attachments/{attachmentId}")
+ public ResponseEntity downloadAttachment(@PathVariable String tenantId, @PathVariable String attachmentId)
+ throws IOException {
+ return tenantService.downloadAttachment(tenantId, attachmentId);
+ }
- @DeleteMapping("/{tenantId}/attachments/{attachmentId}")
- public ResponseEntity deleteAttachment(@PathVariable String tenantId, @PathVariable String attachmentId)
- throws IOException {
- Tenant tenant = tenantRepository.findById(tenantId).orElse(null);
- if (tenant == null) {
- return ResponseEntity.notFound().build();
- }
- TenantAttachment attachment = tenant.getAttachments().stream()
- .filter(item -> item.getId().equals(attachmentId)).findFirst().orElse(null);
- if (attachment == null) {
- return ResponseEntity.notFound().build();
- }
- attachmentStorage.delete(attachment);
- tenant.getAttachments().remove(attachment);
- tenantRepository.save(tenant);
- return ResponseEntity.noContent().build();
- }
+ @DeleteMapping("/{tenantId}/attachments/{attachmentId}")
+ public ResponseEntity deleteAttachment(@PathVariable String tenantId, @PathVariable String attachmentId)
+ throws IOException {
+ return tenantService.deleteAttachment(tenantId, attachmentId);
+ }
}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/UserController.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/UserController.java
index f4e7804..ac1364f 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/UserController.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/UserController.java
@@ -1,26 +1,24 @@
package fr.nessar.lmnporganizer.backend.controller;
+import fr.nessar.lmnporganizer.backend.dto.UserResponse;
+import fr.nessar.lmnporganizer.backend.service.UserService;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
-import java.util.Map;
-
@RestController
@RequestMapping("/api")
public class UserController {
+ private final UserService userService;
- @GetMapping("/user")
- public Map getUser(@AuthenticationPrincipal OidcUser principal) {
- if (principal == null) {
- return Map.of("authenticated", false);
- }
- return Map.of(
- "authenticated", true,
- "username", principal.getPreferredUsername() != null ? principal.getPreferredUsername() : principal.getName(),
- "email", principal.getEmail() != null ? principal.getEmail() : ""
- );
- }
+ public UserController(UserService userService) {
+ this.userService = userService;
+ }
+
+ @GetMapping("/user")
+ public UserResponse getUser(@AuthenticationPrincipal OidcUser principal) {
+ return userService.getUser(principal);
+ }
}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/ApartmentRequest.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/ApartmentRequest.java
new file mode 100644
index 0000000..9dea269
--- /dev/null
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/ApartmentRequest.java
@@ -0,0 +1,4 @@
+package fr.nessar.lmnporganizer.backend.dto;
+
+public record ApartmentRequest(String name, String address, double purchasePrice, String description) {
+}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/ApartmentResponse.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/ApartmentResponse.java
new file mode 100644
index 0000000..2d0dbd8
--- /dev/null
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/ApartmentResponse.java
@@ -0,0 +1,4 @@
+package fr.nessar.lmnporganizer.backend.dto;
+
+public record ApartmentResponse(String id, String name, String address, double purchasePrice, String description) {
+}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/GuarantorDto.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/GuarantorDto.java
new file mode 100644
index 0000000..13015f6
--- /dev/null
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/GuarantorDto.java
@@ -0,0 +1,14 @@
+package fr.nessar.lmnporganizer.backend.dto;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+
+public record GuarantorDto(
+ String id,
+ @NotBlank(message = "Le prénom du garant est obligatoire.") String firstName,
+ @NotBlank(message = "Le nom du garant est obligatoire.") String lastName,
+ @NotBlank(message = "L'adresse e-mail du garant est obligatoire.") @Email(message = "L'adresse e-mail du garant est invalide.") String email,
+ String phone,
+ String address
+) {
+}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/LeaseRequest.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/LeaseRequest.java
new file mode 100644
index 0000000..f22c033
--- /dev/null
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/LeaseRequest.java
@@ -0,0 +1,21 @@
+package fr.nessar.lmnporganizer.backend.dto;
+
+import fr.nessar.lmnporganizer.backend.model.LeaseStatus;
+import jakarta.validation.Valid;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+
+public record LeaseRequest(
+ String apartmentId,
+ List tenantIds,
+ List<@Valid GuarantorDto> guarantors,
+ LocalDate startDate,
+ LocalDate endDate,
+ BigDecimal monthlyRent,
+ BigDecimal monthlyCharges,
+ BigDecimal securityDeposit,
+ LeaseStatus status
+) {
+}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/LeaseResponse.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/LeaseResponse.java
new file mode 100644
index 0000000..af9e12c
--- /dev/null
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/LeaseResponse.java
@@ -0,0 +1,21 @@
+package fr.nessar.lmnporganizer.backend.dto;
+
+import fr.nessar.lmnporganizer.backend.model.LeaseStatus;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+
+public record LeaseResponse(
+ String id,
+ String apartmentId,
+ List tenantIds,
+ List guarantors,
+ LocalDate startDate,
+ LocalDate endDate,
+ BigDecimal monthlyRent,
+ BigDecimal monthlyCharges,
+ BigDecimal securityDeposit,
+ LeaseStatus status
+) {
+}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/TenantAttachmentResponse.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/TenantAttachmentResponse.java
new file mode 100644
index 0000000..72a13cf
--- /dev/null
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/TenantAttachmentResponse.java
@@ -0,0 +1,4 @@
+package fr.nessar.lmnporganizer.backend.dto;
+
+public record TenantAttachmentResponse(String id, String fileName, String contentType, long size) {
+}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/TenantRequest.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/TenantRequest.java
new file mode 100644
index 0000000..25d688a
--- /dev/null
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/TenantRequest.java
@@ -0,0 +1,13 @@
+package fr.nessar.lmnporganizer.backend.dto;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+
+public record TenantRequest(
+ @NotBlank(message = "Le prénom est obligatoire.") String firstName,
+ @NotBlank(message = "Le nom est obligatoire.") String lastName,
+ @NotBlank(message = "L'adresse e-mail est obligatoire.") @Email(message = "L'adresse e-mail est invalide.") String email,
+ String phone,
+ String address
+) {
+}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/TenantResponse.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/TenantResponse.java
new file mode 100644
index 0000000..85998e5
--- /dev/null
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/TenantResponse.java
@@ -0,0 +1,14 @@
+package fr.nessar.lmnporganizer.backend.dto;
+
+import java.util.List;
+
+public record TenantResponse(
+ String id,
+ String firstName,
+ String lastName,
+ String email,
+ String phone,
+ String address,
+ List attachments
+) {
+}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/UserResponse.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/UserResponse.java
new file mode 100644
index 0000000..31f9655
--- /dev/null
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/UserResponse.java
@@ -0,0 +1,4 @@
+package fr.nessar.lmnporganizer.backend.dto;
+
+public record UserResponse(boolean authenticated, String username, String email) {
+}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/mapper/ApartmentMapper.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/mapper/ApartmentMapper.java
new file mode 100644
index 0000000..2699f73
--- /dev/null
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/mapper/ApartmentMapper.java
@@ -0,0 +1,28 @@
+package fr.nessar.lmnporganizer.backend.mapper;
+
+import fr.nessar.lmnporganizer.backend.dto.ApartmentRequest;
+import fr.nessar.lmnporganizer.backend.dto.ApartmentResponse;
+import fr.nessar.lmnporganizer.backend.model.Apartment;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.MappingConstants;
+import org.mapstruct.MappingTarget;
+import org.mapstruct.ReportingPolicy;
+
+import java.util.List;
+
+@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.ERROR)
+public interface ApartmentMapper {
+
+ ApartmentResponse toResponse(Apartment apartment);
+
+ List toResponses(List apartments);
+
+ @Mapping(target = "id", ignore = true)
+ @Mapping(target = "bailleurId", ignore = true)
+ Apartment toEntity(ApartmentRequest request);
+
+ @Mapping(target = "id", ignore = true)
+ @Mapping(target = "bailleurId", ignore = true)
+ void update(ApartmentRequest request, @MappingTarget Apartment target);
+}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/mapper/LeaseMapper.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/mapper/LeaseMapper.java
new file mode 100644
index 0000000..94c2c68
--- /dev/null
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/mapper/LeaseMapper.java
@@ -0,0 +1,34 @@
+package fr.nessar.lmnporganizer.backend.mapper;
+
+import fr.nessar.lmnporganizer.backend.dto.GuarantorDto;
+import fr.nessar.lmnporganizer.backend.dto.LeaseRequest;
+import fr.nessar.lmnporganizer.backend.dto.LeaseResponse;
+import fr.nessar.lmnporganizer.backend.model.Guarantor;
+import fr.nessar.lmnporganizer.backend.model.Lease;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.MappingConstants;
+import org.mapstruct.MappingTarget;
+import org.mapstruct.ReportingPolicy;
+
+import java.util.List;
+
+@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.ERROR)
+public interface LeaseMapper {
+
+ LeaseResponse toResponse(Lease lease);
+
+ List toResponses(List leases);
+
+ GuarantorDto toDto(Guarantor guarantor);
+
+ Guarantor toEntity(GuarantorDto guarantor);
+
+ @Mapping(target = "id", ignore = true)
+ @Mapping(target = "landlordId", ignore = true)
+ Lease toEntity(LeaseRequest request);
+
+ @Mapping(target = "id", ignore = true)
+ @Mapping(target = "landlordId", ignore = true)
+ void update(LeaseRequest request, @MappingTarget Lease target);
+}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/mapper/TenantMapper.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/mapper/TenantMapper.java
new file mode 100644
index 0000000..924aeef
--- /dev/null
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/mapper/TenantMapper.java
@@ -0,0 +1,34 @@
+package fr.nessar.lmnporganizer.backend.mapper;
+
+import fr.nessar.lmnporganizer.backend.dto.TenantAttachmentResponse;
+import fr.nessar.lmnporganizer.backend.dto.TenantRequest;
+import fr.nessar.lmnporganizer.backend.dto.TenantResponse;
+import fr.nessar.lmnporganizer.backend.model.Tenant;
+import fr.nessar.lmnporganizer.backend.model.TenantAttachment;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.MappingConstants;
+import org.mapstruct.MappingTarget;
+import org.mapstruct.ReportingPolicy;
+
+import java.util.List;
+
+@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.ERROR)
+public interface TenantMapper {
+
+ TenantResponse toResponse(Tenant tenant);
+
+ List toResponses(List tenants);
+
+ TenantAttachmentResponse toResponse(TenantAttachment attachment);
+
+ @Mapping(target = "id", ignore = true)
+ @Mapping(target = "landlordId", ignore = true)
+ @Mapping(target = "attachments", ignore = true)
+ Tenant toEntity(TenantRequest request);
+
+ @Mapping(target = "id", ignore = true)
+ @Mapping(target = "landlordId", ignore = true)
+ @Mapping(target = "attachments", ignore = true)
+ void update(TenantRequest request, @MappingTarget Tenant target);
+}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/LeaseRepository.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/LeaseRepository.java
index db9d1a4..f0ebfeb 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/LeaseRepository.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/LeaseRepository.java
@@ -2,6 +2,8 @@ package fr.nessar.lmnporganizer.backend.repository;
import fr.nessar.lmnporganizer.backend.model.Lease;
import org.springframework.data.mongodb.repository.MongoRepository;
+import org.springframework.stereotype.Repository;
+@Repository
public interface LeaseRepository extends MongoRepository {
}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/TenantRepository.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/TenantRepository.java
index 3995cc3..bb81d9a 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/TenantRepository.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/TenantRepository.java
@@ -2,6 +2,8 @@ package fr.nessar.lmnporganizer.backend.repository;
import fr.nessar.lmnporganizer.backend.model.Tenant;
import org.springframework.data.mongodb.repository.MongoRepository;
+import org.springframework.stereotype.Repository;
+@Repository
public interface TenantRepository extends MongoRepository {
}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/ApartmentService.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/ApartmentService.java
new file mode 100644
index 0000000..bbe6f6e
--- /dev/null
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/ApartmentService.java
@@ -0,0 +1,54 @@
+package fr.nessar.lmnporganizer.backend.service;
+
+import fr.nessar.lmnporganizer.backend.dto.ApartmentRequest;
+import fr.nessar.lmnporganizer.backend.dto.ApartmentResponse;
+import fr.nessar.lmnporganizer.backend.mapper.ApartmentMapper;
+import fr.nessar.lmnporganizer.backend.model.Apartment;
+import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class ApartmentService {
+ private final ApartmentRepository apartmentRepository;
+ private final ApartmentMapper apartmentMapper;
+
+ public ApartmentService(ApartmentRepository apartmentRepository, ApartmentMapper apartmentMapper) {
+ this.apartmentRepository = apartmentRepository;
+ this.apartmentMapper = apartmentMapper;
+ }
+
+ public List getAll() {
+ return apartmentMapper.toResponses(apartmentRepository.findAll());
+ }
+
+ public ResponseEntity getById(String id) {
+ return apartmentRepository.findById(id).map(apartmentMapper::toResponse).map(ResponseEntity::ok)
+ .orElse(ResponseEntity.notFound().build());
+ }
+
+ public ResponseEntity create(ApartmentRequest request) {
+ Apartment apartment = apartmentMapper.toEntity(request);
+ apartment.setBailleurId("default-landlord");
+ return ResponseEntity.status(HttpStatus.CREATED).body(apartmentMapper.toResponse(apartmentRepository.save(apartment)));
+ }
+
+ public ResponseEntity update(String id, ApartmentRequest request) {
+ return apartmentRepository.findById(id).map(apartment -> {
+ apartmentMapper.update(request, apartment);
+ apartment.setBailleurId("default-landlord");
+ return ResponseEntity.ok(apartmentMapper.toResponse(apartmentRepository.save(apartment)));
+ }).orElse(ResponseEntity.notFound().build());
+ }
+
+ public ResponseEntity delete(String id) {
+ if (!apartmentRepository.existsById(id)) {
+ return ResponseEntity.notFound().build();
+ }
+ apartmentRepository.deleteById(id);
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/GreetingService.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/GreetingService.java
new file mode 100644
index 0000000..8afa87e
--- /dev/null
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/GreetingService.java
@@ -0,0 +1,15 @@
+package fr.nessar.lmnporganizer.backend.service;
+
+import org.springframework.security.oauth2.core.user.OAuth2User;
+import org.springframework.stereotype.Service;
+
+@Service
+public class GreetingService {
+ public String greetingFor(OAuth2User user) {
+ String name = user.getAttribute("preferred_username");
+ if (name == null) {
+ name = user.getAttribute("name");
+ }
+ return "Hello, " + (name == null ? user.getName() : name) + "!";
+ }
+}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeaseService.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeaseService.java
new file mode 100644
index 0000000..62dcb5e
--- /dev/null
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeaseService.java
@@ -0,0 +1,117 @@
+package fr.nessar.lmnporganizer.backend.service;
+
+import fr.nessar.lmnporganizer.backend.dto.LeaseRequest;
+import fr.nessar.lmnporganizer.backend.dto.LeaseResponse;
+import fr.nessar.lmnporganizer.backend.mapper.LeaseMapper;
+import fr.nessar.lmnporganizer.backend.model.Apartment;
+import fr.nessar.lmnporganizer.backend.model.Lease;
+import fr.nessar.lmnporganizer.backend.model.Tenant;
+import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository;
+import fr.nessar.lmnporganizer.backend.repository.LeaseRepository;
+import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
+import org.springframework.http.ContentDisposition;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+
+@Service
+public class LeaseService {
+ private final LeaseRepository leaseRepository;
+ private final ApartmentRepository apartmentRepository;
+ private final TenantRepository tenantRepository;
+ private final LeasePdfService leasePdfService;
+ private final PhoneNumberNormalizer phoneNumberNormalizer;
+ private final LeaseMapper leaseMapper;
+
+ public LeaseService(LeaseRepository leaseRepository, ApartmentRepository apartmentRepository,
+ TenantRepository tenantRepository, LeasePdfService leasePdfService,
+ PhoneNumberNormalizer phoneNumberNormalizer, LeaseMapper leaseMapper) {
+ this.leaseRepository = leaseRepository;
+ this.apartmentRepository = apartmentRepository;
+ this.tenantRepository = tenantRepository;
+ this.leasePdfService = leasePdfService;
+ this.phoneNumberNormalizer = phoneNumberNormalizer;
+ this.leaseMapper = leaseMapper;
+ }
+
+ public List getAll() {
+ return leaseMapper.toResponses(leaseRepository.findAll());
+ }
+
+ public ResponseEntity getById(String id) {
+ return leaseRepository.findById(id).map(leaseMapper::toResponse).map(ResponseEntity::ok)
+ .orElse(ResponseEntity.notFound().build());
+ }
+
+ public ResponseEntity create(LeaseRequest request) {
+ Lease lease = leaseMapper.toEntity(request);
+ if (!referencesExist(lease)) {
+ return ResponseEntity.badRequest().build();
+ }
+ lease.setLandlordId("default-landlord");
+ normalizeGuarantorPhones(lease);
+ return ResponseEntity.status(HttpStatus.CREATED).body(leaseMapper.toResponse(leaseRepository.save(lease)));
+ }
+
+ public ResponseEntity update(String id, LeaseRequest request) {
+ Lease details = leaseMapper.toEntity(request);
+ if (!referencesExist(details)) {
+ return ResponseEntity.badRequest().build();
+ }
+ return leaseRepository.findById(id).map(lease -> {
+ leaseMapper.update(request, lease);
+ lease.setLandlordId("default-landlord");
+ normalizeGuarantorPhones(lease);
+ return ResponseEntity.ok(leaseMapper.toResponse(leaseRepository.save(lease)));
+ }).orElse(ResponseEntity.notFound().build());
+ }
+
+ public ResponseEntity delete(String id) {
+ if (!leaseRepository.existsById(id)) {
+ return ResponseEntity.notFound().build();
+ }
+ leaseRepository.deleteById(id);
+ return ResponseEntity.noContent().build();
+ }
+
+ public ResponseEntity downloadPdf(String id) {
+ Lease lease = leaseRepository.findById(id).orElse(null);
+ if (lease == null) {
+ return ResponseEntity.notFound().build();
+ }
+ Apartment apartment = apartmentRepository.findById(lease.getApartmentId()).orElse(null);
+ List tenants = tenantsFor(lease.getTenantIds());
+ if (apartment == null || tenants.size() != new HashSet<>(lease.getTenantIds()).size()) {
+ return ResponseEntity.notFound().build();
+ }
+ return ResponseEntity.ok()
+ .contentType(MediaType.APPLICATION_PDF)
+ .header(HttpHeaders.CONTENT_DISPOSITION,
+ ContentDisposition.attachment().filename("bail-" + id + ".pdf").build().toString())
+ .body(leasePdfService.generate(lease, apartment, tenants));
+ }
+
+ private boolean referencesExist(Lease lease) {
+ if (lease.getApartmentId() == null || !apartmentRepository.existsById(lease.getApartmentId())
+ || lease.getTenantIds().isEmpty()) {
+ return false;
+ }
+ return tenantsFor(lease.getTenantIds()).size() == new HashSet<>(lease.getTenantIds()).size();
+ }
+
+ private List tenantsFor(List ids) {
+ List tenants = new ArrayList<>();
+ tenantRepository.findAllById(ids).forEach(tenants::add);
+ return tenants;
+ }
+
+ 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/service/TenantService.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/TenantService.java
new file mode 100644
index 0000000..e58c0bd
--- /dev/null
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/TenantService.java
@@ -0,0 +1,122 @@
+package fr.nessar.lmnporganizer.backend.service;
+
+import fr.nessar.lmnporganizer.backend.dto.TenantAttachmentResponse;
+import fr.nessar.lmnporganizer.backend.dto.TenantRequest;
+import fr.nessar.lmnporganizer.backend.dto.TenantResponse;
+import fr.nessar.lmnporganizer.backend.mapper.TenantMapper;
+import fr.nessar.lmnporganizer.backend.model.Tenant;
+import fr.nessar.lmnporganizer.backend.model.TenantAttachment;
+import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
+import org.springframework.core.io.Resource;
+import org.springframework.http.ContentDisposition;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.util.List;
+
+@Service
+public class TenantService {
+ private final TenantRepository tenantRepository;
+ private final TenantAttachmentStorage attachmentStorage;
+ private final AttachmentUploadPolicy attachmentUploadPolicy;
+ private final PhoneNumberNormalizer phoneNumberNormalizer;
+ private final TenantMapper tenantMapper;
+
+ public TenantService(TenantRepository tenantRepository, TenantAttachmentStorage attachmentStorage,
+ AttachmentUploadPolicy attachmentUploadPolicy, PhoneNumberNormalizer phoneNumberNormalizer,
+ TenantMapper tenantMapper) {
+ this.tenantRepository = tenantRepository;
+ this.attachmentStorage = attachmentStorage;
+ this.attachmentUploadPolicy = attachmentUploadPolicy;
+ this.phoneNumberNormalizer = phoneNumberNormalizer;
+ this.tenantMapper = tenantMapper;
+ }
+
+ public List getAll() {
+ return tenantMapper.toResponses(tenantRepository.findAll());
+ }
+
+ public ResponseEntity getById(String id) {
+ return tenantRepository.findById(id).map(tenantMapper::toResponse).map(ResponseEntity::ok)
+ .orElse(ResponseEntity.notFound().build());
+ }
+
+ public ResponseEntity create(TenantRequest request) {
+ Tenant tenant = tenantMapper.toEntity(request);
+ tenant.setLandlordId("default-landlord");
+ tenant.setPhone(phoneNumberNormalizer.normalize(tenant.getPhone()));
+ return ResponseEntity.status(HttpStatus.CREATED).body(tenantMapper.toResponse(tenantRepository.save(tenant)));
+ }
+
+ public ResponseEntity update(String id, TenantRequest request) {
+ return tenantRepository.findById(id).map(tenant -> {
+ tenantMapper.update(request, tenant);
+ tenant.setPhone(phoneNumberNormalizer.normalize(request.phone()));
+ tenant.setLandlordId("default-landlord");
+ return ResponseEntity.ok(tenantMapper.toResponse(tenantRepository.save(tenant)));
+ }).orElse(ResponseEntity.notFound().build());
+ }
+
+ public ResponseEntity delete(String id) {
+ if (!tenantRepository.existsById(id)) {
+ return ResponseEntity.notFound().build();
+ }
+ tenantRepository.deleteById(id);
+ return ResponseEntity.noContent().build();
+ }
+
+ public ResponseEntity uploadAttachment(String tenantId, MultipartFile file) throws IOException {
+ Tenant tenant = tenantRepository.findById(tenantId).orElse(null);
+ if (tenant == null) {
+ return ResponseEntity.notFound().build();
+ }
+ attachmentUploadPolicy.validate(file);
+ TenantAttachment attachment = attachmentStorage.store(tenantId, file);
+ tenant.getAttachments().add(attachment);
+ tenantRepository.save(tenant);
+ return ResponseEntity.status(HttpStatus.CREATED).body(tenantMapper.toResponse(attachment));
+ }
+
+ public ResponseEntity downloadAttachment(String tenantId, String attachmentId) throws IOException {
+ TenantAttachment attachment = tenantRepository.findById(tenantId)
+ .flatMap(tenant -> tenant.getAttachments().stream().filter(item -> item.getId().equals(attachmentId)).findFirst())
+ .orElse(null);
+ if (attachment == null) {
+ return ResponseEntity.notFound().build();
+ }
+ return ResponseEntity.ok()
+ .contentType(mediaTypeFor(attachment))
+ .header(HttpHeaders.CONTENT_DISPOSITION,
+ ContentDisposition.attachment().filename(attachment.getFileName()).build().toString())
+ .body(attachmentStorage.read(attachment));
+ }
+
+ public ResponseEntity deleteAttachment(String tenantId, String attachmentId) throws IOException {
+ Tenant tenant = tenantRepository.findById(tenantId).orElse(null);
+ if (tenant == null) {
+ return ResponseEntity.notFound().build();
+ }
+ TenantAttachment attachment = tenant.getAttachments().stream()
+ .filter(item -> item.getId().equals(attachmentId)).findFirst().orElse(null);
+ if (attachment == null) {
+ return ResponseEntity.notFound().build();
+ }
+ attachmentStorage.delete(attachment);
+ tenant.getAttachments().remove(attachment);
+ tenantRepository.save(tenant);
+ return ResponseEntity.noContent().build();
+ }
+
+ private MediaType mediaTypeFor(TenantAttachment attachment) {
+ try {
+ return MediaType.parseMediaType(attachment.getContentType());
+ } catch (IllegalArgumentException exception) {
+ return MediaType.APPLICATION_OCTET_STREAM;
+ }
+ }
+}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/UserService.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/UserService.java
new file mode 100644
index 0000000..7dbf223
--- /dev/null
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/UserService.java
@@ -0,0 +1,17 @@
+package fr.nessar.lmnporganizer.backend.service;
+
+import fr.nessar.lmnporganizer.backend.dto.UserResponse;
+import org.springframework.security.oauth2.core.oidc.user.OidcUser;
+import org.springframework.stereotype.Service;
+
+@Service
+public class UserService {
+ public UserResponse getUser(OidcUser principal) {
+ if (principal == null) {
+ return new UserResponse(false, null, null);
+ }
+ String username = principal.getPreferredUsername() != null ? principal.getPreferredUsername() : principal.getName();
+ String email = principal.getEmail() != null ? principal.getEmail() : "";
+ return new UserResponse(true, username, email);
+ }
+}
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/ApartmentControllerTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/ApartmentControllerTest.java
index ac969f2..4fb00d1 100644
--- a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/ApartmentControllerTest.java
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/ApartmentControllerTest.java
@@ -77,7 +77,7 @@ class ApartmentControllerTest {
"{\"name\":\"New Apartment\",\"address\":\"Address\",\"purchasePrice\":200000.0,\"description\":\"Desc\"}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value("1"))
- .andExpect(jsonPath("$.bailleurId").value("default-landlord"));
+ .andExpect(jsonPath("$.bailleurId").doesNotExist());
}
@Test
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 f607733..cfb1227 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,7 @@ class LeaseControllerTest {
.content(leaseJson()))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value("lease-1"))
- .andExpect(jsonPath("$.landlordId").value("default-landlord"))
+ .andExpect(jsonPath("$.landlordId").doesNotExist())
.andExpect(jsonPath("$.apartmentId").value("apartment-1"))
.andExpect(jsonPath("$.tenantIds[0]").value("tenant-1"))
.andExpect(jsonPath("$.guarantors[0].lastName").value("Durand"))
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 bf86fc7..ddc4603 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
@@ -58,7 +58,7 @@ class TenantControllerTest {
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value("tenant-1"))
- .andExpect(jsonPath("$.landlordId").value("default-landlord"));
+ .andExpect(jsonPath("$.landlordId").doesNotExist());
}
@Test
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/mapper/UpdateMapperTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/mapper/UpdateMapperTest.java
new file mode 100644
index 0000000..6ac5bc5
--- /dev/null
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/mapper/UpdateMapperTest.java
@@ -0,0 +1,63 @@
+package fr.nessar.lmnporganizer.backend.mapper;
+
+import fr.nessar.lmnporganizer.backend.dto.ApartmentRequest;
+import fr.nessar.lmnporganizer.backend.dto.LeaseRequest;
+import fr.nessar.lmnporganizer.backend.dto.TenantRequest;
+import fr.nessar.lmnporganizer.backend.model.Apartment;
+import fr.nessar.lmnporganizer.backend.model.Lease;
+import fr.nessar.lmnporganizer.backend.model.LeaseStatus;
+import fr.nessar.lmnporganizer.backend.model.Tenant;
+import fr.nessar.lmnporganizer.backend.model.TenantAttachment;
+import org.junit.jupiter.api.Test;
+import org.mapstruct.factory.Mappers;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class UpdateMapperTest {
+ @Test
+ void preservesApartmentIdentityWhenUpdatingItsDetails() {
+ Apartment target = new Apartment("apartment-1", "default-landlord", "Old", "Old", 1, "Old");
+ ApartmentRequest source = new ApartmentRequest("New", "New", 2, "New");
+
+ Mappers.getMapper(ApartmentMapper.class).update(source, target);
+
+ assertEquals("apartment-1", target.getId());
+ assertEquals("default-landlord", target.getBailleurId());
+ assertEquals("New", target.getName());
+ }
+
+ @Test
+ void preservesTenantOwnershipAndAttachmentsWhenUpdatingContactDetails() {
+ Tenant target = new Tenant("tenant-1", "default-landlord", "Old", "Name", "old@example.com", "", "");
+ target.setAttachments(List.of(new TenantAttachment("attachment-1", "id.pdf", "application/pdf", 1, "id.pdf")));
+ TenantRequest source = new TenantRequest("New", "Name", "new@example.com", "", "");
+
+ Mappers.getMapper(TenantMapper.class).update(source, target);
+
+ assertEquals("tenant-1", target.getId());
+ assertEquals("default-landlord", target.getLandlordId());
+ assertEquals(1, target.getAttachments().size());
+ assertEquals("new@example.com", target.getEmail());
+ }
+
+ @Test
+ void preservesLeaseIdentityAndOwnershipWhenUpdatingTerms() {
+ Lease target = new Lease("lease-1", "default-landlord", "old-apartment", List.of(), List.of(),
+ LocalDate.of(2026, 1, 1), LocalDate.of(2027, 1, 1), BigDecimal.ONE, BigDecimal.ZERO,
+ BigDecimal.ONE, LeaseStatus.DRAFT);
+ LeaseRequest source = new LeaseRequest("new-apartment", List.of("tenant-1"), List.of(),
+ LocalDate.of(2026, 2, 1), LocalDate.of(2027, 2, 1), BigDecimal.TEN, BigDecimal.ONE,
+ BigDecimal.TEN, LeaseStatus.ACTIVE);
+
+ Mappers.getMapper(LeaseMapper.class).update(source, target);
+
+ assertEquals("lease-1", target.getId());
+ assertEquals("default-landlord", target.getLandlordId());
+ assertEquals("new-apartment", target.getApartmentId());
+ assertEquals(LeaseStatus.ACTIVE, target.getStatus());
+ }
+}
diff --git a/frontend/src/app/services/apartment.service.ts b/frontend/src/app/services/apartment.service.ts
index 7b50817..bbceb68 100644
--- a/frontend/src/app/services/apartment.service.ts
+++ b/frontend/src/app/services/apartment.service.ts
@@ -4,7 +4,6 @@ import { Observable } from 'rxjs';
export interface Apartment {
id?: string;
- bailleurId?: string;
name: string;
address: string;
purchasePrice: number;
diff --git a/frontend/src/app/services/lease.service.ts b/frontend/src/app/services/lease.service.ts
index 27894f2..bb9ac54 100644
--- a/frontend/src/app/services/lease.service.ts
+++ b/frontend/src/app/services/lease.service.ts
@@ -13,7 +13,6 @@ export interface Guarantor {
export interface Lease {
id?: string;
- landlordId?: string;
apartmentId: string;
tenantIds: string[];
guarantors: Guarantor[];
diff --git a/frontend/src/app/services/session.service.ts b/frontend/src/app/services/session.service.ts
index 27e0426..d26be33 100644
--- a/frontend/src/app/services/session.service.ts
+++ b/frontend/src/app/services/session.service.ts
@@ -1,6 +1,12 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
+interface UserResponse {
+ authenticated: boolean;
+ username?: string;
+ email?: string;
+}
+
@Injectable({ providedIn: 'root' })
export class SessionService {
private readonly http = inject(HttpClient);
@@ -9,7 +15,7 @@ export class SessionService {
readonly email = signal('');
refresh(): void {
- this.http.get<{ authenticated: boolean; username?: string; email?: string }>('/api/user').subscribe({
+ this.http.get('/api/user').subscribe({
next: (user) => {
this.isLoggedIn.set(user.authenticated);
this.username.set(user.authenticated ? (user.username || 'User') : '');
diff --git a/frontend/src/app/services/tenant.service.ts b/frontend/src/app/services/tenant.service.ts
index 9e9738d..1a00c1f 100644
--- a/frontend/src/app/services/tenant.service.ts
+++ b/frontend/src/app/services/tenant.service.ts
@@ -11,7 +11,6 @@ export interface TenantAttachment {
export interface Tenant {
id?: string;
- landlordId?: string;
firstName: string;
lastName: string;
email: string;