diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..cd48c56 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,19 @@ +name: Deploy + +on: + push: + branches: [ main ] + +permissions: + id-token: write + contents: read + packages: write + +jobs: + deploy: + uses: Management-System-for-Rental-SEP490/.github/.github/workflows/deploy-java-service.yml@main + with: + service_name: notification-service + xmx: 512m + secrets: + GH_PACKAGES_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/src/main/java/com/isums/notificationservice/configurations/KafkaConsumerConfig.java b/src/main/java/com/isums/notificationservice/configurations/KafkaConsumerConfig.java index 12d42a0..07b7c3c 100644 --- a/src/main/java/com/isums/notificationservice/configurations/KafkaConsumerConfig.java +++ b/src/main/java/com/isums/notificationservice/configurations/KafkaConsumerConfig.java @@ -64,7 +64,8 @@ public DefaultErrorHandler kafkaErrorHandler(KafkaTemplate dltKa ); ExponentialBackOff backOff = new ExponentialBackOff(1_000L, 2.0); - backOff.setMaxAttempts(3); + backOff.setMaxInterval(60_000L); + backOff.setMaxAttempts(Long.MAX_VALUE); DefaultErrorHandler handler = new DefaultErrorHandler(recoverer, backOff); @@ -74,7 +75,9 @@ public DefaultErrorHandler kafkaErrorHandler(KafkaTemplate dltKa tools.jackson.databind.exc.UnrecognizedPropertyException.class, IllegalArgumentException.class, org.springframework.messaging.converter.MessageConversionException.class, - PermanentEventFailureException.class + PermanentEventFailureException.class, + org.springframework.dao.DataIntegrityViolationException.class, + org.hibernate.exception.ConstraintViolationException.class ); return handler; diff --git a/src/main/java/com/isums/notificationservice/domains/events/ConfirmAndSendToTenantEvent.java b/src/main/java/com/isums/notificationservice/domains/events/ConfirmAndSendToTenantEvent.java index c47752e..728f983 100644 --- a/src/main/java/com/isums/notificationservice/domains/events/ConfirmAndSendToTenantEvent.java +++ b/src/main/java/com/isums/notificationservice/domains/events/ConfirmAndSendToTenantEvent.java @@ -13,9 +13,11 @@ @AllArgsConstructor @Builder public class ConfirmAndSendToTenantEvent { - private String messageId; - private UUID recipientUserId; - private UUID contractId; + private String messageId; + private UUID recipientUserId; + private String recipientEmail; + private String recipientName; + private UUID contractId; private String contractName; private String url; private String confirmUrl; @@ -26,4 +28,4 @@ public class ConfirmAndSendToTenantEvent { // picks the right email template. Expected values: "VI", "VI_EN", "VI_JA". // Null = legacy event (pre BE-3) → fall back to VI. private String contractLanguage; -} \ No newline at end of file +} diff --git a/src/main/java/com/isums/notificationservice/domains/events/ContractReadyForLandlordSignatureEvent.java b/src/main/java/com/isums/notificationservice/domains/events/ContractReadyForLandlordSignatureEvent.java index aaa7915..108385b 100644 --- a/src/main/java/com/isums/notificationservice/domains/events/ContractReadyForLandlordSignatureEvent.java +++ b/src/main/java/com/isums/notificationservice/domains/events/ContractReadyForLandlordSignatureEvent.java @@ -12,6 +12,7 @@ public class ContractReadyForLandlordSignatureEvent { private String messageId; private UUID contractId; + private UUID houseId; private UUID recipientUserId; private UUID tenantId; private String tenantName; diff --git a/src/main/java/com/isums/notificationservice/domains/events/DepositRefundConfirmedEvent.java b/src/main/java/com/isums/notificationservice/domains/events/DepositRefundConfirmedEvent.java new file mode 100644 index 0000000..356c21f --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/events/DepositRefundConfirmedEvent.java @@ -0,0 +1,22 @@ +package com.isums.notificationservice.domains.events; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DepositRefundConfirmedEvent { + private UUID contractId; + private UUID houseId; + private UUID tenantId; + private String tenantEmail; + private Long refundAmount; + private String note; + private String messageId; +} diff --git a/src/main/java/com/isums/notificationservice/domains/events/DepositRefundPaidEvent.java b/src/main/java/com/isums/notificationservice/domains/events/DepositRefundPaidEvent.java new file mode 100644 index 0000000..894a4a3 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/events/DepositRefundPaidEvent.java @@ -0,0 +1,25 @@ +package com.isums.notificationservice.domains.events; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DepositRefundPaidEvent { + private UUID contractId; + private UUID houseId; + private UUID tenantId; + private String tenantEmail; + private Long refundAmount; + private String paymentMethod; + private String note; + private Instant paidAt; + private String messageId; +} diff --git a/src/main/java/com/isums/notificationservice/domains/events/UserActivatedEvent.java b/src/main/java/com/isums/notificationservice/domains/events/UserActivatedEvent.java index 1504dca..8cf9147 100644 --- a/src/main/java/com/isums/notificationservice/domains/events/UserActivatedEvent.java +++ b/src/main/java/com/isums/notificationservice/domains/events/UserActivatedEvent.java @@ -16,6 +16,7 @@ public record UserActivatedEvent( String email, String name, String password, + String locale, String firstRentPaymentUrl, Long firstRentAmount, Instant firstRentDueDate diff --git a/src/main/java/com/isums/notificationservice/infrastructures/kafka/ContractEventListener.java b/src/main/java/com/isums/notificationservice/infrastructures/kafka/ContractEventListener.java index 64f5946..cc1618d 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/kafka/ContractEventListener.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/kafka/ContractEventListener.java @@ -4,12 +4,19 @@ import com.isums.notificationservice.domains.events.ContractCancelledByTenantEvent; import com.isums.notificationservice.domains.events.ContractCompletedEvent; import com.isums.notificationservice.domains.events.ContractReadyForLandlordSignatureEvent; +import com.isums.notificationservice.domains.events.DepositRefundConfirmedEvent; import com.isums.notificationservice.domains.events.InspectionDoneNotifyEvent; import com.isums.notificationservice.domains.events.InspectionScheduledEvent; +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.infrastructures.abstracts.EmailService; import com.isums.notificationservice.infrastructures.abstracts.ManagerNotificationService; +import com.isums.notificationservice.infrastructures.grpcs.UserGrpcClient; import com.isums.notificationservice.services.NotificationRecipientResolver; +import com.isums.userservice.grpc.UserResponse; import common.kafkas.IdempotencyService; import common.kafkas.KafkaListenerHelper; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; @@ -29,6 +36,8 @@ public class ContractEventListener { private final ManagerNotificationService notificationService; + private final EmailService emailService; + private final UserGrpcClient userGrpcClient; private final NotificationRecipientResolver recipientResolver; private final ObjectMapper objectMapper; private final IdempotencyService idempotencyService; @@ -150,16 +159,33 @@ public void handleReadyForLandlordSignature( if (event.getDocumentId() != null && !event.getDocumentId().isBlank()) { metadata.put("documentId", event.getDocumentId()); } + if (event.getHouseId() != null) { + metadata.put("houseId", event.getHouseId().toString()); + } - notificationService.send( - event.getRecipientUserId(), - NotificationCategory.CONTRACT_READY_FOR_LANDLORD_SIGNATURE, - "Khách thuê đã xác nhận CCCD", - "Hợp đồng " + contractLabel + " của " + tenantLabel + " đã sẵn sàng để chủ nhà ký.", - "vi", - "/contracts/" + event.getContractId(), - metadata - ); + List recipientIds = recipientResolver.resolveLandlordAndManager( + event.getHouseId(), event.getRecipientUserId()); + if (recipientIds.isEmpty() && event.getRecipientUserId() != null) { + recipientIds = List.of(event.getRecipientUserId()); + } + + String title = "Khách thuê đã xác nhận CCCD"; + String body = "Hợp đồng " + contractLabel + " của " + tenantLabel + + " đã sẵn sàng để chủ nhà ký."; + String actionUrl = "/contracts/" + event.getContractId(); + + for (UUID recipientId : recipientIds) { + notificationService.send( + recipientId, + NotificationCategory.CONTRACT_READY_FOR_LANDLORD_SIGNATURE, + title, + body, + "vi", + actionUrl, + metadata + ); + sendReadyForLandlordSignatureEmail(recipientId, event, contractLabel, tenantLabel, actionUrl); + } idempotencyService.markProcessed(messageId); ack.acknowledge(); @@ -170,6 +196,92 @@ public void handleReadyForLandlordSignature( } } + @KafkaListener(topics = "contract.deposit-refund.confirmed", + groupId = "notification-group") + public void handleDepositRefundConfirmed( + ConsumerRecord record, Acknowledgment ack) { + + String messageId = kafkaHelper.extractMessageId(record); + try { + if (idempotencyService.isDuplicate(messageId)) { + ack.acknowledge(); + return; + } + + DepositRefundConfirmedEvent event = objectMapper.readValue( + record.value(), DepositRefundConfirmedEvent.class); + + Map metadata = new HashMap<>(); + metadata.put("contractId", event.getContractId().toString()); + metadata.put("status", "DEPOSIT_REFUND_PENDING"); + metadata.put("refundAmount", String.valueOf(event.getRefundAmount())); + if (event.getHouseId() != null) { + metadata.put("houseId", event.getHouseId().toString()); + } + if (event.getTenantId() != null) { + metadata.put("tenantId", event.getTenantId().toString()); + } + + List recipientIds = recipientResolver.resolveLandlordAndManager(event.getHouseId()); + for (UUID recipientId : recipientIds) { + notificationService.send( + recipientId, + NotificationCategory.DEPOSIT_REFUND_CONFIRM, + "Đã ghi nhận yêu cầu hoàn cọc", + "Khoản hoàn cọc hợp đồng #" + shortId(event.getContractId()) + + " đã được tạo. Vui lòng theo dõi và xác nhận khi đã chuyển tiền.", + "vi", + "/contracts/" + event.getContractId() + "/deposit-refund", + metadata + ); + } + + idempotencyService.markProcessed(messageId); + ack.acknowledge(); + log.info("[Notification] handleDepositRefundConfirmed done messageId={} recipients={}", + messageId, recipientIds.size()); + } catch (Exception e) { + log.error("[Notification] handleDepositRefundConfirmed failed: {}", e.getMessage(), e); + throw new RuntimeException(e); + } + } + + private void sendReadyForLandlordSignatureEmail( + UUID recipientId, + ContractReadyForLandlordSignatureEvent event, + String contractLabel, + String tenantLabel, + String actionUrl) { + try { + UserResponse recipient = userGrpcClient.getUserById(recipientId); + if (recipient == null || recipient.getEmail() == null || recipient.getEmail().isBlank()) { + log.warn("[Notification] Skip ready email, recipient email missing recipientId={} contractId={}", + recipientId, event.getContractId()); + return; + } + + emailService.sendEmail( + recipient.getEmail(), + "econtract_ready_for_landlord_signature", + LocaleType.vi_VN, + Map.of( + "recipientName", safe(recipient.getName(), "anh/chị"), + "tenantName", tenantLabel, + "contractName", contractLabel, + "contractNo", shortId(event.getContractId()), + "actionUrl", actionUrl + ) + ); + } catch (StatusRuntimeException e) { + if (isPermanentGrpcFailure(e)) { + log.warn("[Notification] Skip ready email, user lookup failed code={} recipientId={} contractId={}: {}", + e.getStatus().getCode(), recipientId, event.getContractId(), e.getMessage()); + return; + } + throw e; + } + } + @KafkaListener(topics = "contract-completed-topic", groupId = "notification-group") public void handleContractCompleted( @@ -287,5 +399,21 @@ public void handleContractCancelledByTenant( throw new RuntimeException(e); } } -} + private static boolean isPermanentGrpcFailure(StatusRuntimeException e) { + Status.Code code = e.getStatus().getCode(); + return code == Status.Code.NOT_FOUND + || code == Status.Code.INVALID_ARGUMENT + || code == Status.Code.PERMISSION_DENIED + || code == Status.Code.UNAUTHENTICATED + || code == Status.Code.FAILED_PRECONDITION; + } + + private static String safe(String value, String fallback) { + return value != null && !value.isBlank() ? value.trim() : fallback; + } + + private static String shortId(UUID id) { + return id != null ? id.toString().substring(0, 8).toUpperCase() : "N/A"; + } +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/listeners/EContractEventListener.java b/src/main/java/com/isums/notificationservice/infrastructures/listeners/EContractEventListener.java index 4bd709d..59c649a 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/listeners/EContractEventListener.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/listeners/EContractEventListener.java @@ -56,8 +56,11 @@ public void handleConfirmAndSendToTenant(ConsumerRecord record, return; } - if (event.getRecipientUserId() == null) { - log.error("[EContract] recipientUserId null, skip. contractId={}", event.getContractId()); + String recipientEmail = safe(event.getRecipientEmail(), null); + String recipientName = safe(event.getRecipientName(), null); + if ((recipientEmail == null || recipientEmail.isBlank()) && event.getRecipientUserId() == null) { + log.error("[EContract] recipientUserId and recipientEmail null, skip. contractId={}", + event.getContractId()); ack.acknowledge(); return; } @@ -67,10 +70,31 @@ public void handleConfirmAndSendToTenant(ConsumerRecord record, return; } - UserResponse user = userGrpcClient.getUserById(event.getRecipientUserId()); - if (user == null) { - log.error("[EContract] User not found userId={} contractId={}", + if ((recipientEmail == null || recipientEmail.isBlank()) && event.getRecipientUserId() != null) { + try { + UserResponse user = userGrpcClient.getUserById(event.getRecipientUserId()); + if (user != null) { + recipientEmail = safe(user.getEmail(), null); + recipientName = safe(user.getName(), recipientName); + } + } catch (StatusRuntimeException e) { + if (isPermanentGrpcFailure(e)) { + log.warn("[EContract] User lookup failed code={} userId={} contractId={}, using event email fallback: {}", + e.getStatus().getCode(), event.getRecipientUserId(), event.getContractId(), e.getMessage()); + if (recipientEmail == null || recipientEmail.isBlank()) { + throw new IllegalStateException( + "Recipient user not available yet and event has no email; retry later. userId=" + + event.getRecipientUserId()); + } + } else { + throw e; + } + } + } + if (recipientEmail == null || recipientEmail.isBlank()) { + log.error("[EContract] recipientEmail unavailable, skip. userId={} contractId={}", event.getRecipientUserId(), event.getContractId()); + idempotencyService.markProcessed(messageId); ack.acknowledge(); return; } @@ -78,7 +102,7 @@ public void handleConfirmAndSendToTenant(ConsumerRecord record, LocaleType locale = mapLocale(event.getContractLanguage()); Map vars = new HashMap<>(); - vars.put("tenantName", safe(user.getName(), fallbackTenantName(locale))); + vars.put("tenantName", safe(recipientName, fallbackTenantName(locale))); vars.put("contractName", safe(event.getContractName(), fallbackContractName(locale))); vars.put("contractNo", shortId(event.getContractId())); vars.put("propertyAddress", "N/A"); @@ -89,13 +113,13 @@ public void handleConfirmAndSendToTenant(ConsumerRecord record, vars.put("expiresIn", expiresIn(locale)); vars.put("landlordName", fallbackLandlordName(locale)); - emailService.sendEmail(user.getEmail(), "econtract_view_confirm", locale, vars); + emailService.sendEmail(recipientEmail, "econtract_view_confirm", locale, vars); idempotencyService.markProcessed(messageId); ack.acknowledge(); log.info("[EContract] Email sent messageId={} to={} contractId={}", - messageId, user.getEmail(), event.getContractId()); + messageId, recipientEmail, event.getContractId()); } catch (JacksonException e) { log.error("[EContract] Deserialization failed messageId={} raw={}: {}", diff --git a/src/main/java/com/isums/notificationservice/infrastructures/listeners/PaymentEventListener.java b/src/main/java/com/isums/notificationservice/infrastructures/listeners/PaymentEventListener.java index 00e1b61..507bfcd 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/listeners/PaymentEventListener.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/listeners/PaymentEventListener.java @@ -1,6 +1,7 @@ package com.isums.notificationservice.infrastructures.listeners; import com.isums.notificationservice.domains.events.DepositPaidEvent; +import com.isums.notificationservice.domains.events.DepositRefundPaidEvent; import com.isums.notificationservice.domains.events.SendEmailEvent; import com.isums.notificationservice.domains.enums.LocaleType; import com.isums.notificationservice.infrastructures.abstracts.EmailService; @@ -8,6 +9,8 @@ import com.isums.userservice.grpc.UserResponse; import common.kafkas.IdempotencyService; import common.kafkas.KafkaListenerHelper; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; @@ -53,31 +56,58 @@ public void handlePaymentPaid(ConsumerRecord record, Acknowledgm DepositPaidEvent event = objectMapper.readValue(record.value(), DepositPaidEvent.class); - UserResponse user = userGrpcClient.getUserById(event.tenantId()); - if (user == null) { - log.error("[Payment] User not found tenantId={}", event.tenantId()); + String recipientEmail = safe(event.tenantEmail(), null); + String recipientName = "you"; + + if (event.tenantId() != null) { + try { + UserResponse user = userGrpcClient.getUserById(event.tenantId()); + if (user != null) { + if (recipientEmail == null || recipientEmail.isBlank()) { + recipientEmail = safe(user.getEmail(), null); + } + recipientName = safe(user.getName(), recipientName); + } + } catch (StatusRuntimeException e) { + if (isPermanentGrpcFailure(e)) { + log.warn("[Payment] User lookup failed code={} tenantId={}, using event email fallback={}", + e.getStatus().getCode(), event.tenantId(), recipientEmail); + } else { + throw e; + } + } + } + + if (recipientEmail == null || recipientEmail.isBlank()) { + log.error("[Payment] Receipt email skipped, recipient unavailable tenantId={} invoiceId={}", + event.tenantId(), event.invoiceId()); + idempotencyService.markProcessed(messageId); ack.acknowledge(); return; } Map vars = new HashMap<>(); - vars.put("tenantName", safe(user.getName(), "you")); + vars.put("tenantName", recipientName); vars.put("invoiceType", translateType(event.invoiceType())); vars.put("amount", formatVnd(event.amount())); vars.put("txnNo", event.txnNo()); vars.put("paidAt", event.paidAt() != null ? DMY.format(event.paidAt()) : "N/A"); - emailService.sendEmail(user.getEmail(), "payment_receipt", LocaleType.vi_VN, vars); + emailService.sendEmail(recipientEmail, "payment_receipt", LocaleType.vi_VN, vars); idempotencyService.markProcessed(messageId); ack.acknowledge(); log.info("[Payment] Receipt email sent messageId={} to={} type={}", - messageId, user.getEmail(), event.invoiceType()); + messageId, recipientEmail, event.invoiceType()); } catch (JacksonException e) { log.error("[Payment] Deserialize failed messageId={}: {}", messageId, e.getMessage()); ack.acknowledge(); + } catch (StatusRuntimeException e) { + log.error("[Payment] Transient gRPC failure code={} messageId={}, will retry: {}", + e.getStatus().getCode(), messageId, e.getMessage()); + throw e; } catch (Exception e) { log.error("[Payment] Processing failed messageId={}, will retry: {}", messageId, e.getMessage(), e); @@ -87,6 +117,74 @@ public void handlePaymentPaid(ConsumerRecord record, Acknowledgm } } + private static boolean isPermanentGrpcFailure(StatusRuntimeException e) { + Status.Code code = e.getStatus().getCode(); + return code == Status.Code.NOT_FOUND + || code == Status.Code.INVALID_ARGUMENT + || code == Status.Code.PERMISSION_DENIED + || code == Status.Code.UNAUTHENTICATED + || code == Status.Code.FAILED_PRECONDITION; + } + + @KafkaListener(topics = "deposit-refund-paid-topic", groupId = "notification-group") + public void handleDepositRefundPaid(ConsumerRecord record, Acknowledgment ack) { + String messageId = kafkaHelper.extractMessageId(record); + kafkaHelper.setupMDC(record, messageId); + + try { + if (idempotencyService.isDuplicate(messageId)) { + log.warn("[Payment] Duplicate refund-paid skipped messageId={}", messageId); + ack.acknowledge(); + return; + } + + DepositRefundPaidEvent event = objectMapper.readValue(record.value(), DepositRefundPaidEvent.class); + + String recipientEmail = safe(event.getTenantEmail(), null); + String tenantName = "you"; + if ((recipientEmail == null || recipientEmail.isBlank()) && event.getTenantId() != null) { + UserResponse user = userGrpcClient.getUserById(event.getTenantId()); + if (user != null) { + recipientEmail = safe(user.getEmail(), null); + tenantName = safe(user.getName(), tenantName); + } + } + + if (recipientEmail == null || recipientEmail.isBlank()) { + log.error("[Payment] Deposit refund paid email skipped, recipient unavailable tenantId={} contractId={}", + event.getTenantId(), event.getContractId()); + idempotencyService.markProcessed(messageId); + ack.acknowledge(); + return; + } + + Map vars = new HashMap<>(); + vars.put("tenantName", tenantName); + vars.put("contractId", shortId(event.getContractId())); + vars.put("refundAmount", formatVnd(event.getRefundAmount())); + vars.put("paymentMethod", safe(event.getPaymentMethod(), "N/A")); + vars.put("paidAt", event.getPaidAt() != null ? DMY.format(event.getPaidAt()) : "N/A"); + vars.put("note", safe(event.getNote(), "")); + + emailService.sendEmail(recipientEmail, "deposit_refund_paid_notify", LocaleType.vi_VN, vars); + + idempotencyService.markProcessed(messageId); + ack.acknowledge(); + + log.info("[Payment] Deposit refund paid email sent messageId={} to={} contractId={}", + messageId, recipientEmail, event.getContractId()); + } catch (JacksonException e) { + log.error("[Payment] Deposit refund paid deserialize failed messageId={}: {}", messageId, e.getMessage()); + ack.acknowledge(); + } catch (Exception e) { + log.error("[Payment] Deposit refund paid processing failed messageId={}, will retry: {}", + messageId, e.getMessage(), e); + throw new RuntimeException(e); + } finally { + kafkaHelper.clearMDC(); + } + } + private String translateType(String type) { return switch (type) { case "DEPOSIT" -> "Deposit"; @@ -106,4 +204,8 @@ private String formatVnd(Long amount) { private String safe(String s, String fb) { return (s != null && !s.isBlank()) ? s.trim() : fb; } + + private String shortId(java.util.UUID id) { + return id != null ? id.toString().substring(0, 8).toUpperCase() : "N/A"; + } } diff --git a/src/main/java/com/isums/notificationservice/infrastructures/listeners/UserEventListener.java b/src/main/java/com/isums/notificationservice/infrastructures/listeners/UserEventListener.java index bb2999d..35abb42 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/listeners/UserEventListener.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/listeners/UserEventListener.java @@ -54,7 +54,8 @@ public void handleSendEmail(ConsumerRecord record, Acknowledgmen return; } - emailService.sendEmail(event.to(), event.templateCode().toLowerCase(), LocaleType.vi_VN, event.params()); + emailService.sendEmail(event.to(), event.templateCode().toLowerCase(), LocaleType.vi_VN, + event.params() != null ? event.params() : Map.of()); idempotencyService.markProcessed(messageId); ack.acknowledge(); @@ -102,7 +103,7 @@ public void handleOnUserActivated(ConsumerRecord record, Acknowl params.put("invoicePaymentUrl", event.firstRentPaymentUrl()); } - emailService.sendEmail(event.email(), "user_activated", LocaleType.vi_VN, params); + emailService.sendEmail(event.email(), "user_activated", resolveLocale(event.locale()), params); idempotencyService.markProcessed(messageId); ack.acknowledge(); @@ -123,4 +124,14 @@ private String formatVnd(Long amount) { if (amount == null) return "0 ₫"; return NumberFormat.getNumberInstance(Locale.of("vi", "VN")).format(amount) + " ₫"; } + + private LocaleType resolveLocale(String raw) { + if (raw == null || raw.isBlank()) return LocaleType.vi_VN; + try { + return LocaleType.valueOf(raw.trim()); + } catch (IllegalArgumentException ex) { + log.warn("[Notification] Unknown locale '{}' on user-activated event, falling back to vi_VN", raw); + return LocaleType.vi_VN; + } + } } diff --git a/src/main/java/com/isums/notificationservice/infrastructures/seeders/EmailTemplateSeeder.java b/src/main/java/com/isums/notificationservice/infrastructures/seeders/EmailTemplateSeeder.java index 678edaa..d29b421 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/seeders/EmailTemplateSeeder.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/seeders/EmailTemplateSeeder.java @@ -276,6 +276,87 @@ public void seed(EmailTemplateRepository templateRepo, EmailTemplateVersionRepos "system" ); + upsertActiveV1( + templateRepo, versionRepo, + "econtract_ready_for_landlord_signature", + "CONTRACT", + "MANAGER", + LocaleType.vi_VN, + "Khách thuê đã xác nhận hợp đồng {{contractNo}}", + """ + + + + + + Hợp đồng sẵn sàng để chủ nhà ký + + + + + + +
+ + + + + + + + + + +
+
+ ISUMS • Hợp đồng thuê nhà +
+
+ Khách thuê đã xác nhận, chờ chủ nhà ký +
+
+

+ Xin chào {{recipientName}}, +

+

+ Khách thuê {{tenantName}} đã xác nhận thông tin cho hợp đồng + {{contractName}}. Hợp đồng hiện đã sẵn sàng để chủ nhà ký. +

+ + + + +
+ + Mở hợp đồng + +
+

+ Nếu nút không hoạt động, hãy sao chép liên kết sau vào trình duyệt:
+ {{actionUrl}} +

+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp email này. +
+
+ + + """, + """ + Xin chào {{recipientName}}, + + Khách thuê {{tenantName}} đã xác nhận thông tin cho hợp đồng {{contractName}}. + Hợp đồng hiện đã sẵn sàng để chủ nhà ký. + + Mở hợp đồng: {{actionUrl}} + """, + List.of("recipientName", "tenantName", "contractName", "contractNo", "actionUrl"), + "system" + ); + upsertActiveV1( templateRepo, versionRepo, "econtract_view_confirm", @@ -1722,6 +1803,79 @@ I agree (Confirm) "system" ); + upsertActiveV1(templateRepo, versionRepo, + "late_payment_final_notice", "PAYMENT", "TENANT", LocaleType.vi_VN, + "THÔNG BÁO CUỐI: Hóa đơn quá hạn {{daysLate}} ngày — Hợp đồng sắp bị chấm dứt", + """ + + + + +
+ + + + +
+
+ ISUMS · Thông báo bắt buộc +
+
+ ⚠️ Thông báo cuối — Hợp đồng sắp bị chấm dứt +
+
+

+ Hóa đơn tiền thuê của bạn đã quá hạn {{daysLate}} ngày + (từ {{dueDate}}). Tổng số tiền cần thanh toán: + {{totalAmount}}. +

+
+

+ Theo điều khoản hợp đồng và Luật Nhà ở 2023 (Điều 172), + chủ nhà sẽ tiến hành thủ tục chấm dứt hợp đồng và thu hồi nhà + do vi phạm nghĩa vụ thanh toán. +

+
+

+ Khi hợp đồng chấm dứt: +

+
    +
  • Tiền cọc sẽ bị giữ lại để bù trừ tiền thuê chưa thanh toán
  • +
  • Bạn phải bàn giao nhà theo lịch của bộ phận quản lý
  • +
  • Quyền truy cập app và dịch vụ sẽ bị ngừng vĩnh viễn
  • +
+

+ Để tránh chấm dứt hợp đồng, vui lòng thanh toán ngay + trong vòng 24 giờ tới. +

+
+
+ Trân trọng,
Đội ngũ ISUMS +
+
+
+ Đây là thông báo cuối được gửi tự động trước khi tiến hành chấm dứt hợp đồng. +
+
+
+ + """, + "THÔNG BÁO CUỐI: Hóa đơn quá hạn {{daysLate}} ngày từ {{dueDate}}.\n" + + "Tổng tiền: {{totalAmount}}.\n" + + "Hợp đồng sẽ bị chấm dứt nếu không thanh toán trong 24 giờ tới. " + + "Tiền cọc sẽ bị giữ lại để bù trừ.", + List.of("totalAmount", "dueDate", "daysLate"), + "system" + ); + upsertActiveV1(templateRepo, versionRepo, "power_cut_warning_24h", "PAYMENT", "TENANT", LocaleType.vi_VN, "Cảnh báo: Điện sẽ bị cắt sau 24 giờ do chưa thanh toán tiền thuê", @@ -2080,6 +2234,228 @@ I agree (Confirm) "system" ); + upsertActiveV1( + templateRepo, versionRepo, + "contract_first_month_covered", "PAYMENT", "TENANT", LocaleType.vi_VN, + "Tiền thuê tháng đầu đã được khấu trừ — Hợp đồng #{{contractId}}", + """ + + + + + +
+ + + +
+ Tiền thuê tháng đầu đã được khấu trừ +
+ Hợp đồng #{{contractId}} đã được dùng phần tiền đã thanh toán trước đó để khấu trừ tiền thuê tháng đầu. + + + +
Tiền thuê tháng đầu: {{rentAmount}}
Đã khấu trừ: {{creditAmount}}
+

Bạn không cần thanh toán thêm cho khoản này.

+
+
+ + """, + """ + Hợp đồng #{{contractId}}: tiền thuê tháng đầu {{rentAmount}} đã được khấu trừ {{creditAmount}}. + Bạn không cần thanh toán thêm cho khoản này. + """, + List.of("contractId", "rentAmount", "creditAmount", "billableAmount"), + "system" + ); + + upsertActiveV1( + templateRepo, versionRepo, + "contract_first_month_partial_credit", "PAYMENT", "TENANT", LocaleType.vi_VN, + "Cần thanh toán thêm tiền thuê tháng đầu — Hợp đồng #{{contractId}}", + """ + + + + + +
+ + + +
+ Cần thanh toán thêm tiền thuê tháng đầu +
+ Hợp đồng #{{contractId}} đã được khấu trừ một phần tiền thuê tháng đầu. + + + + +
Tiền thuê tháng đầu: {{rentAmount}}
Đã khấu trừ: {{creditAmount}}
Cần thanh toán thêm: {{billableAmount}}
+
+
+ + """, + """ + Hợp đồng #{{contractId}}: tiền thuê tháng đầu {{rentAmount}}, đã khấu trừ {{creditAmount}}, cần thanh toán thêm {{billableAmount}}. + """, + List.of("contractId", "rentAmount", "creditAmount", "billableAmount"), + "system" + ); + + upsertActiveV1( + templateRepo, versionRepo, + "contract_first_month_refund_due", "PAYMENT", "TENANT", LocaleType.vi_VN, + "Phát sinh tiền cần hoàn sau khi đổi nhà — Hợp đồng #{{contractId}}", + """ + + + + + +
+ + + +
+ Cần hoàn phần tiền dư +
+ Sau khi chuyển tiền từ hợp đồng cũ #{{oldContractId}} sang hợp đồng mới + #{{contractId}}, hệ thống ghi nhận phần dư cần hoàn là + {{excessAmount}}. +
+
+ + """, + """ + Hợp đồng mới #{{contractId}} từ hợp đồng cũ #{{oldContractId}} có phần dư cần hoàn: {{excessAmount}}. + """, + List.of("contractId", "oldContractId", "excessAmount"), + "system" + ); + + upsertActiveV1( + templateRepo, versionRepo, + "contract_deposit_expired_tenant_invoice", "PAYMENT", "TENANT", LocaleType.vi_VN, + "Hết hạn thanh toán tiền cọc — Hợp đồng #{{contractNo}}", + """ + + + + + +
+ + + +
+ Tiền cọc đã quá hạn +
+ Xin chào {{tenantName}}, khoản cọc của hợp đồng #{{contractNo}} + đã quá hạn thanh toán vào {{deadline}}. + + +
Số tiền cọc: {{depositAmount}}
+
+
+ + """, + """ + Xin chào {{tenantName}}, + Tiền cọc hợp đồng #{{contractNo}} số tiền {{depositAmount}} đã quá hạn vào {{deadline}}. + """, + List.of("tenantName", "contractNo", "depositAmount", "deadline"), + "system" + ); + + upsertActiveV1( + templateRepo, versionRepo, + "deposit_refund_notify", "PAYMENT", "TENANT", LocaleType.vi_VN, + "Thông báo hoàn tiền cọc {{refundAmount}}", + """ + + + + + +
+ + + +
+ Thông báo hoàn tiền cọc +
+ Khoản hoàn tiền cọc {{refundAmount}} đã được ghi nhận. + + + +
Hạn xử lý: {{dueDate}}
Ghi chú: {{note}}
+
+
+ + """, + """ + Khoản hoàn tiền cọc {{refundAmount}} đã được ghi nhận. Hạn xử lý: {{dueDate}}. Ghi chú: {{note}}. + """, + List.of("refundAmount", "note", "dueDate"), + "system" + ); + + upsertActiveV1( + templateRepo, versionRepo, + "deposit_refund_paid_notify", "PAYMENT", "TENANT", LocaleType.vi_VN, + "Đã hoàn tiền cọc {{refundAmount}} — Hợp đồng #{{contractId}}", + """ + + + + + +
+ + + + + + + +
+
ISUMS • Hoàn cọc
+
Đã hoàn tiền cọc
+
+ Xin chào {{tenantName}}, khoản hoàn tiền cọc cho hợp đồng + #{{contractId}} đã được xác nhận thanh toán. + + + + + +
Số tiền hoàn: {{refundAmount}}
Phương thức: {{paymentMethod}}
Thời gian xác nhận: {{paidAt}}
Ghi chú: {{note}}
+

+ Nếu chưa nhận được tiền, vui lòng liên hệ bộ phận quản lý để được kiểm tra giao dịch. +

+
+
+ + """, + """ + Xin chào {{tenantName}}, + Khoản hoàn tiền cọc {{refundAmount}} cho hợp đồng #{{contractId}} đã được xác nhận thanh toán. + Phương thức: {{paymentMethod}} + Thời gian xác nhận: {{paidAt}} + Ghi chú: {{note}} + """, + List.of("tenantName", "contractId", "refundAmount", "paymentMethod", "paidAt", "note"), + "system" + ); + } private void upsertActiveV1( diff --git a/src/main/java/com/isums/notificationservice/services/EmailServiceImpl.java b/src/main/java/com/isums/notificationservice/services/EmailServiceImpl.java index 632f1a1..ae3a3ab 100644 --- a/src/main/java/com/isums/notificationservice/services/EmailServiceImpl.java +++ b/src/main/java/com/isums/notificationservice/services/EmailServiceImpl.java @@ -15,8 +15,9 @@ import com.github.mustachejava.DefaultMustacheFactory; import com.github.mustachejava.MustacheFactory; -import java.io.StringReader; -import java.util.Map; +import java.io.StringReader; +import java.util.Collections; +import java.util.Map; import static java.nio.charset.StandardCharsets.UTF_8; @@ -34,13 +35,25 @@ public class EmailServiceImpl implements EmailService { @Value("${app.mail.from}") private String from; - public void sendEmail(String to, String templateKey, LocaleType locale, Map vars) { - EmailTemplateCached tpl = templateService.getActive(templateKey, locale); - validateVars(tpl, vars); - - String subject = render(tpl.subjectTpl(), vars); - String html = render(tpl.htmlTpl(), vars); - String text = (tpl.textTpl() == null) ? null : render(tpl.textTpl(), vars); + public void sendEmail(String to, String templateKey, LocaleType locale, Map vars) { + Map safeVars = vars != null ? vars : Collections.emptyMap(); + LocaleType effectiveLocale = locale != null ? locale : LocaleType.vi_VN; + EmailTemplateCached tpl; + try { + tpl = templateService.getActive(templateKey, effectiveLocale); + } catch (IllegalStateException missing) { + if (effectiveLocale == LocaleType.vi_VN) throw missing; + log.warn("email_template_missing templateKey={} locale={} — falling back to vi_VN", + templateKey, effectiveLocale); + effectiveLocale = LocaleType.vi_VN; + tpl = templateService.getActive(templateKey, effectiveLocale); + } + final LocaleType usedLocale = effectiveLocale; + validateVars(tpl, safeVars); + + String subject = render(tpl.subjectTpl(), safeVars); + String html = render(tpl.htmlTpl(), safeVars); + String text = (tpl.textTpl() == null) ? null : render(tpl.textTpl(), safeVars); try { var msg = mailSender.createMimeMessage(); @@ -58,14 +71,14 @@ public void sendEmail(String to, String templateKey, LocaleType locale, Map cap = ArgumentCaptor.forClass(NotificationCategory.class); verify(notificationService, times(2)).send(any(UUID.class), cap.capture(), - anyString(), anyString(), anyString(), any(Map.class)); + anyString(), anyString(), anyString(), anyString(), any(Map.class)); assertThat(cap.getAllValues()).containsOnly(NotificationCategory.CONTRACT_EXPIRED); verify(ack).acknowledge(); } @@ -127,7 +134,7 @@ void happy() throws Exception { verify(notificationService).send(eq(event.getManagerId()), eq(NotificationCategory.INSPECTION_DONE), - anyString(), anyString(), anyString(), any(Map.class)); + anyString(), anyString(), anyString(), anyString(), any(Map.class)); verify(ack).acknowledge(); } } @@ -140,36 +147,59 @@ class ReadyForLandlordSignature { new ConsumerRecord<>("contract.ready-for-landlord-signature", 0, 0L, "k", "v"); @Test - @DisplayName("sends CONTRACT_READY_FOR_LANDLORD_SIGNATURE notification on happy path") + @DisplayName("sends realtime and email to landlord and manager on happy path") void happy() throws Exception { when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); when(idempotencyService.isDuplicate("m1")).thenReturn(false); + UUID houseId = UUID.randomUUID(); + UUID createdBy = UUID.randomUUID(); + UUID landlordId = UUID.randomUUID(); + UUID managerId = UUID.randomUUID(); ContractReadyForLandlordSignatureEvent event = new ContractReadyForLandlordSignatureEvent( - "m1", UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), + "m1", UUID.randomUUID(), houseId, createdBy, UUID.randomUUID(), "Alice", "Lease April", "doc-123"); when(objectMapper.readValue("v", ContractReadyForLandlordSignatureEvent.class)).thenReturn(event); + when(recipientResolver.resolveLandlordAndManager(houseId, createdBy)) + .thenReturn(List.of(landlordId, managerId, createdBy)); + when(userGrpcClient.getUserById(landlordId)).thenReturn(user("landlord@example.com", "Landlord")); + when(userGrpcClient.getUserById(managerId)).thenReturn(user("manager@example.com", "Manager")); + when(userGrpcClient.getUserById(createdBy)).thenReturn(user("creator@example.com", "Creator")); listener.handleReadyForLandlordSignature(rec, ack); ArgumentCaptor metadataCap = ArgumentCaptor.forClass(Map.class); - verify(notificationService).send( - eq(event.getRecipientUserId()), + verify(notificationService, times(3)).send( + any(UUID.class), eq(NotificationCategory.CONTRACT_READY_FOR_LANDLORD_SIGNATURE), anyString(), anyString(), + anyString(), eq("/contracts/" + event.getContractId()), metadataCap.capture() ); assertThat(metadataCap.getValue()) .containsEntry("contractId", event.getContractId().toString()) .containsEntry("tenantId", event.getTenantId().toString()) + .containsEntry("houseId", houseId.toString()) .containsEntry("documentId", "doc-123") .containsEntry("status", "READY"); + verify(emailService, times(3)).sendEmail( + anyString(), + eq("econtract_ready_for_landlord_signature"), + eq(LocaleType.vi_VN), + any(Map.class)); verify(ack).acknowledge(); } } + private UserResponse user(String email, String name) { + return UserResponse.newBuilder() + .setEmail(email) + .setName(name) + .build(); + } + @Nested @DisplayName("handleContractCompleted") class ContractCompleted { @@ -217,6 +247,7 @@ void happy() throws Exception { eq(NotificationCategory.CONTRACT_COMPLETED), anyString(), anyString(), + anyString(), eq("/contracts/" + contractId), metadataCap.capture() ); @@ -231,6 +262,51 @@ void happy() throws Exception { } } + @Nested + @DisplayName("handleDepositRefundConfirmed") + class DepositRefundConfirmed { + + private final ConsumerRecord rec = + new ConsumerRecord<>("contract.deposit-refund.confirmed", 0, 0L, "k", "v"); + + @Test + @DisplayName("sends DEPOSIT_REFUND_CONFIRM notification to landlord and manager") + void happy() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + + UUID contractId = UUID.randomUUID(); + UUID houseId = UUID.randomUUID(); + UUID landlordId = UUID.randomUUID(); + UUID managerId = UUID.randomUUID(); + DepositRefundConfirmedEvent event = DepositRefundConfirmedEvent.builder() + .contractId(contractId) + .houseId(houseId) + .tenantId(UUID.randomUUID()) + .tenantEmail("alice@example.com") + .refundAmount(2_000_000L) + .note("ok") + .messageId("m1") + .build(); + when(objectMapper.readValue("v", DepositRefundConfirmedEvent.class)).thenReturn(event); + when(recipientResolver.resolveLandlordAndManager(houseId)) + .thenReturn(List.of(landlordId, managerId)); + + listener.handleDepositRefundConfirmed(rec, ack); + + verify(notificationService, times(2)).send( + any(UUID.class), + eq(NotificationCategory.DEPOSIT_REFUND_CONFIRM), + anyString(), + anyString(), + anyString(), + eq("/contracts/" + contractId + "/deposit-refund"), + any(Map.class) + ); + verify(ack).acknowledge(); + } + } + @Nested @DisplayName("handleContractCancelledByTenant") class ContractCancelledByTenant { @@ -275,6 +351,7 @@ void happy() throws Exception { eq(NotificationCategory.CONTRACT_CANCELLED_BY_TENANT), anyString(), anyString(), + anyString(), eq("/contracts/" + contractId), metadataCap.capture() ); diff --git a/src/test/java/com/isums/notificationservice/infrastructures/listeners/PaymentEventListenerTest.java b/src/test/java/com/isums/notificationservice/infrastructures/listeners/PaymentEventListenerTest.java index 167632b..68a7e0d 100644 --- a/src/test/java/com/isums/notificationservice/infrastructures/listeners/PaymentEventListenerTest.java +++ b/src/test/java/com/isums/notificationservice/infrastructures/listeners/PaymentEventListenerTest.java @@ -2,6 +2,7 @@ import com.isums.notificationservice.domains.enums.LocaleType; import com.isums.notificationservice.domains.events.DepositPaidEvent; +import com.isums.notificationservice.domains.events.DepositRefundPaidEvent; import com.isums.notificationservice.infrastructures.abstracts.EmailService; import com.isums.notificationservice.infrastructures.grpcs.UserGrpcClient; import com.isums.userservice.grpc.UserResponse; @@ -129,4 +130,30 @@ void retry() throws Exception { .isInstanceOf(RuntimeException.class); verify(ack, never()).acknowledge(); } + + @Test + @DisplayName("sends deposit_refund_paid_notify email when refund is marked paid") + void depositRefundPaid() throws Exception { + ConsumerRecord refundRec = + new ConsumerRecord<>("deposit-refund-paid-topic", 0, 0L, "k", "v"); + when(kafkaHelper.extractMessageId(refundRec)).thenReturn("m2"); + when(idempotencyService.isDuplicate("m2")).thenReturn(false); + DepositRefundPaidEvent evt = DepositRefundPaidEvent.builder() + .contractId(UUID.randomUUID()) + .tenantId(UUID.randomUUID()) + .tenantEmail("alice@example.com") + .refundAmount(2_000_000L) + .paymentMethod("BANK_TRANSFER") + .note("done") + .paidAt(Instant.now()) + .messageId("m2") + .build(); + when(objectMapper.readValue("v", DepositRefundPaidEvent.class)).thenReturn(evt); + + listener.handleDepositRefundPaid(refundRec, ack); + + verify(emailService).sendEmail(eq("alice@example.com"), eq("deposit_refund_paid_notify"), + eq(LocaleType.vi_VN), any()); + verify(ack).acknowledge(); + } } diff --git a/src/test/java/com/isums/notificationservice/infrastructures/listeners/UserEventListenerTest.java b/src/test/java/com/isums/notificationservice/infrastructures/listeners/UserEventListenerTest.java index 65a6163..7093107 100644 --- a/src/test/java/com/isums/notificationservice/infrastructures/listeners/UserEventListenerTest.java +++ b/src/test/java/com/isums/notificationservice/infrastructures/listeners/UserEventListenerTest.java @@ -180,5 +180,96 @@ void jackson() throws Exception { verify(ack).acknowledge(); verifyNoInteractions(emailService); } + + @Test + @DisplayName("uses en_US locale when event.locale is en_US (foreign tenant English)") + void englishLocale() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + UserActivatedEvent event = UserActivatedEvent.builder() + .userId(UUID.randomUUID()) + .email("john@example.com").name("John") + .password("Tmp@123") + .locale("en_US") + .firstRentPaymentUrl("https://pay.example/1") + .firstRentAmount(10_000_000L) + .firstRentDueDate(Instant.now().plusSeconds(86400)) + .build(); + when(objectMapper.readValue("v", UserActivatedEvent.class)).thenReturn(event); + + listener.handleOnUserActivated(rec, ack); + + verify(emailService).sendEmail(eq("john@example.com"), eq("user_activated"), + eq(LocaleType.en_US), any()); + verify(ack).acknowledge(); + } + + @Test + @DisplayName("uses ja_JP locale when event.locale is ja_JP (foreign tenant Japanese)") + void japaneseLocale() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + UserActivatedEvent event = UserActivatedEvent.builder() + .userId(UUID.randomUUID()) + .email("yamada@example.jp").name("Yamada") + .password("Tmp@123") + .locale("ja_JP") + .firstRentPaymentUrl("https://pay.example/1") + .firstRentAmount(10_000_000L) + .firstRentDueDate(Instant.now().plusSeconds(86400)) + .build(); + when(objectMapper.readValue("v", UserActivatedEvent.class)).thenReturn(event); + + listener.handleOnUserActivated(rec, ack); + + verify(emailService).sendEmail(eq("yamada@example.jp"), eq("user_activated"), + eq(LocaleType.ja_JP), any()); + verify(ack).acknowledge(); + } + + @Test + @DisplayName("falls back to vi_VN when event.locale is null (legacy events)") + void nullLocaleFallsBackToViVn() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + UserActivatedEvent event = UserActivatedEvent.builder() + .userId(UUID.randomUUID()) + .email("legacy@example.com").name("Legacy") + .password("Tmp@123") + .firstRentPaymentUrl("https://pay.example/1") + .firstRentAmount(10_000_000L) + .firstRentDueDate(Instant.now().plusSeconds(86400)) + .build(); + when(objectMapper.readValue("v", UserActivatedEvent.class)).thenReturn(event); + + listener.handleOnUserActivated(rec, ack); + + verify(emailService).sendEmail(eq("legacy@example.com"), eq("user_activated"), + eq(LocaleType.vi_VN), any()); + verify(ack).acknowledge(); + } + + @Test + @DisplayName("falls back to vi_VN when event.locale is unrecognised garbage") + void invalidLocaleFallsBackToViVn() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + UserActivatedEvent event = UserActivatedEvent.builder() + .userId(UUID.randomUUID()) + .email("garbage@example.com").name("Garbage") + .password("Tmp@123") + .locale("xx_YY") + .firstRentPaymentUrl("https://pay.example/1") + .firstRentAmount(10_000_000L) + .firstRentDueDate(Instant.now().plusSeconds(86400)) + .build(); + when(objectMapper.readValue("v", UserActivatedEvent.class)).thenReturn(event); + + listener.handleOnUserActivated(rec, ack); + + verify(emailService).sendEmail(eq("garbage@example.com"), eq("user_activated"), + eq(LocaleType.vi_VN), any()); + verify(ack).acknowledge(); + } } } diff --git a/src/test/java/com/isums/notificationservice/services/EmailServiceImplTest.java b/src/test/java/com/isums/notificationservice/services/EmailServiceImplTest.java index bfd8bb0..64e4b9f 100644 --- a/src/test/java/com/isums/notificationservice/services/EmailServiceImplTest.java +++ b/src/test/java/com/isums/notificationservice/services/EmailServiceImplTest.java @@ -77,15 +77,17 @@ void happy() { } @Test - @DisplayName("throws IllegalArgumentException when variable not allowed") + @DisplayName("logs warning but does not throw when variable not in allowedVars (Mustache ignores extras)") void invalidVar() { - when(templateService.getActive("welcome", LocaleType.vi_VN)).thenReturn(tpl()); + EmailTemplateCached restricted = new EmailTemplateCached( + 1, "Subj {{name}}", "

{{name}}

", null, List.of("name")); + when(templateService.getActive("welcome", LocaleType.vi_VN)).thenReturn(restricted); + when(mailSender.createMimeMessage()).thenReturn(new MimeMessage((jakarta.mail.Session) null)); - assertThatThrownBy(() -> service.sendEmail( - "a@b.com", "welcome", LocaleType.vi_VN, - Map.of("notAllowed", "x"))) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("not allowed"); + service.sendEmail("a@b.com", "welcome", LocaleType.vi_VN, + Map.of("name", "X", "notAllowed", "y")); + + verify(mailSender).send(any(MimeMessage.class)); } @Test @@ -112,5 +114,61 @@ void noRestriction() { verify(mailSender).send(any(MimeMessage.class)); } + + @Test + @DisplayName("falls back to vi_VN when en_US template missing (foreign tenant before en seed)") + void enUsMissingFallsBackToViVn() { + when(templateService.getActive("user_activated", LocaleType.en_US)) + .thenThrow(new IllegalStateException("No ACTIVE template: user_activated / en_US")); + when(templateService.getActive("user_activated", LocaleType.vi_VN)).thenReturn(tpl()); + when(mailSender.createMimeMessage()).thenReturn(new MimeMessage((jakarta.mail.Session) null)); + + service.sendEmail("john@example.com", "user_activated", LocaleType.en_US, + Map.of("name", "John")); + + verify(mailSender).send(any(MimeMessage.class)); + verify(templateService).getActive("user_activated", LocaleType.en_US); + verify(templateService).getActive("user_activated", LocaleType.vi_VN); + } + + @Test + @DisplayName("falls back to vi_VN when ja_JP template missing") + void jaJpMissingFallsBackToViVn() { + when(templateService.getActive("user_activated", LocaleType.ja_JP)) + .thenThrow(new IllegalStateException("No ACTIVE template: user_activated / ja_JP")); + when(templateService.getActive("user_activated", LocaleType.vi_VN)).thenReturn(tpl()); + when(mailSender.createMimeMessage()).thenReturn(new MimeMessage((jakarta.mail.Session) null)); + + service.sendEmail("yamada@example.jp", "user_activated", LocaleType.ja_JP, + Map.of("name", "Yamada")); + + verify(mailSender).send(any(MimeMessage.class)); + } + + @Test + @DisplayName("does NOT silently swallow when vi_VN itself is missing — surfaces the error") + void viVnMissingPropagates() { + when(templateService.getActive("ghost", LocaleType.vi_VN)) + .thenThrow(new IllegalStateException("No ACTIVE template: ghost / vi_VN")); + + assertThatThrownBy(() -> service.sendEmail( + "a@b.com", "ghost", LocaleType.vi_VN, Map.of("name", "A"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No ACTIVE template"); + } + + @Test + @DisplayName("uses en_US directly when template exists (foreign tenant English seeded)") + void enUsDirectWhenAvailable() { + EmailTemplateCached enTpl = new EmailTemplateCached( + 1, "Welcome {{name}}", "

Welcome {{name}}

", null, List.of("name")); + when(templateService.getActive("user_activated", LocaleType.en_US)).thenReturn(enTpl); + when(mailSender.createMimeMessage()).thenReturn(new MimeMessage((jakarta.mail.Session) null)); + + service.sendEmail("john@example.com", "user_activated", LocaleType.en_US, + Map.of("name", "John")); + + verify(mailSender).send(any(MimeMessage.class)); + } } }