From 39312c869822e4f12b3dff1dd1d7d6ebd4d1e7ed Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Tue, 14 Apr 2026 15:23:07 -0600 Subject: [PATCH] add email flows --- backend/pom.xml | 6 + .../backend/controller/AuthController.java | 7 +- .../repository/AdoptionRepository.java | 2 + .../repository/AppointmentRepository.java | 2 + .../backend/service/AdoptionService.java | 15 +- .../backend/service/AppointmentService.java | 26 +- .../petshop/backend/service/ChatService.java | 15 +- .../petshop/backend/service/EmailService.java | 308 ++++++++++++++++++ .../backend/service/PasswordResetService.java | 11 +- .../petshop/backend/service/SaleService.java | 9 +- backend/src/main/resources/application.yml | 5 + 11 files changed, 394 insertions(+), 12 deletions(-) create mode 100644 backend/src/main/java/com/petshop/backend/service/EmailService.java diff --git a/backend/pom.xml b/backend/pom.xml index 206ae451..67b9a807 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -96,6 +96,12 @@ 25.3.0 + + com.resend + resend-java + 3.1.0 + + org.springframework.boot spring-boot-starter-test diff --git a/backend/src/main/java/com/petshop/backend/controller/AuthController.java b/backend/src/main/java/com/petshop/backend/controller/AuthController.java index 33281560..a594f9e1 100644 --- a/backend/src/main/java/com/petshop/backend/controller/AuthController.java +++ b/backend/src/main/java/com/petshop/backend/controller/AuthController.java @@ -17,6 +17,7 @@ import com.petshop.backend.repository.UserRepository; import com.petshop.backend.security.JwtUtil; import com.petshop.backend.service.ActivityLogService; import com.petshop.backend.service.AvatarStorageService; +import com.petshop.backend.service.EmailService; import com.petshop.backend.service.PasswordResetService; import com.petshop.backend.util.AuthenticationHelper; import com.petshop.backend.util.PhoneUtils; @@ -55,8 +56,9 @@ public class AuthController { private final AvatarStorageService avatarStorageService; private final ActivityLogService activityLogService; private final PasswordResetService passwordResetService; + private final EmailService emailService; - public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService, ActivityLogService activityLogService, PasswordResetService passwordResetService) { + public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService, ActivityLogService activityLogService, PasswordResetService passwordResetService, EmailService emailService) { this.authenticationManager = authenticationManager; this.userRepository = userRepository; this.jwtUtil = jwtUtil; @@ -64,6 +66,7 @@ public class AuthController { this.avatarStorageService = avatarStorageService; this.activityLogService = activityLogService; this.passwordResetService = passwordResetService; + this.emailService = emailService; } @PostMapping("/register") @@ -107,6 +110,8 @@ public class AuthController { User savedUser = userRepository.save(user); + emailService.sendWelcome(savedUser); + String token = jwtUtil.generateToken(savedUser); return ResponseEntity.status(HttpStatus.CREATED).body(new RegisterResponse( 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 beb8e0e8..ba351042 100644 --- a/backend/src/main/java/com/petshop/backend/repository/AdoptionRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/AdoptionRepository.java @@ -40,4 +40,6 @@ public interface AdoptionRepository extends JpaRepository { boolean existsByPet_IdAndAdoptionStatusIgnoreCase(Long petId, String adoptionStatus); List findByCustomer_IdAndAdoptionStatusIgnoreCase(Long customerId, String adoptionStatus); + + List findByAdoptionDateAndAdoptionStatusIgnoreCase(LocalDate date, String status); } diff --git a/backend/src/main/java/com/petshop/backend/repository/AppointmentRepository.java b/backend/src/main/java/com/petshop/backend/repository/AppointmentRepository.java index 9da6efdd..616597eb 100644 --- a/backend/src/main/java/com/petshop/backend/repository/AppointmentRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/AppointmentRepository.java @@ -50,4 +50,6 @@ public interface AppointmentRepository extends JpaRepository @Query("SELECT a FROM Appointment a WHERE (a.appointmentDate < :currentDate OR (a.appointmentDate = :currentDate AND a.appointmentTime < :currentTime)) AND LOWER(a.appointmentStatus) = 'booked'") List findPastBookedAppointments(@Param("currentDate") LocalDate currentDate, @Param("currentTime") LocalTime currentTime); + + List findByAppointmentDateAndAppointmentStatusIgnoreCase(LocalDate date, String status); } diff --git a/backend/src/main/java/com/petshop/backend/service/AdoptionService.java b/backend/src/main/java/com/petshop/backend/service/AdoptionService.java index 0874aded..8885b3c8 100644 --- a/backend/src/main/java/com/petshop/backend/service/AdoptionService.java +++ b/backend/src/main/java/com/petshop/backend/service/AdoptionService.java @@ -39,13 +39,15 @@ public class AdoptionService { private final UserRepository userRepository; private final StoreRepository storeRepository; private final SaleRepository saleRepository; + private final EmailService emailService; - public AdoptionService(AdoptionRepository adoptionRepository, PetRepository petRepository, UserRepository userRepository, StoreRepository storeRepository, SaleRepository saleRepository) { + public AdoptionService(AdoptionRepository adoptionRepository, PetRepository petRepository, UserRepository userRepository, StoreRepository storeRepository, SaleRepository saleRepository, EmailService emailService) { this.adoptionRepository = adoptionRepository; this.petRepository = petRepository; this.userRepository = userRepository; this.storeRepository = storeRepository; this.saleRepository = saleRepository; + this.emailService = emailService; } public Page getAllAdoptions(String query, Long customerId, String status, Long storeId, LocalDate date, Pageable pageable) { @@ -106,6 +108,9 @@ public class AdoptionService { if (ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoptionStatus)) { createSaleForAdoption(adoption, request.getPaymentMethod()); } + if (ADOPTION_STATUS_PENDING.equalsIgnoreCase(adoptionStatus) || ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoptionStatus)) { + emailService.sendAdoptionConfirmation(adoption); + } return mapToResponse(adoption); } @@ -125,6 +130,7 @@ public class AdoptionService { .orElseThrow(() -> new ResourceNotFoundException("Store not found with id: " + request.getSourceStoreId())) : null; boolean wasCompleted = ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoption.getAdoptionStatus()); + String previousStatus = adoption.getAdoptionStatus(); String adoptionStatus = normalizeAdoptionStatus(request.getAdoptionStatus()); Long currentPetId = adoption.getPet() != null ? adoption.getPet().getPetId() : null; validatePetAvailability(pet, adoption.getAdoptionId(), currentPetId); @@ -144,6 +150,10 @@ public class AdoptionService { if (!wasCompleted && ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoptionStatus)) { createSaleForAdoption(adoption, request.getPaymentMethod()); } + boolean statusChanged = !adoptionStatus.equalsIgnoreCase(previousStatus); + if (statusChanged && (ADOPTION_STATUS_PENDING.equalsIgnoreCase(adoptionStatus) || ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoptionStatus))) { + emailService.sendAdoptionConfirmation(adoption); + } return mapToResponse(adoption); } @@ -258,7 +268,8 @@ public class AdoptionService { sale.setPaymentMethod(paymentMethod != null && !paymentMethod.isBlank() ? paymentMethod : "Cash"); sale.setIsRefund(false); sale.setChannel("ADOPTION"); - saleRepository.save(sale); + Sale savedSale = saleRepository.save(sale); + emailService.sendPurchaseReceipt(savedSale); } private void syncPetStatus(Pet pet, String adoptionStatus, Long adoptionId, User customer) { diff --git a/backend/src/main/java/com/petshop/backend/service/AppointmentService.java b/backend/src/main/java/com/petshop/backend/service/AppointmentService.java index 26e0a512..45edd69f 100644 --- a/backend/src/main/java/com/petshop/backend/service/AppointmentService.java +++ b/backend/src/main/java/com/petshop/backend/service/AppointmentService.java @@ -3,11 +3,13 @@ package com.petshop.backend.service; import com.petshop.backend.dto.appointment.AppointmentRequest; import com.petshop.backend.dto.appointment.AppointmentResponse; import com.petshop.backend.dto.common.BulkDeleteRequest; +import com.petshop.backend.entity.Adoption; import com.petshop.backend.entity.Appointment; import com.petshop.backend.entity.Pet; import com.petshop.backend.entity.StoreLocation; import com.petshop.backend.entity.User; import com.petshop.backend.exception.ResourceNotFoundException; +import com.petshop.backend.repository.AdoptionRepository; import com.petshop.backend.repository.AppointmentRepository; import com.petshop.backend.repository.PetRepository; import com.petshop.backend.repository.ServiceRepository; @@ -36,13 +38,17 @@ public class AppointmentService { private final PetRepository petRepository; private final StoreRepository storeRepository; private final UserRepository userRepository; + private final AdoptionRepository adoptionRepository; + private final EmailService emailService; - public AppointmentService(AppointmentRepository appointmentRepository, ServiceRepository serviceRepository, PetRepository petRepository, StoreRepository storeRepository, UserRepository userRepository) { + public AppointmentService(AppointmentRepository appointmentRepository, ServiceRepository serviceRepository, PetRepository petRepository, StoreRepository storeRepository, UserRepository userRepository, AdoptionRepository adoptionRepository, EmailService emailService) { this.appointmentRepository = appointmentRepository; this.serviceRepository = serviceRepository; this.petRepository = petRepository; this.storeRepository = storeRepository; this.userRepository = userRepository; + this.adoptionRepository = adoptionRepository; + this.emailService = emailService; } @Transactional(readOnly = true) @@ -115,6 +121,7 @@ public class AppointmentService { appointment.setPet(pet); appointment = appointmentRepository.save(appointment); + emailService.sendAppointmentConfirmation(appointment); return mapToResponse(appointment); } @@ -152,6 +159,7 @@ public class AppointmentService { appointment.setEmployee(employee); appointment = appointmentRepository.save(appointment); + emailService.sendAppointmentConfirmation(appointment); return mapToResponse(appointment); } @@ -210,7 +218,7 @@ public class AppointmentService { return availableSlots; } - //Update booked status to completed at every midnight + //Update booked status to completed at every midnight, and send 24h reminders @Scheduled(cron = "0 0 0 * * ?") @Transactional public void updatePastAppointmentsStatus() { @@ -222,6 +230,20 @@ public class AppointmentService { appointment.setAppointmentStatus("COMPLETED"); appointmentRepository.save(appointment); } + + LocalDate tomorrow = currentDate.plusDays(1); + + List tomorrowAppointments = appointmentRepository + .findByAppointmentDateAndAppointmentStatusIgnoreCase(tomorrow, "Booked"); + for (Appointment appointment : tomorrowAppointments) { + emailService.sendAppointmentReminder(appointment); + } + + List tomorrowAdoptions = adoptionRepository + .findByAdoptionDateAndAdoptionStatusIgnoreCase(tomorrow, "Pending"); + for (Adoption adoption : tomorrowAdoptions) { + emailService.sendAdoptionReminder(adoption); + } } private String normalizeFilter(String value) { diff --git a/backend/src/main/java/com/petshop/backend/service/ChatService.java b/backend/src/main/java/com/petshop/backend/service/ChatService.java index bee48228..97cd584a 100644 --- a/backend/src/main/java/com/petshop/backend/service/ChatService.java +++ b/backend/src/main/java/com/petshop/backend/service/ChatService.java @@ -31,17 +31,20 @@ public class ChatService { private final UserRepository userRepository; private final AvatarStorageService avatarStorageService; private final ChatAttachmentStorageService attachmentStorageService; + private final EmailService emailService; public ChatService(ConversationRepository conversationRepository, MessageRepository messageRepository, UserRepository userRepository, AvatarStorageService avatarStorageService, - ChatAttachmentStorageService attachmentStorageService) { + ChatAttachmentStorageService attachmentStorageService, + EmailService emailService) { this.conversationRepository = conversationRepository; this.messageRepository = messageRepository; this.userRepository = userRepository; this.avatarStorageService = avatarStorageService; this.attachmentStorageService = attachmentStorageService; + this.emailService = emailService; } @Transactional @@ -261,13 +264,19 @@ public class ChatService { } conversation.setStatus(Conversation.ConversationStatus.valueOf(request.getStatus())); - conversation = conversationRepository.save(conversation); + Conversation savedConversation = conversationRepository.save(conversation); List messages = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId); + + if (Conversation.ConversationStatus.CLOSED.name().equals(request.getStatus())) { + userRepository.findById(savedConversation.getCustomerId()).ifPresent(customer -> + emailService.sendChatTranscript(savedConversation, messages, customer)); + } + Message last = messages.isEmpty() ? null : messages.get(messages.size() - 1); String lastMessage = last != null && last.getContent() != null ? last.getContent() : ""; Long lastSenderId = last != null ? last.getSenderId() : null; - return ConversationResponse.fromEntity(conversation, lastMessage, lastSenderId); + return ConversationResponse.fromEntity(savedConversation, lastMessage, lastSenderId); } public List getMessages(Long conversationId, Long userId, User.Role role) { diff --git a/backend/src/main/java/com/petshop/backend/service/EmailService.java b/backend/src/main/java/com/petshop/backend/service/EmailService.java new file mode 100644 index 00000000..667aad08 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/service/EmailService.java @@ -0,0 +1,308 @@ +package com.petshop.backend.service; + +import com.petshop.backend.entity.Adoption; +import com.petshop.backend.entity.Appointment; +import com.petshop.backend.entity.Conversation; +import com.petshop.backend.entity.Message; +import com.petshop.backend.entity.Sale; +import com.petshop.backend.entity.SaleItem; +import com.petshop.backend.entity.User; +import com.resend.Resend; +import com.resend.services.emails.model.CreateEmailOptions; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Service +public class EmailService { + + private static final Logger log = LoggerFactory.getLogger(EmailService.class); + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("MMMM d, yyyy"); + private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("h:mm a"); + + private final Resend resend; + private final String from; + private final String frontendUrl; + private final ActivityLogService activityLogService; + + public EmailService( + @Value("${resend.api-key}") String apiKey, + @Value("${resend.from}") String from, + @Value("${app.frontend-url}") String frontendUrl, + ActivityLogService activityLogService) { + this.resend = new Resend(apiKey); + this.from = from; + this.frontendUrl = frontendUrl; + this.activityLogService = activityLogService; + } + + public void sendWelcome(User user) { + if (user.getEmail() == null || user.getEmail().isBlank()) return; + String subject = "Welcome to PetShop!"; + String html = """ +
+

Welcome to PetShop, %s!

+

Your account has been created successfully.

+

You can now log in and start exploring our pets, products, and services.

+

Thank you for joining us!

+
""".formatted(firstName(user)); + send(user.getId(), user.getEmail(), subject, html); + } + + public void sendPasswordResetLink(User user, String rawToken) { + if (user.getEmail() == null || user.getEmail().isBlank()) return; + String link = frontendUrl + "/reset-password?token=" + rawToken; + String subject = "Reset your PetShop password"; + String html = """ +
+

Password Reset Request

+

Hi %s,

+

We received a request to reset your password. Click the button below to proceed.

+

Reset Password

+

This link expires in 30 minutes. If you did not request a reset, you can safely ignore this email.

+
""".formatted(firstName(user), link); + send(user.getId(), user.getEmail(), subject, html); + } + + public void sendPurchaseReceipt(Sale sale) { + User customer = sale.getCustomer(); + if (customer == null || customer.getEmail() == null || customer.getEmail().isBlank()) return; + String subject = "Your PetShop receipt"; + String html = buildReceiptHtml(sale); + send(customer.getId(), customer.getEmail(), subject, html); + } + + public void sendAdoptionConfirmation(Adoption adoption) { + User customer = adoption.getCustomer(); + if (customer != null && customer.getEmail() != null && !customer.getEmail().isBlank()) { + String subject = "Your adoption — " + adoption.getPet().getPetName(); + send(customer.getId(), customer.getEmail(), subject, buildAdoptionHtml(adoption, customer)); + } + User employee = adoption.getEmployee(); + if (employee != null && employee.getEmail() != null && !employee.getEmail().isBlank()) { + String subject = "Adoption assigned — " + adoption.getPet().getPetName(); + send(employee.getId(), employee.getEmail(), subject, buildAdoptionHtml(adoption, customer)); + } + } + + public void sendAdoptionReminder(Adoption adoption) { + User customer = adoption.getCustomer(); + if (customer != null && customer.getEmail() != null && !customer.getEmail().isBlank()) { + String subject = "Reminder: adoption tomorrow — " + adoption.getPet().getPetName(); + send(customer.getId(), customer.getEmail(), subject, buildAdoptionReminderHtml(adoption)); + } + User employee = adoption.getEmployee(); + if (employee != null && employee.getEmail() != null && !employee.getEmail().isBlank()) { + String subject = "Reminder: adoption tomorrow — " + adoption.getPet().getPetName(); + send(employee.getId(), employee.getEmail(), subject, buildAdoptionReminderHtml(adoption)); + } + } + + public void sendAppointmentConfirmation(Appointment appointment) { + User customer = appointment.getCustomer(); + if (customer != null && customer.getEmail() != null && !customer.getEmail().isBlank()) { + String subject = "Appointment confirmed — " + appointment.getService().getServiceName(); + send(customer.getId(), customer.getEmail(), subject, buildAppointmentHtml(appointment)); + } + User employee = appointment.getEmployee(); + if (employee != null && employee.getEmail() != null && !employee.getEmail().isBlank()) { + String subject = "Appointment assigned — " + appointment.getService().getServiceName(); + send(employee.getId(), employee.getEmail(), subject, buildAppointmentHtml(appointment)); + } + } + + public void sendAppointmentReminder(Appointment appointment) { + User customer = appointment.getCustomer(); + if (customer != null && customer.getEmail() != null && !customer.getEmail().isBlank()) { + String subject = "Reminder: appointment tomorrow — " + appointment.getService().getServiceName(); + send(customer.getId(), customer.getEmail(), subject, buildAppointmentReminderHtml(appointment)); + } + User employee = appointment.getEmployee(); + if (employee != null && employee.getEmail() != null && !employee.getEmail().isBlank()) { + String subject = "Reminder: appointment tomorrow — " + appointment.getService().getServiceName(); + send(employee.getId(), employee.getEmail(), subject, buildAppointmentReminderHtml(appointment)); + } + } + + public void sendChatTranscript(Conversation conversation, List messages, User customer) { + if (customer == null || customer.getEmail() == null || customer.getEmail().isBlank()) return; + String subject = "Your PetShop support transcript"; + String html = buildTranscriptHtml(conversation, messages, customer); + send(customer.getId(), customer.getEmail(), subject, html); + } + + private void send(Long recipientUserId, String to, String subject, String html) { + try { + CreateEmailOptions options = CreateEmailOptions.builder() + .from(from) + .to(List.of(to)) + .subject(subject) + .html(html) + .build(); + resend.emails().send(options); + activityLogService.record(recipientUserId, "Email sent: " + subject + " → " + to); + } catch (Exception ex) { + log.error("Failed to send email '{}' to {}: {}", subject, to, ex.getMessage()); + activityLogService.record(recipientUserId, "Email failed: " + subject + " → " + to); + } + } + + private String buildReceiptHtml(Sale sale) { + StringBuilder rows = new StringBuilder(); + List items = sale.getItems(); + if (items != null) { + for (SaleItem item : items) { + String name = item.getProduct() != null ? item.getProduct().getProdName() : "—"; + rows.append("").append(esc(name)).append("") + .append("").append(item.getQuantity()).append("") + .append("$").append(fmt(item.getUnitPrice())).append("") + .append("$").append(fmt(item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity())))).append(""); + } + } + String date = sale.getSaleDate() != null ? sale.getSaleDate().format(DateTimeFormatter.ofPattern("MMMM d, yyyy h:mm a")) : "—"; + return """ +
+

Thank you for your purchase!

+

Date: %s

+

Payment method: %s

+ + + + + + + + %s +
ItemQtyUnit priceTotal
+

Subtotal: $%s

+ %s + %s + %s +

Total: $%s

+ %s +
""".formatted( + date, + esc(sale.getPaymentMethod()), + rows.toString(), + fmt(sale.getSubtotalAmount()), + discountLine("Coupon discount", sale.getCouponDiscountAmount()), + discountLine("Employee discount", sale.getEmployeeDiscountAmount()), + discountLine("Loyalty discount", sale.getLoyaltyDiscountAmount()), + fmt(sale.getTotalAmount()), + sale.getPointsEarned() != null && sale.getPointsEarned() > 0 + ? "

You earned " + sale.getPointsEarned() + " loyalty points on this order.

" + : "" + ); + } + + private String buildAdoptionHtml(Adoption adoption, User customer) { + String petName = adoption.getPet() != null ? adoption.getPet().getPetName() : "—"; + String storeName = adoption.getSourceStore() != null ? adoption.getSourceStore().getStoreName() : "—"; + String date = adoption.getAdoptionDate() != null ? adoption.getAdoptionDate().format(DATE_FMT) : "—"; + String customerName = customer != null ? firstName(customer) : "—"; + return """ +
+

Adoption update — %s

+

Customer: %s

+

Pet: %s

+

Date: %s

+

Store: %s

+

Status: %s

+
""".formatted(esc(petName), esc(customerName), esc(petName), date, esc(storeName), esc(adoption.getAdoptionStatus())); + } + + private String buildAdoptionReminderHtml(Adoption adoption) { + String petName = adoption.getPet() != null ? adoption.getPet().getPetName() : "—"; + String storeName = adoption.getSourceStore() != null ? adoption.getSourceStore().getStoreName() : "—"; + String date = adoption.getAdoptionDate() != null ? adoption.getAdoptionDate().format(DATE_FMT) : "—"; + return """ +
+

Reminder: adoption tomorrow

+

This is a reminder that the adoption of %s is scheduled for %s at %s.

+
""".formatted(esc(petName), date, esc(storeName)); + } + + private String buildAppointmentHtml(Appointment appointment) { + String service = appointment.getService() != null ? appointment.getService().getServiceName() : "—"; + String store = appointment.getStore() != null ? appointment.getStore().getStoreName() : "—"; + String date = appointment.getAppointmentDate() != null ? appointment.getAppointmentDate().format(DATE_FMT) : "—"; + String time = appointment.getAppointmentTime() != null ? appointment.getAppointmentTime().format(TIME_FMT) : "—"; + String employee = appointment.getEmployee() != null + ? appointment.getEmployee().getFirstName() + " " + appointment.getEmployee().getLastName() : "—"; + String pet = appointment.getPet() != null ? appointment.getPet().getPetName() : null; + return """ +
+

Appointment confirmed

+

Service: %s

+

Date: %s

+

Time: %s

+

Location: %s

+

Staff: %s

+ %s +
""".formatted(esc(service), date, time, esc(store), esc(employee), + pet != null ? "

Pet: " + esc(pet) + "

" : ""); + } + + private String buildAppointmentReminderHtml(Appointment appointment) { + String service = appointment.getService() != null ? appointment.getService().getServiceName() : "—"; + String store = appointment.getStore() != null ? appointment.getStore().getStoreName() : "—"; + String time = appointment.getAppointmentTime() != null ? appointment.getAppointmentTime().format(TIME_FMT) : "—"; + return """ +
+

Reminder: appointment tomorrow

+

Your %s appointment is scheduled for tomorrow at %s at %s.

+
""".formatted(esc(service), time, esc(store)); + } + + private String buildTranscriptHtml(Conversation conversation, List messages, User customer) { + StringBuilder rows = new StringBuilder(); + for (Message msg : messages) { + String sender = msg.getSenderId() != null && msg.getSenderId().equals(customer.getId()) + ? firstName(customer) : "Support"; + String ts = msg.getTimestamp() != null + ? msg.getTimestamp().format(DateTimeFormatter.ofPattern("MMM d, h:mm a")) : ""; + String content = msg.getContent() != null ? esc(msg.getContent()) : ""; + rows.append("").append(ts).append("") + .append("").append(esc(sender)).append("") + .append("").append(content).append(""); + } + return """ +
+

Support chat transcript

+

Hi %s, here is the transcript of your support conversation.

+ + + + + + + %s +
TimeSenderMessage
+
""".formatted(firstName(customer), rows.toString()); + } + + private String firstName(User user) { + if (user.getFirstName() != null && !user.getFirstName().isBlank()) return esc(user.getFirstName()); + if (user.getFullName() != null && !user.getFullName().isBlank()) return esc(user.getFullName().split("\\s+")[0]); + return esc(user.getUsername()); + } + + private String fmt(BigDecimal value) { + return value != null ? String.format("%.2f", value) : "0.00"; + } + + private String discountLine(String label, BigDecimal value) { + if (value == null || value.compareTo(BigDecimal.ZERO) == 0) return ""; + return "

" + label + ": -$" + fmt(value) + "

"; + } + + private String esc(String s) { + if (s == null) return ""; + return s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); + } +} diff --git a/backend/src/main/java/com/petshop/backend/service/PasswordResetService.java b/backend/src/main/java/com/petshop/backend/service/PasswordResetService.java index 77a14d18..5094f613 100644 --- a/backend/src/main/java/com/petshop/backend/service/PasswordResetService.java +++ b/backend/src/main/java/com/petshop/backend/service/PasswordResetService.java @@ -28,14 +28,17 @@ public class PasswordResetService { private final PasswordResetTokenRepository passwordResetTokenRepository; private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; + private final EmailService emailService; private final SecureRandom secureRandom = new SecureRandom(); public PasswordResetService(PasswordResetTokenRepository passwordResetTokenRepository, UserRepository userRepository, - PasswordEncoder passwordEncoder) { + PasswordEncoder passwordEncoder, + EmailService emailService) { this.passwordResetTokenRepository = passwordResetTokenRepository; this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; + this.emailService = emailService; } @Transactional @@ -66,9 +69,11 @@ public class PasswordResetService { resetToken.setExpiresAt(now.plusMinutes(RESET_TOKEN_MINUTES)); passwordResetTokenRepository.save(resetToken); + emailService.sendPasswordResetLink(managedUser, rawToken); + return new ForgotPasswordResponse( - "If an account matches that username or email, a reset token has been generated.", - rawToken + "If an account matches that username or email, a reset link has been sent.", + null ); } diff --git a/backend/src/main/java/com/petshop/backend/service/SaleService.java b/backend/src/main/java/com/petshop/backend/service/SaleService.java index c1239cc3..b53a4c4c 100644 --- a/backend/src/main/java/com/petshop/backend/service/SaleService.java +++ b/backend/src/main/java/com/petshop/backend/service/SaleService.java @@ -31,8 +31,9 @@ public class SaleService { private final UserRepository userRepository; private final CouponRepository couponRepository; private final CartRepository cartRepository; + private final EmailService emailService; - public SaleService(SaleRepository saleRepository, ProductRepository productRepository, StoreRepository storeRepository, InventoryRepository inventoryRepository, UserRepository userRepository, CouponRepository couponRepository, CartRepository cartRepository) { + public SaleService(SaleRepository saleRepository, ProductRepository productRepository, StoreRepository storeRepository, InventoryRepository inventoryRepository, UserRepository userRepository, CouponRepository couponRepository, CartRepository cartRepository, EmailService emailService) { this.saleRepository = saleRepository; this.productRepository = productRepository; this.storeRepository = storeRepository; @@ -40,6 +41,7 @@ public class SaleService { this.userRepository = userRepository; this.couponRepository = couponRepository; this.cartRepository = cartRepository; + this.emailService = emailService; } @Transactional(readOnly = true) @@ -268,6 +270,11 @@ public class SaleService { sale.setItems(saleItems); Sale savedSale = saleRepository.save(sale); + + if (!Boolean.TRUE.equals(savedSale.getIsRefund()) && savedSale.getCustomer() != null) { + emailService.sendPurchaseReceipt(savedSale); + } + return mapToResponse(savedSale); } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index e3c3e0df..f16140f7 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -50,6 +50,11 @@ springdoc: app: upload: base-dir: ${UPLOAD_BASE_DIR:uploads} + frontend-url: ${FRONTEND_URL:http://localhost:3000} + +resend: + api-key: ${RESEND_API_KEY:} + from: ${RESEND_FROM:PetShop } jwt: secret: ${JWT_SECRET}