Skip to content

Commit f200608

Browse files
authored
Feature/quote email notifications (#26)
* feat(quote): add HTML email templates and state-transition notifications Convert quote-submitted and quote-new-request emails from plain text to Thymeleaf HTML. Add sendQuoteAccepted, sendQuotePendingApproval, and sendQuoteApproved notifications wired into the acceptance and approval flows. Fix ExpiryScheduler to also expire PENDING_APPROVAL quotes. * feat(quote): add rejection, expiry emails and admin PDF download Send admin notification when user rejects a quote; notify submitting user when manager rejects spending approval; notify user when quote auto-expires (scheduler now fetches before expiring to enable per-quote emails). Add GET /api/v1/admin/quotes/{id}/pdf endpoint for admin PDF download.
1 parent 0bb9149 commit f200608

16 files changed

Lines changed: 671 additions & 43 deletions

src/main/java/io/k2dv/garden/auth/service/EmailService.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ public interface EmailService {
1010
void sendQuoteSubmitted(String to, UUID quoteId);
1111
void sendQuoteNewRequest(String to, UUID quoteId);
1212
void sendQuotePdf(String to, UUID quoteId, byte[] pdfBytes);
13+
void sendQuoteAccepted(String to, UUID quoteId, UUID orderId);
14+
void sendQuotePendingApproval(String to, UUID quoteId);
15+
void sendQuoteApproved(String to, UUID quoteId);
16+
void sendQuoteRejectedByUser(String to, UUID quoteId);
17+
void sendQuoteApprovalRejected(String to, UUID quoteId);
18+
void sendQuoteExpired(String to, UUID quoteId);
1319
void sendCompanyInvitation(String to, String companyName, String inviterName, String token);
1420
void sendOrderConfirmation(String to, String orderRef, BigDecimal total, String currency, List<String> itemLines, String storeFrontUrl);
1521
void sendShippingNotification(String to, String orderRef, String trackingNumber, String trackingCompany, String trackingUrl, String storeFrontUrl);

src/main/java/io/k2dv/garden/auth/service/SmtpEmailService.java

Lines changed: 129 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -55,31 +55,148 @@ public void sendPasswordReset(String to, String token) {
5555
@Override
5656
public void sendQuoteSubmitted(String to, UUID quoteId) {
5757
try {
58-
var msg = new SimpleMailMessage();
59-
msg.setTo(to);
60-
msg.setSubject("Your quote request has been received");
61-
msg.setText("Thank you for submitting your quote request (ID: " + quoteId + "). "
62-
+ "Our team will review it and get back to you shortly.");
58+
Context ctx = new Context();
59+
ctx.setVariable("quoteId", quoteId);
60+
ctx.setVariable("storeFrontUrl", props.getFrontendUrl());
61+
String html = templateEngine.process("email/quote-submitted", ctx);
62+
MimeMessage msg = mailSender.createMimeMessage();
63+
MimeMessageHelper helper = new MimeMessageHelper(msg, false, "UTF-8");
64+
helper.setTo(to);
65+
helper.setSubject("Your quote request has been received");
66+
helper.setText(html, true);
6367
mailSender.send(msg);
64-
} catch (MailException e) {
68+
} catch (MessagingException | MailException e) {
6569
log.error("Failed to send quote-submitted email to {} for quote {}: {}", to, quoteId, e.getMessage(), e);
6670
}
6771
}
6872

6973
@Override
7074
public void sendQuoteNewRequest(String to, UUID quoteId) {
7175
try {
72-
var msg = new SimpleMailMessage();
73-
msg.setTo(to);
74-
msg.setSubject("New quote request received — #" + quoteId);
75-
msg.setText("A new quote request has been submitted (ID: " + quoteId + "). "
76-
+ "Log in to the admin portal to review and assign it.");
76+
Context ctx = new Context();
77+
ctx.setVariable("quoteId", quoteId);
78+
ctx.setVariable("adminUrl", props.getFrontendUrl());
79+
String html = templateEngine.process("email/quote-new-request", ctx);
80+
MimeMessage msg = mailSender.createMimeMessage();
81+
MimeMessageHelper helper = new MimeMessageHelper(msg, false, "UTF-8");
82+
helper.setTo(to);
83+
helper.setSubject("New quote request received — #" + quoteId);
84+
helper.setText(html, true);
7785
mailSender.send(msg);
78-
} catch (MailException e) {
86+
} catch (MessagingException | MailException e) {
7987
log.error("Failed to send quote-new-request email to {} for quote {}: {}", to, quoteId, e.getMessage(), e);
8088
}
8189
}
8290

91+
@Override
92+
public void sendQuoteAccepted(String to, UUID quoteId, UUID orderId) {
93+
try {
94+
Context ctx = new Context();
95+
ctx.setVariable("quoteId", quoteId);
96+
ctx.setVariable("orderId", orderId);
97+
ctx.setVariable("storeFrontUrl", props.getFrontendUrl());
98+
String html = templateEngine.process("email/quote-accepted", ctx);
99+
MimeMessage msg = mailSender.createMimeMessage();
100+
MimeMessageHelper helper = new MimeMessageHelper(msg, false, "UTF-8");
101+
helper.setTo(to);
102+
helper.setSubject("Your quote has been accepted — Order created");
103+
helper.setText(html, true);
104+
mailSender.send(msg);
105+
} catch (MessagingException | MailException e) {
106+
log.error("Failed to send quote-accepted email to {} for quote {}: {}", to, quoteId, e.getMessage(), e);
107+
}
108+
}
109+
110+
@Override
111+
public void sendQuotePendingApproval(String to, UUID quoteId) {
112+
try {
113+
Context ctx = new Context();
114+
ctx.setVariable("quoteId", quoteId);
115+
ctx.setVariable("storeFrontUrl", props.getFrontendUrl());
116+
String html = templateEngine.process("email/quote-pending-approval", ctx);
117+
MimeMessage msg = mailSender.createMimeMessage();
118+
MimeMessageHelper helper = new MimeMessageHelper(msg, false, "UTF-8");
119+
helper.setTo(to);
120+
helper.setSubject("Action required: Quote approval needed — #" + quoteId);
121+
helper.setText(html, true);
122+
mailSender.send(msg);
123+
} catch (MessagingException | MailException e) {
124+
log.error("Failed to send quote-pending-approval email to {} for quote {}: {}", to, quoteId, e.getMessage(), e);
125+
}
126+
}
127+
128+
@Override
129+
public void sendQuoteApproved(String to, UUID quoteId) {
130+
try {
131+
Context ctx = new Context();
132+
ctx.setVariable("quoteId", quoteId);
133+
ctx.setVariable("storeFrontUrl", props.getFrontendUrl());
134+
String html = templateEngine.process("email/quote-approved", ctx);
135+
MimeMessage msg = mailSender.createMimeMessage();
136+
MimeMessageHelper helper = new MimeMessageHelper(msg, false, "UTF-8");
137+
helper.setTo(to);
138+
helper.setSubject("Your quote has been approved — #" + quoteId);
139+
helper.setText(html, true);
140+
mailSender.send(msg);
141+
} catch (MessagingException | MailException e) {
142+
log.error("Failed to send quote-approved email to {} for quote {}: {}", to, quoteId, e.getMessage(), e);
143+
}
144+
}
145+
146+
@Override
147+
public void sendQuoteRejectedByUser(String to, UUID quoteId) {
148+
try {
149+
Context ctx = new Context();
150+
ctx.setVariable("quoteId", quoteId);
151+
ctx.setVariable("adminUrl", props.getFrontendUrl());
152+
String html = templateEngine.process("email/quote-rejected-by-user", ctx);
153+
MimeMessage msg = mailSender.createMimeMessage();
154+
MimeMessageHelper helper = new MimeMessageHelper(msg, false, "UTF-8");
155+
helper.setTo(to);
156+
helper.setSubject("Quote declined by customer — #" + quoteId);
157+
helper.setText(html, true);
158+
mailSender.send(msg);
159+
} catch (MessagingException | MailException e) {
160+
log.error("Failed to send quote-rejected-by-user email to {} for quote {}: {}", to, quoteId, e.getMessage(), e);
161+
}
162+
}
163+
164+
@Override
165+
public void sendQuoteApprovalRejected(String to, UUID quoteId) {
166+
try {
167+
Context ctx = new Context();
168+
ctx.setVariable("quoteId", quoteId);
169+
ctx.setVariable("storeFrontUrl", props.getFrontendUrl());
170+
String html = templateEngine.process("email/quote-approval-rejected", ctx);
171+
MimeMessage msg = mailSender.createMimeMessage();
172+
MimeMessageHelper helper = new MimeMessageHelper(msg, false, "UTF-8");
173+
helper.setTo(to);
174+
helper.setSubject("Quote approval not granted — #" + quoteId);
175+
helper.setText(html, true);
176+
mailSender.send(msg);
177+
} catch (MessagingException | MailException e) {
178+
log.error("Failed to send quote-approval-rejected email to {} for quote {}: {}", to, quoteId, e.getMessage(), e);
179+
}
180+
}
181+
182+
@Override
183+
public void sendQuoteExpired(String to, UUID quoteId) {
184+
try {
185+
Context ctx = new Context();
186+
ctx.setVariable("quoteId", quoteId);
187+
ctx.setVariable("storeFrontUrl", props.getFrontendUrl());
188+
String html = templateEngine.process("email/quote-expired", ctx);
189+
MimeMessage msg = mailSender.createMimeMessage();
190+
MimeMessageHelper helper = new MimeMessageHelper(msg, false, "UTF-8");
191+
helper.setTo(to);
192+
helper.setSubject("Your quote has expired — #" + quoteId);
193+
helper.setText(html, true);
194+
mailSender.send(msg);
195+
} catch (MessagingException | MailException e) {
196+
log.error("Failed to send quote-expired email to {} for quote {}: {}", to, quoteId, e.getMessage(), e);
197+
}
198+
}
199+
83200
@Override
84201
public void sendCompanyInvitation(String to, String companyName, String inviterName, String token) {
85202
try {

src/main/java/io/k2dv/garden/quote/controller/AdminQuoteController.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import jakarta.validation.Valid;
1111
import lombok.RequiredArgsConstructor;
1212
import org.springframework.data.domain.PageRequest;
13+
import org.springframework.http.HttpHeaders;
14+
import org.springframework.http.MediaType;
1315
import org.springframework.http.ResponseEntity;
1416
import org.springframework.web.bind.annotation.*;
1517

@@ -97,4 +99,14 @@ public ResponseEntity<ApiResponse<QuoteRequestResponse>> send(
9799
public ResponseEntity<ApiResponse<QuoteRequestResponse>> cancel(@PathVariable UUID id) {
98100
return ResponseEntity.ok(ApiResponse.of(quoteService.cancel(id)));
99101
}
102+
103+
@GetMapping("/{id}/pdf")
104+
@HasPermission("quote:read")
105+
public ResponseEntity<byte[]> downloadPdf(@PathVariable UUID id) {
106+
byte[] pdf = quoteService.downloadPdfAdmin(id);
107+
return ResponseEntity.ok()
108+
.contentType(MediaType.APPLICATION_PDF)
109+
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"quote-" + id + ".pdf\"")
110+
.body(pdf);
111+
}
100112
}

src/main/java/io/k2dv/garden/quote/repository/QuoteRequestRepository.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import org.springframework.data.repository.query.Param;
1010

1111
import java.time.Instant;
12+
import java.util.List;
1213
import java.util.Optional;
1314
import java.util.UUID;
1415

@@ -18,4 +19,7 @@ public interface QuoteRequestRepository extends JpaRepository<QuoteRequest, UUID
1819
@Modifying(clearAutomatically = true)
1920
@Query("UPDATE QuoteRequest q SET q.status = :to WHERE q.status = :from AND q.expiresAt < :now")
2021
int expireByStatus(@Param("from") QuoteStatus from, @Param("to") QuoteStatus to, @Param("now") Instant now);
22+
23+
@Query("SELECT q FROM QuoteRequest q WHERE q.status = :status AND q.expiresAt < :now")
24+
List<QuoteRequest> findExpiredByStatus(@Param("status") QuoteStatus status, @Param("now") Instant now);
2125
}

src/main/java/io/k2dv/garden/quote/service/QuoteService.java

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,16 @@ public byte[] downloadPdf(UUID quoteId, UUID userId) {
174174
if (!quote.getUserId().equals(userId)) {
175175
throw new ForbiddenException("NOT_YOUR_QUOTE", "This quote does not belong to you");
176176
}
177+
return fetchPdfBytes(quote);
178+
}
179+
180+
public byte[] downloadPdfAdmin(UUID quoteId) {
181+
QuoteRequest quote = quoteRepo.findById(quoteId)
182+
.orElseThrow(() -> new NotFoundException("QUOTE_NOT_FOUND", "Quote not found"));
183+
return fetchPdfBytes(quote);
184+
}
185+
186+
private byte[] fetchPdfBytes(QuoteRequest quote) {
177187
if (quote.getPdfBlobId() == null) {
178188
throw new NotFoundException("PDF_NOT_AVAILABLE", "Quote PDF has not been generated yet");
179189
}
@@ -215,6 +225,10 @@ public QuoteAcceptResponse accept(UUID quoteId, UUID userId) {
215225
if (total.compareTo(limit) > 0) {
216226
quote.setStatus(QuoteStatus.PENDING_APPROVAL);
217227
quoteRepo.save(quote);
228+
membershipRepo.findByCompanyId(quote.getCompanyId()).stream()
229+
.filter(m -> m.getRole() == CompanyRole.OWNER || m.getRole() == CompanyRole.MANAGER)
230+
.forEach(m -> userRepo.findById(m.getUserId()).ifPresent(
231+
manager -> emailService.sendQuotePendingApproval(manager.getEmail(), quote.getId())));
218232
return new QuoteAcceptResponse(null, null, true, null);
219233
}
220234
}
@@ -240,7 +254,10 @@ public QuoteAcceptResponse approveSpend(UUID quoteId, UUID approverId) {
240254
quote.setApprovedAt(Instant.now());
241255

242256
List<QuoteItem> items = itemRepo.findByQuoteRequestId(quoteId);
243-
return finalizeAcceptance(quote, items);
257+
QuoteAcceptResponse response = finalizeAcceptance(quote, items);
258+
userRepo.findById(quote.getUserId()).ifPresent(
259+
user -> emailService.sendQuoteApproved(user.getEmail(), quote.getId()));
260+
return response;
244261
}
245262

246263
// Reject approval: company OWNER rejects a PENDING_APPROVAL quote
@@ -257,14 +274,19 @@ public QuoteRequestResponse rejectSpend(UUID quoteId, UUID approverId) {
257274
"Only a company owner or manager can reject spend");
258275
}
259276
quote.setStatus(QuoteStatus.REJECTED);
260-
return toResponse(quoteRepo.save(quote));
277+
QuoteRequestResponse response = toResponse(quoteRepo.save(quote));
278+
userRepo.findById(quote.getUserId()).ifPresent(
279+
user -> emailService.sendQuoteApprovalRejected(user.getEmail(), quote.getId()));
280+
return response;
261281
}
262282

263283
private QuoteAcceptResponse finalizeAcceptance(QuoteRequest quote, List<QuoteItem> items) {
264284
Order order = orderService.createFromQuote(quote, items);
265285
quote.setOrderId(order.getId());
266286
quote.setStatus(QuoteStatus.ACCEPTED);
267287
quoteRepo.save(quote);
288+
userRepo.findById(quote.getUserId()).ifPresent(
289+
user -> emailService.sendQuoteAccepted(user.getEmail(), quote.getId(), order.getId()));
268290

269291
// Net terms path: if the company has a credit account, issue an invoice instead of Stripe
270292
return creditAccountService.findByCompanyId(quote.getCompanyId())
@@ -300,7 +322,12 @@ public QuoteRequestResponse reject(UUID quoteId, UUID userId) {
300322
"Quote must be in SENT status to reject");
301323
}
302324
quote.setStatus(QuoteStatus.REJECTED);
303-
return toResponse(quoteRepo.save(quote));
325+
QuoteRequestResponse response = toResponse(quoteRepo.save(quote));
326+
String adminEmail = appProperties.getAdminNotificationEmail();
327+
if (adminEmail != null && !adminEmail.isBlank()) {
328+
emailService.sendQuoteRejectedByUser(adminEmail, quote.getId());
329+
}
330+
return response;
304331
}
305332

306333
// --- Admin operations ---

src/main/java/io/k2dv/garden/scheduler/ExpiryScheduler.java

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package io.k2dv.garden.scheduler;
22

3+
import io.k2dv.garden.auth.service.EmailService;
34
import io.k2dv.garden.b2b.repository.InvoiceRepository;
5+
import io.k2dv.garden.quote.model.QuoteRequest;
46
import io.k2dv.garden.quote.model.QuoteStatus;
57
import io.k2dv.garden.quote.repository.QuoteRequestRepository;
8+
import io.k2dv.garden.user.repository.UserRepository;
69
import lombok.RequiredArgsConstructor;
710
import lombok.extern.slf4j.Slf4j;
811
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
@@ -11,6 +14,8 @@
1114
import org.springframework.transaction.annotation.Transactional;
1215

1316
import java.time.Instant;
17+
import java.util.ArrayList;
18+
import java.util.List;
1419

1520
@Component
1621
@RequiredArgsConstructor
@@ -19,6 +24,8 @@ public class ExpiryScheduler {
1924

2025
private final QuoteRequestRepository quoteRepo;
2126
private final InvoiceRepository invoiceRepo;
27+
private final UserRepository userRepo;
28+
private final EmailService emailService;
2229

2330
@Scheduled(cron = "0 */15 * * * *")
2431
@SchedulerLock(name = "expireQuotes", lockAtMostFor = "PT14M", lockAtLeastFor = "PT1M")
@@ -30,9 +37,20 @@ public void expireQuotes() {
3037
@Transactional
3138
public void doExpireQuotes() {
3239
try {
33-
int count = quoteRepo.expireByStatus(QuoteStatus.SENT, QuoteStatus.EXPIRED, Instant.now());
34-
if (count > 0) {
35-
log.info("Expired {} quote(s)", count);
40+
Instant now = Instant.now();
41+
List<QuoteRequest> toExpire = new ArrayList<>();
42+
toExpire.addAll(quoteRepo.findExpiredByStatus(QuoteStatus.SENT, now));
43+
toExpire.addAll(quoteRepo.findExpiredByStatus(QuoteStatus.PENDING_APPROVAL, now));
44+
45+
for (QuoteRequest q : toExpire) {
46+
q.setStatus(QuoteStatus.EXPIRED);
47+
quoteRepo.save(q);
48+
userRepo.findById(q.getUserId()).ifPresent(
49+
user -> emailService.sendQuoteExpired(user.getEmail(), q.getId()));
50+
}
51+
52+
if (!toExpire.isEmpty()) {
53+
log.info("Expired {} quote(s)", toExpire.size());
3654
}
3755
} catch (Exception e) {
3856
log.error("Failed to expire quotes", e);

0 commit comments

Comments
 (0)