Medicare Prescription Management Module with Spring Boot

Business Scenario

In this lab, we will enhance the Medicare backend by implementing a Prescription Management Module that allows doctors to create prescriptions after completing patient appointments.

Pre-Lab Preparation

Module:

1) Mastering Springboot Security​​

  • Now we’ll implement Phase 11 using our simplified DTO rule:
PrescriptionRequest
PrescriptionResponse
MedicineDto
  • Only 3 DTOs. No separate medicine request/response DTOs.

Final Phase 11 Flow

PATIENT books appointment
        ↓
DOCTOR attends appointment
        ↓
Doctor marks appointment COMPLETED
        ↓
Doctor creates prescription
        ↓
    Diagnosis
    Medicines
   Doctor Notes
        ↓
Patient views prescription

Security :

DOCTOR
  ↓
Can prescribe only for their own appointment

Appointment must be COMPLETED

Only ONE prescription per appointment

PATIENT
  ↓
Can view only their
own prescriptions

Database Relationship

Appointment
    │
    │ 1 : 0..1
    ▼
Prescription
    │
    │ 1 : Many
    ▼
PrescriptionMedicine
entity/
└─ Prescription.java

Create Prescription Entity

1

package com.medicare.entity;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;

@Entity
@Table(name = "prescriptions")
public class Prescription {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToOne
    @JoinColumn(name = "appointment_id",nullable = false,unique = true)
    private Appointment appointment;

    @Column(nullable = false,columnDefinition = "TEXT")
    private String diagnosis;

    @Column(name = "doctor_notes",columnDefinition = "TEXT")
    private String doctorNotes;

    @OneToMany(mappedBy = "prescription",cascade = 
    CascadeType.ALL,orphanRemoval = true)
    private List<PrescriptionMedicine> medicines = new ArrayList<>();

    @Column(name = "created_at",nullable = false,updatable = false)
    private LocalDateTime createdAt = LocalDateTime.now();

    public Prescription() {
    }

    public Long getId() {
        return id;
    }

    public Appointment getAppointment() {
        return appointment;
    }

    public void setAppointment(Appointment appointment) {
        this.appointment = appointment;
    }
    public String getDiagnosis() {
        return diagnosis;
    }

    public void setDiagnosis(String diagnosis) {
        this.diagnosis = diagnosis;
    }

    public String getDoctorNotes() {
        return doctorNotes;
    }

    public void setDoctorNotes(String doctorNotes) {
        this.doctorNotes = doctorNotes;
    }

    public List<PrescriptionMedicine> getMedicines() {
        return medicines;
    }

    public LocalDateTime getCreatedAt() {
        return createdAt;
    }

    public void addMedicine(PrescriptionMedicine medicine) {
        medicines.add(medicine);
        medicine.setPrescription(this);
    }
}

Why unique = true?

  • This: unique = true on: appointment_id means: Appointment 10 --> Prescription 5
  • But not:
Appointment 10
 ├── Prescription 5
 └── Prescription 8 
  • So one appointment gets at most one prescription.

DTO #1 — MedicineDto

2

dto/
└─ MedicineDto.java
package com.medicare.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public class MedicineDto {

    @NotBlank(message = "Medicine name is required")
    @Size(max = 150)
    private String medicineName;

    @NotBlank(message = "Dosage is required")
    @Size(max = 100)
    private String dosage;

    @NotBlank(message = "Frequency is required")
    @Size(max = 100)
    private String frequency;

    @NotBlank(message = "Duration is required")
    @Size(max = 100)
    private String duration;

    public MedicineDto() {
    }
    public MedicineDto(
            String medicineName,
            String dosage,
            String frequency,
            String duration) {
        this.medicineName = medicineName;
        this.dosage = dosage;
        this.frequency = frequency;
        this.duration = duration;
    }

    public String getMedicineName() {
        return medicineName;
    }

    public void setMedicineName(
            String medicineName) {

        this.medicineName = medicineName;
    }

    public String getDosage() {
        return dosage;
    }

    public void setDosage(
            String dosage) {
        this.dosage = dosage;
    }
    public String getFrequency() {
        return frequency;
    }

    public void setFrequency(
            String frequency) {
        this.frequency = frequency;
    }

    public String getDuration() {
        return duration;
    }

    public void setDuration(
            String duration) {
        this.duration = duration;
    }
}
  • This one DTO is used for both:

Prescription Request
Prescription Response

DTO #2 — PrescriptionRequest

3

dto/
└─ PrescriptionRequest.java
package com.medicare.dto;
import java.util.List;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

public class PrescriptionRequest {

    @NotNull(message = "Appointment id is required")
    private Long appointmentId;

    @NotBlank(message = "Diagnosis is required")
    @Size(max = 2000,message = "Diagnosis is too long")
    private String diagnosis;
    @Size(max = 3000,message = "Doctor notes are too long")
    private String doctorNotes;

    @NotEmpty(message = "At least one medicine is required")
    @Valid
    private List<MedicineDto> medicines;

    public Long getAppointmentId() {
        return appointmentId;
    }

    public void setAppointmentId(
            Long appointmentId) {

        this.appointmentId = appointmentId;
    }

    public String getDiagnosis() {
        return diagnosis;
    }

    public void setDiagnosis(String diagnosis) {
        this.diagnosis = diagnosis;
    }

    public String getDoctorNotes() {
        return doctorNotes;
    }
    public void setDoctorNotes(
            String doctorNotes) {
        this.doctorNotes = doctorNotes;
    }

    public List<MedicineDto> getMedicines() {
        return medicines;
    }

    public void setMedicines(
            List<MedicineDto> medicines) {
        this.medicines = medicines;
    }
}

DTO #3 — PrescriptionResponse

4

dto/
└─PrescriptionResponse.java
package com.medicare.dto;
import java.time.LocalDateTime;
import java.util.List;

public class PrescriptionResponse {
    private Long id;
    private Long appointmentId;
    private Long doctorId;
    private String doctorName;
    private Long patientId;
    private String patientName;
    private String diagnosis;
    private String doctorNotes;
    private List<MedicineDto> medicines;
    private LocalDateTime createdAt;
    public PrescriptionResponse(
            Long id,
            Long appointmentId,
            Long doctorId,
            String doctorName,
            Long patientId,
            String patientName,
            String diagnosis,
            String doctorNotes,
            List<MedicineDto> medicines,
            LocalDateTime createdAt) {

        this.id = id;
        this.appointmentId = appointmentId;
        this.doctorId = doctorId;
        this.doctorName = doctorName;
        this.patientId = patientId;
        this.patientName = patientName;
        this.diagnosis = diagnosis;
        this.doctorNotes = doctorNotes;
        this.medicines = medicines;
        this.createdAt = createdAt;
    }

    public Long getId() {
        return id;
    }

    public Long getAppointmentId() {
        return appointmentId;
    }

    public Long getDoctorId() {
        return doctorId;
    }

    public String getDoctorName() {
        return doctorName;
    }

    public Long getPatientId() {
        return patientId;
    }
    public String getPatientName() {
        return patientName;
    }

    public String getDiagnosis() {
        return diagnosis;
    }

    public String getDoctorNotes() {
        return doctorNotes;
    }
    public List<MedicineDto> getMedicines() {
        return medicines;
    }
    public LocalDateTime getCreatedAt() {
        return createdAt;
    }
}

Create PrescriptionRepository

5

repository/
└─PrescriptionRepository.java
package com.medicare.repository;

import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import com.medicare.entity.Prescription;

public interface PrescriptionRepository extends JpaRepository<Prescription, Long> {

    boolean existsByAppointmentId(Long appointmentId);

    Optional<Prescription> findByAppointmentId(Long appointmentId);

    List<Prescription> findByAppointment_Patient_Id(
                    Long patientId
            );
}

Do We Need PrescriptionMedicineRepository?

  • We're managing medicines through: Prescription and: cascade = CascadeType.ALL
  • So creating: PrescriptionMedicineRepository would currently add unnecessary complexity.

Create PrescriptionNotFoundException

6

exception/
└─PrescriptionNotFoundException.java
package com.medicare.exception;

public class PrescriptionNotFoundException extends RuntimeException {
    public PrescriptionNotFoundException(String message) {
        super(message);
    }
}

Create PrescriptionConflictException

7

exception/
└─PrescriptionConflictException.java
package com.medicare.exception;

public class PrescriptionConflictException
        extends RuntimeException {

    public PrescriptionConflictException(
            String message) {

        super(message);
    }
}
  • We'll use this when: Appointment already has a prescription

Create PrescriptionService

8

service/
└─PrescriptionService.java
package com.medicare.service;
import java.util.ArrayList;
import java.util.List;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.medicare.dto.MedicineDto;
import com.medicare.dto.PrescriptionRequest;
import com.medicare.dto.PrescriptionResponse;
import com.medicare.entity.Appointment;
import com.medicare.entity.AppointmentStatus;
import com.medicare.entity.DoctorProfile;
import com.medicare.entity.Prescription;
import com.medicare.entity.PrescriptionMedicine;
import com.medicare.entity.Role;
import com.medicare.entity.User;
import com.medicare.exception.AppointmentNotFoundException;
import com.medicare.exception.DoctorNotFoundException;
import com.medicare.exception.PrescriptionConflictException;
import com.medicare.exception.PrescriptionNotFoundException;
import com.medicare.exception.UserNotFoundException;
import com.medicare.repository.AppointmentRepository;
import com.medicare.repository.DoctorProfileRepository;
import com.medicare.repository.PrescriptionRepository;
import com.medicare.repository.UserRepository;

@Service
public class PrescriptionService {

    private final PrescriptionRepository prescriptionRepository;
    private final AppointmentRepository appointmentRepository;
    private final UserRepository userRepository;
    private final DoctorProfileRepository doctorRepository;

    public PrescriptionService(
            PrescriptionRepository prescriptionRepository,
            AppointmentRepository appointmentRepository,
            UserRepository userRepository,
            DoctorProfileRepository doctorRepository) {
        this.prescriptionRepository = prescriptionRepository;
        this.appointmentRepository = appointmentRepository;
        this.userRepository = userRepository;
        this.doctorRepository = doctorRepository;
    }
    // DOCTOR - CREATE PRESCRIPTION
    @Transactional
    @PreAuthorize("hasRole('DOCTOR')")
    public PrescriptionResponse createPrescription(
    PrescriptionRequest request, String email) {
        User user = findUserByEmail(email);
        DoctorProfile doctor = findDoctorByUserId(user.getId());
        Appointment appointment = findAppointmentById(
        request.getAppointmentId());

        // Only the assigned doctor can create the prescription.
        if (!appointment.getDoctor().getId().equals(doctor.getId())) {
            throw new AccessDeniedException(
            "You cannot create a prescription for this appointment");
        }

        // Prescription can only be created after consultation is completed.
        if (appointment.getAppointmentStatus() != AppointmentStatus.COMPLETED) {
            throw new IllegalArgumentException(
            "Prescription can only be created for a completed appointment");
        }

        // Only one prescription is allowed per appointment.
        if (prescriptionRepository.existsByAppointmentId(appointment.getId())) {
            throw new PrescriptionConflictException(
            "Prescription already exists for this appointment");
        }

        Prescription prescription = new Prescription();
        prescription.setAppointment(appointment);
        prescription.setDiagnosis(request.getDiagnosis());
        prescription.setDoctorNotes(request.getDoctorNotes());

        for (MedicineDto medicineDto : request.getMedicines()) {
            PrescriptionMedicine medicine = new PrescriptionMedicine();
            medicine.setMedicineName(medicineDto.getMedicineName());
            medicine.setDosage(medicineDto.getDosage());
            medicine.setFrequency(medicineDto.getFrequency());
            medicine.setDuration(medicineDto.getDuration());
            prescription.addMedicine(medicine);
        }

        Prescription savedPrescription = prescriptionRepository
        .save(prescription);
        return mapToPrescriptionResponse(savedPrescription);
    }

    // VIEW PRESCRIPTION FOR APPOINTMENT
    @Transactional(readOnly = true)
    @PreAuthorize("hasAnyRole('PATIENT', 'DOCTOR')")
    public PrescriptionResponse getPrescriptionByAppointment(
    Long appointmentId, String email) {
        Prescription prescription = prescriptionRepository
        .findByAppointmentId(appointmentId)
                .orElseThrow(() -> new PrescriptionNotFoundException(
                 "Prescription not found for appointment id: " 
                 + appointmentId));

        User user = findUserByEmail(email);
        verifyPrescriptionAccess(prescription, user);
        return mapToPrescriptionResponse(prescription);
    }

    // PATIENT - VIEW OWN PRESCRIPTIONS
    @Transactional(readOnly = true)
    @PreAuthorize("hasRole('PATIENT')")
    public List<PrescriptionResponse> getMyPrescriptions(String email) {
        User patient = findUserByEmail(email);
        List<Prescription> prescriptions =
                prescriptionRepository.findByAppointment_Patient_Id(
                patient.getId());

        List<PrescriptionResponse> responses = new ArrayList<>();
        for (Prescription prescription : prescriptions) {
            responses.add(mapToPrescriptionResponse(prescription));
        }

        return responses;
    }

    // OWNERSHIP CHECK
    private void verifyPrescriptionAccess(
    Prescription prescription, User user) {
        Appointment appointment = prescription.getAppointment();

        if (user.getRole() == Role.PATIENT) {
            if (!appointment.getPatient().getId().equals(user.getId())) {
                throw new AccessDeniedException("
                You cannot access this prescription");
            }
            return;
        }

        if (user.getRole() == Role.DOCTOR) {
            DoctorProfile doctor = findDoctorByUserId(user.getId());

            if (!appointment.getDoctor().getId().equals(doctor.getId())) {
                throw new AccessDeniedException(
                "You cannot access this prescription");
            }
        }
    }

    // HELPER METHODS
    private User findUserByEmail(String email) {
        return userRepository.findByEmailIgnoreCase(email)
                .orElseThrow(() -> new UserNotFoundException("User not found"));
    }

    private DoctorProfile findDoctorByUserId(Long userId) {
        return doctorRepository.findByUserId(userId)
                .orElseThrow(() -> 
                    new DoctorNotFoundException("Doctor profile not found"));
    }

    private Appointment findAppointmentById(Long appointmentId) {
        return appointmentRepository.findById(appointmentId)
                .orElseThrow(() -> new AppointmentNotFoundException(
                        "Appointment not found with id: " + appointmentId));
    }
    // RESPONSE MAPPING
    private PrescriptionResponse mapToPrescriptionResponse(
    Prescription prescription) {
        List<MedicineDto> medicines = new ArrayList<>();

        for (PrescriptionMedicine medicine : prescription.getMedicines()) {
            MedicineDto medicineDto = new MedicineDto(
                    medicine.getMedicineName(),
                    medicine.getDosage(),
                    medicine.getFrequency(),
                    medicine.getDuration());
            medicines.add(medicineDto);
        }

        Appointment appointment = prescription.getAppointment();

        return new PrescriptionResponse(
                prescription.getId(),
                appointment.getId(),
                appointment.getDoctor().getId(),
                appointment.getDoctor().getUser().getName(),
                appointment.getPatient().getId(),
                appointment.getPatient().getName(),
                prescription.getDiagnosis(),
                prescription.getDoctorNotes(),
                medicines, prescription.getCreatedAt());
    }
}

Why @Transactional?

  • During creation:
Prescription
    +
Medicine 1
    +
Medicine 2
    +
Medicine 3
  • should be one operation.
  • Conceptually:
BEGIN

save prescription
save medicine 1
save medicine 2
save medicine 3

SUCCESS
 ↓
COMMIT
  • If something fails:ROLLBACK so we don't leave partially saved prescription data.
    public void setTimeSlot(
            TimeSlot timeSlot) {

        this.timeSlot = timeSlot;
    }
}

How Do We Know the Patient Now ?

  • Remember:
JWT
 ↓
email
 ↓
SecurityContextHolder
 ↓
Authentication
  • So if Rahul sends: Authorization: Bearer <rahul-token>
  • Spring already knows: rahul@example.com
  • The frontend no longer tells us: "I am patient 5"
  • The backend determines who the user actually is and this approach is much safer.

Enable Method Security

3

  • Open : SecurityConfig.java
  • Add:
import org.springframework.security.config.annotation.method.configuration.
EnableMethodSecurity;
  • Then:
  • Now we can use: @PreAuthorize(...)

@Configuration
@EnableMethodSecurity
public class SecurityConfig {
  • Spring Security's current documentation recommends @EnableMethodSecurity; once enabled, @PreAuthorize can guard Spring-managed methods.

Understanding hasRole()

PATIENT
 ↓
ROLE_PATIENT
  • and:
DOCTOR
 ↓
ROLE_DOCTOR
  • When using: hasRole("PATIENT") do not write: hasRole("ROLE_PATIENT") because hasRole automatically applies the usual ROLE_ prefix. 

Update DoctorProfileRepository

4

  • We'll need to find the doctor profile belonging to the logged-in doctor.
  • Add:
Optional<DoctorProfile> findByUserId( Long userId );
  • So repository now includes:
boolean existsByUserId(Long userId);

Optional<DoctorProfile> findByUserId(
        Long userId
);
  • Import:
import java.util.Optional;

Secure Doctor Profile Ownership

5

  • Our old DoctorProfileRequest contained: userId
  • Remove it.
  • Doctor should never tell the backend:Update Doctor User 5.
JWT
 ↓
Current Doctor User
 ↓
Their DoctorProfile
  • New request:
package com.medicare.dto;
import java.math.BigDecimal;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;

public class DoctorProfileRequest {

    @NotBlank(message = "Specialization is required")
    @Size(max = 100)
    private String specialization;

    @NotNull(message = "Experience is required")
    @Min(0)
    @Max(60)
    private Integer experienceYears;

    @NotNull(message = "Consultation fee is required")
    @Positive
    private BigDecimal consultationFee;

    @Size(max = 500)
    private String photoUrl;

    public String getSpecialization() {
        return specialization;
    }

    public void setSpecialization(
            String specialization) {
        this.specialization = specialization;
    }
    public Integer getExperienceYears() {
        return experienceYears;
    }

    public void setExperienceYears(
            Integer experienceYears) {
        this.experienceYears = experienceYears;
    }

    public BigDecimal getConsultationFee() {
        return consultationFee;
    }

    public void setConsultationFee(
            BigDecimal consultationFee) {
        this.consultationFee = consultationFee;
    }

    public String getPhotoUrl() {
        return photoUrl;
    }

    public void setPhotoUrl(
            String photoUrl) {

        this.photoUrl = photoUrl;
    }
}

Doctor Profile Endpoints Change

6

  • Remove the insecure approach:
PUT /api/doctors/{doctorId}
DELETE /api/doctors/{doctorId}

for profile management.

  • Use:
POST   /api/doctors/profile
GET    /api/doctors/profile/me
PUT    /api/doctors/profile/me
DELETE /api/doctors/profile/me
  • Meaning: "Operate on MY doctor profile."
  • No doctor ID required.

Update DoctorService

7

  • Add methods such as:
@PreAuthorize("hasRole('DOCTOR')")
public DoctorResponse createMyProfile(DoctorProfileRequest request,String email) {

    User user = findUserByEmail(email);

    if (doctorRepository.existsByUserId(user.getId())) {
        throw new IllegalArgumentException( "Doctor profile already exists");
    }

    DoctorProfile doctor = new DoctorProfile();
    doctor.setUser(user);
    doctor.setSpecialization(request.getSpecialization());
    doctor.setExperienceYears(request.getExperienceYears());
    doctor.setConsultationFee(request.getConsultationFee());
    doctor.setPhotoUrl(request.getPhotoUrl());

    return mapToDoctorResponse(doctorRepository.save(doctor));
}
@PreAuthorize("hasRole('DOCTOR')")
public DoctorResponse getMyProfile(String email) {
    User user = findUserByEmail(email);
    DoctorProfile doctor = findDoctorByUserId(user.getId());
    
    return mapToDoctorResponse(doctor);
}
  • Add getMyProfile
  • Add updateMyProfile
@PreAuthorize("hasRole('DOCTOR')")
public DoctorResponse updateMyProfile(String email,DoctorProfileRequest request) {
    User user = findUserByEmail(email);
    DoctorProfile doctor = findDoctorByUserId(user.getId());

    doctor.setSpecialization(request.getSpecialization());

    doctor.setExperienceYears(request.getExperienceYears());

    doctor.setConsultationFee(request.getConsultationFee());

    doctor.setPhotoUrl(request.getPhotoUrl());

    return mapToDoctorResponse(doctorRepository.save(doctor));
}
  • Add deleteMyProfile
@PreAuthorize("hasRole('DOCTOR')")
public void deleteMyProfile(String email) {

    User user = findUserByEmail(email);
    DoctorProfile doctor = findDoctorByUserId(user.getId());
    doctorRepository.delete(doctor);
}

Add Helper Methods

8

  • Inside DoctorService:
private User findUserByEmail(String email) {
    return userRepository.findByEmailIgnoreCase(email).orElseThrow(() ->
                    new UserNotFoundException("User not found")
            );
}
private DoctorProfile findDoctorByUserId(Long userId) {
    return doctorRepository.findByUserId(userId).orElseThrow(() ->
                    new DoctorNotFoundException("Doctor profile not found")
            );
}
  • Add

Before proceeding to the next steps, we need to clean up the existing DoctorService code by removing outdated methods

  • Fix 1 — Delete the old createDoctor()
  • Fix 2 — Remove old updateDoctor() and deleteDoctor()
  • Fix 1 — Delete the old createDoctor()
  • Fix 3 — Remove findUserById()
  • Fix 4 — Remove unused Role import

Update DoctorController

9

  • Add
@PostMapping("/profile")
public ResponseEntity<DoctorResponse>createMyProfile(
                @Valid @RequestBodyDoctorProfileRequest request,
                Authentication authentication) {

    DoctorResponse response = doctorService.createMyProfile(request,
                    authentication.getName());
    return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
  • Get own profile:
@GetMapping("/profile/me")
public ResponseEntity<DoctorResponse>getMyProfile(Authentication authentication) {
    return ResponseEntity.ok(doctorService.getMyProfile(authentication.getName())
    );
}
  • Update:
@PutMapping("/profile/me")
public ResponseEntity<DoctorResponse>updateMyProfile(
                @Valid @RequestBody DoctorProfileRequest request,
                Authentication authentication) {

    return ResponseEntity.ok(doctorService.updateMyProfile(
                    authentication.getName(),request)
    );
}
  • Delete
@DeleteMapping("/profile/me")
public ResponseEntity<Void> deleteMyProfile(Authentication authentication) {
    doctorService.deleteMyProfile(authentication.getName());
    return ResponseEntity.noContent().build();
}
  • Import :
import org.springframework.security.core.Authentication;

Fix Appointment Slot Query

10

  • Now that patients can cancel appointments, a cancelled slot should become available again.
  • Change
existsByDoctorIdAndAppointmentDateAndTimeSlot(...)
  • To:
boolean existsByDoctorIdAndAppointmentDateAndTimeSlotAndAppointmentStatusNot(
        Long doctorId,
        LocalDate appointmentDate,
        TimeSlot timeSlot,
        AppointmentStatus appointmentStatus
);
  • Then we'll check: AppointmentStatus.CANCELLED

Refactor AppointmentService

11

  • Our service will now use the authenticated email.
  • Create Appointment
@PreAuthorize("hasRole('PATIENT')")
public AppointmentResponse createAppointment(AppointmentRequest request,
                    String email) {

    User patient = findUserByEmail(email);
    DoctorProfile doctor = doctorRepository.findById(request.getDoctorId())
                    .orElseThrow(() -> new DoctorNotFoundException(
                           "Doctor not found with id: "+ request.getDoctorId()
                            )
                    );
    boolean booked = appointmentRepository
     .existsByDoctorIdAndAppointmentDateAndTimeSlotAndAppointmentStatusNot(
     doctor.getId(),request.getAppointmentDate(),request.getTimeSlot(),
     AppointmentStatus.CANCELLED);
    if (booked) {
        throw new AppointmentConflictException(
        "Selected appointment slot is already booked");
    }

    Appointment appointment = new Appointment();
    appointment.setPatient(patient);
    appointment.setDoctor(doctor);
    appointment.setAppointmentDate(request.getAppointmentDate());
    appointment.setTimeSlot(request.getTimeSlot());
    appointment.setConsultationFee(doctor.getConsultationFee());
    appointment.setAppointmentStatus(AppointmentStatus.PENDING);
    appointment.setPaymentStatus(PaymentStatus.UNPAID);
    Appointment savedAppointment = appointmentRepository.save(appointment);

    return mapToAppointmentResponse(savedAppointment);
}

Patient — My Appointments

12

@PreAuthorize("hasRole('PATIENT')")
public List<AppointmentResponse>
        getMyAppointments(String email) {
    User patient = findUserByEmail(email);

    List<Appointment> appointments = appointmentRepository
                    .findByPatientId(patient.getId());

    return mapAppointmentList(appointments);
}
  • Now the endpoint can simply be: GET /api/appointments/my instead of /api/appointments/patient/5

Doctor — My Schedule

13

@PreAuthorize("hasRole('DOCTOR')")
public List<AppointmentResponse>
        getMyDoctorAppointments(
                String email) {

    User user = findUserByEmail(email);

    DoctorProfile doctor = findDoctorByUserId(user.getId());
    List<Appointment> appointments = appointmentRepository
                    .findByDoctorId(doctor.getId());
                    

    return mapAppointmentList(appointments);
    
}
  • Endpoint: GET /api/appointments/doctor/me
  • No doctor ID from frontend.

Secure Appointment by ID

14

  • Suppose Rahul owns Appointment 10.
  • Another patient tries: GET /api/appointments/10
  • Having: ROLE_PATIENT isn't enough.
  • We must verify ownership.
@PreAuthorize("hasAnyRole('PATIENT', 'DOCTOR')")
public AppointmentResponse getAppointmentById( Long appointmentId,String email) {

    Appointment appointment = findAppointmentById(appointmentId);

    User user = findUserByEmail(email);
    verifyAppointmentAccess(appointment,user);

    return mapToAppointmentResponse(appointment);
}

Ownership Check

15

  • Add :
private void verifyAppointmentAccess(Appointment appointment,User user) {

    if (user.getRole() == Role.PATIENT) {
        if (!appointment.getPatient().getId().equals(user.getId())) {
            throw new AccessDeniedException( "You cannot access this appointment");
        }
        return;
    }

    if (user.getRole() == Role.DOCTOR) {
    
        DoctorProfile doctor = findDoctorByUserId(user.getId());

        if (!appointment.getDoctor().getId().equals(doctor.getId())) {
            throw new AccessDeniedException("You cannot access this appointment");
        }
    }
}
  • Import:
import org.springframework.security.access.AccessDeniedException;
  • Spring Security uses an access-denied exception to represent failed authorization, which results in a 403 during an HTTP request.

Patient Cancel Appointment

16

  • Add :
@PreAuthorize("hasRole('PATIENT')")
public AppointmentResponse cancelAppointment(Long appointmentId,String email) {

    User patient = findUserByEmail(email);
    Appointment appointment = findAppointmentById(appointmentId);

    if (!appointment.getPatient().getId().equals(patient.getId())) {
        throw new AccessDeniedException("You cannot cancel this appointment");
    }

    if (appointment.getAppointmentStatus()== AppointmentStatus.COMPLETED) {
        throw new IllegalArgumentException(
        "Completed appointment cannot be cancelled");
    }

    if (appointment.getAppointmentStatus() == AppointmentStatus.CANCELLED) {
        throw new IllegalArgumentException("Appointment is already cancelled");
    }

    appointment.setAppointmentStatus(AppointmentStatus.CANCELLED);
    
    return mapToAppointmentResponse(appointmentRepository.save(appointment));
}

Doctor Complete Appointment

17

  • Add :
@PreAuthorize("hasRole('DOCTOR')")
public AppointmentResponse completeAppointment(Long appointmentId,String email) {

    User user = findUserByEmail(email);
    DoctorProfile doctor = findDoctorByUserId(user.getId());
    Appointment appointment = findAppointmentById(appointmentId);

    if (!appointment.getDoctor().getId().equals(doctor.getId())) {
        throw new AccessDeniedException("You cannot complete this appointment");
    }

    if (appointment.getAppointmentStatus() == AppointmentStatus.CANCELLED) {
      throw new IllegalArgumentException("Cancelled appointment cannot be completed");
    }

    appointment.setAppointmentStatus(AppointmentStatus.COMPLETED);

    return mapToAppointmentResponse(appointmentRepository.save(appointment)
    );
}
    return mapToAppointmentResponse(appointmentRepository.save(appointment)
    );
}

Appointment Mapping

18

  • Add :
private AppointmentResponse mapToAppointmentResponse(Appointment appointment) {

    return new AppointmentResponse(appointment
           .getId(),appointment.getPatient().getId(),
            appointment.getPatient().getName(),
            appointment.getDoctor().getId(),
            appointment.getDoctor().getUser().getName(),
            appointment.getDoctor().getSpecialization(),
            
            appointment.getAppointmentDate(),
            appointment.getTimeSlot(),
            appointment.getConsultationFee(),
            appointment.getAppointmentStatus(),
            appointment.getPaymentStatus(),
            appointment.getCreatedAt()
    );
}
  • List mapper:
private List<AppointmentResponse>mapAppointmentList(
                List<Appointment> appointments) {

    List<AppointmentResponse> responses = new ArrayList<>();

    for (Appointment appointment : appointments) {

        responses.add(mapToAppointmentResponse(appointment));
    }

    return responses;
}

Update AppointmentController

19

  • Our appointment controller becomes much safer.
@PostMapping
public ResponseEntity<AppointmentResponse>
        createAppointment(@Valid @RequestBody AppointmentRequest request,
                Authentication authentication) {

    AppointmentResponse response = appointmentService.createAppointment(
                            request,authentication.getName()
                    );

    return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
  • Patient appointments
@GetMapping("/my")
public ResponseEntity<List<AppointmentResponse>>
        getMyAppointments(Authentication authentication) {
    return ResponseEntity.ok(appointmentService.getMyAppointments(
  authentication.getName()
                    )
    );
}
  • Doctor schedule
@GetMapping("/doctor/me")
public ResponseEntity<List<AppointmentResponse>>
        getMyDoctorAppointments(Authentication authentication) {
    return ResponseEntity.ok(appointmentService.getMyDoctorAppointments(
                            authentication.getName()
                    )
    );
}
@GetMapping("/{id}")
  • Doctor schedule
public ResponseEntity<AppointmentResponse>
        getAppointmentById( @PathVariable Long id,Authentication authentication) {

    return ResponseEntity.ok(appointmentService.getAppointmentById(
                            id,authentication.getName()
                    )
    );
}
  • Cancel
@PatchMapping("/{id}/cancel")
public ResponseEntity<AppointmentResponse>
        cancelAppointment(@PathVariable Long id, Authentication authentication) {

    return ResponseEntity.ok(appointmentService.cancelAppointment(
                            id,authentication.getName()
                    )
    );
}
  • Complete
@PatchMapping("/{id}/complete")
public ResponseEntity<AppointmentResponse>
        completeAppointment(@PathVariable Long id,Authentication authentication) {
    return ResponseEntity.ok(appointmentService.completeAppointment(
                            id,authentication.getName()
                    )
    );
}

Remove Insecure Appointment Endpoints

20

  • Remove:
GET /api/appointments
GET /api/appointments/patient/{patientId}
GET /api/appointments/doctor/{doctorId}
  • Because these make ID manipulation much easier.
  • Now:
PATIENT
 ↓
/appointments/my
  • And:
DOCTOR
 ↓
/appointments/doctor/me

Remove Insecure Appointment Endpoints

21

  • Previously: GET /api/users/me just showed username/authorities.
  • Now make it useful for React.
  • UserService:
public UserResponse getCurrentUser(String email) {

    User user = userRepository.findByEmailIgnoreCase(email).orElseThrow(() ->
                            new UserNotFoundException("User not found")
                    );
                    
    return mapToUserResponse(user);
}
  • Controller:
@GetMapping("/me")
public ResponseEntity<UserResponse>getCurrentUser(Authentication authentication) {
    return ResponseEntity.ok(userService.getCurrentUser(authentication.getName())
    );
}
  • Result:
{
    "id": 1,
    "name": "Rahul Shah",
    "email": "rahul@example.com",
    "phone": "9876543210",
    "role": "PATIENT"
}
  • Remove public:
GET /api/users
GET /api/users/{id}

Configure Endpoint-Level RBAC

22

  • Now update the authorization section in SecurityConfig.

from the controller for now.

  • There is no business reason for patients/doctors to list every user.
  • Order matters: Spring Security evaluates matching authorization rules in the declared order.
.authorizeHttpRequests(auth -> auth

        // Public APIs
        .requestMatchers("/api/health","/api/auth/register","/api/auth/login")
        .permitAll()

        // Current user's profile
        .requestMatchers("/api/users/me").authenticated()

        // Doctor can manage only own profile
        .requestMatchers("/api/doctors/profile","/api/doctors/profile/**")
        .hasRole("DOCTOR")

        // Doctor search/listing
        .requestMatchers(HttpMethod.GET,"/api/doctors/**")
        .hasAnyRole("PATIENT", "DOCTOR")

        // Patient books appointments
        .requestMatchers(HttpMethod.POST,"/api/appointments")
        .hasRole("PATIENT")

        // Patient appointment history
        .requestMatchers("/api/appointments/my").hasRole("PATIENT")

        // Doctor schedule
        .requestMatchers("/api/appointments/doctor/**").hasRole("DOCTOR")

        // Patient cancellation
        .requestMatchers(HttpMethod.PATCH,"/api/appointments/*/cancel")
        .hasRole("PATIENT")

        // Doctor completion
        .requestMatchers(HttpMethod.PATCH,"/api/appointments/*/complete")
        .hasRole("DOCTOR")

        // Appointment details
        .requestMatchers(HttpMethod.GET,"/api/appointments/*")
        .hasAnyRole("PATIENT", "DOCTOR")

        /*
         * Safer default: Anything we forgot to configure is denied.
         */
         
        .anyRequest()
        .denyAll()
        
)
  • Using a deny-by-default fallback helps avoid accidentally exposing new endpoints that were forgotten in the security configuration.
  • Spring Security's documentation explicitly lists denyAll as a supported authorization rule and shows it as a defensive default.

Why Both SecurityConfig AND @PreAuthorize?

  • You might ask: Aren't we checking the role twice?

Yes — intentionally for sensitive business actions.

  • For Example :
HTTP endpoint:
POST /appointments
      ↓
SecurityConfig:
PATIENT required
      ↓
Controller
      ↓
Service:
@PreAuthorize("hasRole('PATIENT')")
  • Why? -- Because someday another service could call: appointmentService.createAppointment(...) without coming through that controller.
  • Method security protects the business logic too.

Proper 401 and 403 JSON Responses

23

  • One issue:ResponseEntity works inside controllers.
security/
└── CustomAuthenticationEntryPoint.java
  • But: 401 and  403 often happen before the controller runs.
  • So our normal: @RestControllerAdvice is not the right place for every security-filter failure.
  • We'll create dedicated security handlers.
  • Create CustomAuthenticationEntryPoint
package com.medicare.security;

import java.io.IOException;
import java.time.LocalDateTime;

import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import com.medicare.response.ErrorResponse;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import tools.jackson.databind.json.JsonMapper;

@Component
public class CustomAuthenticationEntryPoint
        implements AuthenticationEntryPoint {

    private final JsonMapper jsonMapper;

    public CustomAuthenticationEntryPoint(
            JsonMapper jsonMapper) {

        this.jsonMapper = jsonMapper;
    }

    @Override
    public void commence(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException exception)
            throws IOException {

        ErrorResponse errorResponse =
                new ErrorResponse(
                        LocalDateTime.now(),
                        HttpStatus.UNAUTHORIZED.value(),
                        "Authentication is required",
                        request.getRequestURI()
                );

        response.setStatus(
                HttpStatus.UNAUTHORIZED.value()
        );

        response.setContentType(
                MediaType.APPLICATION_JSON_VALUE
        );

        response.getWriter().write(
                jsonMapper.writeValueAsString(
                        errorResponse
                )
        );
    }
}
import java.io.IOException;
import java.time.LocalDateTime;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import com.medicare.response.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import tools.jackson.databind.json.JsonMapper;

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final JsonMapper jsonMapper;
    public CustomAuthenticationEntryPoint(JsonMapper jsonMapper) {
        this.jsonMapper = jsonMapper;
    }

    @Override
    public void commence(HttpServletRequest request,HttpServletResponse response,
            AuthenticationException exception) throws IOException {

        ErrorResponse errorResponse = new ErrorResponse(
                        LocalDateTime.now(),
                        HttpStatus.UNAUTHORIZED.value(),
                        "Authentication is required",
                        request.getRequestURI()
                );
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write(jsonMapper.writeValueAsString(errorResponse));
    }
}

Create CustomAccessDeniedHandler

24

security/
└── CustomAccessDeniedHandler.java
package com.medicare.security;
import java.io.IOException;
import java.time.LocalDateTime;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import com.medicare.response.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import tools.jackson.databind.json.JsonMapper;

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    private final JsonMapper jsonMapper;

    public CustomAccessDeniedHandler(JsonMapper jsonMapper) {
        this.jsonMapper = jsonMapper;
    }

    @Override
    public void handle(
            HttpServletRequest request,HttpServletResponse response,
            AccessDeniedException exception) throws IOException {

        ErrorResponse errorResponse = new ErrorResponse(
                        LocalDateTime.now(), HttpStatus.FORBIDDEN.value(),
                        "You do not have permission to access this resource",
                        request.getRequestURI()
                );

        response.setStatus(HttpStatus.FORBIDDEN.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write(jsonMapper.writeValueAsString(errorResponse)
        );
    }
}

Create CustomAccessDeniedHandler

25

  • Inject them into: securityFilterChain(...)
  • Then:
.exceptionHandling(exception ->
        exception.authenticationEntryPoint(customAuthenticationEntryPoint)
                .accessDeniedHandler(customAccessDeniedHandler)
)
  • So now:
Not authenticated - 401 UNAUTHORIZED
Authenticated but wrong role - 403 FORBIDDEN

Important Difference — Role vs Ownership

  • Role check
Is Rahul a PATIENT? --> YES
Does not mean: Rahul can view every patient appointment
We still need: Does this appointment belong to Rahul?
  • So security becomes:
Authentication
     ↓
    Role
     ↓
 Ownership
  • This prevents a common authorization issue where changing an object ID gives access to someone else's record.

Testing — Patient Books Appointment

26

  • Login as Rahul: ROLE_PATIENT

  • Request and body:
POST /api/appointments
Authorization: Bearer <patient-token>
{
    "doctorId": 1,
    "appointmentDate": "2026-09-25",
    "timeSlot": "MORNING_10_AM"
}
  • Notice : We do not pass patientId from the frontend because the backend automatically identifies the logged-in patient, Rahul, using the JWT token.
  • Expected: 201 CREATED

Doctor Tries to Book Appointment

27

  • Login as doctor.
  • Call: POST /api/appointments
  • Expected: 403 FORBIDDEN
Because: DOCTOR ≠ PATIENT

Patient — My Appointments

28

GET /api/appointments/my
Authorization: Bearer <patient-token>

Doctor Tries /appointments/my

29

  • Expected: 200 OK
  • Using doctor token: GET /api/appointments/my
  • Expected: 403 FORBIDDEN

Doctor — Own Schedule

30

GET /api/appointments/doctor/me
Authorization: Bearer <doctor-token>
  • Expected: 200 OK

Patient Tries Doctor Schedule

31

GET /api/appointments/doctor/me
  • using patient token.
  • Expected: 403 FORBIDDEN

Test Patient Ownership

32

Rahul owns Appointment 5
Priya owns Appointment 6
  • Suppose:
GET /api/appointments/5
  • Rahul:
  • Expected: 200 OK
GET /api/appointments/6
  • Rahul:
  • Expected : 403 FORBIDDEN
  • This confirms ownership protection.

Test Doctor Ownership

33

  • Appointment 5 belongs to: Dr. Ananya

GET /api/appointments/5
  • Expected: 200
  • Another doctor:
GET /api/appointments/5
  • Expected: 403 FORBIDDEN

Patient Cancel Own Appointment

34

PATCH /api/appointments/5/cancel
  • Expected: 200
  • Status becomes: CANCELLED

Another Patient Tries to Cancel It

35

PATCH /api/appointments/5/complete
  • Expected: 200
  • Expected: 403 FORBIDDEN

Doctor Completes Own Appointment

36

  • using assigned doctor token.
  • Status becomes: COMPLETED

Patient Tries Complete

37

  • Expected: 403 FORBIDDEN

Doctor Profile Security

38

  • Doctor :
POST /api/doctors/profile
  • Body:
{
    "specialization": "Cardiologist",
    "experienceYears": 8,
    "consultationFee": 1200,
    "photoUrl": "..."
}
  • Expected: 201 CREATED

Patient Tries Creating Doctor Profile

39

  • Expected: 403 FORBIDDEN

We are done with this lab. The latest source code has been uploaded to GitHub. You can access the latest commit using the link below: 

 

Great job!

You have successfully implemented Role-Based Access Control (RBAC) and ownership security in the Medicare backend using Spring Security and JWT authentication.

Checkpoint

   Git Push

git push origin branchName

Next-Lab Preparation

Module:

1) Mastering Springboot Security​​

SpringBoot Lab - 12

By Content ITV

SpringBoot Lab - 12

  • 63