From 9f82c403098bb96acd8b7116f84416d3b4643b57 Mon Sep 17 00:00:00 2001 From: lucashemi Date: Fri, 24 Feb 2023 16:14:01 -0300 Subject: api-appointments --- api/pom.xml | 6 ++ .../voll/api/controller/AppointmentController.java | 34 +++++++ .../api/controller/AuthenticationController.java | 3 +- .../med/voll/api/controller/DoctorController.java | 2 + .../med/voll/api/controller/PatientController.java | 2 + .../voll/api/domain/appointment/Appointment.java | 47 +++++++++ .../appointment/AppointmentDeletionData.java | 11 +++ .../domain/appointment/AppointmentListingData.java | 10 ++ .../appointment/AppointmentRegistrationData.java | 17 ++++ .../domain/appointment/AppointmentRepository.java | 18 ++++ .../domain/appointment/AppointmentUpdateData.java | 19 ++++ .../domain/appointment/AppointmentsSchedule.java | 75 +++++++++++++++ .../domain/appointment/ReasonForCancellation.java | 8 ++ .../AppointmentCancellationValidator.java | 7 ++ .../cancellation/ValidatesTimeInAdvance.java | 27 ++++++ .../scheduling/AppointmentSchedulingValidator.java | 7 ++ .../scheduling/ValidatesActiveDoctor.java | 26 +++++ .../scheduling/ValidatesActivePatient.java | 21 ++++ .../scheduling/ValidatesDateAndTime.java | 21 ++++ ...tesDoctorWithOtherAppointmentAtTheSameTime.java | 21 ++++ ...esPatientDoesntHaveAnotherAppointmentToday.java | 24 +++++ .../scheduling/ValidatesTimeInAdvance.java | 21 ++++ .../voll/api/domain/doctor/DoctorRepository.java | 20 ++++ .../voll/api/domain/patient/PatientRepository.java | 7 ++ .../voll/api/infra/exception/ErrorTreatment.java | 5 + .../api/infra/security/SecurityConfigurations.java | 1 + .../med/voll/api/infra/security/TokenService.java | 2 +- .../infra/springdoc/SpringDocConfigurations.java | 34 +++++++ api/src/main/resources/application-prod.properties | 4 + api/src/main/resources/application-test.properties | 1 + .../db/migration/V7__create-table-appointments.sql | 12 +++ ...intments-add-column-reason-for-cancellation.sql | 1 + ..._alter-table-appointments-add-column-active.sql | 1 + .../java/med/voll/api/ApiApplicationTests.java | 13 --- .../api/controller/AppointmentControllerTest.java | 82 ++++++++++++++++ .../voll/api/controller/DoctorControllerTest.java | 77 +++++++++++++++ .../api/domain/doctor/DoctorRepositoryTest.java | 107 +++++++++++++++++++++ 37 files changed, 779 insertions(+), 15 deletions(-) create mode 100644 api/src/main/java/med/voll/api/controller/AppointmentController.java create mode 100644 api/src/main/java/med/voll/api/domain/appointment/Appointment.java create mode 100644 api/src/main/java/med/voll/api/domain/appointment/AppointmentDeletionData.java create mode 100644 api/src/main/java/med/voll/api/domain/appointment/AppointmentListingData.java create mode 100644 api/src/main/java/med/voll/api/domain/appointment/AppointmentRegistrationData.java create mode 100644 api/src/main/java/med/voll/api/domain/appointment/AppointmentRepository.java create mode 100644 api/src/main/java/med/voll/api/domain/appointment/AppointmentUpdateData.java create mode 100644 api/src/main/java/med/voll/api/domain/appointment/AppointmentsSchedule.java create mode 100644 api/src/main/java/med/voll/api/domain/appointment/ReasonForCancellation.java create mode 100644 api/src/main/java/med/voll/api/domain/appointment/validations/cancellation/AppointmentCancellationValidator.java create mode 100644 api/src/main/java/med/voll/api/domain/appointment/validations/cancellation/ValidatesTimeInAdvance.java create mode 100644 api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/AppointmentSchedulingValidator.java create mode 100644 api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/ValidatesActiveDoctor.java create mode 100644 api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/ValidatesActivePatient.java create mode 100644 api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/ValidatesDateAndTime.java create mode 100644 api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/ValidatesDoctorWithOtherAppointmentAtTheSameTime.java create mode 100644 api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/ValidatesPatientDoesntHaveAnotherAppointmentToday.java create mode 100644 api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/ValidatesTimeInAdvance.java create mode 100644 api/src/main/java/med/voll/api/infra/springdoc/SpringDocConfigurations.java create mode 100644 api/src/main/resources/application-prod.properties create mode 100644 api/src/main/resources/application-test.properties create mode 100644 api/src/main/resources/db/migration/V7__create-table-appointments.sql create mode 100644 api/src/main/resources/db/migration/V8__alter-table-appointments-add-column-reason-for-cancellation.sql create mode 100644 api/src/main/resources/db/migration/V9__alter-table-appointments-add-column-active.sql delete mode 100644 api/src/test/java/med/voll/api/ApiApplicationTests.java create mode 100644 api/src/test/java/med/voll/api/controller/AppointmentControllerTest.java create mode 100644 api/src/test/java/med/voll/api/controller/DoctorControllerTest.java create mode 100644 api/src/test/java/med/voll/api/domain/doctor/DoctorRepositoryTest.java (limited to 'api') diff --git a/api/pom.xml b/api/pom.xml index d7bb09b..6b3a430 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -59,6 +59,12 @@ java-jwt 4.2.1 + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.0.2 + + org.springframework.boot diff --git a/api/src/main/java/med/voll/api/controller/AppointmentController.java b/api/src/main/java/med/voll/api/controller/AppointmentController.java new file mode 100644 index 0000000..e5a2ba4 --- /dev/null +++ b/api/src/main/java/med/voll/api/controller/AppointmentController.java @@ -0,0 +1,34 @@ +package med.voll.api.controller; + +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.validation.Valid; +import med.voll.api.domain.appointment.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.util.UriComponentsBuilder; + +@RestController +@RequestMapping("/appointments") +@SecurityRequirement(name = "bearer-key") +public class AppointmentController { + @Autowired + private AppointmentsSchedule appointmentsSchedule; + + @PostMapping + @Transactional + public ResponseEntity register(@RequestBody @Valid AppointmentRegistrationData data, UriComponentsBuilder uriBuilder) { + var dto = appointmentsSchedule.schedule(data); + return ResponseEntity.ok(dto); + } + + @DeleteMapping + @Transactional + public ResponseEntity cancel(@RequestBody @Valid AppointmentDeletionData data) { + appointmentsSchedule.cancel(data); + return ResponseEntity.noContent().build(); + } + + +} diff --git a/api/src/main/java/med/voll/api/controller/AuthenticationController.java b/api/src/main/java/med/voll/api/controller/AuthenticationController.java index b7dcf27..f86fc02 100644 --- a/api/src/main/java/med/voll/api/controller/AuthenticationController.java +++ b/api/src/main/java/med/voll/api/controller/AuthenticationController.java @@ -14,8 +14,9 @@ import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; -@Controller +@RestController @RequestMapping("/login") public class AuthenticationController { diff --git a/api/src/main/java/med/voll/api/controller/DoctorController.java b/api/src/main/java/med/voll/api/controller/DoctorController.java index a262d34..f87da56 100644 --- a/api/src/main/java/med/voll/api/controller/DoctorController.java +++ b/api/src/main/java/med/voll/api/controller/DoctorController.java @@ -1,5 +1,6 @@ package med.voll.api.controller; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import jakarta.validation.Valid; import med.voll.api.domain.doctor.DoctorListingData; import med.voll.api.domain.doctor.Doctor; @@ -17,6 +18,7 @@ import org.springframework.web.util.UriComponentsBuilder; @RestController @RequestMapping("/doctors") +@SecurityRequirement(name = "bearer-key") public class DoctorController { @Autowired diff --git a/api/src/main/java/med/voll/api/controller/PatientController.java b/api/src/main/java/med/voll/api/controller/PatientController.java index bd774a1..5675cfb 100644 --- a/api/src/main/java/med/voll/api/controller/PatientController.java +++ b/api/src/main/java/med/voll/api/controller/PatientController.java @@ -1,5 +1,6 @@ package med.voll.api.controller; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import jakarta.validation.Valid; import med.voll.api.domain.patient.PatientListingData; import med.voll.api.domain.patient.Patient; @@ -17,6 +18,7 @@ import org.springframework.web.util.UriComponentsBuilder; @RestController @RequestMapping("/patients") +@SecurityRequirement(name = "bearer-key") public class PatientController { @Autowired PatientRepository patientRepository; diff --git a/api/src/main/java/med/voll/api/domain/appointment/Appointment.java b/api/src/main/java/med/voll/api/domain/appointment/Appointment.java new file mode 100644 index 0000000..a48c15f --- /dev/null +++ b/api/src/main/java/med/voll/api/domain/appointment/Appointment.java @@ -0,0 +1,47 @@ +package med.voll.api.domain.appointment; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import med.voll.api.domain.doctor.Doctor; +import med.voll.api.domain.patient.Patient; + +import java.time.LocalDateTime; + +@Table(name = "appointments") +@Entity(name = "Appointment") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(of = "id") +public class Appointment { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private LocalDateTime date; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "doctor_id") + private Doctor doctor; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "patient_id") + private Patient patient; + private Boolean active = true; + + @Column(name = "reason_for_cancellation") + @Enumerated(EnumType.STRING) + private ReasonForCancellation reasonForCancellation; + + public Appointment(Long id, LocalDateTime date, Doctor doctor, Patient patient) { + this.id = id; + this.date = date; + this.doctor = doctor; + this.patient = patient; + } + + public void cancel(ReasonForCancellation reasonForCancellation) { + this.reasonForCancellation = reasonForCancellation; + this.active = false; + } +} diff --git a/api/src/main/java/med/voll/api/domain/appointment/AppointmentDeletionData.java b/api/src/main/java/med/voll/api/domain/appointment/AppointmentDeletionData.java new file mode 100644 index 0000000..81dc28e --- /dev/null +++ b/api/src/main/java/med/voll/api/domain/appointment/AppointmentDeletionData.java @@ -0,0 +1,11 @@ +package med.voll.api.domain.appointment; + +import jakarta.validation.constraints.NotNull; + +public record AppointmentDeletionData( + @NotNull + Long idAppointment, + @NotNull + ReasonForCancellation reasonForCancellation +) { +} diff --git a/api/src/main/java/med/voll/api/domain/appointment/AppointmentListingData.java b/api/src/main/java/med/voll/api/domain/appointment/AppointmentListingData.java new file mode 100644 index 0000000..4070a4c --- /dev/null +++ b/api/src/main/java/med/voll/api/domain/appointment/AppointmentListingData.java @@ -0,0 +1,10 @@ +package med.voll.api.domain.appointment; + +import java.time.LocalDateTime; + +public record AppointmentListingData(Long id, LocalDateTime date, Long idDoctor, Long idPatient) { + + public AppointmentListingData(Appointment appointment) { + this(appointment.getId(), appointment.getDate(), appointment.getDoctor().getId(), appointment.getPatient().getId()); + } +} diff --git a/api/src/main/java/med/voll/api/domain/appointment/AppointmentRegistrationData.java b/api/src/main/java/med/voll/api/domain/appointment/AppointmentRegistrationData.java new file mode 100644 index 0000000..fa62bde --- /dev/null +++ b/api/src/main/java/med/voll/api/domain/appointment/AppointmentRegistrationData.java @@ -0,0 +1,17 @@ +package med.voll.api.domain.appointment; + +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.NotNull; +import med.voll.api.domain.doctor.Specialty; + +import java.time.LocalDateTime; + +public record AppointmentRegistrationData( + Long idDoctor, + @NotNull(message = "Patient is required") + Long idPatient, + @NotNull + @Future + LocalDateTime date, + Specialty specialty) { +} diff --git a/api/src/main/java/med/voll/api/domain/appointment/AppointmentRepository.java b/api/src/main/java/med/voll/api/domain/appointment/AppointmentRepository.java new file mode 100644 index 0000000..a56e3c7 --- /dev/null +++ b/api/src/main/java/med/voll/api/domain/appointment/AppointmentRepository.java @@ -0,0 +1,18 @@ +package med.voll.api.domain.appointment; + +import med.voll.api.domain.doctor.Doctor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; + +@Repository +public interface AppointmentRepository extends JpaRepository { + Page findAllByActiveTrue(Pageable pagination); + + boolean existsByDoctorIdAndDateAndActiveIsTrue(Long idDoctor, LocalDateTime date); + + boolean existsByPatientIdAndDateBetween(Long idPatient, LocalDateTime earliestTime, LocalDateTime latestTime); +} diff --git a/api/src/main/java/med/voll/api/domain/appointment/AppointmentUpdateData.java b/api/src/main/java/med/voll/api/domain/appointment/AppointmentUpdateData.java new file mode 100644 index 0000000..c13712c --- /dev/null +++ b/api/src/main/java/med/voll/api/domain/appointment/AppointmentUpdateData.java @@ -0,0 +1,19 @@ +package med.voll.api.domain.appointment; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDateTime; + +public record AppointmentUpdateData( + @NotNull + Long id, + Integer year, + Integer month, + Integer day, + Integer hour, + Integer minute, + String doctor, + String patient +) { +} diff --git a/api/src/main/java/med/voll/api/domain/appointment/AppointmentsSchedule.java b/api/src/main/java/med/voll/api/domain/appointment/AppointmentsSchedule.java new file mode 100644 index 0000000..fd7d4e8 --- /dev/null +++ b/api/src/main/java/med/voll/api/domain/appointment/AppointmentsSchedule.java @@ -0,0 +1,75 @@ +package med.voll.api.domain.appointment; + +import jakarta.validation.ValidationException; +import med.voll.api.domain.appointment.validations.cancellation.AppointmentCancellationValidator; +import med.voll.api.domain.appointment.validations.scheduling.AppointmentSchedulingValidator; +import med.voll.api.domain.doctor.Doctor; +import med.voll.api.domain.doctor.DoctorRepository; +import med.voll.api.domain.patient.PatientRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class AppointmentsSchedule { + @Autowired + private AppointmentRepository appointmentRepository; + @Autowired + private DoctorRepository doctorRepository; + @Autowired + private PatientRepository patientRepository; + @Autowired + private List validators; + + @Autowired + private List cancellationValidators; + + public AppointmentListingData schedule(AppointmentRegistrationData data) { + if (!patientRepository.existsById(data.idPatient())) { + throw new ValidationException("Patient id doesn't exist"); + } + + if (data.idDoctor() != null && !doctorRepository.existsById(data.idDoctor())) { + throw new ValidationException("Doctor id doesn't exist"); + } + + validators.forEach(v -> v.validate(data)); + + var doctor = chooseDoctor(data); + if (doctor == null) { + throw new ValidationException("There are no available doctors on this date"); + } + var patient = patientRepository.getReferenceById(data.idPatient()); + + var appointment = new Appointment(null, data.date(), doctor, patient, true, null); + + appointmentRepository.save(appointment); + + return new AppointmentListingData(appointment); + } + + public void cancel(AppointmentDeletionData data) { + if (!appointmentRepository.existsById(data.idAppointment())) { + throw new ValidationException("Invalid appointment id"); + } + + cancellationValidators.forEach(v -> v.validate(data)); + + var appointment = appointmentRepository.getReferenceById(data.idAppointment()); + appointment.cancel(data.reasonForCancellation()); + } + + private Doctor chooseDoctor(AppointmentRegistrationData data) { + if(data.idDoctor() != null) { + return doctorRepository.getReferenceById(data.idDoctor()); + } + + if (data.specialty() == null) { + throw new ValidationException("Specialty is mandatory when a doctor is not chosen"); + } + + return doctorRepository.chooseRandomDoctorAvailable(data.specialty(), data.date()); + } + +} diff --git a/api/src/main/java/med/voll/api/domain/appointment/ReasonForCancellation.java b/api/src/main/java/med/voll/api/domain/appointment/ReasonForCancellation.java new file mode 100644 index 0000000..db6e98a --- /dev/null +++ b/api/src/main/java/med/voll/api/domain/appointment/ReasonForCancellation.java @@ -0,0 +1,8 @@ +package med.voll.api.domain.appointment; + +public enum ReasonForCancellation { + PATIENT_GAVE_UP, + LONG_WAITING_TIME, + DOCTOR_CANCELED, + OTHER_REASON +} diff --git a/api/src/main/java/med/voll/api/domain/appointment/validations/cancellation/AppointmentCancellationValidator.java b/api/src/main/java/med/voll/api/domain/appointment/validations/cancellation/AppointmentCancellationValidator.java new file mode 100644 index 0000000..6c9e0cc --- /dev/null +++ b/api/src/main/java/med/voll/api/domain/appointment/validations/cancellation/AppointmentCancellationValidator.java @@ -0,0 +1,7 @@ +package med.voll.api.domain.appointment.validations.cancellation; + +import med.voll.api.domain.appointment.AppointmentDeletionData; + +public interface AppointmentCancellationValidator { + void validate(AppointmentDeletionData data); +} diff --git a/api/src/main/java/med/voll/api/domain/appointment/validations/cancellation/ValidatesTimeInAdvance.java b/api/src/main/java/med/voll/api/domain/appointment/validations/cancellation/ValidatesTimeInAdvance.java new file mode 100644 index 0000000..03688ce --- /dev/null +++ b/api/src/main/java/med/voll/api/domain/appointment/validations/cancellation/ValidatesTimeInAdvance.java @@ -0,0 +1,27 @@ +package med.voll.api.domain.appointment.validations.cancellation; + +import jakarta.validation.ValidationException; +import med.voll.api.domain.appointment.AppointmentDeletionData; +import med.voll.api.domain.appointment.AppointmentRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.LocalDateTime; + +@Component("ValidatesTimeInAdvanceCancellation") +public class ValidatesTimeInAdvance implements AppointmentCancellationValidator { + @Autowired + private AppointmentRepository appointmentRepository; + + @Override + public void validate(AppointmentDeletionData data) { + var appointment = appointmentRepository.getReferenceById(data.idAppointment()); + var now = LocalDateTime.now(); + var differenceInHours = Duration.between(now, appointment.getDate()).toHours(); + + if (differenceInHours < 24) { + throw new ValidationException("Appointments can only be cancelled with at least 24 hours in advance!"); + } + } +} diff --git a/api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/AppointmentSchedulingValidator.java b/api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/AppointmentSchedulingValidator.java new file mode 100644 index 0000000..99f6128 --- /dev/null +++ b/api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/AppointmentSchedulingValidator.java @@ -0,0 +1,7 @@ +package med.voll.api.domain.appointment.validations.scheduling; + +import med.voll.api.domain.appointment.AppointmentRegistrationData; + +public interface AppointmentSchedulingValidator { + void validate(AppointmentRegistrationData data); +} diff --git a/api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/ValidatesActiveDoctor.java b/api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/ValidatesActiveDoctor.java new file mode 100644 index 0000000..59bb761 --- /dev/null +++ b/api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/ValidatesActiveDoctor.java @@ -0,0 +1,26 @@ +package med.voll.api.domain.appointment.validations.scheduling; + +import jakarta.validation.ValidationException; +import med.voll.api.domain.appointment.AppointmentRegistrationData; +import med.voll.api.domain.doctor.DoctorRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class ValidatesActiveDoctor implements AppointmentSchedulingValidator { + + @Autowired + private DoctorRepository doctorRepository; + + public void validate(AppointmentRegistrationData data) { + // random doctor + if (data.idDoctor() == null) { + return; + } + + var doctorIsActive = doctorRepository.findActiveById(data.idDoctor()); + if (!doctorIsActive) { + throw new ValidationException("Appointment can't be scheduled with an inactive doctor"); + } + } +} diff --git a/api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/ValidatesActivePatient.java b/api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/ValidatesActivePatient.java new file mode 100644 index 0000000..439877f --- /dev/null +++ b/api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/ValidatesActivePatient.java @@ -0,0 +1,21 @@ +package med.voll.api.domain.appointment.validations.scheduling; + +import jakarta.validation.ValidationException; +import med.voll.api.domain.appointment.AppointmentRegistrationData; +import med.voll.api.domain.patient.PatientRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class ValidatesActivePatient implements AppointmentSchedulingValidator { + + @Autowired + private PatientRepository patientRepository; + + public void validate(AppointmentRegistrationData data) { + var patientIsActive = patientRepository.findActiveById(data.idPatient()); + if (!patientIsActive) { + throw new ValidationException("Appointment can't be scheduled with an inactive patient"); + } + } +} diff --git a/api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/ValidatesDateAndTime.java b/api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/ValidatesDateAndTime.java new file mode 100644 index 0000000..beee6c6 --- /dev/null +++ b/api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/ValidatesDateAndTime.java @@ -0,0 +1,21 @@ +package med.voll.api.domain.appointment.validations.scheduling; + +import jakarta.validation.ValidationException; +import med.voll.api.domain.appointment.AppointmentRegistrationData; +import org.springframework.stereotype.Component; + +import java.time.DayOfWeek; + +@Component +public class ValidatesDateAndTime implements AppointmentSchedulingValidator { + public void validate(AppointmentRegistrationData data) { + var appointmentDate = data.date(); + var sunday = appointmentDate.getDayOfWeek().equals(DayOfWeek.SUNDAY); + var beforeTheClinicOpens = appointmentDate.getHour() < 7; + var afterTheClinicCloses = appointmentDate.getHour() > 18; + + if (sunday || beforeTheClinicOpens || afterTheClinicCloses) { + throw new ValidationException("Appointment outside opening hours or date"); + } + } +} diff --git a/api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/ValidatesDoctorWithOtherAppointmentAtTheSameTime.java b/api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/ValidatesDoctorWithOtherAppointmentAtTheSameTime.java new file mode 100644 index 0000000..fa94d81 --- /dev/null +++ b/api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/ValidatesDoctorWithOtherAppointmentAtTheSameTime.java @@ -0,0 +1,21 @@ +package med.voll.api.domain.appointment.validations.scheduling; + +import jakarta.validation.ValidationException; +import med.voll.api.domain.appointment.AppointmentRegistrationData; +import med.voll.api.domain.appointment.AppointmentRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class ValidatesDoctorWithOtherAppointmentAtTheSameTime implements AppointmentSchedulingValidator { + + @Autowired + private AppointmentRepository appointmentRepository; + + public void validate(AppointmentRegistrationData data) { + var doctorHasAnotherAppointmentAtTheSameTime = appointmentRepository.existsByDoctorIdAndDateAndActiveIsTrue(data.idDoctor(), data.date()); + if (doctorHasAnotherAppointmentAtTheSameTime) { + throw new ValidationException("This doctor already has an appointment at this time and date"); + } + } +} diff --git a/api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/ValidatesPatientDoesntHaveAnotherAppointmentToday.java b/api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/ValidatesPatientDoesntHaveAnotherAppointmentToday.java new file mode 100644 index 0000000..a412bfb --- /dev/null +++ b/api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/ValidatesPatientDoesntHaveAnotherAppointmentToday.java @@ -0,0 +1,24 @@ +package med.voll.api.domain.appointment.validations.scheduling; + +import jakarta.validation.ValidationException; +import med.voll.api.domain.appointment.AppointmentRegistrationData; +import med.voll.api.domain.appointment.AppointmentRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class ValidatesPatientDoesntHaveAnotherAppointmentToday implements AppointmentSchedulingValidator { + + @Autowired + private AppointmentRepository appointmentRepository; + + public void validate(AppointmentRegistrationData data) { + var earliestTime = data.date().withHour(7); + var latestTime = data.date().withHour(18); + var patientHasAnotherAppointmentToday = appointmentRepository.existsByPatientIdAndDateBetween(data.idPatient(), earliestTime, latestTime); + + if (patientHasAnotherAppointmentToday) { + throw new ValidationException("Patient already has an scheduled appointment today"); + } + } +} diff --git a/api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/ValidatesTimeInAdvance.java b/api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/ValidatesTimeInAdvance.java new file mode 100644 index 0000000..442f4f9 --- /dev/null +++ b/api/src/main/java/med/voll/api/domain/appointment/validations/scheduling/ValidatesTimeInAdvance.java @@ -0,0 +1,21 @@ +package med.voll.api.domain.appointment.validations.scheduling; + +import jakarta.validation.ValidationException; +import med.voll.api.domain.appointment.AppointmentRegistrationData; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.LocalDateTime; + +@Component("ValidatesTimeInAdvanceScheduling") +public class ValidatesTimeInAdvance implements AppointmentSchedulingValidator { + public void validate(AppointmentRegistrationData data) { + var appointmentDate = data.date(); + var now = LocalDateTime.now(); + var differenceInMinutes = Duration.between(now, appointmentDate).toMinutes(); + + if (differenceInMinutes < 30) { + throw new ValidationException("Appointment should be scheduled half an hour in advance"); + } + } +} diff --git a/api/src/main/java/med/voll/api/domain/doctor/DoctorRepository.java b/api/src/main/java/med/voll/api/domain/doctor/DoctorRepository.java index 1efd0af..8cd6c46 100644 --- a/api/src/main/java/med/voll/api/domain/doctor/DoctorRepository.java +++ b/api/src/main/java/med/voll/api/domain/doctor/DoctorRepository.java @@ -3,9 +3,29 @@ package med.voll.api.domain.doctor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; + @Repository public interface DoctorRepository extends JpaRepository { Page findAllByActiveTrue(Pageable pagination); + + //order by rand() limit 1 + @Query(""" + SELECT d from Doctor d where d.active = true + and d.specialty = :specialty + and d.id not in ( + select a.doctor.id from Appointment a where a.date = :date + and a.active = true + ) + """) + Doctor chooseRandomDoctorAvailable(Specialty specialty, LocalDateTime date); + + @Query(""" + select d.active from Doctor d + where d.id = :id + """) + Boolean findActiveById(Long id); } diff --git a/api/src/main/java/med/voll/api/domain/patient/PatientRepository.java b/api/src/main/java/med/voll/api/domain/patient/PatientRepository.java index e550ac2..dbdcd87 100644 --- a/api/src/main/java/med/voll/api/domain/patient/PatientRepository.java +++ b/api/src/main/java/med/voll/api/domain/patient/PatientRepository.java @@ -3,9 +3,16 @@ package med.voll.api.domain.patient; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; @Repository public interface PatientRepository extends JpaRepository { Page findAllByActiveTrue(Pageable pagination); + + @Query(""" + select p.active from Patient p + where p.id = :id + """) + boolean findActiveById(Long id); } diff --git a/api/src/main/java/med/voll/api/infra/exception/ErrorTreatment.java b/api/src/main/java/med/voll/api/infra/exception/ErrorTreatment.java index 9e49fd3..f2d40cc 100644 --- a/api/src/main/java/med/voll/api/infra/exception/ErrorTreatment.java +++ b/api/src/main/java/med/voll/api/infra/exception/ErrorTreatment.java @@ -1,6 +1,7 @@ package med.voll.api.infra.exception; import jakarta.persistence.EntityNotFoundException; +import jakarta.validation.ValidationException; import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -20,6 +21,10 @@ public class ErrorTreatment { var errors = ex.getFieldErrors(); return ResponseEntity.badRequest().body(errors.stream().map(ValidationErrorData::new).toList()); } + @ExceptionHandler(ValidationException.class) + public ResponseEntity treatBusinessRule(ValidationException ex) { + return ResponseEntity.badRequest().body(ex.getMessage()); + } private record ValidationErrorData(String field, String message) { public ValidationErrorData(FieldError error) { diff --git a/api/src/main/java/med/voll/api/infra/security/SecurityConfigurations.java b/api/src/main/java/med/voll/api/infra/security/SecurityConfigurations.java index 5055162..46038c6 100644 --- a/api/src/main/java/med/voll/api/infra/security/SecurityConfigurations.java +++ b/api/src/main/java/med/voll/api/infra/security/SecurityConfigurations.java @@ -27,6 +27,7 @@ public class SecurityConfigurations { .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and().authorizeHttpRequests() .requestMatchers(HttpMethod.POST, "/login").permitAll() + .requestMatchers("/v3/api-docs/**", "/swagger-ui.html", "/swagger-ui/**").permitAll() .anyRequest().authenticated() .and().addFilterBefore(securityFilter, UsernamePasswordAuthenticationFilter.class) .build(); diff --git a/api/src/main/java/med/voll/api/infra/security/TokenService.java b/api/src/main/java/med/voll/api/infra/security/TokenService.java index 63dda73..bf19a3d 100644 --- a/api/src/main/java/med/voll/api/infra/security/TokenService.java +++ b/api/src/main/java/med/voll/api/infra/security/TokenService.java @@ -45,6 +45,6 @@ public class TokenService { } private Instant expirationDate() { - return LocalDateTime.now().plusHours(2).toInstant(ZoneOffset.of("-03:00")); + return LocalDateTime.now().plusHours(12).toInstant(ZoneOffset.of("-03:00")); } } diff --git a/api/src/main/java/med/voll/api/infra/springdoc/SpringDocConfigurations.java b/api/src/main/java/med/voll/api/infra/springdoc/SpringDocConfigurations.java new file mode 100644 index 0000000..c575160 --- /dev/null +++ b/api/src/main/java/med/voll/api/infra/springdoc/SpringDocConfigurations.java @@ -0,0 +1,34 @@ +package med.voll.api.infra.springdoc; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SpringDocConfigurations { + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .components(new Components() + .addSecuritySchemes("bearer-key", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))) + .info(new Info() + .title("Voll.med API") + .description("Voll.med Rest API application, containing CRUD functionalities for doctors and patients, in addition to scheduling and canceling appointments") + .contact(new Contact() + .name("Backend Team") + .email("backend@voll.med")) + .license(new License() + .name("Apache 2.0") + .url("http://voll.med/api/license"))); + } +} diff --git a/api/src/main/resources/application-prod.properties b/api/src/main/resources/application-prod.properties new file mode 100644 index 0000000..49041d6 --- /dev/null +++ b/api/src/main/resources/application-prod.properties @@ -0,0 +1,4 @@ +# Database config +spring.datasource.url=${DATASOURCE_URL} +spring.datasource.username=${DATASOURCE_USERNAME} +spring.datasource.password=${DATASOURCE_PASSWORD} \ No newline at end of file diff --git a/api/src/main/resources/application-test.properties b/api/src/main/resources/application-test.properties new file mode 100644 index 0000000..66d87f4 --- /dev/null +++ b/api/src/main/resources/application-test.properties @@ -0,0 +1 @@ +spring.datasource.url=jdbc:mariadb://localhost/voll_med_api_test \ No newline at end of file diff --git a/api/src/main/resources/db/migration/V7__create-table-appointments.sql b/api/src/main/resources/db/migration/V7__create-table-appointments.sql new file mode 100644 index 0000000..388810c --- /dev/null +++ b/api/src/main/resources/db/migration/V7__create-table-appointments.sql @@ -0,0 +1,12 @@ +create table appointments +( + id bigint not null auto_increment, + doctor_id bigint not null, + patient_id bigint not null, + date datetime not null, + + primary key (id), + constraint fk_appointments_doctor_id foreign key(doctor_id) references doctors(id), + constraint fk_appointments_patient_id foreign key(patient_id) references patients(id) + +); \ No newline at end of file diff --git a/api/src/main/resources/db/migration/V8__alter-table-appointments-add-column-reason-for-cancellation.sql b/api/src/main/resources/db/migration/V8__alter-table-appointments-add-column-reason-for-cancellation.sql new file mode 100644 index 0000000..b1b3fde --- /dev/null +++ b/api/src/main/resources/db/migration/V8__alter-table-appointments-add-column-reason-for-cancellation.sql @@ -0,0 +1 @@ +alter table appointments add column reason_for_cancellation varchar(100); \ No newline at end of file diff --git a/api/src/main/resources/db/migration/V9__alter-table-appointments-add-column-active.sql b/api/src/main/resources/db/migration/V9__alter-table-appointments-add-column-active.sql new file mode 100644 index 0000000..b02bda9 --- /dev/null +++ b/api/src/main/resources/db/migration/V9__alter-table-appointments-add-column-active.sql @@ -0,0 +1 @@ +alter table appointments add column active tinyint; \ No newline at end of file diff --git a/api/src/test/java/med/voll/api/ApiApplicationTests.java b/api/src/test/java/med/voll/api/ApiApplicationTests.java deleted file mode 100644 index eb360a5..0000000 --- a/api/src/test/java/med/voll/api/ApiApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package med.voll.api; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class ApiApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/api/src/test/java/med/voll/api/controller/AppointmentControllerTest.java b/api/src/test/java/med/voll/api/controller/AppointmentControllerTest.java new file mode 100644 index 0000000..6a0356c --- /dev/null +++ b/api/src/test/java/med/voll/api/controller/AppointmentControllerTest.java @@ -0,0 +1,82 @@ +package med.voll.api.controller; + +import med.voll.api.domain.appointment.AppointmentListingData; +import med.voll.api.domain.appointment.AppointmentRegistrationData; +import med.voll.api.domain.appointment.AppointmentsSchedule; +import med.voll.api.domain.doctor.Specialty; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.json.JacksonTester; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureJsonTesters +class AppointmentControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JacksonTester jacksonTesterRegistration; + + @Autowired + private JacksonTester jacksonTesterListing; + + @MockBean + private AppointmentsSchedule appointmentsSchedule; + + @Test + @DisplayName("Should return http error 400 when receiving invalid data") + @WithMockUser + void registerTest1() throws Exception { + var response = mockMvc.perform(post("/appointments")) + .andReturn().getResponse(); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + } + + @Test + @DisplayName("Should return http error 200 when receiving valid data") + @WithMockUser + void registerTest2() throws Exception { + var date = LocalDateTime.now().plusHours(1); + var specialty = Specialty.CARDIOLOGY; + + var listingData = new AppointmentListingData(null, date, 1l, 1l); + + when(appointmentsSchedule.schedule(any())).thenReturn(listingData); + + var response = mockMvc.perform(post("/appointments") + .contentType(MediaType.APPLICATION_JSON) + .content(jacksonTesterRegistration.write( + new AppointmentRegistrationData(1l, 1l, date, specialty) + ).getJson()) + ) + .andReturn().getResponse(); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + + var expectedJson = jacksonTesterListing.write( + listingData + ).getJson(); + + assertThat(response.getContentAsString()).isEqualTo(expectedJson); + } +} \ No newline at end of file diff --git a/api/src/test/java/med/voll/api/controller/DoctorControllerTest.java b/api/src/test/java/med/voll/api/controller/DoctorControllerTest.java new file mode 100644 index 0000000..dbd3338 --- /dev/null +++ b/api/src/test/java/med/voll/api/controller/DoctorControllerTest.java @@ -0,0 +1,77 @@ +package med.voll.api.controller; + +import med.voll.api.domain.address.Address; +import med.voll.api.domain.address.AddressData; +import med.voll.api.domain.appointment.AppointmentListingData; +import med.voll.api.domain.appointment.AppointmentRegistrationData; +import med.voll.api.domain.doctor.*; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.json.JacksonTester; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureJsonTesters +class DoctorControllerTest { + + @Autowired + private MockMvc mockMvc; + @Autowired + private JacksonTester jacksonTesterRegistration; + @Autowired + private JacksonTester jacksonTesterListing; + @MockBean + private DoctorRepository doctorRepository; + + @Test + @DisplayName("Should return http error 400 when receiving invalid data") + @WithMockUser + void registerTest1() throws Exception { + var response = mockMvc.perform(post("/doctors")) + .andReturn().getResponse(); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + } + + @Test + @DisplayName("Should return http error 200 when receiving valid data") + @WithMockUser + void registerTest2() throws Exception { + var addressData = new AddressData("Route 66", "", "55555", "Los Angeles", "CA"); + var registerData = new DoctorRegistrationData("doctor", "doctor@voll.med", "9999999999", Specialty.CARDIOLOGY, addressData); + + when(doctorRepository.save(any())).thenReturn(new Doctor(registerData)); + + var response = mockMvc.perform(post("/doctors") + .contentType(MediaType.APPLICATION_JSON) + .content(jacksonTesterRegistration.write(registerData).getJson())) + .andReturn().getResponse(); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.CREATED.value()); + + var detailingData = new DoctorDetailingData(null, registerData.name(), registerData.email(), registerData.phone(), registerData.specialty(), new Address(registerData.addressData())); + + var expectedJson = jacksonTesterListing.write( + detailingData + ).getJson(); + + assertThat(response.getContentAsString()).isEqualTo(expectedJson); + } +} \ No newline at end of file diff --git a/api/src/test/java/med/voll/api/domain/doctor/DoctorRepositoryTest.java b/api/src/test/java/med/voll/api/domain/doctor/DoctorRepositoryTest.java new file mode 100644 index 0000000..1ff80be --- /dev/null +++ b/api/src/test/java/med/voll/api/domain/doctor/DoctorRepositoryTest.java @@ -0,0 +1,107 @@ +package med.voll.api.domain.doctor; + +import med.voll.api.domain.address.AddressData; +import med.voll.api.domain.appointment.Appointment; +import med.voll.api.domain.patient.Patient; +import med.voll.api.domain.patient.PatientRegistrationData; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.test.context.ActiveProfiles; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.TemporalAdjusters; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +class DoctorRepositoryTest { + + @Autowired + private DoctorRepository doctorRepository; + + @Autowired + private TestEntityManager testEntityManager; + + @Test + @DisplayName("Should return null when there's no doctor available in the date") + void chooseRandomDoctorAvailableTest1() { + var nextMonday10AM = LocalDate.now() + .with(TemporalAdjusters.next(DayOfWeek.MONDAY)) + .atTime(10, 0); + + var doctor = registerDoctor("doctor", "doctor@voll.med", Specialty.CARDIOLOGY); + var patient = registerPatient("patient", "patient@voll.med", "00000000"); + scheduleAppointment(nextMonday10AM, doctor, patient); + + var availableDoctor = doctorRepository.chooseRandomDoctorAvailable(Specialty.CARDIOLOGY, nextMonday10AM); + assertThat(availableDoctor).isNull(); + } + + @Test + @DisplayName("Should return doctor when there's an doctor available in the date") + void chooseRandomDoctorAvailableTest2() { + var nextMonday10AM = LocalDate.now() + .with(TemporalAdjusters.next(DayOfWeek.MONDAY)) + .atTime(10, 0); + + var doctor = registerDoctor("doctor", "doctor@voll.med", Specialty.CARDIOLOGY); + + var availableDoctor = doctorRepository.chooseRandomDoctorAvailable(Specialty.CARDIOLOGY, nextMonday10AM); + assertThat(availableDoctor).isEqualTo(doctor); + } + + private void scheduleAppointment(LocalDateTime date, Doctor doctor, Patient patient) { + testEntityManager.persist(new Appointment(null, date, doctor, patient)); + } + + private Doctor registerDoctor(String name, String email, Specialty specialty) { + var doctor = new Doctor(doctorData(name, email, specialty)); + testEntityManager.persist(doctor); + return doctor; + } + + private Patient registerPatient(String name, String email, String ssn) { + var patient = new Patient(patientData(name, email, ssn)); + testEntityManager.persist(patient); + return patient; + } + + private DoctorRegistrationData doctorData(String name, String email, Specialty specialty) { + return new DoctorRegistrationData( + name, + email, + "999999999", + specialty, + addressData() + ); + } + + private PatientRegistrationData patientData(String name, String email, String ssn) { + return new PatientRegistrationData( + name, + email, + "999999999", + ssn, + addressData() + ); + } + + private AddressData addressData() { + return new AddressData( + "Route 66", + "", + "55555", + "Los Angeles", + "CA" + ); + } +} \ No newline at end of file -- cgit v1.2.3-18-g5258