Merge remote-tracking branch 'origin/main' into web-products

This commit is contained in:
2026-03-30 09:50:57 -06:00
140 changed files with 7952 additions and 1650 deletions

View File

@@ -33,6 +33,7 @@ public class DevStackApplication {
docker.ensureDockerAvailable();
docker.startDatabase();
context = new SpringApplicationBuilder(BackendApplication.class)
.profiles("local")
.initializers(new FlywayContextInitializer())
.run(args);
context.addApplicationListener(event -> {

View File

@@ -0,0 +1,37 @@
package com.petshop.backend.config;
import com.petshop.backend.repository.PetRepository;
import com.petshop.backend.repository.ProductRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
@Component
@Profile("local")
public class LocalCatalogSeedInitializer implements CommandLineRunner {
private final DataSource dataSource;
private final PetRepository petRepository;
private final ProductRepository productRepository;
public LocalCatalogSeedInitializer(DataSource dataSource, PetRepository petRepository, ProductRepository productRepository) {
this.dataSource = dataSource;
this.petRepository = petRepository;
this.productRepository = productRepository;
}
@Override
public void run(String... args) {
if (petRepository.count() > 6 || productRepository.count() > 6) {
return;
}
ResourceDatabasePopulator populator = new ResourceDatabasePopulator(false, false, "UTF-8",
new ClassPathResource("dev/expand_pet_product_seed.sql"));
populator.execute(dataSource);
}
}

View File

@@ -1,26 +1,40 @@
package com.petshop.backend.controller;
import com.petshop.backend.dto.analytics.DashboardResponse;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.service.AnalyticsService;
import com.petshop.backend.util.AuthenticationHelper;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/v1/analytics")
@PreAuthorize("hasRole('ADMIN')")
@PreAuthorize("hasAnyRole('ADMIN', 'STAFF')")
public class AnalyticsController {
private final AnalyticsService analyticsService;
private final UserRepository userRepository;
public AnalyticsController(AnalyticsService analyticsService) {
public AnalyticsController(AnalyticsService analyticsService, UserRepository userRepository) {
this.analyticsService = analyticsService;
this.userRepository = userRepository;
}
@GetMapping("/dashboard")
public ResponseEntity<DashboardResponse> getDashboard(
@RequestParam(defaultValue = "30") int days,
@RequestParam(defaultValue = "10") int top) {
return ResponseEntity.ok(analyticsService.getDashboardData(days, top));
if (days < 1 || days > 365) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "days must be between 1 and 365");
}
if (top < 1 || top > 50) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "top must be between 1 and 50");
}
User user = AuthenticationHelper.getAuthenticatedUser(userRepository);
return ResponseEntity.ok(analyticsService.getDashboardData(days, top, user));
}
}

View File

@@ -25,8 +25,9 @@ public class CategoryController {
@GetMapping
public ResponseEntity<Page<CategoryResponse>> getAllCategories(
@RequestParam(required = false) String q,
@RequestParam(required = false) String type,
Pageable pageable) {
return ResponseEntity.ok(categoryService.getAllCategories(q, pageable));
return ResponseEntity.ok(categoryService.getAllCategories(q, type, pageable));
}
@GetMapping("/{id}")

View File

@@ -4,6 +4,7 @@ import com.petshop.backend.dto.chat.ConversationRequest;
import com.petshop.backend.dto.chat.ConversationResponse;
import com.petshop.backend.dto.chat.MessageRequest;
import com.petshop.backend.dto.chat.MessageResponse;
import com.petshop.backend.dto.chat.UpdateConversationRequest;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.CustomerRepository;
import com.petshop.backend.repository.UserRepository;
@@ -96,4 +97,13 @@ public class ChatController {
chatRealtimeService.publishConversationUpdate(id);
return ResponseEntity.ok(conversation);
}
@PutMapping("/conversations/{id}")
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
public ResponseEntity<ConversationResponse> updateConversation(@PathVariable Long id, @Valid @RequestBody UpdateConversationRequest request) {
User user = getCurrentUser();
ConversationResponse conversation = chatService.updateConversation(id, user.getId(), user.getRole(), request);
chatRealtimeService.publishConversationUpdate(id);
return ResponseEntity.ok(conversation);
}
}

View File

@@ -82,6 +82,29 @@ public class DropdownController {
);
}
@GetMapping("/product-categories")
public ResponseEntity<List<DropdownOption>> getProductCategories() {
return ResponseEntity.ok(
categoryRepository.findAll().stream()
.filter(c -> "product".equalsIgnoreCase(c.getCategoryType()))
.map(c -> new DropdownOption(c.getCategoryId(), c.getCategoryName()))
.collect(Collectors.toList())
);
}
@GetMapping("/pet-species")
public ResponseEntity<List<DropdownOption>> getPetSpecies() {
return ResponseEntity.ok(
petRepository.findAll().stream()
.map(p -> p.getPetSpecies())
.filter(species -> species != null && !species.isBlank())
.distinct()
.sorted(String.CASE_INSENSITIVE_ORDER)
.map(species -> new DropdownOption(null, species))
.collect(Collectors.toList())
);
}
@GetMapping("/stores")
public ResponseEntity<List<DropdownOption>> getStores() {
return ResponseEntity.ok(

View File

@@ -25,8 +25,10 @@ public class PetController {
@GetMapping
public ResponseEntity<Page<PetResponse>> getAllPets(
@RequestParam(required = false) String q,
@RequestParam(required = false) String species,
@RequestParam(required = false) String status,
Pageable pageable) {
return ResponseEntity.ok(petService.getAllPets(q, pageable));
return ResponseEntity.ok(petService.getAllPets(q, species, status, pageable));
}
@GetMapping("/{id}")

View File

@@ -25,8 +25,9 @@ public class ProductController {
@GetMapping
public ResponseEntity<Page<ProductResponse>> getAllProducts(
@RequestParam(required = false) String q,
@RequestParam(required = false) Long categoryId,
Pageable pageable) {
return ResponseEntity.ok(productService.getAllProducts(q, pageable));
return ResponseEntity.ok(productService.getAllProducts(q, categoryId, pageable));
}
@GetMapping("/{id}")

View File

@@ -9,15 +9,19 @@ public class DashboardResponse {
private InventorySummary inventorySummary;
private List<TopProduct> topProducts;
private List<DailySales> dailySales;
private List<PaymentMethodData> paymentMethods;
private List<EmployeePerformanceData> employeePerformance;
public DashboardResponse() {
}
public DashboardResponse(SalesSummary salesSummary, InventorySummary inventorySummary, List<TopProduct> topProducts, List<DailySales> dailySales) {
public DashboardResponse(SalesSummary salesSummary, InventorySummary inventorySummary, List<TopProduct> topProducts, List<DailySales> dailySales, List<PaymentMethodData> paymentMethods, List<EmployeePerformanceData> employeePerformance) {
this.salesSummary = salesSummary;
this.inventorySummary = inventorySummary;
this.topProducts = topProducts;
this.dailySales = dailySales;
this.paymentMethods = paymentMethods;
this.employeePerformance = employeePerformance;
}
public SalesSummary getSalesSummary() {
@@ -52,17 +56,33 @@ public class DashboardResponse {
this.dailySales = dailySales;
}
public List<PaymentMethodData> getPaymentMethods() {
return paymentMethods;
}
public void setPaymentMethods(List<PaymentMethodData> paymentMethods) {
this.paymentMethods = paymentMethods;
}
public List<EmployeePerformanceData> getEmployeePerformance() {
return employeePerformance;
}
public void setEmployeePerformance(List<EmployeePerformanceData> employeePerformance) {
this.employeePerformance = employeePerformance;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
DashboardResponse that = (DashboardResponse) o;
return Objects.equals(salesSummary, that.salesSummary) && Objects.equals(inventorySummary, that.inventorySummary) && Objects.equals(topProducts, that.topProducts) && Objects.equals(dailySales, that.dailySales);
return Objects.equals(salesSummary, that.salesSummary) && Objects.equals(inventorySummary, that.inventorySummary) && Objects.equals(topProducts, that.topProducts) && Objects.equals(dailySales, that.dailySales) && Objects.equals(paymentMethods, that.paymentMethods) && Objects.equals(employeePerformance, that.employeePerformance);
}
@Override
public int hashCode() {
return Objects.hash(salesSummary, inventorySummary, topProducts, dailySales);
return Objects.hash(salesSummary, inventorySummary, topProducts, dailySales, paymentMethods, employeePerformance);
}
@Override
@@ -72,6 +92,8 @@ public class DashboardResponse {
", inventorySummary=" + inventorySummary +
", topProducts=" + topProducts +
", dailySales=" + dailySales +
", paymentMethods=" + paymentMethods +
", employeePerformance=" + employeePerformance +
'}';
}
@@ -80,15 +102,17 @@ public class DashboardResponse {
private Long totalSales;
private BigDecimal totalRefunds;
private Long totalRefundCount;
private Long totalItemsSold;
public SalesSummary() {
}
public SalesSummary(BigDecimal totalRevenue, Long totalSales, BigDecimal totalRefunds, Long totalRefundCount) {
public SalesSummary(BigDecimal totalRevenue, Long totalSales, BigDecimal totalRefunds, Long totalRefundCount, Long totalItemsSold) {
this.totalRevenue = totalRevenue;
this.totalSales = totalSales;
this.totalRefunds = totalRefunds;
this.totalRefundCount = totalRefundCount;
this.totalItemsSold = totalItemsSold;
}
public BigDecimal getTotalRevenue() {
@@ -123,17 +147,25 @@ public class DashboardResponse {
this.totalRefundCount = totalRefundCount;
}
public Long getTotalItemsSold() {
return totalItemsSold;
}
public void setTotalItemsSold(Long totalItemsSold) {
this.totalItemsSold = totalItemsSold;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
SalesSummary that = (SalesSummary) o;
return Objects.equals(totalRevenue, that.totalRevenue) && Objects.equals(totalSales, that.totalSales) && Objects.equals(totalRefunds, that.totalRefunds) && Objects.equals(totalRefundCount, that.totalRefundCount);
return Objects.equals(totalRevenue, that.totalRevenue) && Objects.equals(totalSales, that.totalSales) && Objects.equals(totalRefunds, that.totalRefunds) && Objects.equals(totalRefundCount, that.totalRefundCount) && Objects.equals(totalItemsSold, that.totalItemsSold);
}
@Override
public int hashCode() {
return Objects.hash(totalRevenue, totalSales, totalRefunds, totalRefundCount);
return Objects.hash(totalRevenue, totalSales, totalRefunds, totalRefundCount, totalItemsSold);
}
@Override
@@ -143,10 +175,69 @@ public class DashboardResponse {
", totalSales=" + totalSales +
", totalRefunds=" + totalRefunds +
", totalRefundCount=" + totalRefundCount +
", totalItemsSold=" + totalItemsSold +
'}';
}
}
public static class PaymentMethodData {
private String paymentMethod;
private Long count;
public PaymentMethodData() {
}
public PaymentMethodData(String paymentMethod, Long count) {
this.paymentMethod = paymentMethod;
this.count = count;
}
public String getPaymentMethod() {
return paymentMethod;
}
public void setPaymentMethod(String paymentMethod) {
this.paymentMethod = paymentMethod;
}
public Long getCount() {
return count;
}
public void setCount(Long count) {
this.count = count;
}
}
public static class EmployeePerformanceData {
private String employeeName;
private BigDecimal revenue;
public EmployeePerformanceData() {
}
public EmployeePerformanceData(String employeeName, BigDecimal revenue) {
this.employeeName = employeeName;
this.revenue = revenue;
}
public String getEmployeeName() {
return employeeName;
}
public void setEmployeeName(String employeeName) {
this.employeeName = employeeName;
}
public BigDecimal getRevenue() {
return revenue;
}
public void setRevenue(BigDecimal revenue) {
this.revenue = revenue;
}
}
public static class InventorySummary {
private Long totalProducts;
private Long lowStockProducts;

View File

@@ -0,0 +1,25 @@
package com.petshop.backend.dto.chat;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
public class UpdateConversationRequest {
@NotBlank(message = "Status is required")
@Pattern(regexp = "^(OPEN|CLOSED)$", message = "Status must be OPEN or CLOSED")
private String status;
public UpdateConversationRequest() {
}
public UpdateConversationRequest(String status) {
this.status = status;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}

View File

@@ -16,7 +16,7 @@ public interface CategoryRepository extends JpaRepository<Category, Long> {
Optional<Category> findByCategoryName(String categoryName);
@Query("SELECT c FROM Category c WHERE " +
"LOWER(c.categoryName) LIKE LOWER(CONCAT('%', :q, '%')) OR " +
"LOWER(c.categoryType) LIKE LOWER(CONCAT('%', :q, '%'))")
Page<Category> searchCategories(@Param("q") String query, Pageable pageable);
"(:q IS NULL OR LOWER(c.categoryName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(c.categoryType) LIKE LOWER(CONCAT('%', :q, '%'))) AND " +
"(:type IS NULL OR LOWER(c.categoryType) = LOWER(:type))")
Page<Category> searchCategories(@Param("q") String query, @Param("type") String type, Pageable pageable);
}

View File

@@ -12,8 +12,8 @@ import org.springframework.stereotype.Repository;
public interface PetRepository extends JpaRepository<Pet, Long> {
@Query("SELECT p FROM Pet p WHERE " +
"LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')) OR " +
"LOWER(p.petSpecies) LIKE LOWER(CONCAT('%', :q, '%')) OR " +
"LOWER(p.petBreed) LIKE LOWER(CONCAT('%', :q, '%'))")
Page<Pet> searchPets(@Param("q") String query, Pageable pageable);
"(:q IS NULL OR LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petSpecies) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petBreed) LIKE LOWER(CONCAT('%', :q, '%'))) AND " +
"(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species)) AND " +
"(:status IS NULL OR LOWER(p.petStatus) = LOWER(:status))")
Page<Pet> searchPets(@Param("q") String query, @Param("species") String species, @Param("status") String status, Pageable pageable);
}

View File

@@ -12,7 +12,7 @@ import org.springframework.stereotype.Repository;
public interface ProductRepository extends JpaRepository<Product, Long> {
@Query("SELECT p FROM Product p WHERE " +
"LOWER(p.prodName) LIKE LOWER(CONCAT('%', :q, '%')) OR " +
"LOWER(p.prodDesc) LIKE LOWER(CONCAT('%', :q, '%'))")
Page<Product> searchProducts(@Param("q") String query, Pageable pageable);
"(:q IS NULL OR LOWER(p.prodName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(p.prodDesc, '')) LIKE LOWER(CONCAT('%', :q, '%'))) AND " +
"(:categoryId IS NULL OR p.category.categoryId = :categoryId)")
Page<Product> searchProducts(@Param("q") String query, @Param("categoryId") Long categoryId, Pageable pageable);
}

View File

@@ -1,9 +1,12 @@
package com.petshop.backend.service;
import com.petshop.backend.dto.analytics.DashboardResponse;
import com.petshop.backend.entity.Employee;
import com.petshop.backend.entity.Inventory;
import com.petshop.backend.entity.Product;
import com.petshop.backend.entity.Sale;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.EmployeeRepository;
import com.petshop.backend.repository.InventoryRepository;
import com.petshop.backend.repository.ProductRepository;
import com.petshop.backend.repository.SaleRepository;
@@ -23,28 +26,33 @@ public class AnalyticsService {
private final SaleRepository saleRepository;
private final InventoryRepository inventoryRepository;
private final ProductRepository productRepository;
private final EmployeeRepository employeeRepository;
public AnalyticsService(SaleRepository saleRepository,
InventoryRepository inventoryRepository, ProductRepository productRepository) {
InventoryRepository inventoryRepository, ProductRepository productRepository, EmployeeRepository employeeRepository) {
this.saleRepository = saleRepository;
this.inventoryRepository = inventoryRepository;
this.productRepository = productRepository;
this.employeeRepository = employeeRepository;
}
@Transactional(readOnly = true)
public DashboardResponse getDashboardData(int days, int top) {
public DashboardResponse getDashboardData(int days, int top, User user) {
LocalDateTime startDate = LocalDateTime.now().minusDays(days);
List<Sale> sales = saleRepository.findAll().stream()
.filter(sale -> sale.getSaleDate().isAfter(startDate))
.filter(sale -> includeSaleForUser(sale, user))
.collect(Collectors.toList());
DashboardResponse.SalesSummary salesSummary = calculateSalesSummary(sales);
DashboardResponse.InventorySummary inventorySummary = calculateInventorySummary();
DashboardResponse.InventorySummary inventorySummary = user.getRole() == User.Role.ADMIN ? calculateInventorySummary() : null;
List<DashboardResponse.TopProduct> topProducts = calculateTopProducts(sales, top);
List<DashboardResponse.DailySales> dailySales = calculateDailySales(sales, days);
List<DashboardResponse.PaymentMethodData> paymentMethods = calculatePaymentMethods(sales);
List<DashboardResponse.EmployeePerformanceData> employeePerformance = calculateEmployeePerformance(sales, user);
return new DashboardResponse(salesSummary, inventorySummary, topProducts, dailySales);
return new DashboardResponse(salesSummary, inventorySummary, topProducts, dailySales, paymentMethods, employeePerformance);
}
private DashboardResponse.SalesSummary calculateSalesSummary(List<Sale> sales) {
@@ -66,7 +74,13 @@ public class AnalyticsService {
.filter(Sale::getIsRefund)
.count();
return new DashboardResponse.SalesSummary(totalRevenue, totalSales, totalRefunds, totalRefundCount);
Long totalItemsSold = sales.stream()
.filter(sale -> !sale.getIsRefund())
.flatMap(sale -> sale.getItems().stream())
.mapToLong(item -> item.getQuantity())
.sum();
return new DashboardResponse.SalesSummary(totalRevenue, totalSales, totalRefunds, totalRefundCount, totalItemsSold);
}
private DashboardResponse.InventorySummary calculateInventorySummary() {
@@ -93,6 +107,9 @@ public class AnalyticsService {
Map<Long, DashboardResponse.TopProduct> productSalesMap = new HashMap<>();
for (Sale sale : sales) {
if (sale.getIsRefund()) {
continue;
}
for (var item : sale.getItems()) {
Long productId = item.getProduct().getProdId();
String productName = item.getProduct().getProdName();
@@ -128,6 +145,9 @@ public class AnalyticsService {
}
for (Sale sale : sales) {
if (sale.getIsRefund()) {
continue;
}
LocalDate saleDate = sale.getSaleDate().toLocalDate();
if (dailySalesMap.containsKey(saleDate)) {
DashboardResponse.DailySales dailySale = dailySalesMap.get(saleDate);
@@ -138,4 +158,50 @@ public class AnalyticsService {
return new ArrayList<>(dailySalesMap.values());
}
private List<DashboardResponse.PaymentMethodData> calculatePaymentMethods(List<Sale> sales) {
return sales.stream()
.filter(sale -> !sale.getIsRefund())
.collect(Collectors.groupingBy(
sale -> sale.getPaymentMethod() == null ? "Unknown" : sale.getPaymentMethod(),
TreeMap::new,
Collectors.counting()))
.entrySet().stream()
.map(entry -> new DashboardResponse.PaymentMethodData(entry.getKey(), entry.getValue()))
.collect(Collectors.toList());
}
private List<DashboardResponse.EmployeePerformanceData> calculateEmployeePerformance(List<Sale> sales, User user) {
Map<String, BigDecimal> employeeRevenue = new TreeMap<>();
for (Sale sale : sales) {
if (sale.getIsRefund()) {
continue;
}
String employeeName = sale.getEmployee().getFirstName() + " " + sale.getEmployee().getLastName();
employeeRevenue.merge(employeeName, sale.getTotalAmount(), BigDecimal::add);
}
if (user.getRole() == User.Role.STAFF && employeeRevenue.isEmpty()) {
Employee employee = employeeRepository.findByUserId(user.getId()).orElse(null);
if (employee != null) {
String employeeName = employee.getFirstName() + " " + employee.getLastName();
employeeRevenue.put(employeeName, BigDecimal.ZERO);
}
}
return employeeRevenue.entrySet().stream()
.map(entry -> new DashboardResponse.EmployeePerformanceData(entry.getKey(), entry.getValue()))
.collect(Collectors.toList());
}
private boolean includeSaleForUser(Sale sale, User user) {
if (user.getRole() == User.Role.ADMIN) {
return true;
}
if (user.getRole() == User.Role.STAFF) {
return sale.getEmployee() != null && sale.getEmployee().getUserId() != null && sale.getEmployee().getUserId().equals(user.getId());
}
return false;
}
}

View File

@@ -193,6 +193,7 @@ public class AppointmentService {
}
@Transactional(readOnly = true)
public List<String> checkAvailability(Long storeId, Long serviceId, LocalDate date) {
storeRepository.findById(storeId)
.orElseThrow(() -> new ResourceNotFoundException("Store not found with id: " + storeId));
@@ -200,7 +201,16 @@ public class AppointmentService {
com.petshop.backend.entity.Service service = serviceRepository.findById(serviceId)
.orElseThrow(() -> new ResourceNotFoundException("Service not found with id: " + serviceId));
List<Appointment> existingAppointments = appointmentRepository.findByStoreAndDate(storeId, date);
//--------------------------------------------------------------------------
// CHANGED: filter by serviceId too
List<Appointment> existingAppointments = appointmentRepository
.findByStoreAndDate(storeId, date)
.stream()
.filter(a -> a.getService().getServiceId().equals(serviceId))
.collect(Collectors.toList());
// -------------------------------------------------------
List<String> availableSlots = new ArrayList<>();
LocalTime startTime = LocalTime.of(9, 0);
@@ -286,13 +296,21 @@ public class AppointmentService {
return response;
}
//------------------------------------
private void validateAvailability(StoreLocation store, com.petshop.backend.entity.Service service, LocalDate date, LocalTime time, Long appointmentIdToIgnore) {
List<Appointment> existingAppointments = appointmentRepository.findByStoreAndDate(store.getStoreId(), date);
// Filter by same service only - different services can run at same time
List<Appointment> existingAppointments = appointmentRepository
.findByStoreAndDate(store.getStoreId(), date)
.stream()
.filter(a -> a.getService().getServiceId().equals(service.getServiceId()))
.collect(Collectors.toList());
if (!isSlotAvailable(existingAppointments, service, time, appointmentIdToIgnore)) {
throw new IllegalArgumentException("Appointment time is not available for the selected store and service");
}
}
//------------------------------------------------
private boolean isSlotAvailable(List<Appointment> existingAppointments, com.petshop.backend.entity.Service requestedService, LocalTime requestedStart, Long appointmentIdToIgnore) {
LocalTime requestedEnd = requestedStart.plusMinutes(requestedService.getServiceDuration());
for (Appointment existingAppointment : existingAppointments) {

View File

@@ -20,14 +20,9 @@ public class CategoryService {
this.categoryRepository = categoryRepository;
}
public Page<CategoryResponse> getAllCategories(String query, Pageable pageable) {
Page<Category> categories;
if (query != null && !query.trim().isEmpty()) {
categories = categoryRepository.searchCategories(query, pageable);
} else {
categories = categoryRepository.findAll(pageable);
}
return categories.map(this::mapToResponse);
public Page<CategoryResponse> getAllCategories(String query, String type, Pageable pageable) {
return categoryRepository.searchCategories(normalizeFilter(query), normalizeFilter(type), pageable)
.map(this::mapToResponse);
}
public CategoryResponse getCategoryById(Long id) {
@@ -80,4 +75,12 @@ public class CategoryService {
category.getUpdatedAt()
);
}
private String normalizeFilter(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}

View File

@@ -4,6 +4,7 @@ import com.petshop.backend.dto.chat.ConversationRequest;
import com.petshop.backend.dto.chat.ConversationResponse;
import com.petshop.backend.dto.chat.MessageRequest;
import com.petshop.backend.dto.chat.MessageResponse;
import com.petshop.backend.dto.chat.UpdateConversationRequest;
import com.petshop.backend.entity.Conversation;
import com.petshop.backend.entity.Customer;
import com.petshop.backend.entity.Message;
@@ -116,6 +117,10 @@ public class ChatService {
Conversation conversation = conversationRepository.findById(conversationId)
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));
if (conversation.getStatus() == Conversation.ConversationStatus.CLOSED) {
throw new AccessDeniedException("Conversation is closed");
}
if (!hasConversationAccess(conversation, userId, role)) {
if (role == User.Role.CUSTOMER) {
throw new AccessDeniedException("You can only send messages to your own conversations");
@@ -149,6 +154,10 @@ public class ChatService {
Conversation conversation = conversationRepository.findById(conversationId)
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));
if (conversation.getStatus() == Conversation.ConversationStatus.CLOSED) {
throw new AccessDeniedException("Conversation is closed");
}
if (role != User.Role.CUSTOMER || !hasConversationAccess(conversation, userId, role)) {
throw new AccessDeniedException("You can only request human takeover for your own conversations");
}
@@ -163,6 +172,28 @@ public class ChatService {
return ConversationResponse.fromEntity(conversation, lastMessage);
}
@Transactional
public ConversationResponse updateConversation(Long conversationId, Long userId, User.Role role, UpdateConversationRequest request) {
Conversation conversation = conversationRepository.findById(conversationId)
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));
if (!hasConversationAccess(conversation, userId, role)) {
if (role == User.Role.CUSTOMER) {
throw new AccessDeniedException("You can only close your own conversations");
}
if (role == User.Role.STAFF) {
throw new AccessDeniedException("You can only close conversations assigned to you or unassigned conversations");
}
}
conversation.setStatus(Conversation.ConversationStatus.valueOf(request.getStatus()));
conversation = conversationRepository.save(conversation);
List<Message> messages = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId);
String lastMessage = messages.isEmpty() ? "" : messages.get(messages.size() - 1).getContent();
return ConversationResponse.fromEntity(conversation, lastMessage);
}
public List<MessageResponse> getMessages(Long conversationId, Long userId, User.Role role) {
Conversation conversation = conversationRepository.findById(conversationId)
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));

View File

@@ -4,23 +4,34 @@ import com.petshop.backend.dto.common.BulkDeleteRequest;
import com.petshop.backend.dto.customer.CustomerRequest;
import com.petshop.backend.dto.customer.CustomerResponse;
import com.petshop.backend.entity.Customer;
import com.petshop.backend.entity.User;
import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.repository.CustomerRepository;
import com.petshop.backend.repository.UserRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import static org.springframework.http.HttpStatus.CONFLICT;
@Service
public class CustomerService {
private static final String TEMP_PASSWORD = "TempPass123!";
private final CustomerRepository customerRepository;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final UserBusinessLinkageService userBusinessLinkageService;
public CustomerService(CustomerRepository customerRepository, UserRepository userRepository) {
public CustomerService(CustomerRepository customerRepository, UserRepository userRepository, PasswordEncoder passwordEncoder, UserBusinessLinkageService userBusinessLinkageService) {
this.customerRepository = customerRepository;
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.userBusinessLinkageService = userBusinessLinkageService;
}
public Page<CustomerResponse> getAllCustomers(String query, Pageable pageable) {
@@ -41,14 +52,19 @@ public class CustomerService {
@Transactional
public CustomerResponse createCustomer(CustomerRequest request) {
ensureEmailAvailable(request.getEmail(), null);
Customer customer = new Customer();
customer.setFirstName(request.getFirstName());
customer.setLastName(request.getLastName());
customer.setEmail(request.getEmail());
customer = customerRepository.save(customer);
syncLinkedUser(customer);
return mapToResponse(customer);
User user = createLinkedUser(customer);
Customer linkedCustomer = userBusinessLinkageService.ensureLinkedCustomer(user);
syncLinkedUser(linkedCustomer);
return mapToResponse(linkedCustomer);
}
@Transactional
@@ -56,6 +72,8 @@ public class CustomerService {
Customer customer = customerRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Customer not found with id: " + id));
ensureEmailAvailable(request.getEmail(), customer.getUserId());
customer.setFirstName(request.getFirstName());
customer.setLastName(request.getLastName());
customer.setEmail(request.getEmail());
@@ -67,9 +85,14 @@ public class CustomerService {
@Transactional
public void deleteCustomer(Long id) {
if (!customerRepository.existsById(id)) {
throw new ResourceNotFoundException("Customer not found with id: " + id);
Customer customer = customerRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Customer not found with id: " + id));
if (customer.getUserId() != null && userRepository.existsById(customer.getUserId())) {
userRepository.deleteById(customer.getUserId());
return;
}
customerRepository.deleteById(id);
}
@@ -99,4 +122,37 @@ public class CustomerService {
userRepository.save(user);
});
}
private User createLinkedUser(Customer customer) {
User user = new User();
user.setUsername(generateUsername(customer));
user.setPassword(passwordEncoder.encode(TEMP_PASSWORD));
user.setEmail(customer.getEmail());
user.setFullName((customer.getFirstName() + " " + customer.getLastName()).trim());
user.setPhone(generatePhone(customer));
user.setRole(User.Role.CUSTOMER);
user.setActive(false);
user.setTokenVersion(0);
return userRepository.save(user);
}
private String generateUsername(Customer customer) {
return "customer_" + customer.getCustomerId();
}
private String generatePhone(Customer customer) {
return String.format("200-000-%04d", customer.getCustomerId());
}
private void ensureEmailAvailable(String email, Long currentUserId) {
if (email == null || email.isBlank()) {
return;
}
userRepository.findByEmail(email).ifPresent(existing -> {
if (currentUserId == null || !existing.getId().equals(currentUserId)) {
throw new ResponseStatusException(CONFLICT, "Email already exists");
}
});
}
}

View File

@@ -33,14 +33,9 @@ public class PetService {
this.catalogImageStorageService = catalogImageStorageService;
}
public Page<PetResponse> getAllPets(String query, Pageable pageable) {
Page<Pet> pets;
if (query != null && !query.trim().isEmpty()) {
pets = petRepository.searchPets(query, pageable);
} else {
pets = petRepository.findAll(pageable);
}
return pets.map(this::mapToResponse);
public Page<PetResponse> getAllPets(String query, String species, String status, Pageable pageable) {
return petRepository.searchPets(normalizeFilter(query), normalizeFilter(species), normalizeFilter(status), pageable)
.map(this::mapToResponse);
}
public PetResponse getPetById(Long id) {
@@ -182,6 +177,14 @@ public class PetService {
return status == null ? "" : status.trim();
}
private String normalizeFilter(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private PetResponse mapToResponse(Pet pet) {
return new PetResponse(
pet.getPetId(),

View File

@@ -32,14 +32,9 @@ public class ProductService {
this.catalogImageStorageService = catalogImageStorageService;
}
public Page<ProductResponse> getAllProducts(String query, Pageable pageable) {
Page<Product> products;
if (query != null && !query.trim().isEmpty()) {
products = productRepository.searchProducts(query, pageable);
} else {
products = productRepository.findAll(pageable);
}
return products.map(this::mapToResponse);
public Page<ProductResponse> getAllProducts(String query, Long categoryId, Pageable pageable) {
return productRepository.searchProducts(normalizeFilter(query), categoryId, pageable)
.map(this::mapToResponse);
}
public ProductResponse getProductById(Long id) {
@@ -168,4 +163,12 @@ public class ProductService {
public record ImagePayload(Resource resource, MediaType mediaType) {
}
private String normalizeFilter(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}

View File

@@ -78,7 +78,7 @@ public class SaleService {
sale.setSaleDate(LocalDateTime.now());
sale.setEmployee(employee);
sale.setStore(store);
sale.setPaymentMethod(request.getPaymentMethod());
sale.setPaymentMethod(normalizePaymentMethod(request.getPaymentMethod()));
sale.setIsRefund(request.getIsRefund() != null ? request.getIsRefund() : false);
if (request.getCustomerId() != null) {
@@ -215,4 +215,22 @@ public class SaleService {
return response;
}
String normalizePaymentMethod(String paymentMethod) {
if (paymentMethod == null) {
return null;
}
String normalized = paymentMethod.trim();
if (normalized.equalsIgnoreCase("Debit")) {
return "Card";
}
if (normalized.equalsIgnoreCase("Cash")) {
return "Cash";
}
if (normalized.equalsIgnoreCase("Card")) {
return "Card";
}
return normalized;
}
}