add email flows

This commit is contained in:
2026-04-14 15:23:07 -06:00
parent ceafee2a80
commit 39312c8698
11 changed files with 394 additions and 12 deletions

View File

@@ -96,6 +96,12 @@
<version>25.3.0</version> <version>25.3.0</version>
</dependency> </dependency>
<dependency>
<groupId>com.resend</groupId>
<artifactId>resend-java</artifactId>
<version>3.1.0</version>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>

View File

@@ -17,6 +17,7 @@ import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.security.JwtUtil; import com.petshop.backend.security.JwtUtil;
import com.petshop.backend.service.ActivityLogService; import com.petshop.backend.service.ActivityLogService;
import com.petshop.backend.service.AvatarStorageService; import com.petshop.backend.service.AvatarStorageService;
import com.petshop.backend.service.EmailService;
import com.petshop.backend.service.PasswordResetService; import com.petshop.backend.service.PasswordResetService;
import com.petshop.backend.util.AuthenticationHelper; import com.petshop.backend.util.AuthenticationHelper;
import com.petshop.backend.util.PhoneUtils; import com.petshop.backend.util.PhoneUtils;
@@ -55,8 +56,9 @@ public class AuthController {
private final AvatarStorageService avatarStorageService; private final AvatarStorageService avatarStorageService;
private final ActivityLogService activityLogService; private final ActivityLogService activityLogService;
private final PasswordResetService passwordResetService; 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.authenticationManager = authenticationManager;
this.userRepository = userRepository; this.userRepository = userRepository;
this.jwtUtil = jwtUtil; this.jwtUtil = jwtUtil;
@@ -64,6 +66,7 @@ public class AuthController {
this.avatarStorageService = avatarStorageService; this.avatarStorageService = avatarStorageService;
this.activityLogService = activityLogService; this.activityLogService = activityLogService;
this.passwordResetService = passwordResetService; this.passwordResetService = passwordResetService;
this.emailService = emailService;
} }
@PostMapping("/register") @PostMapping("/register")
@@ -107,6 +110,8 @@ public class AuthController {
User savedUser = userRepository.save(user); User savedUser = userRepository.save(user);
emailService.sendWelcome(savedUser);
String token = jwtUtil.generateToken(savedUser); String token = jwtUtil.generateToken(savedUser);
return ResponseEntity.status(HttpStatus.CREATED).body(new RegisterResponse( return ResponseEntity.status(HttpStatus.CREATED).body(new RegisterResponse(

View File

@@ -40,4 +40,6 @@ public interface AdoptionRepository extends JpaRepository<Adoption, Long> {
boolean existsByPet_IdAndAdoptionStatusIgnoreCase(Long petId, String adoptionStatus); boolean existsByPet_IdAndAdoptionStatusIgnoreCase(Long petId, String adoptionStatus);
List<Adoption> findByCustomer_IdAndAdoptionStatusIgnoreCase(Long customerId, String adoptionStatus); List<Adoption> findByCustomer_IdAndAdoptionStatusIgnoreCase(Long customerId, String adoptionStatus);
List<Adoption> findByAdoptionDateAndAdoptionStatusIgnoreCase(LocalDate date, String status);
} }

View File

@@ -50,4 +50,6 @@ public interface AppointmentRepository extends JpaRepository<Appointment, Long>
@Query("SELECT a FROM Appointment a WHERE (a.appointmentDate < :currentDate OR (a.appointmentDate = :currentDate AND a.appointmentTime < :currentTime)) AND LOWER(a.appointmentStatus) = 'booked'") @Query("SELECT a FROM Appointment a WHERE (a.appointmentDate < :currentDate OR (a.appointmentDate = :currentDate AND a.appointmentTime < :currentTime)) AND LOWER(a.appointmentStatus) = 'booked'")
List<Appointment> findPastBookedAppointments(@Param("currentDate") LocalDate currentDate, @Param("currentTime") LocalTime currentTime); List<Appointment> findPastBookedAppointments(@Param("currentDate") LocalDate currentDate, @Param("currentTime") LocalTime currentTime);
List<Appointment> findByAppointmentDateAndAppointmentStatusIgnoreCase(LocalDate date, String status);
} }

View File

@@ -39,13 +39,15 @@ public class AdoptionService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final StoreRepository storeRepository; private final StoreRepository storeRepository;
private final SaleRepository saleRepository; 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.adoptionRepository = adoptionRepository;
this.petRepository = petRepository; this.petRepository = petRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.storeRepository = storeRepository; this.storeRepository = storeRepository;
this.saleRepository = saleRepository; this.saleRepository = saleRepository;
this.emailService = emailService;
} }
public Page<AdoptionResponse> getAllAdoptions(String query, Long customerId, String status, Long storeId, LocalDate date, Pageable pageable) { public Page<AdoptionResponse> 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)) { if (ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoptionStatus)) {
createSaleForAdoption(adoption, request.getPaymentMethod()); createSaleForAdoption(adoption, request.getPaymentMethod());
} }
if (ADOPTION_STATUS_PENDING.equalsIgnoreCase(adoptionStatus) || ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoptionStatus)) {
emailService.sendAdoptionConfirmation(adoption);
}
return mapToResponse(adoption); return mapToResponse(adoption);
} }
@@ -125,6 +130,7 @@ public class AdoptionService {
.orElseThrow(() -> new ResourceNotFoundException("Store not found with id: " + request.getSourceStoreId())) .orElseThrow(() -> new ResourceNotFoundException("Store not found with id: " + request.getSourceStoreId()))
: null; : null;
boolean wasCompleted = ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoption.getAdoptionStatus()); boolean wasCompleted = ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoption.getAdoptionStatus());
String previousStatus = adoption.getAdoptionStatus();
String adoptionStatus = normalizeAdoptionStatus(request.getAdoptionStatus()); String adoptionStatus = normalizeAdoptionStatus(request.getAdoptionStatus());
Long currentPetId = adoption.getPet() != null ? adoption.getPet().getPetId() : null; Long currentPetId = adoption.getPet() != null ? adoption.getPet().getPetId() : null;
validatePetAvailability(pet, adoption.getAdoptionId(), currentPetId); validatePetAvailability(pet, adoption.getAdoptionId(), currentPetId);
@@ -144,6 +150,10 @@ public class AdoptionService {
if (!wasCompleted && ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoptionStatus)) { if (!wasCompleted && ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoptionStatus)) {
createSaleForAdoption(adoption, request.getPaymentMethod()); 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); return mapToResponse(adoption);
} }
@@ -258,7 +268,8 @@ public class AdoptionService {
sale.setPaymentMethod(paymentMethod != null && !paymentMethod.isBlank() ? paymentMethod : "Cash"); sale.setPaymentMethod(paymentMethod != null && !paymentMethod.isBlank() ? paymentMethod : "Cash");
sale.setIsRefund(false); sale.setIsRefund(false);
sale.setChannel("ADOPTION"); 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) { private void syncPetStatus(Pet pet, String adoptionStatus, Long adoptionId, User customer) {

View File

@@ -3,11 +3,13 @@ package com.petshop.backend.service;
import com.petshop.backend.dto.appointment.AppointmentRequest; import com.petshop.backend.dto.appointment.AppointmentRequest;
import com.petshop.backend.dto.appointment.AppointmentResponse; import com.petshop.backend.dto.appointment.AppointmentResponse;
import com.petshop.backend.dto.common.BulkDeleteRequest; import com.petshop.backend.dto.common.BulkDeleteRequest;
import com.petshop.backend.entity.Adoption;
import com.petshop.backend.entity.Appointment; import com.petshop.backend.entity.Appointment;
import com.petshop.backend.entity.Pet; import com.petshop.backend.entity.Pet;
import com.petshop.backend.entity.StoreLocation; import com.petshop.backend.entity.StoreLocation;
import com.petshop.backend.entity.User; import com.petshop.backend.entity.User;
import com.petshop.backend.exception.ResourceNotFoundException; import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.repository.AdoptionRepository;
import com.petshop.backend.repository.AppointmentRepository; import com.petshop.backend.repository.AppointmentRepository;
import com.petshop.backend.repository.PetRepository; import com.petshop.backend.repository.PetRepository;
import com.petshop.backend.repository.ServiceRepository; import com.petshop.backend.repository.ServiceRepository;
@@ -36,13 +38,17 @@ public class AppointmentService {
private final PetRepository petRepository; private final PetRepository petRepository;
private final StoreRepository storeRepository; private final StoreRepository storeRepository;
private final UserRepository userRepository; 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.appointmentRepository = appointmentRepository;
this.serviceRepository = serviceRepository; this.serviceRepository = serviceRepository;
this.petRepository = petRepository; this.petRepository = petRepository;
this.storeRepository = storeRepository; this.storeRepository = storeRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.adoptionRepository = adoptionRepository;
this.emailService = emailService;
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
@@ -115,6 +121,7 @@ public class AppointmentService {
appointment.setPet(pet); appointment.setPet(pet);
appointment = appointmentRepository.save(appointment); appointment = appointmentRepository.save(appointment);
emailService.sendAppointmentConfirmation(appointment);
return mapToResponse(appointment); return mapToResponse(appointment);
} }
@@ -152,6 +159,7 @@ public class AppointmentService {
appointment.setEmployee(employee); appointment.setEmployee(employee);
appointment = appointmentRepository.save(appointment); appointment = appointmentRepository.save(appointment);
emailService.sendAppointmentConfirmation(appointment);
return mapToResponse(appointment); return mapToResponse(appointment);
} }
@@ -210,7 +218,7 @@ public class AppointmentService {
return availableSlots; 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 * * ?") @Scheduled(cron = "0 0 0 * * ?")
@Transactional @Transactional
public void updatePastAppointmentsStatus() { public void updatePastAppointmentsStatus() {
@@ -222,6 +230,20 @@ public class AppointmentService {
appointment.setAppointmentStatus("COMPLETED"); appointment.setAppointmentStatus("COMPLETED");
appointmentRepository.save(appointment); appointmentRepository.save(appointment);
} }
LocalDate tomorrow = currentDate.plusDays(1);
List<Appointment> tomorrowAppointments = appointmentRepository
.findByAppointmentDateAndAppointmentStatusIgnoreCase(tomorrow, "Booked");
for (Appointment appointment : tomorrowAppointments) {
emailService.sendAppointmentReminder(appointment);
}
List<Adoption> tomorrowAdoptions = adoptionRepository
.findByAdoptionDateAndAdoptionStatusIgnoreCase(tomorrow, "Pending");
for (Adoption adoption : tomorrowAdoptions) {
emailService.sendAdoptionReminder(adoption);
}
} }
private String normalizeFilter(String value) { private String normalizeFilter(String value) {

View File

@@ -31,17 +31,20 @@ public class ChatService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final AvatarStorageService avatarStorageService; private final AvatarStorageService avatarStorageService;
private final ChatAttachmentStorageService attachmentStorageService; private final ChatAttachmentStorageService attachmentStorageService;
private final EmailService emailService;
public ChatService(ConversationRepository conversationRepository, public ChatService(ConversationRepository conversationRepository,
MessageRepository messageRepository, MessageRepository messageRepository,
UserRepository userRepository, UserRepository userRepository,
AvatarStorageService avatarStorageService, AvatarStorageService avatarStorageService,
ChatAttachmentStorageService attachmentStorageService) { ChatAttachmentStorageService attachmentStorageService,
EmailService emailService) {
this.conversationRepository = conversationRepository; this.conversationRepository = conversationRepository;
this.messageRepository = messageRepository; this.messageRepository = messageRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.avatarStorageService = avatarStorageService; this.avatarStorageService = avatarStorageService;
this.attachmentStorageService = attachmentStorageService; this.attachmentStorageService = attachmentStorageService;
this.emailService = emailService;
} }
@Transactional @Transactional
@@ -261,13 +264,19 @@ public class ChatService {
} }
conversation.setStatus(Conversation.ConversationStatus.valueOf(request.getStatus())); conversation.setStatus(Conversation.ConversationStatus.valueOf(request.getStatus()));
conversation = conversationRepository.save(conversation); Conversation savedConversation = conversationRepository.save(conversation);
List<Message> messages = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId); List<Message> 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); Message last = messages.isEmpty() ? null : messages.get(messages.size() - 1);
String lastMessage = last != null && last.getContent() != null ? last.getContent() : ""; String lastMessage = last != null && last.getContent() != null ? last.getContent() : "";
Long lastSenderId = last != null ? last.getSenderId() : null; Long lastSenderId = last != null ? last.getSenderId() : null;
return ConversationResponse.fromEntity(conversation, lastMessage, lastSenderId); return ConversationResponse.fromEntity(savedConversation, lastMessage, lastSenderId);
} }
public List<MessageResponse> getMessages(Long conversationId, Long userId, User.Role role) { public List<MessageResponse> getMessages(Long conversationId, Long userId, User.Role role) {

View File

@@ -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 = """
<div style="font-family:sans-serif;max-width:600px;margin:auto">
<h2>Welcome to PetShop, %s!</h2>
<p>Your account has been created successfully.</p>
<p>You can now log in and start exploring our pets, products, and services.</p>
<p>Thank you for joining us!</p>
</div>""".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 = """
<div style="font-family:sans-serif;max-width:600px;margin:auto">
<h2>Password Reset Request</h2>
<p>Hi %s,</p>
<p>We received a request to reset your password. Click the button below to proceed.</p>
<p><a href="%s" style="background:#2563eb;color:#fff;padding:10px 20px;border-radius:4px;text-decoration:none;display:inline-block">Reset Password</a></p>
<p>This link expires in <strong>30 minutes</strong>. If you did not request a reset, you can safely ignore this email.</p>
</div>""".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<Message> 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<SaleItem> items = sale.getItems();
if (items != null) {
for (SaleItem item : items) {
String name = item.getProduct() != null ? item.getProduct().getProdName() : "";
rows.append("<tr><td style='padding:6px 8px'>").append(esc(name)).append("</td>")
.append("<td style='padding:6px 8px;text-align:center'>").append(item.getQuantity()).append("</td>")
.append("<td style='padding:6px 8px;text-align:right'>$").append(fmt(item.getUnitPrice())).append("</td>")
.append("<td style='padding:6px 8px;text-align:right'>$").append(fmt(item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity())))).append("</td></tr>");
}
}
String date = sale.getSaleDate() != null ? sale.getSaleDate().format(DateTimeFormatter.ofPattern("MMMM d, yyyy h:mm a")) : "";
return """
<div style="font-family:sans-serif;max-width:600px;margin:auto">
<h2>Thank you for your purchase!</h2>
<p><strong>Date:</strong> %s</p>
<p><strong>Payment method:</strong> %s</p>
<table style="width:100%%;border-collapse:collapse;margin:16px 0">
<thead><tr style="background:#f3f4f6">
<th style="padding:6px 8px;text-align:left">Item</th>
<th style="padding:6px 8px;text-align:center">Qty</th>
<th style="padding:6px 8px;text-align:right">Unit price</th>
<th style="padding:6px 8px;text-align:right">Total</th>
</tr></thead>
<tbody>%s</tbody>
</table>
<p><strong>Subtotal:</strong> $%s</p>
%s
%s
%s
<p style="font-size:1.1em"><strong>Total:</strong> $%s</p>
%s
</div>""".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
? "<p>You earned <strong>" + sale.getPointsEarned() + " loyalty points</strong> on this order.</p>"
: ""
);
}
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 """
<div style="font-family:sans-serif;max-width:600px;margin:auto">
<h2>Adoption update — %s</h2>
<p><strong>Customer:</strong> %s</p>
<p><strong>Pet:</strong> %s</p>
<p><strong>Date:</strong> %s</p>
<p><strong>Store:</strong> %s</p>
<p><strong>Status:</strong> %s</p>
</div>""".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 """
<div style="font-family:sans-serif;max-width:600px;margin:auto">
<h2>Reminder: adoption tomorrow</h2>
<p>This is a reminder that the adoption of <strong>%s</strong> is scheduled for <strong>%s</strong> at <strong>%s</strong>.</p>
</div>""".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 """
<div style="font-family:sans-serif;max-width:600px;margin:auto">
<h2>Appointment confirmed</h2>
<p><strong>Service:</strong> %s</p>
<p><strong>Date:</strong> %s</p>
<p><strong>Time:</strong> %s</p>
<p><strong>Location:</strong> %s</p>
<p><strong>Staff:</strong> %s</p>
%s
</div>""".formatted(esc(service), date, time, esc(store), esc(employee),
pet != null ? "<p><strong>Pet:</strong> " + esc(pet) + "</p>" : "");
}
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 """
<div style="font-family:sans-serif;max-width:600px;margin:auto">
<h2>Reminder: appointment tomorrow</h2>
<p>Your <strong>%s</strong> appointment is scheduled for tomorrow at <strong>%s</strong> at <strong>%s</strong>.</p>
</div>""".formatted(esc(service), time, esc(store));
}
private String buildTranscriptHtml(Conversation conversation, List<Message> 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("<tr><td style='padding:6px 8px;white-space:nowrap'>").append(ts).append("</td>")
.append("<td style='padding:6px 8px;white-space:nowrap'>").append(esc(sender)).append("</td>")
.append("<td style='padding:6px 8px'>").append(content).append("</td></tr>");
}
return """
<div style="font-family:sans-serif;max-width:700px;margin:auto">
<h2>Support chat transcript</h2>
<p>Hi %s, here is the transcript of your support conversation.</p>
<table style="width:100%%;border-collapse:collapse;margin:16px 0;font-size:0.9em">
<thead><tr style="background:#f3f4f6">
<th style="padding:6px 8px;text-align:left">Time</th>
<th style="padding:6px 8px;text-align:left">Sender</th>
<th style="padding:6px 8px;text-align:left">Message</th>
</tr></thead>
<tbody>%s</tbody>
</table>
</div>""".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 "<p><strong>" + label + ":</strong> -$" + fmt(value) + "</p>";
}
private String esc(String s) {
if (s == null) return "";
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;");
}
}

View File

@@ -28,14 +28,17 @@ public class PasswordResetService {
private final PasswordResetTokenRepository passwordResetTokenRepository; private final PasswordResetTokenRepository passwordResetTokenRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final EmailService emailService;
private final SecureRandom secureRandom = new SecureRandom(); private final SecureRandom secureRandom = new SecureRandom();
public PasswordResetService(PasswordResetTokenRepository passwordResetTokenRepository, public PasswordResetService(PasswordResetTokenRepository passwordResetTokenRepository,
UserRepository userRepository, UserRepository userRepository,
PasswordEncoder passwordEncoder) { PasswordEncoder passwordEncoder,
EmailService emailService) {
this.passwordResetTokenRepository = passwordResetTokenRepository; this.passwordResetTokenRepository = passwordResetTokenRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.emailService = emailService;
} }
@Transactional @Transactional
@@ -66,9 +69,11 @@ public class PasswordResetService {
resetToken.setExpiresAt(now.plusMinutes(RESET_TOKEN_MINUTES)); resetToken.setExpiresAt(now.plusMinutes(RESET_TOKEN_MINUTES));
passwordResetTokenRepository.save(resetToken); passwordResetTokenRepository.save(resetToken);
emailService.sendPasswordResetLink(managedUser, rawToken);
return new ForgotPasswordResponse( return new ForgotPasswordResponse(
"If an account matches that username or email, a reset token has been generated.", "If an account matches that username or email, a reset link has been sent.",
rawToken null
); );
} }

View File

@@ -31,8 +31,9 @@ public class SaleService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final CouponRepository couponRepository; private final CouponRepository couponRepository;
private final CartRepository cartRepository; 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.saleRepository = saleRepository;
this.productRepository = productRepository; this.productRepository = productRepository;
this.storeRepository = storeRepository; this.storeRepository = storeRepository;
@@ -40,6 +41,7 @@ public class SaleService {
this.userRepository = userRepository; this.userRepository = userRepository;
this.couponRepository = couponRepository; this.couponRepository = couponRepository;
this.cartRepository = cartRepository; this.cartRepository = cartRepository;
this.emailService = emailService;
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
@@ -268,6 +270,11 @@ public class SaleService {
sale.setItems(saleItems); sale.setItems(saleItems);
Sale savedSale = saleRepository.save(sale); Sale savedSale = saleRepository.save(sale);
if (!Boolean.TRUE.equals(savedSale.getIsRefund()) && savedSale.getCustomer() != null) {
emailService.sendPurchaseReceipt(savedSale);
}
return mapToResponse(savedSale); return mapToResponse(savedSale);
} }

View File

@@ -50,6 +50,11 @@ springdoc:
app: app:
upload: upload:
base-dir: ${UPLOAD_BASE_DIR:uploads} base-dir: ${UPLOAD_BASE_DIR:uploads}
frontend-url: ${FRONTEND_URL:http://localhost:3000}
resend:
api-key: ${RESEND_API_KEY:}
from: ${RESEND_FROM:PetShop <onboarding@resend.dev>}
jwt: jwt:
secret: ${JWT_SECRET} secret: ${JWT_SECRET}