From aec9f7b9e0f72675aec6cb7d9e480f93a315877b Mon Sep 17 00:00:00 2001 From: Alex <78383757+Lextical@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:44:56 -0600 Subject: [PATCH 1/3] Added role based access to android login - Admin has access to everything - Staff has limited access to what they can edit in listfragment - Customers cannot login to app - added validations to pets, supplier and services in their detailed view --- .../activities/MainActivity.java | 27 ++++++++++++++---- .../fragments/ListFragment.java | 10 ++++++- .../detailfragments/PetDetailFragment.java | 25 +++++++++++------ .../ServiceDetailFragment.java | 28 ++++++++++++------- .../SupplierDetailFragment.java | 26 +++++++++++++---- 5 files changed, 85 insertions(+), 31 deletions(-) diff --git a/android/app/src/main/java/com/example/petstoremobile/activities/MainActivity.java b/android/app/src/main/java/com/example/petstoremobile/activities/MainActivity.java index be50fd5a..242000c8 100644 --- a/android/app/src/main/java/com/example/petstoremobile/activities/MainActivity.java +++ b/android/app/src/main/java/com/example/petstoremobile/activities/MainActivity.java @@ -39,11 +39,17 @@ public class MainActivity extends AppCompatActivity { super.onCreate(savedInstanceState); // Check if user is already logged in - if (TokenManager.getInstance(this).isLoggedIn()) { - Intent intent = new Intent(this, HomeActivity.class); - startActivity(intent); - finish(); - return; + TokenManager tokenManager = TokenManager.getInstance(this); + if (tokenManager.isLoggedIn()) { + if ("CUSTOMER".equalsIgnoreCase(tokenManager.getRole())) { + // If a customer somehow remained logged in, clear them out + tokenManager.clearLoginData(); + } else { + Intent intent = new Intent(this, HomeActivity.class); + startActivity(intent); + finish(); + return; + } } EdgeToEdge.enable(this); @@ -83,11 +89,20 @@ public class MainActivity extends AppCompatActivity { @Override public void onResponse(Call call, Response response) { if (response.isSuccessful() && response.body() != null) { + String role = response.body().getRole(); + + // Check if the user is a CUSTOMER and deny login if so + if ("CUSTOMER".equalsIgnoreCase(role)) { + Toast.makeText(MainActivity.this, "Access denied: Customers are not allowed to log in.", Toast.LENGTH_LONG).show(); + tvLoginStatus.setText("Customers are not allowed to log in"); + return; + } + //save login data in shared preferences TokenManager.getInstance(MainActivity.this).saveLoginData( response.body().getToken(), response.body().getUsername(), - response.body().getRole() + role ); //fetch user id from api then login to home activity diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/ListFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/ListFragment.java index cd07df63..75d8aacd 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/ListFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/ListFragment.java @@ -14,6 +14,7 @@ import android.widget.LinearLayout; import com.example.petstoremobile.R; +import com.example.petstoremobile.api.auth.TokenManager; import com.example.petstoremobile.fragments.listfragments.PetFragment; import com.example.petstoremobile.fragments.listfragments.ServiceFragment; import com.example.petstoremobile.fragments.listfragments.SupplierFragment; @@ -49,6 +50,13 @@ public class ListFragment extends Fragment { drawerInventory = view.findViewById(R.id.drawerInventory); drawerProducts = view.findViewById(R.id.drawerProducts); + // Check user role and restrict access for STAFF + String role = TokenManager.getInstance(requireContext()).getRole(); + if ("STAFF".equalsIgnoreCase(role)) { + drawerSuppliers.setVisibility(View.GONE); + drawerInventory.setVisibility(View.GONE); + } + //needed to disable touches on the innerContainer while the drawer is open touchBlocker = view.findViewById(R.id.touchBlocker); @@ -142,4 +150,4 @@ public class ListFragment extends Fragment { .addToBackStack(null) .commit(); } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/PetDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/PetDetailFragment.java index 6906bc73..70c19833 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/PetDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/PetDetailFragment.java @@ -25,6 +25,8 @@ import com.example.petstoremobile.api.RetrofitClient; import com.example.petstoremobile.dtos.PetDTO; import com.example.petstoremobile.fragments.ListFragment; import com.example.petstoremobile.fragments.listfragments.PetFragment; +import com.example.petstoremobile.utils.ActivityLogger; +import com.example.petstoremobile.utils.InputValidator; import retrofit2.Call; import retrofit2.Callback; @@ -65,26 +67,27 @@ public class PetDetailFragment extends Fragment { //Method to Update or Add a pet private void savePet() { + // Validates all fields using InputValidator + if (!InputValidator.isNotEmpty(etPetName, "Pet Name")) return; + if (!InputValidator.isNotEmpty(etPetSpecies, "Species")) return; + if (!InputValidator.isNotEmpty(etPetBreed, "Breed")) return; + if (!InputValidator.isPositiveInteger(etPetAge, "Age")) return; + if (!InputValidator.isPositiveDecimal(etPetPrice, "Price")) return; + //get all the values from the fields String name = etPetName.getText().toString().trim(); String species = etPetSpecies.getText().toString().trim(); String breed = etPetBreed.getText().toString().trim(); - String ageStr = etPetAge.getText().toString().trim(); + int age = Integer.parseInt(etPetAge.getText().toString().trim()); String priceStr = etPetPrice.getText().toString().trim(); String status = spinnerPetStatus.getSelectedItem().toString(); - //check if all the fields are filled - if (name.isEmpty() || species.isEmpty() || breed.isEmpty() || ageStr.isEmpty() || priceStr.isEmpty()) { - Toast.makeText(getContext(), "Please fill in all fields", Toast.LENGTH_SHORT).show(); - return; - } - //create a pet object to send to the API PetDTO petDTO = new PetDTO(); petDTO.setPetName(name); petDTO.setPetSpecies(species); petDTO.setPetBreed(breed); - petDTO.setPetAge(Integer.parseInt(ageStr)); + petDTO.setPetAge(age); petDTO.setPetPrice(priceStr); petDTO.setPetStatus(status); @@ -98,6 +101,7 @@ public class PetDetailFragment extends Fragment { @Override public void onResponse(Call call, Response response) { if (response.isSuccessful()) { + ActivityLogger.logChange(requireContext(), "Pet", "UPDATED", petId); Toast.makeText(getContext(), "Pet updated successfully!", Toast.LENGTH_SHORT).show(); navigateBack(); } else { @@ -107,6 +111,7 @@ public class PetDetailFragment extends Fragment { @Override public void onFailure(Call call, Throwable t) { + ActivityLogger.logException(requireContext(), "PetDetailFragment.updatePet", new Exception(t)); Log.e("PetDetailFragment", "Error updating pet", t); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); } @@ -117,6 +122,7 @@ public class PetDetailFragment extends Fragment { @Override public void onResponse(Call call, Response response) { if (response.isSuccessful()) { + ActivityLogger.log(requireContext(), "Added new Pet: " + name); Toast.makeText(getContext(), "Pet added successfully!", Toast.LENGTH_SHORT).show(); navigateBack(); } else { @@ -126,6 +132,7 @@ public class PetDetailFragment extends Fragment { @Override public void onFailure(Call call, Throwable t) { + ActivityLogger.logException(requireContext(), "PetDetailFragment.createPet", new Exception(t)); Log.e("PetDetailFragment", "Error adding pet", t); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); } @@ -146,6 +153,7 @@ public class PetDetailFragment extends Fragment { @Override public void onResponse(Call call, Response response) { if (response.isSuccessful()) { + ActivityLogger.logChange(requireContext(), "Pet", "DELETED", petId); Toast.makeText(getContext(), "Pet deleted successfully!", Toast.LENGTH_SHORT).show(); navigateBack(); } else { @@ -155,6 +163,7 @@ public class PetDetailFragment extends Fragment { @Override public void onFailure(Call call, Throwable t) { + ActivityLogger.logException(requireContext(), "PetDetailFragment.deletePet", new Exception(t)); Log.e("PetDetailFragment", "Error deleting pet", t); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); } diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/ServiceDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/ServiceDetailFragment.java index 2defbb69..c7b0a4c5 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/ServiceDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/ServiceDetailFragment.java @@ -20,6 +20,8 @@ import com.example.petstoremobile.api.ServiceApi; import com.example.petstoremobile.dtos.ServiceDTO; import com.example.petstoremobile.fragments.ListFragment; import com.example.petstoremobile.fragments.listfragments.ServiceFragment; +import com.example.petstoremobile.utils.ActivityLogger; +import com.example.petstoremobile.utils.InputValidator; import retrofit2.Call; import retrofit2.Callback; @@ -58,24 +60,24 @@ public class ServiceDetailFragment extends Fragment { //Method to Update or Add a service private void saveService() { + // Validates all fields using InputValidator + if (!InputValidator.isNotEmpty(etServiceName, "Service Name")) return; + if (!InputValidator.isNotEmpty(etServiceDesc, "Description")) return; + if (!InputValidator.isPositiveInteger(etServiceDuration, "Duration")) return; + if (!InputValidator.isPositiveDecimal(etServicePrice, "Price")) return; + //get all the values from the fields String name = etServiceName.getText().toString().trim(); String desc = etServiceDesc.getText().toString().trim(); - String durationStr = etServiceDuration.getText().toString().trim(); - String priceStr = etServicePrice.getText().toString().trim(); - - //check if all the fields are filled (desc is optional) - if (name.isEmpty() || durationStr.isEmpty() || priceStr.isEmpty()) { - Toast.makeText(getContext(), "Please fill in all fields", Toast.LENGTH_SHORT).show(); - return; - } + int duration = Integer.parseInt(etServiceDuration.getText().toString().trim()); + double price = Double.parseDouble(etServicePrice.getText().toString().trim()); //create a service object to send to the API ServiceDTO serviceDTO = new ServiceDTO(); serviceDTO.setServiceName(name); serviceDTO.setServiceDesc(desc); - serviceDTO.setServiceDuration(Integer.parseInt(durationStr)); - serviceDTO.setServicePrice(Double.parseDouble(priceStr)); + serviceDTO.setServiceDuration(duration); + serviceDTO.setServicePrice(price); ServiceApi serviceApi = RetrofitClient.getServiceApi(requireContext()); @@ -87,6 +89,7 @@ public class ServiceDetailFragment extends Fragment { @Override public void onResponse(Call call, Response response) { if (response.isSuccessful()) { + ActivityLogger.logChange(requireContext(), "Service", "UPDATED", serviceId); Toast.makeText(getContext(), "Service updated successfully!", Toast.LENGTH_SHORT).show(); navigateBack(); } else { @@ -96,6 +99,7 @@ public class ServiceDetailFragment extends Fragment { @Override public void onFailure(Call call, Throwable t) { + ActivityLogger.logException(requireContext(), "ServiceDetailFragment.updateService", new Exception(t)); Log.e("ServiceDetailFragment", "Error updating service", t); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); } @@ -106,6 +110,7 @@ public class ServiceDetailFragment extends Fragment { @Override public void onResponse(Call call, Response response) { if (response.isSuccessful()) { + ActivityLogger.log(requireContext(), "Added new Service: " + name); Toast.makeText(getContext(), "Service added successfully!", Toast.LENGTH_SHORT).show(); navigateBack(); } else { @@ -115,6 +120,7 @@ public class ServiceDetailFragment extends Fragment { @Override public void onFailure(Call call, Throwable t) { + ActivityLogger.logException(requireContext(), "ServiceDetailFragment.createService", new Exception(t)); Log.e("ServiceDetailFragment", "Error adding service", t); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); } @@ -134,6 +140,7 @@ public class ServiceDetailFragment extends Fragment { @Override public void onResponse(Call call, Response response) { if (response.isSuccessful()) { + ActivityLogger.logChange(requireContext(), "Service", "DELETED", serviceId); Toast.makeText(getContext(), "Service deleted successfully!", Toast.LENGTH_SHORT).show(); navigateBack(); } else { @@ -143,6 +150,7 @@ public class ServiceDetailFragment extends Fragment { @Override public void onFailure(Call call, Throwable t) { + ActivityLogger.logException(requireContext(), "ServiceDetailFragment.deleteService", new Exception(t)); Log.e("ServiceDetailFragment", "Error deleting service", t); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); } diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/SupplierDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/SupplierDetailFragment.java index 8537d6c2..df5c5520 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/SupplierDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/SupplierDetailFragment.java @@ -20,6 +20,8 @@ import com.example.petstoremobile.api.SupplierApi; import com.example.petstoremobile.dtos.SupplierDTO; import com.example.petstoremobile.fragments.ListFragment; import com.example.petstoremobile.fragments.listfragments.SupplierFragment; +import com.example.petstoremobile.utils.ActivityLogger; +import com.example.petstoremobile.utils.InputValidator; import retrofit2.Call; import retrofit2.Callback; @@ -58,6 +60,13 @@ public class SupplierDetailFragment extends Fragment { //Method to Update or Add a supplier private void saveSupplier() { + // Validates all fields using InputValidator + if (!InputValidator.isNotEmpty(etSupCompany, "Company Name")) return; + if (!InputValidator.isNotEmpty(etSupContactFirstName, "First Name")) return; + if (!InputValidator.isNotEmpty(etSupContactLastName, "Last Name")) return; + if (!InputValidator.isValidEmail(etSupEmail)) return; + if (!InputValidator.isValidPhone(etSupPhone)) return; + //get all the values from the fields String company = etSupCompany.getText().toString().trim(); String firstName = etSupContactFirstName.getText().toString().trim(); @@ -65,12 +74,6 @@ public class SupplierDetailFragment extends Fragment { String email = etSupEmail.getText().toString().trim(); String phone = etSupPhone.getText().toString().trim(); - //check if all the fields are filled - if (company.isEmpty() || firstName.isEmpty() || lastName.isEmpty() || email.isEmpty() || phone.isEmpty()) { - Toast.makeText(getContext(), "Please fill in all fields", Toast.LENGTH_SHORT).show(); - return; - } - //create a supplier object to send to the API SupplierDTO supplierDTO = new SupplierDTO(); supplierDTO.setSupCompany(company); @@ -89,6 +92,7 @@ public class SupplierDetailFragment extends Fragment { @Override public void onResponse(Call call, Response response) { if (response.isSuccessful()) { + ActivityLogger.logChange(requireContext(), "Supplier", "UPDATED", supId); Toast.makeText(getContext(), "Supplier updated successfully!", Toast.LENGTH_SHORT).show(); navigateBack(); } else { @@ -98,6 +102,7 @@ public class SupplierDetailFragment extends Fragment { @Override public void onFailure(Call call, Throwable t) { + ActivityLogger.logException(requireContext(), "SupplierDetailFragment.updateSupplier", new Exception(t)); Log.e("SupplierDetailFragment", "Error updating supplier", t); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); } @@ -108,6 +113,7 @@ public class SupplierDetailFragment extends Fragment { @Override public void onResponse(Call call, Response response) { if (response.isSuccessful()) { + ActivityLogger.log(requireContext(), "Added new Supplier: " + company); Toast.makeText(getContext(), "Supplier added successfully!", Toast.LENGTH_SHORT).show(); navigateBack(); } else { @@ -117,6 +123,7 @@ public class SupplierDetailFragment extends Fragment { @Override public void onFailure(Call call, Throwable t) { + ActivityLogger.logException(requireContext(), "SupplierDetailFragment.createSupplier", new Exception(t)); Log.e("SupplierDetailFragment", "Error adding supplier", t); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); } @@ -136,6 +143,7 @@ public class SupplierDetailFragment extends Fragment { @Override public void onResponse(Call call, Response response) { if (response.isSuccessful()) { + ActivityLogger.logChange(requireContext(), "Supplier", "DELETED", supId); Toast.makeText(getContext(), "Supplier deleted successfully!", Toast.LENGTH_SHORT).show(); navigateBack(); } else { @@ -145,6 +153,7 @@ public class SupplierDetailFragment extends Fragment { @Override public void onFailure(Call call, Throwable t) { + ActivityLogger.logException(requireContext(), "SupplierDetailFragment.deleteSupplier", new Exception(t)); Log.e("SupplierDetailFragment", "Error deleting supplier", t); Toast.makeText(getContext(), "Error: " + t.getMessage(), Toast.LENGTH_SHORT).show(); } @@ -197,6 +206,11 @@ public class SupplierDetailFragment extends Fragment { etSupContactLastName = view.findViewById(R.id.etSupContactLastName); etSupEmail = view.findViewById(R.id.etSupEmail); etSupPhone = view.findViewById(R.id.etSupPhone); + + // Add phone number formatting (CA) and limit length to 14 characters + etSupPhone.addTextChangedListener(new android.telephony.PhoneNumberFormattingTextWatcher("CA")); + etSupPhone.setFilters(new android.text.InputFilter[]{new android.text.InputFilter.LengthFilter(14)}); + btnSaveSupplier = view.findViewById(R.id.btnSaveSupplier); btnDeleteSupplier = view.findViewById(R.id.btnDeleteSupplier); btnBack = view.findViewById(R.id.btnBack); -- 2.49.1 From 75c39312fe1233f328625b2e9a2dd0012fd96f2a Mon Sep 17 00:00:00 2001 From: Alex <78383757+Lextical@users.noreply.github.com> Date: Thu, 26 Mar 2026 00:47:47 -0600 Subject: [PATCH 2/3] Added profile photo loading and uploading - profile photos now load from backend - profile photos can be uploaded to the backend - RetrofitClient now automatically determines if the device is an emulator or hardware so we dont have to comment and uncomment everytime we test with a different device --- android/app/build.gradle.kts | 3 + .../petstoremobile/api/RetrofitClient.java | 23 +++- .../petstoremobile/api/auth/AuthApi.java | 11 ++ .../fragments/ProfileFragment.java | 100 ++++++++++++++++-- 4 files changed, 125 insertions(+), 12 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d1cc3c30..fa8c88a7 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -56,6 +56,9 @@ dependencies { implementation("io.reactivex.rxjava2:rxjava:2.2.21") implementation("io.reactivex.rxjava2:rxandroid:2.1.1") + implementation("com.github.bumptech.glide:glide:4.16.0") + annotationProcessor("com.github.bumptech.glide:compiler:4.16.0") + testImplementation(libs.junit) androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.espresso.core) diff --git a/android/app/src/main/java/com/example/petstoremobile/api/RetrofitClient.java b/android/app/src/main/java/com/example/petstoremobile/api/RetrofitClient.java index 8a7f48bc..1500315f 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/RetrofitClient.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/RetrofitClient.java @@ -1,6 +1,7 @@ package com.example.petstoremobile.api; import android.content.Context; +import android.os.Build; import com.example.petstoremobile.api.auth.AuthApi; import com.example.petstoremobile.api.auth.AuthInterceptor; @@ -12,9 +13,23 @@ import retrofit2.converter.gson.GsonConverterFactory; //Retrofit client Used for API calls public class RetrofitClient { - //base URL - public static final String BASE_URL = "http://10.0.2.2:8080"; //for emulator testing -// public static final String BASE_URL = "http://10.0.0.200:8080/"; //for hardware testing + public static final String BASE_URL = getBaseUrl(); + + // Helper function to determine BASE_URL based on whether we are testing on an emulator or a real device + private static String getBaseUrl() { + if (Build.FINGERPRINT.contains("generic") + || Build.FINGERPRINT.contains("unknown") + || Build.MODEL.contains("google_sdk") + || Build.MODEL.contains("Emulator") + || Build.MODEL.contains("Android SDK built for x86") + || Build.MANUFACTURER.contains("Genymotion") + || (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) + || "google_sdk".equals(Build.PRODUCT)) { + return "http://10.0.2.2:8080/"; //emulator testing + } else { + return "http://10.0.0.200:8080/"; //Hardware testing + } + } private static Retrofit retrofit = null; @@ -67,4 +82,4 @@ public class RetrofitClient { return getClient(context).create(MessageApi.class); } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/example/petstoremobile/api/auth/AuthApi.java b/android/app/src/main/java/com/example/petstoremobile/api/auth/AuthApi.java index a60fc536..75605083 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/auth/AuthApi.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/auth/AuthApi.java @@ -5,15 +5,21 @@ import com.example.petstoremobile.dtos.UserDTO; import java.util.Map; +import okhttp3.MultipartBody; import retrofit2.Call; import retrofit2.http.Body; import retrofit2.http.GET; +import retrofit2.http.Multipart; import retrofit2.http.POST; import retrofit2.http.PUT; +import retrofit2.http.Part; //Api for logging in and getting current user public interface AuthApi { + // endpoint for downloading the current user's avatar file + String AVATAR_FILE_PATH = "api/v1/auth/me/avatar/file"; + //login endpoint @POST("api/v1/auth/login") Call login(@Body AuthDTO.LoginRequest loginRequest); @@ -26,4 +32,9 @@ public interface AuthApi { @PUT("api/v1/auth/me") Call updateMe(@Body Map updates); + //upload avatar endpoint + @Multipart + @POST("api/v1/auth/me/avatar") + Call uploadAvatar(@Part MultipartBody.Part avatar); + } diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/ProfileFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/ProfileFragment.java index e754b7fa..c7253c70 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/ProfileFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/ProfileFragment.java @@ -26,6 +26,10 @@ import android.widget.ImageView; import android.widget.TextView; import android.widget.Toast; +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.load.model.GlideUrl; +import com.bumptech.glide.load.model.LazyHeaders; import com.example.petstoremobile.R; import com.example.petstoremobile.activities.MainActivity; import com.example.petstoremobile.api.RetrofitClient; @@ -38,9 +42,14 @@ import com.example.petstoremobile.utils.InputValidator; import com.google.gson.Gson; import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; import java.util.HashMap; import java.util.Map; +import okhttp3.MediaType; +import okhttp3.MultipartBody; +import okhttp3.RequestBody; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; @@ -73,8 +82,7 @@ public class ProfileFragment extends Fragment { && result.getData() != null) { //get the selected image and set the image to the profile Uri selectedImage = result.getData().getData(); - imgProfile.setImageURI(selectedImage); - //TODO: SAVE CHANGED PHOTO TO DATABASE + uploadAvatar(selectedImage); } } ); @@ -86,10 +94,7 @@ public class ProfileFragment extends Fragment { success -> { //if a photo is taken set the image profile to it otherwise do nothing if (success) { - //Clear the old image and set the new one - imgProfile.setImageURI(null); - imgProfile.setImageURI(photoUri); - //TODO: SAVE CHANGED PHOTO TO DATABASE + uploadAvatar(photoUri); } } ); @@ -167,7 +172,6 @@ public class ProfileFragment extends Fragment { } }) .show(); - //TODO: UPDATE PHOTO IN DATABASE }); //Edit email button @@ -272,7 +276,31 @@ public class ProfileFragment extends Fragment { tvProfileEmail.setText(currentUser.getEmail()); tvProfilePhone.setText(currentUser.getPhone()); tvProfileRole.setText(currentUser.getRole()); - //TODO: LOAD PHOTO FROM DATABASE + + // get the avatar endpoint to load profile image and the token for authorization + String avatarUrl = RetrofitClient.BASE_URL + AuthApi.AVATAR_FILE_PATH; + String token = TokenManager.getInstance(requireContext()).getToken(); + + if (token != null) { + // Create GlideUrl with token to fetch the image + GlideUrl glideUrl = new GlideUrl(avatarUrl, new LazyHeaders.Builder() + .addHeader("Authorization", "Bearer " + token) + .build()); + + // Load image using Glide + Glide.with(ProfileFragment.this) + .load(glideUrl) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + .placeholder(R.drawable.placeholder) + .error(R.drawable.placeholder) + .into(imgProfile); + } else { + // load placeholder image if token is null + Glide.with(ProfileFragment.this) + .load(R.drawable.placeholder) + .into(imgProfile); + } } else { Log.e("onResponse: ", response.message()); @@ -288,6 +316,62 @@ public class ProfileFragment extends Fragment { }); } + //Helper function to call the backend to upload a profile image + private void uploadAvatar(Uri uri) { + try { + File file = getFileFromUri(uri); + if (file == null) return; + + // Create RequestBody for file upload + RequestBody requestFile = RequestBody.create(file, MediaType.parse(requireContext().getContentResolver().getType(uri))); + MultipartBody.Part body = MultipartBody.Part.createFormData("avatar", file.getName(), requestFile); + + //Call the backend to upload the avatar + AuthApi authApi = RetrofitClient.getAuthApi(requireContext()); + authApi.uploadAvatar(body).enqueue(new Callback() { + @Override + public void onResponse(Call call, Response response) { + if (response.isSuccessful() && response.body() != null) { + currentUser = response.body(); + Toast.makeText(requireContext(), "Avatar updated successfully", Toast.LENGTH_SHORT).show(); + // Reload image after successful upload + loadProfileData(); + } else { + Toast.makeText(requireContext(), "Failed to upload avatar", Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onFailure(Call call, Throwable t) { + Log.e("UPLOAD_AVATAR", "Failure: " + t.getMessage()); + Toast.makeText(requireContext(), "Network error", Toast.LENGTH_SHORT).show(); + } + }); + } catch (Exception e) { + Log.e("UPLOAD_AVATAR", "Error: " + e.getMessage()); + } + } + + // Helper function to create a temporary File object from a Uri for uploading the avatar + private File getFileFromUri(Uri uri) { + try { + InputStream inputStream = requireContext().getContentResolver().openInputStream(uri); + File tempFile = new File(requireContext().getCacheDir(), "upload_avatar.jpg"); + FileOutputStream outputStream = new FileOutputStream(tempFile); + byte[] buffer = new byte[1024]; + int length; + while ((length = inputStream.read(buffer)) > 0) { + outputStream.write(buffer, 0, length); + } + outputStream.close(); + inputStream.close(); + return tempFile; + } catch (Exception e) { + Log.e("FILE_UTILS", "Error creating temp file", e); + return null; + } + } + //Helper function to update a profile field in the backend private void updateProfileField(String fieldName, String value) { AuthApi authApi = RetrofitClient.getAuthApi(requireContext()); -- 2.49.1 From ba2764280709c78f6d38f22224f6933834516140 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Thu, 26 Mar 2026 19:56:17 -0600 Subject: [PATCH 3/3] add pet and product images --- backend/petshop-api.postman_collection.json | 298 +++++++++++++++++- .../controller/PetImageController.java | 94 ++++++ .../controller/ProductImageController.java | 61 ++++ .../petshop/backend/dto/pet/PetResponse.java | 17 +- .../backend/dto/product/ProductResponse.java | 17 +- .../java/com/petshop/backend/entity/Pet.java | 15 +- .../com/petshop/backend/entity/Product.java | 15 +- .../repository/AdoptionRepository.java | 4 + .../service/CatalogImageStorageService.java | 97 ++++++ .../petshop/backend/service/PetService.java | 120 ++++++- .../backend/service/ProductService.java | 81 ++++- .../migration/V8__pet_product_image_urls.sql | 5 + .../api/dto/pet/PetResponse.java | 9 + .../api/dto/product/ProductResponse.java | 9 + 14 files changed, 823 insertions(+), 19 deletions(-) create mode 100644 backend/src/main/java/com/petshop/backend/controller/PetImageController.java create mode 100644 backend/src/main/java/com/petshop/backend/controller/ProductImageController.java create mode 100644 backend/src/main/java/com/petshop/backend/service/CatalogImageStorageService.java create mode 100644 backend/src/main/resources/db/migration/V8__pet_product_image_urls.sql diff --git a/backend/petshop-api.postman_collection.json b/backend/petshop-api.postman_collection.json index 0d59e4ae..ace413b0 100644 --- a/backend/petshop-api.postman_collection.json +++ b/backend/petshop-api.postman_collection.json @@ -121,6 +121,10 @@ { "key": "bulkInventoryId", "value": "" + }, + { + "key": "adoptedPetId", + "value": "4" } ], "item": [ @@ -731,6 +735,95 @@ } ] }, + { + "name": "Upload Pet Image", + "request": { + "method": "POST", + "url": "{{baseUrl}}/api/v1/pets/{{petId}}/image", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{staffToken}}", + "type": "text" + } + ], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "image", + "type": "file", + "src": "{{avatarFile}}" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "var jsonData = pm.response.json();", + "pm.expect(jsonData.imageUrl).to.be.a('string');" + ] + } + } + ] + }, + { + "name": "Get Pet Image Public", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/v1/pets/{{petId}}/image" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "pm.test('Pet image response is an image', function () {", + " pm.expect(pm.response.headers.get('Content-Type')).to.include('image/');", + "});" + ] + } + } + ] + }, + { + "name": "Delete Pet Image", + "request": { + "method": "DELETE", + "url": "{{baseUrl}}/api/v1/pets/{{petId}}/image", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{staffToken}}", + "type": "text" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});" + ] + } + } + ] + }, { "name": "Delete Pet", "request": { @@ -838,6 +931,120 @@ } } ] + }, + { + "name": "Upload Adopted Pet Image", + "request": { + "method": "POST", + "url": "{{baseUrl}}/api/v1/pets/{{adoptedPetId}}/image", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{staffToken}}", + "type": "text" + } + ], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "image", + "type": "file", + "src": "{{avatarFile}}" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});" + ] + } + } + ] + }, + { + "name": "Get Adopted Pet Image Public", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/v1/pets/{{adoptedPetId}}/image" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 403', function () {", + " pm.response.to.have.status(403);", + "});" + ] + } + } + ] + }, + { + "name": "Get Adopted Pet Image As Staff", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/v1/pets/{{adoptedPetId}}/image", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{staffToken}}", + "type": "text" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "pm.test('Pet image response is an image', function () {", + " pm.expect(pm.response.headers.get('Content-Type')).to.include('image/');", + "});" + ] + } + } + ] + }, + { + "name": "Delete Adopted Pet Image", + "request": { + "method": "DELETE", + "url": "{{baseUrl}}/api/v1/pets/{{adoptedPetId}}/image", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{staffToken}}", + "type": "text" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});" + ] + } + } + ] } ] }, @@ -1084,6 +1291,95 @@ } } ] + }, + { + "name": "Upload Product Image", + "request": { + "method": "POST", + "url": "{{baseUrl}}/api/v1/products/{{productId}}/image", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{staffToken}}", + "type": "text" + } + ], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "image", + "type": "file", + "src": "{{avatarFile}}" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "var jsonData = pm.response.json();", + "pm.expect(jsonData.imageUrl).to.be.a('string');" + ] + } + } + ] + }, + { + "name": "Get Product Image", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/v1/products/{{productId}}/image" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "pm.test('Product image response is an image', function () {", + " pm.expect(pm.response.headers.get('Content-Type')).to.include('image/');", + "});" + ] + } + } + ] + }, + { + "name": "Delete Product Image", + "request": { + "method": "DELETE", + "url": "{{baseUrl}}/api/v1/products/{{productId}}/image", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminToken}}", + "type": "text" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});" + ] + } + } + ] } ] }, @@ -4537,4 +4833,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/backend/src/main/java/com/petshop/backend/controller/PetImageController.java b/backend/src/main/java/com/petshop/backend/controller/PetImageController.java new file mode 100644 index 00000000..bd9717ca --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/controller/PetImageController.java @@ -0,0 +1,94 @@ +package com.petshop.backend.controller; + +import com.petshop.backend.dto.pet.PetResponse; +import com.petshop.backend.entity.User; +import com.petshop.backend.security.AppPrincipal; +import com.petshop.backend.service.PetService; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/pets") +public class PetImageController { + + private final PetService petService; + + public PetImageController(PetService petService) { + this.petService = petService; + } + + @PostMapping("/{id}/image") + @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')") + public ResponseEntity uploadPetImage(@PathVariable Long id, @RequestParam("image") MultipartFile image) { + try { + PetResponse response = petService.uploadPetImage(id, image); + return ResponseEntity.ok(response); + } catch (IllegalArgumentException ex) { + return badRequest(ex.getMessage()); + } catch (IOException ex) { + return badRequest("Failed to upload pet image: " + ex.getMessage()); + } + } + + @GetMapping("/{id}/image") + public ResponseEntity getPetImage(@PathVariable Long id) { + try { + PetService.ImagePayload payload = petService.loadPetImage(id, currentUserId(), currentUserRole()); + return ResponseEntity.ok().contentType(payload.mediaType()).body(payload.resource()); + } catch (PetService.ForbiddenImageAccessException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + } + + @DeleteMapping("/{id}/image") + @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')") + public ResponseEntity deletePetImage(@PathVariable Long id) { + return ResponseEntity.ok(petService.deletePetImage(id)); + } + + private ResponseEntity> badRequest(String message) { + Map error = new HashMap<>(); + error.put("message", message); + return ResponseEntity.badRequest().body(error); + } + + private Long currentUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + return null; + } + Object principal = authentication.getPrincipal(); + if (principal instanceof AppPrincipal appPrincipal) { + return appPrincipal.getUserId(); + } + return null; + } + + private User.Role currentUserRole() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + return null; + } + Object principal = authentication.getPrincipal(); + if (principal instanceof AppPrincipal appPrincipal) { + return appPrincipal.getRole(); + } + return null; + } +} diff --git a/backend/src/main/java/com/petshop/backend/controller/ProductImageController.java b/backend/src/main/java/com/petshop/backend/controller/ProductImageController.java new file mode 100644 index 00000000..015847ad --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/controller/ProductImageController.java @@ -0,0 +1,61 @@ +package com.petshop.backend.controller; + +import com.petshop.backend.dto.product.ProductResponse; +import com.petshop.backend.service.ProductService; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/products") +public class ProductImageController { + + private final ProductService productService; + + public ProductImageController(ProductService productService) { + this.productService = productService; + } + + @PostMapping("/{id}/image") + @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')") + public ResponseEntity uploadProductImage(@PathVariable Long id, @RequestParam("image") MultipartFile image) { + try { + ProductResponse response = productService.uploadProductImage(id, image); + return ResponseEntity.ok(response); + } catch (IllegalArgumentException ex) { + return badRequest(ex.getMessage()); + } catch (IOException ex) { + return badRequest("Failed to upload product image: " + ex.getMessage()); + } + } + + @GetMapping("/{id}/image") + public ResponseEntity getProductImage(@PathVariable Long id) { + ProductService.ImagePayload payload = productService.loadProductImage(id); + return ResponseEntity.ok().contentType(payload.mediaType()).body(payload.resource()); + } + + @DeleteMapping("/{id}/image") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity deleteProductImage(@PathVariable Long id) { + return ResponseEntity.ok(productService.deleteProductImage(id)); + } + + private ResponseEntity> badRequest(String message) { + Map error = new HashMap<>(); + error.put("message", message); + return ResponseEntity.badRequest().body(error); + } +} diff --git a/backend/src/main/java/com/petshop/backend/dto/pet/PetResponse.java b/backend/src/main/java/com/petshop/backend/dto/pet/PetResponse.java index f691361a..e3213653 100644 --- a/backend/src/main/java/com/petshop/backend/dto/pet/PetResponse.java +++ b/backend/src/main/java/com/petshop/backend/dto/pet/PetResponse.java @@ -12,13 +12,14 @@ public class PetResponse { private Integer petAge; private String petStatus; private BigDecimal petPrice; + private String imageUrl; private LocalDateTime createdAt; private LocalDateTime updatedAt; public PetResponse() { } - public PetResponse(Long petId, String petName, String petSpecies, String petBreed, Integer petAge, String petStatus, BigDecimal petPrice, LocalDateTime createdAt, LocalDateTime updatedAt) { + public PetResponse(Long petId, String petName, String petSpecies, String petBreed, Integer petAge, String petStatus, BigDecimal petPrice, String imageUrl, LocalDateTime createdAt, LocalDateTime updatedAt) { this.petId = petId; this.petName = petName; this.petSpecies = petSpecies; @@ -26,6 +27,7 @@ public class PetResponse { this.petAge = petAge; this.petStatus = petStatus; this.petPrice = petPrice; + this.imageUrl = imageUrl; this.createdAt = createdAt; this.updatedAt = updatedAt; } @@ -86,6 +88,14 @@ public class PetResponse { this.petPrice = petPrice; } + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + public LocalDateTime getCreatedAt() { return createdAt; } @@ -107,12 +117,12 @@ public class PetResponse { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; PetResponse that = (PetResponse) o; - return Objects.equals(petId, that.petId) && Objects.equals(petName, that.petName) && Objects.equals(petSpecies, that.petSpecies) && Objects.equals(petBreed, that.petBreed) && Objects.equals(petAge, that.petAge) && Objects.equals(petStatus, that.petStatus) && Objects.equals(petPrice, that.petPrice) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt); + return Objects.equals(petId, that.petId) && Objects.equals(petName, that.petName) && Objects.equals(petSpecies, that.petSpecies) && Objects.equals(petBreed, that.petBreed) && Objects.equals(petAge, that.petAge) && Objects.equals(petStatus, that.petStatus) && Objects.equals(petPrice, that.petPrice) && Objects.equals(imageUrl, that.imageUrl) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt); } @Override public int hashCode() { - return Objects.hash(petId, petName, petSpecies, petBreed, petAge, petStatus, petPrice, createdAt, updatedAt); + return Objects.hash(petId, petName, petSpecies, petBreed, petAge, petStatus, petPrice, imageUrl, createdAt, updatedAt); } @Override @@ -125,6 +135,7 @@ public class PetResponse { ", petAge=" + petAge + ", petStatus='" + petStatus + '\'' + ", petPrice=" + petPrice + + ", imageUrl='" + imageUrl + '\'' + ", createdAt=" + createdAt + ", updatedAt=" + updatedAt + '}'; diff --git a/backend/src/main/java/com/petshop/backend/dto/product/ProductResponse.java b/backend/src/main/java/com/petshop/backend/dto/product/ProductResponse.java index 96baa5ce..c08abf9a 100644 --- a/backend/src/main/java/com/petshop/backend/dto/product/ProductResponse.java +++ b/backend/src/main/java/com/petshop/backend/dto/product/ProductResponse.java @@ -11,19 +11,21 @@ public class ProductResponse { private String categoryName; private String prodDesc; private BigDecimal prodPrice; + private String imageUrl; private LocalDateTime createdAt; private LocalDateTime updatedAt; public ProductResponse() { } - public ProductResponse(Long prodId, String prodName, Long categoryId, String categoryName, String prodDesc, BigDecimal prodPrice, LocalDateTime createdAt, LocalDateTime updatedAt) { + public ProductResponse(Long prodId, String prodName, Long categoryId, String categoryName, String prodDesc, BigDecimal prodPrice, String imageUrl, LocalDateTime createdAt, LocalDateTime updatedAt) { this.prodId = prodId; this.prodName = prodName; this.categoryId = categoryId; this.categoryName = categoryName; this.prodDesc = prodDesc; this.prodPrice = prodPrice; + this.imageUrl = imageUrl; this.createdAt = createdAt; this.updatedAt = updatedAt; } @@ -76,6 +78,14 @@ public class ProductResponse { this.prodPrice = prodPrice; } + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + public LocalDateTime getCreatedAt() { return createdAt; } @@ -97,12 +107,12 @@ public class ProductResponse { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; ProductResponse that = (ProductResponse) o; - return Objects.equals(prodId, that.prodId) && Objects.equals(prodName, that.prodName) && Objects.equals(categoryId, that.categoryId) && Objects.equals(categoryName, that.categoryName) && Objects.equals(prodDesc, that.prodDesc) && Objects.equals(prodPrice, that.prodPrice) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt); + return Objects.equals(prodId, that.prodId) && Objects.equals(prodName, that.prodName) && Objects.equals(categoryId, that.categoryId) && Objects.equals(categoryName, that.categoryName) && Objects.equals(prodDesc, that.prodDesc) && Objects.equals(prodPrice, that.prodPrice) && Objects.equals(imageUrl, that.imageUrl) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt); } @Override public int hashCode() { - return Objects.hash(prodId, prodName, categoryId, categoryName, prodDesc, prodPrice, createdAt, updatedAt); + return Objects.hash(prodId, prodName, categoryId, categoryName, prodDesc, prodPrice, imageUrl, createdAt, updatedAt); } @Override @@ -114,6 +124,7 @@ public class ProductResponse { ", categoryName='" + categoryName + '\'' + ", prodDesc='" + prodDesc + '\'' + ", prodPrice=" + prodPrice + + ", imageUrl='" + imageUrl + '\'' + ", createdAt=" + createdAt + ", updatedAt=" + updatedAt + '}'; diff --git a/backend/src/main/java/com/petshop/backend/entity/Pet.java b/backend/src/main/java/com/petshop/backend/entity/Pet.java index e827f612..8f6a6020 100644 --- a/backend/src/main/java/com/petshop/backend/entity/Pet.java +++ b/backend/src/main/java/com/petshop/backend/entity/Pet.java @@ -35,6 +35,9 @@ public class Pet { @Column(nullable = false, precision = 10, scale = 2) private BigDecimal petPrice; + @Column(length = 255) + private String imageUrl; + @CreationTimestamp @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; @@ -46,7 +49,7 @@ public class Pet { public Pet() { } - public Pet(Long id, String petName, String petSpecies, String petBreed, Integer petAge, String petStatus, BigDecimal petPrice, LocalDateTime createdAt, LocalDateTime updatedAt) { + public Pet(Long id, String petName, String petSpecies, String petBreed, Integer petAge, String petStatus, BigDecimal petPrice, String imageUrl, LocalDateTime createdAt, LocalDateTime updatedAt) { this.id = id; this.petName = petName; this.petSpecies = petSpecies; @@ -54,6 +57,7 @@ public class Pet { this.petAge = petAge; this.petStatus = petStatus; this.petPrice = petPrice; + this.imageUrl = imageUrl; this.createdAt = createdAt; this.updatedAt = updatedAt; } @@ -114,6 +118,14 @@ public class Pet { this.petPrice = petPrice; } + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + public LocalDateTime getCreatedAt() { return createdAt; } @@ -153,6 +165,7 @@ public class Pet { ", petAge=" + petAge + ", petStatus='" + petStatus + '\'' + ", petPrice=" + petPrice + + ", imageUrl='" + imageUrl + '\'' + ", createdAt=" + createdAt + ", updatedAt=" + updatedAt + '}'; diff --git a/backend/src/main/java/com/petshop/backend/entity/Product.java b/backend/src/main/java/com/petshop/backend/entity/Product.java index 9eb9c2d6..84c17c1a 100644 --- a/backend/src/main/java/com/petshop/backend/entity/Product.java +++ b/backend/src/main/java/com/petshop/backend/entity/Product.java @@ -29,6 +29,9 @@ public class Product { @Column(nullable = false, precision = 10, scale = 2) private BigDecimal prodPrice; + @Column(length = 255) + private String imageUrl; + @CreationTimestamp @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; @@ -40,12 +43,13 @@ public class Product { public Product() { } - public Product(Long prodId, String prodName, Category category, String prodDesc, BigDecimal prodPrice, LocalDateTime createdAt, LocalDateTime updatedAt) { + public Product(Long prodId, String prodName, Category category, String prodDesc, BigDecimal prodPrice, String imageUrl, LocalDateTime createdAt, LocalDateTime updatedAt) { this.prodId = prodId; this.prodName = prodName; this.category = category; this.prodDesc = prodDesc; this.prodPrice = prodPrice; + this.imageUrl = imageUrl; this.createdAt = createdAt; this.updatedAt = updatedAt; } @@ -90,6 +94,14 @@ public class Product { this.prodPrice = prodPrice; } + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + public LocalDateTime getCreatedAt() { return createdAt; } @@ -127,6 +139,7 @@ public class Product { ", category=" + category + ", prodDesc='" + prodDesc + '\'' + ", prodPrice=" + prodPrice + + ", imageUrl='" + imageUrl + '\'' + ", createdAt=" + createdAt + ", updatedAt=" + updatedAt + '}'; diff --git a/backend/src/main/java/com/petshop/backend/repository/AdoptionRepository.java b/backend/src/main/java/com/petshop/backend/repository/AdoptionRepository.java index d009b17a..2af9c52f 100644 --- a/backend/src/main/java/com/petshop/backend/repository/AdoptionRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/AdoptionRepository.java @@ -8,6 +8,8 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface AdoptionRepository extends JpaRepository { @@ -24,4 +26,6 @@ public interface AdoptionRepository extends JpaRepository { "LOWER(a.customer.lastName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + "LOWER(a.pet.petName) LIKE LOWER(CONCAT('%', :q, '%')))") Page searchAdoptionsByCustomer(@Param("customerId") Long customerId, @Param("q") String query, Pageable pageable); + + Optional findFirstByPet_IdAndAdoptionStatusOrderByAdoptionDateDesc(Long petId, String adoptionStatus); } diff --git a/backend/src/main/java/com/petshop/backend/service/CatalogImageStorageService.java b/backend/src/main/java/com/petshop/backend/service/CatalogImageStorageService.java new file mode 100644 index 00000000..34a92ff0 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/service/CatalogImageStorageService.java @@ -0,0 +1,97 @@ +package com.petshop.backend.service; + +import org.springframework.core.io.PathResource; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.http.MediaTypeFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.Locale; +import java.util.UUID; + +@Service +public class CatalogImageStorageService { + + private static final String PET_PREFIX = "/uploads/pets/"; + private static final String PRODUCT_PREFIX = "/uploads/products/"; + + public String storePetImage(MultipartFile file) throws IOException { + return storeImage(file, Paths.get("uploads", "pets").toAbsolutePath().normalize(), PET_PREFIX); + } + + public String storeProductImage(MultipartFile file) throws IOException { + return storeImage(file, Paths.get("uploads", "products").toAbsolutePath().normalize(), PRODUCT_PREFIX); + } + + public Resource loadPetImage(String storedPath) { + return new PathResource(resolveStoredPath(storedPath, Paths.get("uploads", "pets").toAbsolutePath().normalize(), PET_PREFIX)); + } + + public Resource loadProductImage(String storedPath) { + return new PathResource(resolveStoredPath(storedPath, Paths.get("uploads", "products").toAbsolutePath().normalize(), PRODUCT_PREFIX)); + } + + public MediaType resolveMediaType(Resource resource) { + return MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM); + } + + public void deletePetImage(String storedPath) throws IOException { + deleteImage(storedPath, Paths.get("uploads", "pets").toAbsolutePath().normalize(), PET_PREFIX); + } + + public void deleteProductImage(String storedPath) throws IOException { + deleteImage(storedPath, Paths.get("uploads", "products").toAbsolutePath().normalize(), PRODUCT_PREFIX); + } + + private String storeImage(MultipartFile file, Path directory, String prefix) throws IOException { + Files.createDirectories(directory); + String extension = resolveExtension(file.getOriginalFilename()); + String filename = UUID.randomUUID() + extension; + Path filePath = directory.resolve(filename).normalize(); + Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING); + return prefix + filename; + } + + private void deleteImage(String storedPath, Path directory, String prefix) throws IOException { + if (storedPath == null || storedPath.isBlank()) { + return; + } + Files.deleteIfExists(resolveStoredPath(storedPath, directory, prefix)); + } + + private Path resolveStoredPath(String storedPath, Path directory, String prefix) { + if (storedPath == null || storedPath.isBlank() || !storedPath.startsWith(prefix)) { + throw new IllegalArgumentException("Image file was not found"); + } + String filename = storedPath.substring(prefix.length()); + if (filename.isBlank() || filename.contains("/") || filename.contains("\\") || filename.contains("..")) { + throw new IllegalArgumentException("Image file was not found"); + } + Path resolved = directory.resolve(filename).normalize(); + if (!resolved.startsWith(directory)) { + throw new IllegalArgumentException("Image file was not found"); + } + return resolved; + } + + private String resolveExtension(String originalFilename) { + if (originalFilename == null) { + return ".jpg"; + } + int extensionIndex = originalFilename.lastIndexOf('.'); + if (extensionIndex < 0 || extensionIndex == originalFilename.length() - 1) { + return ".jpg"; + } + String extension = originalFilename.substring(extensionIndex).toLowerCase(Locale.ROOT); + return switch (extension) { + case ".jpg", ".jpeg", ".png", ".gif" -> extension; + default -> ".jpg"; + }; + } +} diff --git a/backend/src/main/java/com/petshop/backend/service/PetService.java b/backend/src/main/java/com/petshop/backend/service/PetService.java index b59d589b..5c35dfd1 100644 --- a/backend/src/main/java/com/petshop/backend/service/PetService.java +++ b/backend/src/main/java/com/petshop/backend/service/PetService.java @@ -3,21 +3,34 @@ package com.petshop.backend.service; import com.petshop.backend.dto.common.BulkDeleteRequest; import com.petshop.backend.dto.pet.PetRequest; import com.petshop.backend.dto.pet.PetResponse; +import com.petshop.backend.entity.Adoption; import com.petshop.backend.entity.Pet; +import com.petshop.backend.entity.User; import com.petshop.backend.exception.ResourceNotFoundException; +import com.petshop.backend.repository.AdoptionRepository; import com.petshop.backend.repository.PetRepository; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.Locale; @Service public class PetService { private final PetRepository petRepository; + private final AdoptionRepository adoptionRepository; + private final CatalogImageStorageService catalogImageStorageService; - public PetService(PetRepository petRepository) { + public PetService(PetRepository petRepository, AdoptionRepository adoptionRepository, CatalogImageStorageService catalogImageStorageService) { this.petRepository = petRepository; + this.adoptionRepository = adoptionRepository; + this.catalogImageStorageService = catalogImageStorageService; } public Page getAllPets(String query, Pageable pageable) { @@ -68,17 +81,107 @@ public class PetService { @Transactional public void deletePet(Long id) { - if (!petRepository.existsById(id)) { - throw new ResourceNotFoundException("Pet not found with id: " + id); - } - petRepository.deleteById(id); + Pet pet = petRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id)); + deleteStoredImageIfPresent(pet.getImageUrl()); + petRepository.delete(pet); } @Transactional public void bulkDeletePets(BulkDeleteRequest request) { + petRepository.findAllById(request.getIds()).forEach(pet -> deleteStoredImageIfPresent(pet.getImageUrl())); petRepository.deleteAllById(request.getIds()); } + @Transactional + public PetResponse uploadPetImage(Long id, MultipartFile file) throws IOException { + validateImageFile(file); + Pet pet = findPet(id); + deleteStoredImageIfPresent(pet.getImageUrl()); + pet.setImageUrl(catalogImageStorageService.storePetImage(file)); + return mapToResponse(petRepository.save(pet)); + } + + @Transactional + public PetResponse deletePetImage(Long id) { + Pet pet = findPet(id); + deleteStoredImageIfPresent(pet.getImageUrl()); + pet.setImageUrl(null); + return mapToResponse(petRepository.save(pet)); + } + + public ImagePayload loadPetImage(Long id, Long requesterUserId, User.Role requesterRole) { + Pet pet = findPet(id); + if (pet.getImageUrl() == null || pet.getImageUrl().isBlank()) { + throw new ResourceNotFoundException("Pet image not found for id: " + id); + } + if (!canViewPetImage(pet, requesterUserId, requesterRole)) { + throw new ForbiddenImageAccessException(); + } + Resource resource = catalogImageStorageService.loadPetImage(pet.getImageUrl()); + MediaType mediaType = catalogImageStorageService.resolveMediaType(resource); + return new ImagePayload(resource, mediaType); + } + + public boolean isPubliclyVisible(Pet pet) { + return "available".equalsIgnoreCase(normalizeStatus(pet.getPetStatus())); + } + + private boolean canViewPetImage(Pet pet, Long requesterUserId, User.Role requesterRole) { + if (isPubliclyVisible(pet)) { + return true; + } + if (requesterRole == User.Role.STAFF || requesterRole == User.Role.ADMIN) { + return true; + } + if (requesterUserId == null) { + return false; + } + if (!"adopted".equalsIgnoreCase(normalizeStatus(pet.getPetStatus()))) { + return false; + } + return adoptionRepository.findFirstByPet_IdAndAdoptionStatusOrderByAdoptionDateDesc(pet.getPetId(), "Completed") + .map(Adoption::getCustomer) + .map(customer -> requesterUserId.equals(customer.getUserId())) + .orElse(false); + } + + private Pet findPet(Long id) { + return petRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id)); + } + + private void validateImageFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("Please select an image to upload"); + } + if (file.getSize() > 5 * 1024 * 1024) { + throw new IllegalArgumentException("Image file size must be less than 5MB"); + } + String contentType = file.getContentType(); + if (contentType == null) { + throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed"); + } + String normalized = contentType.toLowerCase(Locale.ROOT); + if (!normalized.equals("image/jpeg") && !normalized.equals("image/png") && !normalized.equals("image/gif")) { + throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed"); + } + } + + private void deleteStoredImageIfPresent(String storedImagePath) { + if (storedImagePath == null || storedImagePath.isBlank()) { + return; + } + try { + catalogImageStorageService.deletePetImage(storedImagePath); + } catch (IOException ignored) { + } + } + + private String normalizeStatus(String status) { + return status == null ? "" : status.trim(); + } + private PetResponse mapToResponse(Pet pet) { return new PetResponse( pet.getPetId(), @@ -88,8 +191,15 @@ public class PetService { pet.getPetAge(), pet.getPetStatus(), pet.getPetPrice(), + pet.getImageUrl() != null && !pet.getImageUrl().isBlank() ? "/api/v1/pets/" + pet.getPetId() + "/image" : null, pet.getCreatedAt(), pet.getUpdatedAt() ); } + + public record ImagePayload(Resource resource, MediaType mediaType) { + } + + public static class ForbiddenImageAccessException extends RuntimeException { + } } diff --git a/backend/src/main/java/com/petshop/backend/service/ProductService.java b/backend/src/main/java/com/petshop/backend/service/ProductService.java index b907e38f..0473a8eb 100644 --- a/backend/src/main/java/com/petshop/backend/service/ProductService.java +++ b/backend/src/main/java/com/petshop/backend/service/ProductService.java @@ -8,20 +8,28 @@ import com.petshop.backend.entity.Product; import com.petshop.backend.exception.ResourceNotFoundException; import com.petshop.backend.repository.CategoryRepository; import com.petshop.backend.repository.ProductRepository; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.Locale; @Service public class ProductService { private final ProductRepository productRepository; private final CategoryRepository categoryRepository; + private final CatalogImageStorageService catalogImageStorageService; - public ProductService(ProductRepository productRepository, CategoryRepository categoryRepository) { + public ProductService(ProductRepository productRepository, CategoryRepository categoryRepository, CatalogImageStorageService catalogImageStorageService) { this.productRepository = productRepository; this.categoryRepository = categoryRepository; + this.catalogImageStorageService = catalogImageStorageService; } public Page getAllProducts(String query, Pageable pageable) { @@ -74,17 +82,76 @@ public class ProductService { @Transactional public void deleteProduct(Long id) { - if (!productRepository.existsById(id)) { - throw new ResourceNotFoundException("Product not found with id: " + id); - } - productRepository.deleteById(id); + Product product = findProduct(id); + deleteStoredImageIfPresent(product.getImageUrl()); + productRepository.delete(product); } @Transactional public void bulkDeleteProducts(BulkDeleteRequest request) { + productRepository.findAllById(request.getIds()).forEach(product -> deleteStoredImageIfPresent(product.getImageUrl())); productRepository.deleteAllById(request.getIds()); } + @Transactional + public ProductResponse uploadProductImage(Long id, MultipartFile file) throws IOException { + validateImageFile(file); + Product product = findProduct(id); + deleteStoredImageIfPresent(product.getImageUrl()); + product.setImageUrl(catalogImageStorageService.storeProductImage(file)); + return mapToResponse(productRepository.save(product)); + } + + @Transactional + public ProductResponse deleteProductImage(Long id) { + Product product = findProduct(id); + deleteStoredImageIfPresent(product.getImageUrl()); + product.setImageUrl(null); + return mapToResponse(productRepository.save(product)); + } + + public ImagePayload loadProductImage(Long id) { + Product product = findProduct(id); + if (product.getImageUrl() == null || product.getImageUrl().isBlank()) { + throw new ResourceNotFoundException("Product image not found with id: " + id); + } + Resource resource = catalogImageStorageService.loadProductImage(product.getImageUrl()); + MediaType mediaType = catalogImageStorageService.resolveMediaType(resource); + return new ImagePayload(resource, mediaType); + } + + private Product findProduct(Long id) { + return productRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id)); + } + + private void validateImageFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("Please select an image to upload"); + } + if (file.getSize() > 5 * 1024 * 1024) { + throw new IllegalArgumentException("Image file size must be less than 5MB"); + } + String contentType = file.getContentType(); + if (contentType == null) { + throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed"); + } + String normalized = contentType.toLowerCase(Locale.ROOT); + if (!normalized.equals("image/jpeg") && !normalized.equals("image/png") && !normalized.equals("image/gif")) { + throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed"); + } + } + + private void deleteStoredImageIfPresent(String storedImagePath) { + if (storedImagePath == null || storedImagePath.isBlank()) { + return; + } + try { + catalogImageStorageService.deleteProductImage(storedImagePath); + } catch (IOException ignored) { + } + } + private ProductResponse mapToResponse(Product product) { return new ProductResponse( product.getProdId(), @@ -93,8 +160,12 @@ public class ProductService { product.getCategory().getCategoryName(), product.getProdDesc(), product.getProdPrice(), + product.getImageUrl() != null && !product.getImageUrl().isBlank() ? "/api/v1/products/" + product.getProdId() + "/image" : null, product.getCreatedAt(), product.getUpdatedAt() ); } + + public record ImagePayload(Resource resource, MediaType mediaType) { + } } diff --git a/backend/src/main/resources/db/migration/V8__pet_product_image_urls.sql b/backend/src/main/resources/db/migration/V8__pet_product_image_urls.sql new file mode 100644 index 00000000..a4c98248 --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__pet_product_image_urls.sql @@ -0,0 +1,5 @@ +ALTER TABLE pet + ADD COLUMN imageUrl VARCHAR(255) NULL; + +ALTER TABLE product + ADD COLUMN imageUrl VARCHAR(255) NULL; diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/dto/pet/PetResponse.java b/desktop/src/main/java/org/example/petshopdesktop/api/dto/pet/PetResponse.java index a7932253..b1155214 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/dto/pet/PetResponse.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/dto/pet/PetResponse.java @@ -11,6 +11,7 @@ public class PetResponse { private Integer petAge; private String petStatus; private BigDecimal petPrice; + private String imageUrl; private LocalDateTime createdAt; private LocalDateTime updatedAt; @@ -73,6 +74,14 @@ public class PetResponse { this.petPrice = petPrice; } + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + public LocalDateTime getCreatedAt() { return createdAt; } diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/dto/product/ProductResponse.java b/desktop/src/main/java/org/example/petshopdesktop/api/dto/product/ProductResponse.java index c989fdf5..18f9b678 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/dto/product/ProductResponse.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/dto/product/ProductResponse.java @@ -8,6 +8,7 @@ public class ProductResponse { private String categoryName; private BigDecimal prodPrice; private String prodDesc; + private String imageUrl; public ProductResponse() { } @@ -51,4 +52,12 @@ public class ProductResponse { public void setProdDesc(String prodDesc) { this.prodDesc = prodDesc; } + + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } } -- 2.49.1