Merge pull request #266 from RecentRunner/resend-email
resend email
This commit was merged in pull request #266.
This commit is contained in:
@@ -96,6 +96,12 @@
|
||||
<version>25.3.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.resend</groupId>
|
||||
<artifactId>resend-java</artifactId>
|
||||
<version>3.1.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -40,4 +40,6 @@ public interface AdoptionRepository extends JpaRepository<Adoption, Long> {
|
||||
boolean existsByPet_IdAndAdoptionStatusIgnoreCase(Long petId, String adoptionStatus);
|
||||
|
||||
List<Adoption> findByCustomer_IdAndAdoptionStatusIgnoreCase(Long customerId, String adoptionStatus);
|
||||
|
||||
List<Adoption> findByAdoptionDateAndAdoptionStatusIgnoreCase(LocalDate date, String status);
|
||||
}
|
||||
|
||||
@@ -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'")
|
||||
List<Appointment> findPastBookedAppointments(@Param("currentDate") LocalDate currentDate, @Param("currentTime") LocalTime currentTime);
|
||||
|
||||
List<Appointment> findByAppointmentDateAndAppointmentStatusIgnoreCase(LocalDate date, String status);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.petshop.backend.security;
|
||||
|
||||
import com.petshop.backend.exception.ApiErrorResponder;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class RateLimitFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final Map<String, int[]> RULES = Map.of(
|
||||
"/api/v1/auth/login", new int[]{10, 15},
|
||||
"/api/v1/auth/register", new int[]{5, 60},
|
||||
"/api/v1/auth/forgot-password", new int[]{3, 10},
|
||||
"/api/v1/auth/reset-password", new int[]{10, 15}
|
||||
);
|
||||
|
||||
private final RateLimiterService rateLimiterService;
|
||||
private final ApiErrorResponder apiErrorResponder;
|
||||
|
||||
public RateLimitFilter(RateLimiterService rateLimiterService, ApiErrorResponder apiErrorResponder) {
|
||||
this.rateLimiterService = rateLimiterService;
|
||||
this.apiErrorResponder = apiErrorResponder;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(@NonNull HttpServletRequest request,
|
||||
@NonNull HttpServletResponse response,
|
||||
@NonNull FilterChain filterChain) throws ServletException, IOException {
|
||||
String path = request.getRequestURI();
|
||||
int[] rule = RULES.get(path);
|
||||
|
||||
if (rule != null) {
|
||||
String ip = extractIp(request);
|
||||
String key = path + ":" + ip;
|
||||
if (!rateLimiterService.isAllowed(key, rule[0], Duration.ofMinutes(rule[1]))) {
|
||||
apiErrorResponder.write(response, HttpStatus.TOO_MANY_REQUESTS,
|
||||
"Too many requests. Please try again later.", null, path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private String extractIp(HttpServletRequest request) {
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded != null && !forwarded.isBlank()) {
|
||||
return forwarded.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.petshop.backend.security;
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Service
|
||||
public class RateLimiterService {
|
||||
|
||||
private final Map<String, Deque<Instant>> buckets = new ConcurrentHashMap<>();
|
||||
|
||||
public boolean isAllowed(String key, int maxRequests, Duration window) {
|
||||
Instant now = Instant.now();
|
||||
Instant windowStart = now.minus(window);
|
||||
|
||||
Deque<Instant> timestamps = buckets.computeIfAbsent(key, k -> new ArrayDeque<>());
|
||||
synchronized (timestamps) {
|
||||
while (!timestamps.isEmpty() && timestamps.peekFirst().isBefore(windowStart)) {
|
||||
timestamps.pollFirst();
|
||||
}
|
||||
if (timestamps.size() >= maxRequests) {
|
||||
return false;
|
||||
}
|
||||
timestamps.addLast(now);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 300_000)
|
||||
public void evictStale() {
|
||||
Instant cutoff = Instant.now().minus(Duration.ofHours(2));
|
||||
buckets.entrySet().removeIf(entry -> {
|
||||
Deque<Instant> timestamps = entry.getValue();
|
||||
synchronized (timestamps) {
|
||||
return timestamps.isEmpty() || timestamps.peekLast().isBefore(cutoff);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -31,15 +31,18 @@ import java.util.List;
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthenticationFilter jwtAuthFilter;
|
||||
private final RateLimitFilter rateLimitFilter;
|
||||
private final UserDetailsService userDetailsService;
|
||||
private final RestAuthenticationEntryPoint restAuthenticationEntryPoint;
|
||||
private final RestAccessDeniedHandler restAccessDeniedHandler;
|
||||
|
||||
public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter,
|
||||
RateLimitFilter rateLimitFilter,
|
||||
UserDetailsService userDetailsService,
|
||||
RestAuthenticationEntryPoint restAuthenticationEntryPoint,
|
||||
RestAccessDeniedHandler restAccessDeniedHandler) {
|
||||
this.jwtAuthFilter = jwtAuthFilter;
|
||||
this.rateLimitFilter = rateLimitFilter;
|
||||
this.userDetailsService = userDetailsService;
|
||||
this.restAuthenticationEntryPoint = restAuthenticationEntryPoint;
|
||||
this.restAccessDeniedHandler = restAccessDeniedHandler;
|
||||
@@ -75,6 +78,7 @@ public class SecurityConfig {
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authenticationProvider(daoAuthenticationProvider())
|
||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
http.addFilterBefore(rateLimitFilter, JwtAuthenticationFilter.class);
|
||||
http.addFilterAfter(activityLoggingFilter, JwtAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
|
||||
@@ -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<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)) {
|
||||
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) {
|
||||
|
||||
@@ -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<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) {
|
||||
|
||||
@@ -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<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);
|
||||
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<MessageResponse> getMessages(Long conversationId, Long userId, User.Role role) {
|
||||
|
||||
@@ -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("&", "&").replace("<", "<").replace(">", ">").replace("\"", """);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <onboarding@resend.dev>}
|
||||
|
||||
jwt:
|
||||
secret: ${JWT_SECRET}
|
||||
|
||||
Reference in New Issue
Block a user