From 0b64b4e1bace4ed996ba22e6f94a21e57b25602d Mon Sep 17 00:00:00 2001 From: hoangtuzami Date: Thu, 9 Apr 2026 03:13:21 +0700 Subject: [PATCH 01/11] Implement manager notification system with Kafka integration, SSE streaming, and global exception handling. Include Notification DTO, entity, and repository. --- .../configurations/GrpcClientConfig.java | 15 -- .../GrpcServiceAuthInterceptor.java | 31 --- .../ManagerNotificationController.java | 79 +++++++ .../domains/dtos/ApiError.java | 14 ++ .../domains/dtos/ApiResponse.java | 18 ++ .../domains/dtos/ApiResponses.java | 41 ++++ .../domains/dtos/NotificationDto.java | 39 ++++ .../domains/entities/ManagerNotification.java | 61 ++++++ .../domains/enums/NotificationCategory.java | 9 + .../events/InspectionDoneNotifyEvent.java | 18 ++ .../events/InspectionScheduledEvent.java | 18 ++ .../exceptions/ConflictException.java | 7 + .../exceptions/GlobalExceptionHandler.java | 111 ++++++++++ .../exceptions/IllegalStateException.java | 7 + .../exceptions/NotFoundException.java | 7 + .../Websockets/SseConnectionManager.java | 60 ++++++ .../abstracts/ManagerNotificationService.java | 24 +++ .../kafka/ContractNotificationConsumer.java | 103 +++++++++ .../ManagerNotificationRepository.java | 26 +++ .../seeders/EmailTemplateSeeder.java | 199 ++++++++++++++++++ .../ManagerNotificationServiceImpl.java | 77 +++++++ 21 files changed, 918 insertions(+), 46 deletions(-) delete mode 100644 src/main/java/com/isums/notificationservice/configurations/GrpcClientConfig.java delete mode 100644 src/main/java/com/isums/notificationservice/configurations/GrpcServiceAuthInterceptor.java create mode 100644 src/main/java/com/isums/notificationservice/controllers/ManagerNotificationController.java create mode 100644 src/main/java/com/isums/notificationservice/domains/dtos/ApiError.java create mode 100644 src/main/java/com/isums/notificationservice/domains/dtos/ApiResponse.java create mode 100644 src/main/java/com/isums/notificationservice/domains/dtos/ApiResponses.java create mode 100644 src/main/java/com/isums/notificationservice/domains/dtos/NotificationDto.java create mode 100644 src/main/java/com/isums/notificationservice/domains/entities/ManagerNotification.java create mode 100644 src/main/java/com/isums/notificationservice/domains/enums/NotificationCategory.java create mode 100644 src/main/java/com/isums/notificationservice/domains/events/InspectionDoneNotifyEvent.java create mode 100644 src/main/java/com/isums/notificationservice/domains/events/InspectionScheduledEvent.java create mode 100644 src/main/java/com/isums/notificationservice/exceptions/ConflictException.java create mode 100644 src/main/java/com/isums/notificationservice/exceptions/GlobalExceptionHandler.java create mode 100644 src/main/java/com/isums/notificationservice/exceptions/IllegalStateException.java create mode 100644 src/main/java/com/isums/notificationservice/exceptions/NotFoundException.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/Websockets/SseConnectionManager.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/abstracts/ManagerNotificationService.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/kafka/ContractNotificationConsumer.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/repositories/ManagerNotificationRepository.java create mode 100644 src/main/java/com/isums/notificationservice/services/ManagerNotificationServiceImpl.java diff --git a/src/main/java/com/isums/notificationservice/configurations/GrpcClientConfig.java b/src/main/java/com/isums/notificationservice/configurations/GrpcClientConfig.java deleted file mode 100644 index 68dce37..0000000 --- a/src/main/java/com/isums/notificationservice/configurations/GrpcClientConfig.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.isums.notificationservice.configurations; - -import com.isums.userservice.grpc.UserServiceGrpc; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.grpc.client.GrpcChannelFactory; - -@Configuration -public class GrpcClientConfig { - @Bean - UserServiceGrpc.UserServiceBlockingStub userStub(GrpcChannelFactory channels, GrpcServiceAuthInterceptor tokenInterceptor) { - return UserServiceGrpc.newBlockingStub(channels.createChannel("user")) - .withInterceptors(tokenInterceptor); - } -} diff --git a/src/main/java/com/isums/notificationservice/configurations/GrpcServiceAuthInterceptor.java b/src/main/java/com/isums/notificationservice/configurations/GrpcServiceAuthInterceptor.java deleted file mode 100644 index 5cbc7cb..0000000 --- a/src/main/java/com/isums/notificationservice/configurations/GrpcServiceAuthInterceptor.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.isums.notificationservice.configurations; - -import io.grpc.*; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class GrpcServiceAuthInterceptor implements ClientInterceptor { - - private static final Metadata.Key AUTHORIZATION = - Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER); - - private final ServiceTokenProvider tokenProvider; - - @Override - public ClientCall interceptCall( - MethodDescriptor method, - CallOptions callOptions, - Channel next - ) { - return new ForwardingClientCall.SimpleForwardingClientCall<>(next.newCall(method, callOptions)) { - @Override - public void start(ClientCall.Listener responseListener, Metadata headers) { - String token = tokenProvider.getAccessTokenValue(); - headers.put(AUTHORIZATION, "Bearer " + token); - super.start(responseListener, headers); - } - }; - } -} \ No newline at end of file diff --git a/src/main/java/com/isums/notificationservice/controllers/ManagerNotificationController.java b/src/main/java/com/isums/notificationservice/controllers/ManagerNotificationController.java new file mode 100644 index 0000000..7fa2a5a --- /dev/null +++ b/src/main/java/com/isums/notificationservice/controllers/ManagerNotificationController.java @@ -0,0 +1,79 @@ +package com.isums.notificationservice.controllers; + +import com.isums.notificationservice.domains.dtos.ApiResponse; +import com.isums.notificationservice.domains.dtos.ApiResponses; +import com.isums.notificationservice.domains.dtos.NotificationDto; +import com.isums.notificationservice.infrastructures.Websockets.SseConnectionManager; +import com.isums.notificationservice.infrastructures.abstracts.ManagerNotificationService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/notifications/manager") +@RequiredArgsConstructor +public class ManagerNotificationController { + + private final ManagerNotificationService service; + private final SseConnectionManager sseManager; + + // SSE endpoint — web connect 1 lần khi login + @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) +// @PreAuthorize("hasAnyRole('MANAGER', 'LANDLORD')") + public SseEmitter stream(@AuthenticationPrincipal Jwt jwt) { + UUID userId = UUID.fromString(jwt.getSubject()); + SseEmitter emitter = sseManager.subscribe(userId); + + // Gửi unread count ngay khi connect + try { + emitter.send(SseEmitter.event() + .name("unread_count") + .data(Map.of("count", service.countUnread(userId)))); + } catch (Exception ignored) {} + + return emitter; + } + + @GetMapping +// @PreAuthorize("hasAnyRole('MANAGER', 'LANDLORD')") + public ApiResponse> list( + @AuthenticationPrincipal Jwt jwt, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + UUID userId = UUID.fromString(jwt.getSubject()); + return ApiResponses.ok(service.getByRecipient(userId, PageRequest.of(page, size)), "Success"); + } + + @GetMapping("/unread-count") +// @PreAuthorize("hasAnyRole('MANAGER', 'LANDLORD')") + public ApiResponse> unreadCount(@AuthenticationPrincipal Jwt jwt) { + UUID userId = UUID.fromString(jwt.getSubject()); + return ApiResponses.ok( + Map.of("count", service.countUnread(userId)), + "Success"); + } + + @PutMapping("/{id}/read") +// @PreAuthorize("hasAnyRole('MANAGER', 'LANDLORD')") + public ApiResponse markRead( + @PathVariable UUID id, + @AuthenticationPrincipal Jwt jwt) { + service.markRead(id, UUID.fromString(jwt.getSubject())); + return ApiResponses.ok(null, "Marked as read"); + } + + @PutMapping("/read-all") +// @PreAuthorize("hasAnyRole('MANAGER', 'LANDLORD')") + public ApiResponse markAllRead(@AuthenticationPrincipal Jwt jwt) { + service.markAllRead(UUID.fromString(jwt.getSubject())); + return ApiResponses.ok(null, "All marked as read"); + } +} diff --git a/src/main/java/com/isums/notificationservice/domains/dtos/ApiError.java b/src/main/java/com/isums/notificationservice/domains/dtos/ApiError.java new file mode 100644 index 0000000..f8b7831 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/dtos/ApiError.java @@ -0,0 +1,14 @@ +package com.isums.notificationservice.domains.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ApiError { + String code; + String field; + String message; +} diff --git a/src/main/java/com/isums/notificationservice/domains/dtos/ApiResponse.java b/src/main/java/com/isums/notificationservice/domains/dtos/ApiResponse.java new file mode 100644 index 0000000..4c7a905 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/dtos/ApiResponse.java @@ -0,0 +1,18 @@ +package com.isums.notificationservice.domains.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Value; + +import java.util.List; + +@Value +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ApiResponse { + Integer statusCode; + Boolean success; + String message; + List errors; + T data; +} diff --git a/src/main/java/com/isums/notificationservice/domains/dtos/ApiResponses.java b/src/main/java/com/isums/notificationservice/domains/dtos/ApiResponses.java new file mode 100644 index 0000000..d424b40 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/dtos/ApiResponses.java @@ -0,0 +1,41 @@ +package com.isums.notificationservice.domains.dtos; + +import org.springframework.http.HttpStatus; + +import java.util.List; + +public final class ApiResponses { + private ApiResponses() {} + + public static ApiResponse ok(T data, String message) { + return ApiResponse.builder() + .statusCode(HttpStatus.OK.value()) + .success(true) + .message(message) + .data(data) + .build(); + } + + public static ApiResponse created(T data, String message) { + return ApiResponse.builder() + .statusCode(HttpStatus.CREATED.value()) + .success(true) + .message(message) + .data(data) + .build(); + } + + public static ApiResponse fail(HttpStatus status, String message, List errors) { + return ApiResponse.builder() + .statusCode(status.value()) + .success(false) + .message(message) + .errors(errors) + .build(); + } + + public static ApiResponse fail(HttpStatus status, String message) { + return fail(status, message, null); + } +} + diff --git a/src/main/java/com/isums/notificationservice/domains/dtos/NotificationDto.java b/src/main/java/com/isums/notificationservice/domains/dtos/NotificationDto.java new file mode 100644 index 0000000..76a5534 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/dtos/NotificationDto.java @@ -0,0 +1,39 @@ +package com.isums.notificationservice.domains.dtos; + +import com.isums.notificationservice.domains.entities.ManagerNotification; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NotificationDto { + private UUID id; + private String category; + private String title; + private String body; + private String actionUrl; + private Map metadata; + private boolean isRead; + private Instant createdAt; + + public static NotificationDto from(ManagerNotification n) { + return NotificationDto.builder() + .id(n.getId()) + .category(n.getCategory().name()) + .title(n.getTitle()) + .body(n.getBody()) + .actionUrl(n.getActionUrl()) + .metadata(n.getMetadata()) + .isRead(n.isRead()) + .createdAt(n.getCreatedAt()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/isums/notificationservice/domains/entities/ManagerNotification.java b/src/main/java/com/isums/notificationservice/domains/entities/ManagerNotification.java new file mode 100644 index 0000000..febeee9 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/entities/ManagerNotification.java @@ -0,0 +1,61 @@ +package com.isums.notificationservice.domains.entities; + +import com.isums.notificationservice.domains.enums.NotificationCategory; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.UuidGenerator; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +@Entity +@Table(name = "manager_notifications", + indexes = { + @Index(columnList = "recipient_id, is_read, created_at"), + @Index(columnList = "recipient_id, category") + }) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ManagerNotification { + + @Id + @GeneratedValue + @UuidGenerator + private UUID id; + + @Column(name = "recipient_id", nullable = false) + private UUID recipientId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private NotificationCategory category; + + @Column(nullable = false) + private String title; + + @Column(nullable = false, columnDefinition = "text") + private String body; + + private String actionUrl; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + private Map metadata; + + @Column(name = "is_read", nullable = false) + private boolean isRead = false; + + private Instant readAt; + + @CreationTimestamp + private Instant createdAt; +} diff --git a/src/main/java/com/isums/notificationservice/domains/enums/NotificationCategory.java b/src/main/java/com/isums/notificationservice/domains/enums/NotificationCategory.java new file mode 100644 index 0000000..b18ac1c --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/enums/NotificationCategory.java @@ -0,0 +1,9 @@ +package com.isums.notificationservice.domains.enums; + +public enum NotificationCategory { + CONTRACT_EXPIRED, + INSPECTION_DONE, + RENEWAL_REQUEST, + PAYMENT_OVERDUE, + DEPOSIT_REFUND_CONFIRM +} diff --git a/src/main/java/com/isums/notificationservice/domains/events/InspectionDoneNotifyEvent.java b/src/main/java/com/isums/notificationservice/domains/events/InspectionDoneNotifyEvent.java new file mode 100644 index 0000000..c64309e --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/events/InspectionDoneNotifyEvent.java @@ -0,0 +1,18 @@ +package com.isums.notificationservice.domains.events; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class InspectionDoneNotifyEvent { + private UUID contractId; + private UUID inspectionId; + private UUID managerId; + private Long deductionAmount; + private String messageId; +} diff --git a/src/main/java/com/isums/notificationservice/domains/events/InspectionScheduledEvent.java b/src/main/java/com/isums/notificationservice/domains/events/InspectionScheduledEvent.java new file mode 100644 index 0000000..062fd2c --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/events/InspectionScheduledEvent.java @@ -0,0 +1,18 @@ +package com.isums.notificationservice.domains.events; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class InspectionScheduledEvent { + private UUID contractId; + private UUID inspectionId; + private UUID managerId; + private String tenantName; + private String messageId; +} diff --git a/src/main/java/com/isums/notificationservice/exceptions/ConflictException.java b/src/main/java/com/isums/notificationservice/exceptions/ConflictException.java new file mode 100644 index 0000000..7301cfa --- /dev/null +++ b/src/main/java/com/isums/notificationservice/exceptions/ConflictException.java @@ -0,0 +1,7 @@ +package com.isums.notificationservice.exceptions; + +public class ConflictException extends RuntimeException { + public ConflictException(String message) { + super(message); + } +} diff --git a/src/main/java/com/isums/notificationservice/exceptions/GlobalExceptionHandler.java b/src/main/java/com/isums/notificationservice/exceptions/GlobalExceptionHandler.java new file mode 100644 index 0000000..2065820 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/exceptions/GlobalExceptionHandler.java @@ -0,0 +1,111 @@ +package com.isums.notificationservice.exceptions; + +import com.isums.notificationservice.domains.dtos.ApiError; +import com.isums.notificationservice.domains.dtos.ApiResponse; +import com.isums.notificationservice.domains.dtos.ApiResponses; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.client.RestClientResponseException; + +import java.util.List; + + +@RestControllerAdvice +@Slf4j +public class GlobalExceptionHandler { + + @ExceptionHandler(DataAccessException.class) + public ResponseEntity> handleDb(DataAccessException ex) { + ex.getMostSpecificCause(); + String detail = ex.getMostSpecificCause().getMessage(); + + ApiResponse res = ApiResponses.fail( + HttpStatus.INTERNAL_SERVER_ERROR, + "Database error", + List.of(ApiError.builder() + .code("DB_ERROR") + .message(detail) + .build()) + ); + + return ResponseEntity.status(res.getStatusCode()).body(res); + } + + @ExceptionHandler(NotFoundException.class) + public ResponseEntity> handleNotFoundException(NotFoundException ex) { + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body(ApiResponses.fail(HttpStatus.NOT_FOUND, ex.getMessage())); + } + + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity> handleIllegalStateException(IllegalStateException ex) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponses.fail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage())); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleBadRequest(IllegalArgumentException ex) { + ApiResponse res = ApiResponses.fail( + HttpStatus.BAD_REQUEST, + ex.getMessage(), + List.of(ApiError.builder() + .code("BAD_REQUEST") + .message(ex.getMessage()) + .build()) + ); + + return ResponseEntity.status(res.getStatusCode()).body(res); + } + + @ExceptionHandler(RestClientResponseException.class) + public ResponseEntity> handleRestClient(RestClientResponseException ex) { + + log.error("Upstream HTTP error: status={} body={}", ex.getStatusCode().value(), ex.getResponseBodyAsString(), ex); + + HttpStatus status = HttpStatus.resolve(ex.getStatusCode().value()); + if (status == null) status = HttpStatus.BAD_GATEWAY; + + ApiResponse res = ApiResponses.fail( + status, + "Upstream service error", + List.of(ApiError.builder() + .code("UPSTREAM_ERROR") + .message("HTTP " + ex.getStatusCode().value() + " " + ex.getStatusText()) + .build()) + ); + + return ResponseEntity.status(res.getStatusCode()).body(res); + } + + @ExceptionHandler(ConflictException.class) + public ResponseEntity> handleConflict(ConflictException ex) { + ApiResponse res = ApiResponses.fail( + HttpStatus.CONFLICT, + ex.getMessage(), + List.of(ApiError.builder().code("CONFLICT").message(ex.getMessage()).build()) + ); + return ResponseEntity.status(res.getStatusCode()).body(res); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneric(Exception ex) { + log.error("Unhandled error", ex); + + ApiResponse res = ApiResponses.fail( + HttpStatus.INTERNAL_SERVER_ERROR, + "Unexpected error", + List.of(ApiError.builder() + .code("INTERNAL_ERROR") + .message("An unexpected error occurred") + .build()) + ); + + return ResponseEntity.status(res.getStatusCode()).body(res); + } +} diff --git a/src/main/java/com/isums/notificationservice/exceptions/IllegalStateException.java b/src/main/java/com/isums/notificationservice/exceptions/IllegalStateException.java new file mode 100644 index 0000000..7d39e3d --- /dev/null +++ b/src/main/java/com/isums/notificationservice/exceptions/IllegalStateException.java @@ -0,0 +1,7 @@ +package com.isums.notificationservice.exceptions; + +public class IllegalStateException extends RuntimeException { + public IllegalStateException(String message) { + super(message); + } +} diff --git a/src/main/java/com/isums/notificationservice/exceptions/NotFoundException.java b/src/main/java/com/isums/notificationservice/exceptions/NotFoundException.java new file mode 100644 index 0000000..880b598 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/exceptions/NotFoundException.java @@ -0,0 +1,7 @@ +package com.isums.notificationservice.exceptions; + +public class NotFoundException extends RuntimeException { + public NotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/Websockets/SseConnectionManager.java b/src/main/java/com/isums/notificationservice/infrastructures/Websockets/SseConnectionManager.java new file mode 100644 index 0000000..4689f9a --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/Websockets/SseConnectionManager.java @@ -0,0 +1,60 @@ +package com.isums.notificationservice.infrastructures.Websockets; + +import com.isums.notificationservice.domains.dtos.NotificationDto; +import com.isums.notificationservice.domains.entities.ManagerNotification; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +@Component +@Slf4j +public class SseConnectionManager { + + private final Map> emitters + = new ConcurrentHashMap<>(); + + public SseEmitter subscribe(UUID recipientId) { + SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); + + emitters.computeIfAbsent(recipientId, k -> new CopyOnWriteArrayList<>()) + .add(emitter); + + Runnable remove = () -> { + CopyOnWriteArrayList list = emitters.get(recipientId); + if (list != null) list.remove(emitter); + }; + + emitter.onCompletion(remove); + emitter.onTimeout(remove); + emitter.onError(e -> remove.run()); + + log.info("[SSE] Subscribed recipientId={} total={}", + recipientId, emitters.getOrDefault(recipientId, + new CopyOnWriteArrayList<>()).size()); + return emitter; + } + + public void push(UUID recipientId, ManagerNotification notification) { + CopyOnWriteArrayList list = emitters.get(recipientId); + if (list == null || list.isEmpty()) return; + + NotificationDto dto = NotificationDto.from(notification); + + list.forEach(emitter -> { + try { + emitter.send(SseEmitter.event() + .id(notification.getId().toString()) + .name("notification") + .data(dto)); + } catch (Exception e) { + log.warn("[SSE] Push failed recipientId={}: {}", recipientId, e.getMessage()); + list.remove(emitter); + } + }); + } +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/abstracts/ManagerNotificationService.java b/src/main/java/com/isums/notificationservice/infrastructures/abstracts/ManagerNotificationService.java new file mode 100644 index 0000000..ff06393 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/abstracts/ManagerNotificationService.java @@ -0,0 +1,24 @@ +package com.isums.notificationservice.infrastructures.abstracts; + +import com.isums.notificationservice.domains.dtos.NotificationDto; +import com.isums.notificationservice.domains.enums.NotificationCategory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Map; +import java.util.UUID; + +public interface ManagerNotificationService { + + void send(UUID recipientId, NotificationCategory category, + String title, String body, + String actionUrl, Map metadata); + + Page getByRecipient(UUID recipientId, Pageable pageable); + + long countUnread(UUID recipientId); + + void markRead(UUID notificationId, UUID recipientId); + + void markAllRead(UUID recipientId); +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/kafka/ContractNotificationConsumer.java b/src/main/java/com/isums/notificationservice/infrastructures/kafka/ContractNotificationConsumer.java new file mode 100644 index 0000000..cfa5161 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/kafka/ContractNotificationConsumer.java @@ -0,0 +1,103 @@ +package com.isums.notificationservice.infrastructures.kafka; + +import com.isums.notificationservice.domains.enums.NotificationCategory; +import com.isums.notificationservice.domains.events.InspectionDoneNotifyEvent; +import com.isums.notificationservice.domains.events.InspectionScheduledEvent; +import com.isums.notificationservice.infrastructures.abstracts.ManagerNotificationService; +import common.kafkas.IdempotencyService; +import common.kafkas.KafkaListenerHelper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +import java.util.Map; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ContractNotificationConsumer { + + private final ManagerNotificationService notificationService; + private final ObjectMapper objectMapper; + private final IdempotencyService idempotencyService; + private final KafkaListenerHelper kafkaHelper; + + // Hợp đồng hết hạn → phân công nhân viên kiểm tra + @KafkaListener(topics = "contract.inspection.scheduled", + groupId = "notification-group") + public void handleInspectionScheduled( + ConsumerRecord record, Acknowledgment ack) { + + String messageId = kafkaHelper.extractMessageId(record); + try { + if (idempotencyService.isDuplicate(messageId)) { + ack.acknowledge(); + return; + } + + InspectionScheduledEvent event = objectMapper.readValue( + record.value(), InspectionScheduledEvent.class); + + notificationService.send( + event.getManagerId(), + NotificationCategory.CONTRACT_EXPIRED, + "Hợp đồng hết hạn — Đã lên lịch kiểm tra nhà", + "Hợp đồng #" + event.getContractId().toString().substring(0, 8).toUpperCase() + + " của khách " + event.getTenantName() + + " đã hết hạn. Nhân viên đã được phân công kiểm tra.", + "/contracts/" + event.getContractId() + "/termination", + Map.of( + "contractId", event.getContractId().toString(), + "inspectionId", event.getInspectionId().toString() + ) + ); + + idempotencyService.markProcessed(messageId); + ack.acknowledge(); + log.info("[Notification] handleInspectionScheduled done messageId={}", messageId); + } catch (Exception e) { + log.error("[Notification] handleInspectionScheduled failed: {}", e.getMessage(), e); + throw new RuntimeException(e); + } + } + + @KafkaListener(topics = "contract.inspection.done", + groupId = "notification-group") + public void handleInspectionDone( + ConsumerRecord record, Acknowledgment ack) { + + String messageId = kafkaHelper.extractMessageId(record); + try { + if (idempotencyService.isDuplicate(messageId)) { + ack.acknowledge(); + return; + } + + InspectionDoneNotifyEvent event = objectMapper.readValue(record.value(), InspectionDoneNotifyEvent.class); + + notificationService.send(event.getManagerId(), NotificationCategory.INSPECTION_DONE, + "Kiểm tra nhà hoàn tất — Cần xác nhận hoàn cọc", + "Nhân viên đã kiểm tra xong hợp đồng #" + + event.getContractId().toString().substring(0, 8).toUpperCase() + + ". Vui lòng xem và xác nhận số tiền hoàn cọc.", + "/contracts/" + event.getContractId() + "/deposit-refund", + Map.of( + "contractId", event.getContractId().toString(), + "inspectionId", event.getInspectionId().toString(), + "deductionAmount", event.getDeductionAmount().toString() + ) + ); + + idempotencyService.markProcessed(messageId); + ack.acknowledge(); + log.info("[Notification] handleInspectionDone done messageId={}", messageId); + } catch (Exception e) { + log.error("[Notification] handleInspectionDone failed: {}", e.getMessage(), e); + throw new RuntimeException(e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/isums/notificationservice/infrastructures/repositories/ManagerNotificationRepository.java b/src/main/java/com/isums/notificationservice/infrastructures/repositories/ManagerNotificationRepository.java new file mode 100644 index 0000000..f62a788 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/repositories/ManagerNotificationRepository.java @@ -0,0 +1,26 @@ +package com.isums.notificationservice.infrastructures.repositories; + +import com.isums.notificationservice.domains.entities.ManagerNotification; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +public interface ManagerNotificationRepository extends JpaRepository { + + Page findByRecipientIdOrderByCreatedAtDesc(UUID recipientId, Pageable pageable); + + long countByRecipientIdAndIsReadFalse(UUID recipientId); + + Optional findByIdAndRecipientId(UUID id, UUID recipientId); + + @Modifying + @Query("UPDATE ManagerNotification n SET n.isRead = true, n.readAt = :readAt WHERE n.recipientId = :recipientId AND n.isRead = false") + void markAllReadByRecipientId(@Param("recipientId") UUID recipientId, @Param("readAt") Instant readAt); +} \ No newline at end of file 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 c989632..ddc2ac2 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/seeders/EmailTemplateSeeder.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/seeders/EmailTemplateSeeder.java @@ -905,6 +905,205 @@ public void seed(EmailTemplateRepository templateRepo, EmailTemplateVersionRepos "system" ); + + // ── INSPECTION DONE REVIEW (manager) ────────────────────────────── + upsertActiveV1( + templateRepo, versionRepo, + "inspection_done_review", "CONTRACT", "MANAGER", + LocaleType.vi_VN, + "Kiểm tra nhà hoàn tất — Hợp đồng #{{contractId}}", + """ + + + + + +
+ + + + + + + + + + +
+
+ ✅ Kiểm tra nhà hoàn tất +
+
+

+ Kính gửi {{managerName}}, +

+

+ Nhân viên đã hoàn thành kiểm tra nhà cho hợp đồng + #{{contractId}}. +

+ + + + + + + + + + + + + +
+ Mã kiểm tra + + {{inspectionId}} +
+ Số tiền khấu trừ đề xuất + + {{deductionAmount}} +
+ Ghi chú + + {{notes}} +
+

+ Vui lòng đăng nhập hệ thống để xem chi tiết và xác nhận + số tiền hoàn cọc cho khách. +

+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + + """, + """ + Kính gửi {{managerName}}, + + Nhân viên đã hoàn thành kiểm tra nhà cho hợp đồng #{{contractId}}. + + Mã kiểm tra: {{inspectionId}} + Số tiền khấu trừ đề xuất: {{deductionAmount}} + Ghi chú: {{notes}} + + Vui lòng đăng nhập hệ thống để xác nhận hoàn cọc. + """, + List.of("managerName", "contractId", "inspectionId", + "houseId", "deductionAmount", "notes"), + "system" + ); + +// ── CONTRACT EXPIRED INSPECTION SCHEDULED (manager) ─────────────── + upsertActiveV1( + templateRepo, versionRepo, + "contract_expired_inspection_scheduled", "CONTRACT", "MANAGER", + LocaleType.vi_VN, + "Hợp đồng #{{contractId}} đã hết hạn — Đã lên lịch kiểm tra nhà", + """ + + + + + +
+ + + + + + + + + + +
+
+ 🔔 Hợp đồng hết hạn — Đã phân công kiểm tra nhà +
+
+

+ Kính gửi {{managerName}}, +

+

+ Hợp đồng #{{contractId}} của khách + {{tenantName}} đã hết hạn. +

+

+ Hệ thống đã tự động tạo lịch kiểm tra nhà và phân công + nhân viên phụ trách. +

+ + + + + + + + + +
+ Mã kiểm tra + + {{inspectionId}} +
+ Khách thuê + + {{tenantName}} +
+

+ Vui lòng theo dõi tiến trình kiểm tra trên hệ thống. +

+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + + """, + """ + Kính gửi {{managerName}}, + + Hợp đồng #{{contractId}} của khách {{tenantName}} đã hết hạn. + + Mã kiểm tra: {{inspectionId}} + + Hệ thống đã tự động phân công nhân viên kiểm tra nhà. + Vui lòng theo dõi tiến trình trên hệ thống. + """, + List.of("managerName", "contractId", "tenantName", + "houseId", "inspectionId"), + "system" + ); + } private void upsertActiveV1( diff --git a/src/main/java/com/isums/notificationservice/services/ManagerNotificationServiceImpl.java b/src/main/java/com/isums/notificationservice/services/ManagerNotificationServiceImpl.java new file mode 100644 index 0000000..2029759 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/services/ManagerNotificationServiceImpl.java @@ -0,0 +1,77 @@ +package com.isums.notificationservice.services; + +import com.isums.notificationservice.domains.dtos.NotificationDto; +import com.isums.notificationservice.domains.entities.ManagerNotification; +import com.isums.notificationservice.domains.enums.NotificationCategory; +import com.isums.notificationservice.exceptions.NotFoundException; +import com.isums.notificationservice.infrastructures.Websockets.SseConnectionManager; +import com.isums.notificationservice.infrastructures.abstracts.ManagerNotificationService; +import com.isums.notificationservice.infrastructures.repositories.ManagerNotificationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ManagerNotificationServiceImpl implements ManagerNotificationService { + + private final ManagerNotificationRepository repo; + private final SseConnectionManager sseManager; + + @Override + @Transactional + public void send(UUID recipientId, NotificationCategory category, + String title, String body, + String actionUrl, Map metadata) { + + ManagerNotification n = ManagerNotification.builder() + .recipientId(recipientId) + .category(category) + .title(title) + .body(body) + .actionUrl(actionUrl) + .metadata(metadata) + .isRead(false) + .build(); + + repo.save(n); + sseManager.push(recipientId, n); + + log.info("[Notification] Sent recipientId={} category={}", recipientId, category); + } + + @Override + public Page getByRecipient(UUID recipientId, Pageable pageable) { + return repo.findByRecipientIdOrderByCreatedAtDesc(recipientId, pageable) + .map(NotificationDto::from); + } + + @Override + public long countUnread(UUID recipientId) { + return repo.countByRecipientIdAndIsReadFalse(recipientId); + } + + @Override + @Transactional + public void markRead(UUID notificationId, UUID recipientId) { + ManagerNotification n = repo.findByIdAndRecipientId(notificationId, recipientId) + .orElseThrow(() -> new NotFoundException("Notification not found")); + n.setRead(true); + n.setReadAt(Instant.now()); + repo.save(n); + } + + @Override + @Transactional + public void markAllRead(UUID recipientId) { + repo.markAllReadByRecipientId(recipientId, Instant.now()); + } +} From 47e63f83d2d01b269028444242af687cfc7afaff Mon Sep 17 00:00:00 2001 From: hoangtuzami Date: Thu, 9 Apr 2026 05:34:16 +0700 Subject: [PATCH 02/11] Add renewal-related event handling, email templates, and Kafka listener for contract reminders --- .../domains/events/RenewalReminderEvent.java | 19 ++ .../events/RenewalRequestReceivedEvent.java | 21 ++ .../listeners/EContractEventListener.java | 41 +++ .../seeders/EmailTemplateSeeder.java | 236 ++++++++++++++++++ 4 files changed, 317 insertions(+) create mode 100644 src/main/java/com/isums/notificationservice/domains/events/RenewalReminderEvent.java create mode 100644 src/main/java/com/isums/notificationservice/domains/events/RenewalRequestReceivedEvent.java diff --git a/src/main/java/com/isums/notificationservice/domains/events/RenewalReminderEvent.java b/src/main/java/com/isums/notificationservice/domains/events/RenewalReminderEvent.java new file mode 100644 index 0000000..8089152 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/events/RenewalReminderEvent.java @@ -0,0 +1,19 @@ +package com.isums.notificationservice.domains.events; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class RenewalReminderEvent { + private UUID contractId; + private UUID tenantId; + private int daysRemaining; + private Instant endDate; + private String messageId; +} diff --git a/src/main/java/com/isums/notificationservice/domains/events/RenewalRequestReceivedEvent.java b/src/main/java/com/isums/notificationservice/domains/events/RenewalRequestReceivedEvent.java new file mode 100644 index 0000000..586736b --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/events/RenewalRequestReceivedEvent.java @@ -0,0 +1,21 @@ +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 RenewalRequestReceivedEvent { + private UUID contractId; + private UUID houseId; + private UUID managerId; + private String tenantName; + private boolean hasCompetingDeposit; + private String messageId; +} \ No newline at end of file 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 5f6f70b..d523dae 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/listeners/EContractEventListener.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/listeners/EContractEventListener.java @@ -1,5 +1,6 @@ package com.isums.notificationservice.infrastructures.listeners; +import com.isums.notificationservice.domains.events.RenewalReminderEvent; import tools.jackson.core.JacksonException; import tools.jackson.databind.ObjectMapper; import com.isums.notificationservice.domains.events.ConfirmAndSendToTenantEvent; @@ -103,6 +104,46 @@ public void handleConfirmAndSendToTenant(ConsumerRecord record, } } + @KafkaListener(topics = "contract.renewal.reminder", groupId = "notification-group") + public void handleRenewalReminder( + ConsumerRecord record, Acknowledgment ack) { + + String messageId = kafkaHelper.extractMessageId(record); + try { + if (idempotencyService.isDuplicate(messageId)) { + ack.acknowledge(); + return; + } + + RenewalReminderEvent event = objectMapper.readValue(record.value(), RenewalReminderEvent.class); + + UserResponse tenant = userGrpcClient.getUserById(event.getTenantId()); + + emailService.sendEmail( + tenant.getEmail(), + "contract_renewal_reminder", + LocaleType.vi_VN, + Map.of( + "tenantName", tenant.getName(), + "contractId", event.getContractId().toString() + .substring(0, 8).toUpperCase(), + "daysRemaining", String.valueOf(event.getDaysRemaining()), + "endDate", DMY.format(event.getEndDate()), + "openForNew", event.getDaysRemaining() == 0 + ) + ); + + idempotencyService.markProcessed(messageId); + ack.acknowledge(); + log.info("[Notification] RenewalReminder sent tenantId={} daysRemaining={}", + event.getTenantId(), event.getDaysRemaining()); + + } catch (Exception e) { + log.error("[Notification] handleRenewalReminder failed: {}", e.getMessage(), e); + throw new RuntimeException(e); + } + } + private String safe(String s, String fallback) { return (s != null && !s.isBlank()) ? s.trim() : fallback; } 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 ddc2ac2..2e8b033 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/seeders/EmailTemplateSeeder.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/seeders/EmailTemplateSeeder.java @@ -1104,6 +1104,242 @@ public void seed(EmailTemplateRepository templateRepo, EmailTemplateVersionRepos "system" ); + // ── CONTRACT RENEWAL REMINDER (tenant) ──────────────────────────── + upsertActiveV1( + templateRepo, versionRepo, + "contract_renewal_reminder", "CONTRACT", "TENANT", + LocaleType.vi_VN, + "Hợp đồng của bạn còn {{daysRemaining}} ngày — Bạn có muốn gia hạn?", + """ + + + + + + +
+ + + + + + + + + + +
+
+ ⏰ Hợp đồng sắp hết hạn +
+
+

+ Kính gửi {{tenantName}}, +

+

+ Hợp đồng thuê nhà #{{contractId}} của bạn + {{#openForNew}} + đã hết hạn hôm nay. Phòng đã được mở cho khách mới đặt cọc. + {{/openForNew}} + {{^openForNew}} + còn {{daysRemaining}} ngày nữa sẽ hết hạn vào + {{endDate}}. + {{/openForNew}} +

+

+ Nếu bạn muốn tiếp tục thuê, vui lòng liên hệ quản lý hoặc + bấm nút Gia hạn trong ứng dụng ISUMS. +

+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + + """, + """ + Kính gửi {{tenantName}}, + + Hợp đồng #{{contractId}} của bạn còn {{daysRemaining}} ngày (hết hạn {{endDate}}). + + Nếu muốn gia hạn, vui lòng liên hệ quản lý hoặc bấm Gia hạn trong app ISUMS. + """, + List.of("tenantName", "contractId", "daysRemaining", "endDate", "openForNew"), + "system" + ); + +// ── RENEWAL REQUEST RECEIVED (manager) ──────────────────────────── + upsertActiveV1( + templateRepo, versionRepo, + "renewal_request_received", "CONTRACT", "MANAGER", + LocaleType.vi_VN, + "Khách {{tenantName}} muốn gia hạn hợp đồng #{{contractId}}", + """ + + + + + + +
+ + + + + + + + + + +
+
+ 🔔 Yêu cầu gia hạn hợp đồng +
+
+

+ Kính gửi {{managerName}}, +

+

+ Khách {{tenantName}} vừa gửi yêu cầu gia hạn + hợp đồng #{{contractId}}. +

+ + + + + + + + + +
+ Tình trạng cạnh tranh + + {{hasCompetingDeposit}} +
+ Ghi chú của khách + + {{note}} +
+

+ Vui lòng đăng nhập hệ thống để liên hệ khách và soạn hợp đồng mới nếu đồng ý. +

+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + + """, + """ + Kính gửi {{managerName}}, + + Khách {{tenantName}} vừa gửi yêu cầu gia hạn hợp đồng #{{contractId}}. + + Tình trạng cạnh tranh: {{hasCompetingDeposit}} + Ghi chú: {{note}} + + Vui lòng đăng nhập hệ thống để xử lý. + """, + List.of("managerName", "tenantName", "contractId", "hasCompetingDeposit", "note"), + "system" + ); + +// ── RENEWAL DECLINED (tenant) ────────────────────────────────────── + upsertActiveV1( + templateRepo, versionRepo, + "renewal_declined", "CONTRACT", "TENANT", + LocaleType.vi_VN, + "Yêu cầu gia hạn hợp đồng #{{contractId}} không được chấp thuận", + """ + + + + + + +
+ + + + + + + + + + +
+
+ ❌ Yêu cầu gia hạn không được chấp thuận +
+
+

+ Kính gửi {{tenantName}}, +

+

+ Rất tiếc, yêu cầu gia hạn hợp đồng #{{contractId}} + của bạn không được chấp thuận. +

+ + + + + +
+ Lý do + + {{reason}} +
+

+ Nếu có thắc mắc, vui lòng liên hệ quản lý để được hỗ trợ. +

+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + + """, + """ + Kính gửi {{tenantName}}, + + Yêu cầu gia hạn hợp đồng #{{contractId}} của bạn không được chấp thuận. + + Lý do: {{reason}} + + Nếu có thắc mắc, vui lòng liên hệ quản lý. + """, + List.of("tenantName", "contractId", "reason"), + "system" + ); + } private void upsertActiveV1( From 1256bf8068dc9cb26af7d0042052919597232667 Mon Sep 17 00:00:00 2001 From: hoangtuzami Date: Fri, 10 Apr 2026 15:08:36 +0700 Subject: [PATCH 03/11] Add Kafka listeners for payment-related events and overdue termination, implement corresponding event classes and email templates --- .../OverdueTerminationRequestedEvent.java | 18 + .../events/PowerCutConfirmedEvent.java | 20 + .../domains/events/PowerCutRequestEvent.java | 20 + .../events/PowerCutReviewRequestedEvent.java | 22 + .../events/TerminationRequestedEvent.java | 19 + .../kafka/PaymentConsumer.java | 156 ++ .../seeders/EmailTemplateSeeder.java | 1688 ++++++++++------- 7 files changed, 1246 insertions(+), 697 deletions(-) create mode 100644 src/main/java/com/isums/notificationservice/domains/events/OverdueTerminationRequestedEvent.java create mode 100644 src/main/java/com/isums/notificationservice/domains/events/PowerCutConfirmedEvent.java create mode 100644 src/main/java/com/isums/notificationservice/domains/events/PowerCutRequestEvent.java create mode 100644 src/main/java/com/isums/notificationservice/domains/events/PowerCutReviewRequestedEvent.java create mode 100644 src/main/java/com/isums/notificationservice/domains/events/TerminationRequestedEvent.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/kafka/PaymentConsumer.java diff --git a/src/main/java/com/isums/notificationservice/domains/events/OverdueTerminationRequestedEvent.java b/src/main/java/com/isums/notificationservice/domains/events/OverdueTerminationRequestedEvent.java new file mode 100644 index 0000000..d9a35d3 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/events/OverdueTerminationRequestedEvent.java @@ -0,0 +1,18 @@ +package com.isums.notificationservice.domains.events; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class OverdueTerminationRequestedEvent { + private UUID contractId; + private UUID houseId; + private UUID managerId; + private String tenantName; + private String messageId; +} diff --git a/src/main/java/com/isums/notificationservice/domains/events/PowerCutConfirmedEvent.java b/src/main/java/com/isums/notificationservice/domains/events/PowerCutConfirmedEvent.java new file mode 100644 index 0000000..b52e9d7 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/events/PowerCutConfirmedEvent.java @@ -0,0 +1,20 @@ +package com.isums.notificationservice.domains.events; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PowerCutConfirmedEvent { + private UUID contractId; + private UUID houseId; + private UUID tenantId; + private UUID confirmedBy; + private Instant executeAt; + private String messageId; +} diff --git a/src/main/java/com/isums/notificationservice/domains/events/PowerCutRequestEvent.java b/src/main/java/com/isums/notificationservice/domains/events/PowerCutRequestEvent.java new file mode 100644 index 0000000..8cf1933 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/events/PowerCutRequestEvent.java @@ -0,0 +1,20 @@ +package com.isums.notificationservice.domains.events; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PowerCutRequestEvent { + private UUID invoiceId; + private UUID contractId; + private UUID houseId; + private UUID tenantId; + private int daysLate; + private Long totalAmount; + private String messageId; +} diff --git a/src/main/java/com/isums/notificationservice/domains/events/PowerCutReviewRequestedEvent.java b/src/main/java/com/isums/notificationservice/domains/events/PowerCutReviewRequestedEvent.java new file mode 100644 index 0000000..be251c8 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/events/PowerCutReviewRequestedEvent.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 PowerCutReviewRequestedEvent { + private UUID contractId; + private UUID houseId; + private UUID managerId; + private String tenantName; + private int daysLate; + private Long totalAmount; + private String messageId; +} diff --git a/src/main/java/com/isums/notificationservice/domains/events/TerminationRequestedEvent.java b/src/main/java/com/isums/notificationservice/domains/events/TerminationRequestedEvent.java new file mode 100644 index 0000000..5161e26 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/events/TerminationRequestedEvent.java @@ -0,0 +1,19 @@ +package com.isums.notificationservice.domains.events; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class TerminationRequestedEvent { + private UUID contractId; + private UUID houseId; + private UUID tenantId; + private UUID invoiceId; + private String reason; + private String messageId; +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/kafka/PaymentConsumer.java b/src/main/java/com/isums/notificationservice/infrastructures/kafka/PaymentConsumer.java new file mode 100644 index 0000000..3971563 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/kafka/PaymentConsumer.java @@ -0,0 +1,156 @@ +package com.isums.notificationservice.infrastructures.kafka; + +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.domains.enums.NotificationCategory; +import com.isums.notificationservice.domains.events.OverdueTerminationRequestedEvent; +import com.isums.notificationservice.domains.events.PowerCutConfirmedEvent; +import com.isums.notificationservice.domains.events.PowerCutRequestEvent; +import com.isums.notificationservice.domains.events.PowerCutReviewRequestedEvent; +import com.isums.notificationservice.infrastructures.abstracts.EmailService; +import com.isums.notificationservice.infrastructures.abstracts.ManagerNotificationService; +import com.isums.notificationservice.infrastructures.grpcs.UserGrpcClient; +import com.isums.userservice.grpc.UserResponse; +import common.kafkas.IdempotencyService; +import common.kafkas.KafkaListenerHelper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +import java.text.NumberFormat; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.Map; + +@Component +@RequiredArgsConstructor +@Slf4j +public class PaymentConsumer { + + private final ManagerNotificationService notificationService; + private final UserGrpcClient userGrpcClient; + private final EmailService emailService; + private final IdempotencyService idempotencyService; + private final KafkaListenerHelper kafkaHelper; + private final ObjectMapper objectMapper; + + private static final ZoneId VN = ZoneId.of("Asia/Ho_Chi_Minh"); + private static final DateTimeFormatter DMY = + DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm").withZone(VN); + + @KafkaListener(topics = "contract.power-cut-confirmed", groupId = "notification-group") + public void handlePowerCutConfirmed( + ConsumerRecord record, Acknowledgment ack) { + + String messageId = kafkaHelper.extractMessageId(record); + kafkaHelper.setupMDC(record, messageId); + try { + if (idempotencyService.isDuplicate(messageId)) { + ack.acknowledge(); + return; + } + + PowerCutConfirmedEvent event = objectMapper.readValue( + record.value(), PowerCutConfirmedEvent.class); + + UserResponse tenant = userGrpcClient.getUserById(event.getTenantId()); + + emailService.sendEmail( + tenant.getEmail(), + "power_cut_warning_24h", + LocaleType.vi_VN, + Map.of("executeAt", DMY.format(event.getExecuteAt())) + ); + + idempotencyService.markProcessed(messageId); + ack.acknowledge(); + log.info("[Notification] PowerCutWarning24h sent tenantId={}", event.getTenantId()); + } catch (Exception e) { + log.error("[Notification] handlePowerCutConfirmed failed: {}", e.getMessage(), e); + throw new RuntimeException(e); + } finally { + kafkaHelper.clearMDC(); + } + } + + @KafkaListener(topics = "contract.power-cut-review-requested", + groupId = "notification-group") + public void handlePowerCutReviewRequested( + ConsumerRecord record, Acknowledgment ack) { + + String messageId = kafkaHelper.extractMessageId(record); + kafkaHelper.setupMDC(record, messageId); + try { + if (idempotencyService.isDuplicate(messageId)) { + ack.acknowledge(); + return; + } + + PowerCutReviewRequestedEvent event = objectMapper.readValue( + record.value(), PowerCutReviewRequestedEvent.class); + + notificationService.send(event.getManagerId(), NotificationCategory.PAYMENT_OVERDUE, + "Khách " + event.getTenantName() + + " trễ " + event.getDaysLate() + " ngày — Xem xét cắt điện", + "Tổng tiền cần thu: " + formatVnd(event.getTotalAmount()) + + ". Vào hệ thống để xác nhận cắt điện nếu cần.", + "/contracts/" + event.getContractId() + "/power-cut", + Map.of( + "contractId", event.getContractId().toString(), + "daysLate", String.valueOf(event.getDaysLate()) + ) + ); + + idempotencyService.markProcessed(messageId); + ack.acknowledge(); + log.info("[Notification] PowerCutReview notified managerId={}", event.getManagerId()); + } catch (Exception e) { + log.error("[Notification] handlePowerCutReviewRequested failed: {}", e.getMessage(), e); + throw new RuntimeException(e); + } finally { + kafkaHelper.clearMDC(); + } + } + + @KafkaListener(topics = "contract.termination-overdue-requested", + groupId = "notification-group") + public void handleOverdueTerminationRequested( + ConsumerRecord record, Acknowledgment ack) { + + String messageId = kafkaHelper.extractMessageId(record); + kafkaHelper.setupMDC(record, messageId); + try { + if (idempotencyService.isDuplicate(messageId)) { + ack.acknowledge(); + return; + } + + OverdueTerminationRequestedEvent event = objectMapper.readValue( + record.value(), OverdueTerminationRequestedEvent.class); + + notificationService.send(event.getManagerId(), NotificationCategory.PAYMENT_OVERDUE, + "Khách " + event.getTenantName() + " trễ tiền thuê 30 ngày", + "Khách đã chậm thanh toán 30 ngày. Vui lòng xem xét chấm dứt hợp đồng.", + "/contracts/" + event.getContractId() + "/termination", + Map.of("contractId", event.getContractId().toString()) + ); + + idempotencyService.markProcessed(messageId); + ack.acknowledge(); + log.info("[Notification] OverdueTermination notified managerId={}", event.getManagerId()); + } catch (Exception e) { + log.error("[Notification] handleOverdueTerminationRequested failed: {}", e.getMessage(), e); + throw new RuntimeException(e); + } finally { + kafkaHelper.clearMDC(); + } + } + + private String formatVnd(Long amount) { + return NumberFormat.getNumberInstance(Locale.of("vi", "VN")).format(amount) + " ₫"; + } +} 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 2e8b033..c0d3414 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/seeders/EmailTemplateSeeder.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/seeders/EmailTemplateSeeder.java @@ -541,110 +541,110 @@ public void seed(EmailTemplateRepository templateRepo, EmailTemplateVersionRepos LocaleType.vi_VN, "Xác nhận thanh toán {{invoiceType}} thành công", """ - - - Xác nhận thanh toán - - - -
- - - - - - - - - - - - - - -
-
- ✅ Thanh toán thành công -
-
- ISUMS — Hệ thống quản lý nhà trọ -
-
-
- Xin chào {{tenantName}},
- Hệ thống đã ghi nhận thanh toán của bạn. -
- - - - - - - - - - - - - - -
-
Loại thanh toán
-
- {{invoiceType}} -
-
-
Số tiền
-
- {{amount}} -
-
-
Mã giao dịch
-
- {{txnNo}} -
-
-
Thời gian
-
- {{paidAt}} -
-
- -
- Vui lòng lưu lại email này như biên nhận thanh toán. - Nếu có thắc mắc, liên hệ chủ nhà hoặc hỗ trợ ISUMS. -
- -
-
- Trân trọng,
Đội ngũ ISUMS -
-
-
- Email này được gửi tự động. Vui lòng không trả lời trực tiếp. -
-
-
- - - """, + + + Xác nhận thanh toán + + + +
+ + + + + + + + + + + + + + +
+
+ ✅ Thanh toán thành công +
+
+ ISUMS — Hệ thống quản lý nhà trọ +
+
+
+ Xin chào {{tenantName}},
+ Hệ thống đã ghi nhận thanh toán của bạn. +
+ + + + + + + + + + + + + + +
+
Loại thanh toán
+
+ {{invoiceType}} +
+
+
Số tiền
+
+ {{amount}} +
+
+
Mã giao dịch
+
+ {{txnNo}} +
+
+
Thời gian
+
+ {{paidAt}} +
+
+ +
+ Vui lòng lưu lại email này như biên nhận thanh toán. + Nếu có thắc mắc, liên hệ chủ nhà hoặc hỗ trợ ISUMS. +
+ +
+
+ Trân trọng,
Đội ngũ ISUMS +
+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + + """, """ - Xin chào {{tenantName}}, - - Hệ thống đã ghi nhận thanh toán: - - Loại: {{invoiceType}} - - Số tiền: {{amount}} - - Mã GD: {{txnNo}} - - Thời gian: {{paidAt}} - - Vui lòng lưu lại email này như biên nhận. - - Trân trọng, - Đội ngũ ISUMS - """, + Xin chào {{tenantName}}, + + Hệ thống đã ghi nhận thanh toán: + - Loại: {{invoiceType}} + - Số tiền: {{amount}} + - Mã GD: {{txnNo}} + - Thời gian: {{paidAt}} + + Vui lòng lưu lại email này như biên nhận. + + Trân trọng, + Đội ngũ ISUMS + """, List.of("tenantName", "invoiceType", "amount", "txnNo", "paidAt"), "system" ); @@ -657,171 +657,171 @@ public void seed(EmailTemplateRepository templateRepo, EmailTemplateVersionRepos LocaleType.vi_VN, "Chào mừng {{name}} — Tài khoản đã sẵn sàng", """ - - - - Tài khoản đã kích hoạt - - - +
- - - - - - - - - - -
-
- ISUMS · Quản lý nhà trọ -
-
- Chào mừng bạn! 🎉 -
-
- Tài khoản của bạn đã được kích hoạt thành công -
-
-
- Xin chào {{name}},
- Chủ nhà đã kích hoạt tài khoản ISUMS cho bạn. - Dưới đây là thông tin đăng nhập tạm thời — vui lòng đổi mật khẩu ngay sau khi đăng nhập. -
- - - - - - - - - - - - -
-
- Thông tin đăng nhập -
-
-
Email
-
{{email}}
-
-
Mật khẩu tạm thời
-
{{password}}
-
- - - {{#hasInvoice}} -
-
- ⚡ Khoản cần thanh toán ngay -
- - - - + + + + Tài khoản đã kích hoạt + +
-
Loại hóa đơn
-
{{invoiceType}}
-
+ - - - - - - - -
+ + + - + + - + + - +
-
Số tiền
-
{{invoiceAmount}}
+
+
+ ISUMS · Quản lý nhà trọ +
+
+ Chào mừng bạn! 🎉 +
+
+ Tài khoản của bạn đã được kích hoạt thành công +
-
Hạn thanh toán
-
{{invoiceDueDate}}
+
+
+ Xin chào {{name}},
+ Chủ nhà đã kích hoạt tài khoản ISUMS cho bạn. + Dưới đây là thông tin đăng nhập tạm thời — vui lòng đổi mật khẩu ngay sau khi đăng nhập. +
+ + + + + + + + + + + + +
+
+ Thông tin đăng nhập +
+
+
Email
+
{{email}}
+
+
Mật khẩu tạm thời
+
{{password}}
+
+ + + {{#hasInvoice}} +
+
+ ⚡ Khoản cần thanh toán ngay +
+ + + + + + + + + + + + + +
+
Loại hóa đơn
+
{{invoiceType}}
+
+
Số tiền
+
{{invoiceAmount}}
+
+
Hạn thanh toán
+
{{invoiceDueDate}}
+
+ + Thanh toán ngay → + +
+
+ {{/hasInvoice}} + + +
+
+ 💡 Sau khi đăng nhập lần đầu, hệ thống sẽ yêu cầu bạn đổi mật khẩu mới.
+ Mọi hóa đơn và lịch sử thanh toán có thể xem trong ứng dụng ISUMS. +
+
+ +
+
+ Trân trọng,
Đội ngũ ISUMS +
- - Thanh toán ngay → - + +
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
- - {{/hasInvoice}} - - -
-
- 💡 Sau khi đăng nhập lần đầu, hệ thống sẽ yêu cầu bạn đổi mật khẩu mới.
- Mọi hóa đơn và lịch sử thanh toán có thể xem trong ứng dụng ISUMS. -
-
- -
-
- Trân trọng,
Đội ngũ ISUMS -
-
-
- Email này được gửi tự động. Vui lòng không trả lời trực tiếp. -
-
-
- - - """, +
+ + + """, """ - Xin chào {{name}}, - - Tài khoản ISUMS của bạn đã được kích hoạt. - - Thông tin đăng nhập: - - Email : {{email}} - - Mật khẩu: {{password}} - - {{#hasInvoice}} - Khoản cần thanh toán: - - Loại : {{invoiceType}} - - Số tiền : {{invoiceAmount}} - - Hạn TT : {{invoiceDueDate}} - - Link : {{invoicePaymentUrl}} - {{/hasInvoice}} - - Vui lòng đổi mật khẩu sau khi đăng nhập lần đầu. - - Trân trọng, - Đội ngũ ISUMS - """, + Xin chào {{name}}, + + Tài khoản ISUMS của bạn đã được kích hoạt. + + Thông tin đăng nhập: + - Email : {{email}} + - Mật khẩu: {{password}} + + {{#hasInvoice}} + Khoản cần thanh toán: + - Loại : {{invoiceType}} + - Số tiền : {{invoiceAmount}} + - Hạn TT : {{invoiceDueDate}} + - Link : {{invoicePaymentUrl}} + {{/hasInvoice}} + + Vui lòng đổi mật khẩu sau khi đăng nhập lần đầu. + + Trân trọng, + Đội ngũ ISUMS + """, List.of("name", "email", "password", "hasInvoice", "invoiceType", "invoiceAmount", "invoiceDueDate", "invoicePaymentUrl"), "system" @@ -832,75 +832,75 @@ public void seed(EmailTemplateRepository templateRepo, EmailTemplateVersionRepos "contract_completed", "CONTRACT", "TENANT", LocaleType.vi_VN, "Hợp đồng đã ký thành công — Tải về tại đây", """ - - - - - - -
- - - - - - - - - - -
-
- ISUMS · Quản lý nhà trọ -
-
- Hợp đồng đã hoàn tất ✅ -
-
- Cả hai bên đã ký điện tử thành công -
-
-
- Hợp đồng mã {{contractId}} - đã được ký bởi tất cả các bên và có hiệu lực pháp lý.
- Bạn có thể tải về bản gốc có chữ ký số tại đây: -
- -
-
- ✅ Hợp đồng này có giá trị pháp lý tương đương bản giấy theo quy định.
- 📎 Link tải sẽ hết hạn sau 7 ngày. Vui lòng lưu lại file PDF. -
-
-
-
- Trân trọng,
Đội ngũ ISUMS -
-
-
- Email này được gửi tự động. Vui lòng không trả lời trực tiếp. -
-
-
- - - """, + + + + + + +
+ + + + + + + + + + +
+
+ ISUMS · Quản lý nhà trọ +
+
+ Hợp đồng đã hoàn tất ✅ +
+
+ Cả hai bên đã ký điện tử thành công +
+
+
+ Hợp đồng mã {{contractId}} + đã được ký bởi tất cả các bên và có hiệu lực pháp lý.
+ Bạn có thể tải về bản gốc có chữ ký số tại đây: +
+ +
+
+ ✅ Hợp đồng này có giá trị pháp lý tương đương bản giấy theo quy định.
+ 📎 Link tải sẽ hết hạn sau 7 ngày. Vui lòng lưu lại file PDF. +
+
+
+
+ Trân trọng,
Đội ngũ ISUMS +
+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + + """, """ - Hợp đồng {{contractId}} đã được ký hoàn tất. - Tải về tại: {{signedPdfUrl}} - Link hết hạn sau 7 ngày. - """, + Hợp đồng {{contractId}} đã được ký hoàn tất. + Tải về tại: {{signedPdfUrl}} + Link hết hạn sau 7 ngày. + """, List.of("contractId", "signedPdfUrl"), "system" ); @@ -913,98 +913,98 @@ public void seed(EmailTemplateRepository templateRepo, EmailTemplateVersionRepos LocaleType.vi_VN, "Kiểm tra nhà hoàn tất — Hợp đồng #{{contractId}}", """ - - - - - -
- - - - - - - - - - -
-
- ✅ Kiểm tra nhà hoàn tất -
-
-

- Kính gửi {{managerName}}, -

-

- Nhân viên đã hoàn thành kiểm tra nhà cho hợp đồng - #{{contractId}}. -

- - - - - - - - - - - - - -
- Mã kiểm tra - - {{inspectionId}} -
- Số tiền khấu trừ đề xuất - - {{deductionAmount}} -
- Ghi chú - - {{notes}} -
-

- Vui lòng đăng nhập hệ thống để xem chi tiết và xác nhận - số tiền hoàn cọc cho khách. -

-
-
- Email này được gửi tự động. Vui lòng không trả lời trực tiếp. -
-
-
- - - """, + + + + + +
+ + + + + + + + + + +
+
+ ✅ Kiểm tra nhà hoàn tất +
+
+

+ Kính gửi {{managerName}}, +

+

+ Nhân viên đã hoàn thành kiểm tra nhà cho hợp đồng + #{{contractId}}. +

+ + + + + + + + + + + + + +
+ Mã kiểm tra + + {{inspectionId}} +
+ Số tiền khấu trừ đề xuất + + {{deductionAmount}} +
+ Ghi chú + + {{notes}} +
+

+ Vui lòng đăng nhập hệ thống để xem chi tiết và xác nhận + số tiền hoàn cọc cho khách. +

+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + + """, """ - Kính gửi {{managerName}}, - - Nhân viên đã hoàn thành kiểm tra nhà cho hợp đồng #{{contractId}}. - - Mã kiểm tra: {{inspectionId}} - Số tiền khấu trừ đề xuất: {{deductionAmount}} - Ghi chú: {{notes}} - - Vui lòng đăng nhập hệ thống để xác nhận hoàn cọc. - """, + Kính gửi {{managerName}}, + + Nhân viên đã hoàn thành kiểm tra nhà cho hợp đồng #{{contractId}}. + + Mã kiểm tra: {{inspectionId}} + Số tiền khấu trừ đề xuất: {{deductionAmount}} + Ghi chú: {{notes}} + + Vui lòng đăng nhập hệ thống để xác nhận hoàn cọc. + """, List.of("managerName", "contractId", "inspectionId", "houseId", "deductionAmount", "notes"), "system" @@ -1017,88 +1017,88 @@ public void seed(EmailTemplateRepository templateRepo, EmailTemplateVersionRepos LocaleType.vi_VN, "Hợp đồng #{{contractId}} đã hết hạn — Đã lên lịch kiểm tra nhà", """ - - - - - -
- - - - - - - - - - -
-
- 🔔 Hợp đồng hết hạn — Đã phân công kiểm tra nhà -
-
-

- Kính gửi {{managerName}}, -

-

- Hợp đồng #{{contractId}} của khách - {{tenantName}} đã hết hạn. -

-

- Hệ thống đã tự động tạo lịch kiểm tra nhà và phân công - nhân viên phụ trách. -

- - - - - - - - - -
- Mã kiểm tra - - {{inspectionId}} -
- Khách thuê - - {{tenantName}} -
-

- Vui lòng theo dõi tiến trình kiểm tra trên hệ thống. -

-
-
- Email này được gửi tự động. Vui lòng không trả lời trực tiếp. -
-
-
- - - """, + + + + + +
+ + + + + + + + + + +
+
+ 🔔 Hợp đồng hết hạn — Đã phân công kiểm tra nhà +
+
+

+ Kính gửi {{managerName}}, +

+

+ Hợp đồng #{{contractId}} của khách + {{tenantName}} đã hết hạn. +

+

+ Hệ thống đã tự động tạo lịch kiểm tra nhà và phân công + nhân viên phụ trách. +

+ + + + + + + + + +
+ Mã kiểm tra + + {{inspectionId}} +
+ Khách thuê + + {{tenantName}} +
+

+ Vui lòng theo dõi tiến trình kiểm tra trên hệ thống. +

+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + + """, """ - Kính gửi {{managerName}}, - - Hợp đồng #{{contractId}} của khách {{tenantName}} đã hết hạn. - - Mã kiểm tra: {{inspectionId}} - - Hệ thống đã tự động phân công nhân viên kiểm tra nhà. - Vui lòng theo dõi tiến trình trên hệ thống. - """, + Kính gửi {{managerName}}, + + Hợp đồng #{{contractId}} của khách {{tenantName}} đã hết hạn. + + Mã kiểm tra: {{inspectionId}} + + Hệ thống đã tự động phân công nhân viên kiểm tra nhà. + Vui lòng theo dõi tiến trình trên hệ thống. + """, List.of("managerName", "contractId", "tenantName", "houseId", "inspectionId"), "system" @@ -1111,64 +1111,64 @@ public void seed(EmailTemplateRepository templateRepo, EmailTemplateVersionRepos LocaleType.vi_VN, "Hợp đồng của bạn còn {{daysRemaining}} ngày — Bạn có muốn gia hạn?", """ - - - - - - -
- - - - - - - - - - -
-
- ⏰ Hợp đồng sắp hết hạn -
-
-

- Kính gửi {{tenantName}}, -

-

- Hợp đồng thuê nhà #{{contractId}} của bạn - {{#openForNew}} - đã hết hạn hôm nay. Phòng đã được mở cho khách mới đặt cọc. - {{/openForNew}} - {{^openForNew}} - còn {{daysRemaining}} ngày nữa sẽ hết hạn vào - {{endDate}}. - {{/openForNew}} -

-

- Nếu bạn muốn tiếp tục thuê, vui lòng liên hệ quản lý hoặc - bấm nút Gia hạn trong ứng dụng ISUMS. -

-
-
- Email này được gửi tự động. Vui lòng không trả lời trực tiếp. -
-
-
- - - """, + + + + + + +
+ + + + + + + + + + +
+
+ ⏰ Hợp đồng sắp hết hạn +
+
+

+ Kính gửi {{tenantName}}, +

+

+ Hợp đồng thuê nhà #{{contractId}} của bạn + {{#openForNew}} + đã hết hạn hôm nay. Phòng đã được mở cho khách mới đặt cọc. + {{/openForNew}} + {{^openForNew}} + còn {{daysRemaining}} ngày nữa sẽ hết hạn vào + {{endDate}}. + {{/openForNew}} +

+

+ Nếu bạn muốn tiếp tục thuê, vui lòng liên hệ quản lý hoặc + bấm nút Gia hạn trong ứng dụng ISUMS. +

+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + + """, """ - Kính gửi {{tenantName}}, - - Hợp đồng #{{contractId}} của bạn còn {{daysRemaining}} ngày (hết hạn {{endDate}}). - - Nếu muốn gia hạn, vui lòng liên hệ quản lý hoặc bấm Gia hạn trong app ISUMS. - """, + Kính gửi {{tenantName}}, + + Hợp đồng #{{contractId}} của bạn còn {{daysRemaining}} ngày (hết hạn {{endDate}}). + + Nếu muốn gia hạn, vui lòng liên hệ quản lý hoặc bấm Gia hạn trong app ISUMS. + """, List.of("tenantName", "contractId", "daysRemaining", "endDate", "openForNew"), "system" ); @@ -1180,85 +1180,85 @@ public void seed(EmailTemplateRepository templateRepo, EmailTemplateVersionRepos LocaleType.vi_VN, "Khách {{tenantName}} muốn gia hạn hợp đồng #{{contractId}}", """ - - - - - - -
- - - - - - - - - - -
-
- 🔔 Yêu cầu gia hạn hợp đồng -
-
-

- Kính gửi {{managerName}}, -

-

- Khách {{tenantName}} vừa gửi yêu cầu gia hạn - hợp đồng #{{contractId}}. -

- - - - - - - - - -
- Tình trạng cạnh tranh - - {{hasCompetingDeposit}} -
- Ghi chú của khách - - {{note}} -
-

- Vui lòng đăng nhập hệ thống để liên hệ khách và soạn hợp đồng mới nếu đồng ý. -

-
-
- Email này được gửi tự động. Vui lòng không trả lời trực tiếp. -
-
-
- - - """, + + + + + + +
+ + + + + + + + + + +
+
+ 🔔 Yêu cầu gia hạn hợp đồng +
+
+

+ Kính gửi {{managerName}}, +

+

+ Khách {{tenantName}} vừa gửi yêu cầu gia hạn + hợp đồng #{{contractId}}. +

+ + + + + + + + + +
+ Tình trạng cạnh tranh + + {{hasCompetingDeposit}} +
+ Ghi chú của khách + + {{note}} +
+

+ Vui lòng đăng nhập hệ thống để liên hệ khách và soạn hợp đồng mới nếu đồng ý. +

+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + + """, """ - Kính gửi {{managerName}}, - - Khách {{tenantName}} vừa gửi yêu cầu gia hạn hợp đồng #{{contractId}}. - - Tình trạng cạnh tranh: {{hasCompetingDeposit}} - Ghi chú: {{note}} - - Vui lòng đăng nhập hệ thống để xử lý. - """, + Kính gửi {{managerName}}, + + Khách {{tenantName}} vừa gửi yêu cầu gia hạn hợp đồng #{{contractId}}. + + Tình trạng cạnh tranh: {{hasCompetingDeposit}} + Ghi chú: {{note}} + + Vui lòng đăng nhập hệ thống để xử lý. + """, List.of("managerName", "tenantName", "contractId", "hasCompetingDeposit", "note"), "system" ); @@ -1270,76 +1270,370 @@ public void seed(EmailTemplateRepository templateRepo, EmailTemplateVersionRepos LocaleType.vi_VN, "Yêu cầu gia hạn hợp đồng #{{contractId}} không được chấp thuận", """ - - - - - - -
- - - - - - - - - - -
-
- ❌ Yêu cầu gia hạn không được chấp thuận -
-
-

- Kính gửi {{tenantName}}, -

-

- Rất tiếc, yêu cầu gia hạn hợp đồng #{{contractId}} - của bạn không được chấp thuận. -

- - - - - -
- Lý do - - {{reason}} -
-

- Nếu có thắc mắc, vui lòng liên hệ quản lý để được hỗ trợ. -

-
-
- Email này được gửi tự động. Vui lòng không trả lời trực tiếp. -
-
-
- - - """, + + + + + + +
+ + + + + + + + + + +
+
+ ❌ Yêu cầu gia hạn không được chấp thuận +
+
+

+ Kính gửi {{tenantName}}, +

+

+ Rất tiếc, yêu cầu gia hạn hợp đồng #{{contractId}} + của bạn không được chấp thuận. +

+ + + + + +
+ Lý do + + {{reason}} +
+

+ Nếu có thắc mắc, vui lòng liên hệ quản lý để được hỗ trợ. +

+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + + """, """ - Kính gửi {{tenantName}}, - - Yêu cầu gia hạn hợp đồng #{{contractId}} của bạn không được chấp thuận. - - Lý do: {{reason}} - - Nếu có thắc mắc, vui lòng liên hệ quản lý. - """, + Kính gửi {{tenantName}}, + + Yêu cầu gia hạn hợp đồng #{{contractId}} của bạn không được chấp thuận. + + Lý do: {{reason}} + + Nếu có thắc mắc, vui lòng liên hệ quản lý. + """, List.of("tenantName", "contractId", "reason"), "system" ); + // late_payment_reminder_day0 + upsertActiveV1(templateRepo, versionRepo, + "late_payment_reminder_day0", "PAYMENT", "TENANT", LocaleType.vi_VN, + "Nhắc nhở: Hóa đơn tiền thuê đến hạn hôm nay", + """ + + + + +
+ + + + +
+
+ 💳 Hóa đơn tiền thuê đến hạn +
+
+

+ Hóa đơn tiền thuê tháng này đến hạn thanh toán hôm nay + ({{dueDate}}). +

+

+ Số tiền: {{totalAmount}} +

+

+ Vui lòng thanh toán đúng hạn để tránh phát sinh phí phạt. +

+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + """, + "Hóa đơn tiền thuê tháng này đến hạn hôm nay ({{dueDate}}).\nSố tiền: {{totalAmount}}\nVui lòng thanh toán đúng hạn.", + List.of("totalAmount", "dueDate", "daysLate"), + "system" + ); + +// late_payment_reminder_day1 + upsertActiveV1(templateRepo, versionRepo, + "late_payment_reminder_day1", "PAYMENT", "TENANT", LocaleType.vi_VN, + "Nhắc lần 2: Hóa đơn tiền thuê quá hạn 1 ngày", + """ + + + + +
+ + + + +
+
+ ⚠️ Hóa đơn quá hạn 1 ngày +
+
+

+ Hóa đơn tiền thuê của bạn đã quá hạn 1 ngày. + Số tiền cần thanh toán: {{totalAmount}}. +

+

+ Sau 3 ngày quá hạn, hệ thống sẽ tự động áp dụng phí phạt trễ thanh toán. +

+
+
Email này được gửi tự động.
+
+
+ + """, + "Hóa đơn tiền thuê quá hạn 1 ngày. Số tiền: {{totalAmount}}. Thanh toán ngay để tránh phạt.", + List.of("totalAmount", "dueDate", "daysLate"), + "system" + ); + +// late_payment_reminder_day2 + upsertActiveV1(templateRepo, versionRepo, + "late_payment_reminder_day2", "PAYMENT", "TENANT", LocaleType.vi_VN, + "Cảnh báo: Hóa đơn tiền thuê quá hạn 2 ngày — còn 1 ngày trước khi bị phạt", + """ + + + + +
+ + + + +
+
+ 🚨 Còn 1 ngày trước khi bị phạt trễ thanh toán +
+
+

+ Hóa đơn tiền thuê của bạn đã quá hạn 2 ngày. + Số tiền: {{totalAmount}}. +

+

+ Nếu chưa thanh toán sau ngày mai, hệ thống sẽ áp dụng phí phạt 5% tiền thuê tháng. +

+
+
Email này được gửi tự động.
+
+
+ + """, + "CẢNH BÁO: Hóa đơn quá hạn 2 ngày. Còn 1 ngày trước khi bị phạt 5%. Số tiền: {{totalAmount}}.", + List.of("totalAmount", "dueDate", "daysLate"), + "system" + ); + +// late_payment_penalty_applied + upsertActiveV1(templateRepo, versionRepo, + "late_payment_penalty_applied", "PAYMENT", "TENANT", LocaleType.vi_VN, + "Thông báo: Áp dụng phí phạt trễ thanh toán {{penaltyPercent}}%", + """ + + + + +
+ + + + +
+
+ 💸 Phí phạt trễ thanh toán đã được áp dụng +
+
+

+ Do thanh toán trễ {{daysLate}} ngày, phí phạt + {{penaltyPercent}}% đã được áp dụng vào hóa đơn của bạn. +

+ + + + + + + + + +
Phí phạt{{penaltyAmount}}
Tổng cần thanh toán{{totalAmount}}
+

+ Vui lòng thanh toán ngay để tránh phát sinh thêm phí phạt. +

+
+
Email này được gửi tự động.
+
+
+ + """, + "Phí phạt {{penaltyPercent}}% đã được áp dụng do trễ {{daysLate}} ngày.\nPhí phạt: {{penaltyAmount}}\nTổng cần thanh toán: {{totalAmount}}", + List.of("penaltyPercent", "penaltyAmount", "totalAmount", "daysLate"), + "system" + ); + +// late_payment_formal_warning + upsertActiveV1(templateRepo, versionRepo, + "late_payment_formal_warning", "PAYMENT", "TENANT", LocaleType.vi_VN, + "Cảnh báo chính thức: Hóa đơn tiền thuê quá hạn 7 ngày — Tính năng app bị hạn chế", + """ + + + + +
+ + + + +
+
+ 🔒 Cảnh báo chính thức — Tài khoản bị hạn chế +
+
+

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

+

+ Tính năng ứng dụng của bạn đã bị hạn chế cho đến khi hoàn tất thanh toán. +

+

+ Nếu không thanh toán trong thời gian sớm, chủ nhà có quyền thực hiện + các biện pháp mạnh hơn theo quy định hợp đồng. +

+
+
Email này được gửi tự động.
+
+
+ + """, + "CẢNH BÁO CHÍNH THỨC: Hóa đơn quá hạn 7 ngày. Tài khoản bị hạn chế.\nTổng tiền: {{totalAmount}}\nVui lòng thanh toán ngay.", + List.of("totalAmount", "dueDate", "daysLate"), + "system" + ); + +// power_cut_warning_24h + 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ê", + """ + + + + +
+ + + + +
+
+ ⚡ Cảnh báo cắt điện sau 24 giờ +
+
+

+ Do chưa thanh toán tiền thuê, chủ nhà đã xác nhận cắt điện. + Điện sẽ bị cắt vào lúc {{executeAt}}. +

+

+ Vui lòng thanh toán ngay để tránh bị cắt điện. +

+

+ Đây là thông báo bắt buộc theo quy định hợp đồng thuê nhà. +

+
+
Email này được gửi tự động.
+
+
+ + """, + "CẢNH BÁO: Điện sẽ bị cắt vào {{executeAt}} do chưa thanh toán tiền thuê.\nVui lòng thanh toán ngay để tránh bị cắt điện.", + List.of("executeAt"), + "system" + ); + +// overdue_termination_notice + upsertActiveV1(templateRepo, versionRepo, + "overdue_termination_notice", "PAYMENT", "MANAGER", LocaleType.vi_VN, + "Thông báo: Khách {{tenantName}} trễ tiền thuê 30 ngày — Xem xét chấm dứt hợp đồng", + """ + + + + +
+ + + + +
+
+ 📋 Khách trễ tiền thuê 30 ngày +
+
+

+ Kính gửi {{managerName}}, +

+

+ Khách {{tenantName}} (Hợp đồng #{{contractId}}) đã + chậm thanh toán tiền thuê 30 ngày. +

+

+ Theo Luật Nhà ở 2023, bạn có quyền khởi động thủ tục chấm dứt hợp đồng. + Vui lòng đăng nhập hệ thống để xem xét và quyết định. +

+
+
Email này được gửi tự động.
+
+
+ + """, + "Kính gửi {{managerName}},\nKhách {{tenantName}} (HĐ #{{contractId}}) đã trễ tiền thuê 30 ngày.\nVui lòng đăng nhập hệ thống để xem xét chấm dứt hợp đồng.", + List.of("managerName", "tenantName", "contractId", "daysLate"), + "system" + ); + } private void upsertActiveV1( From 13db660bcfa947cc77a97c9ddb11459b5424ebc7 Mon Sep 17 00:00:00 2001 From: hoangtuzami Date: Fri, 10 Apr 2026 15:11:38 +0700 Subject: [PATCH 04/11] Comment out the Kafka listener for `payment.power-cut-requested` in `PaymentConsumer`. --- .../kafka/PaymentConsumer.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/main/java/com/isums/notificationservice/infrastructures/kafka/PaymentConsumer.java b/src/main/java/com/isums/notificationservice/infrastructures/kafka/PaymentConsumer.java index 3971563..a4768d0 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/kafka/PaymentConsumer.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/kafka/PaymentConsumer.java @@ -150,6 +150,42 @@ public void handleOverdueTerminationRequested( } } +// @KafkaListener(topics = "payment.power-cut-requested", groupId = "notification-group") +// public void handlePowerCutRequest( +// ConsumerRecord record, Acknowledgment ack) { +// +// String messageId = kafkaHelper.extractMessageId(record); +// try { +// if (idempotencyService.isDuplicate(messageId)) { +// ack.acknowledge(); +// return; +// } +// +// PowerCutRequestEvent event = objectMapper.readValue( +// record.value(), PowerCutRequestEvent.class); +// +// notificationService.send(event.getContractId(), +// NotificationCategory.PAYMENT_OVERDUE, +// "Tenant trễ tiền thuê 14 ngày — Xem xét cắt điện", +// "Khách thuê đã chậm thanh toán " + event.getDaysLate() +// + " ngày. Tổng tiền: " + formatVnd(event.getTotalAmount()) +// + ". Bấm xác nhận nếu muốn cắt điện.", +// "/contracts/" + event.getContractId() + "/power-cut", +// Map.of( +// "contractId", event.getContractId().toString(), +// "invoiceId", event.getInvoiceId().toString(), +// "daysLate", String.valueOf(event.getDaysLate()) +// ) +// ); +// +// idempotencyService.markProcessed(messageId); +// ack.acknowledge(); +// } catch (Exception e) { +// log.error("[Notification] handlePowerCutRequest failed: {}", e.getMessage(), e); +// throw new RuntimeException(e); +// } +// } + private String formatVnd(Long amount) { return NumberFormat.getNumberInstance(Locale.of("vi", "VN")).format(amount) + " ₫"; } From 68c69988cea96b9130842c0a1a16742555ef59bb Mon Sep 17 00:00:00 2001 From: hoangtuzami Date: Fri, 10 Apr 2026 16:22:29 +0700 Subject: [PATCH 05/11] Rename `ContractNotificationConsumer` to `ContractEventListener` for consistency with event naming conventions --- ...ractNotificationConsumer.java => ContractEventListener.java} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/main/java/com/isums/notificationservice/infrastructures/kafka/{ContractNotificationConsumer.java => ContractEventListener.java} (99%) diff --git a/src/main/java/com/isums/notificationservice/infrastructures/kafka/ContractNotificationConsumer.java b/src/main/java/com/isums/notificationservice/infrastructures/kafka/ContractEventListener.java similarity index 99% rename from src/main/java/com/isums/notificationservice/infrastructures/kafka/ContractNotificationConsumer.java rename to src/main/java/com/isums/notificationservice/infrastructures/kafka/ContractEventListener.java index cfa5161..0c7456c 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/kafka/ContractNotificationConsumer.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/kafka/ContractEventListener.java @@ -19,7 +19,7 @@ @Component @RequiredArgsConstructor @Slf4j -public class ContractNotificationConsumer { +public class ContractEventListener { private final ManagerNotificationService notificationService; private final ObjectMapper objectMapper; From f5dad9319c1a835be0a4a85a531f0042f2b2c9c3 Mon Sep 17 00:00:00 2001 From: hoangtuzami Date: Sat, 11 Apr 2026 11:19:03 +0700 Subject: [PATCH 06/11] Integrate OpenAPI with JWT security and configure Swagger UI. --- build.gradle | 1 + .../configurations/OpenApiConfig.java | 30 +++++++++++++++++++ .../OpenApiStripServersConfig.java | 18 +++++++++++ 3 files changed, 49 insertions(+) create mode 100644 src/main/java/com/isums/notificationservice/configurations/OpenApiConfig.java create mode 100644 src/main/java/com/isums/notificationservice/configurations/OpenApiStripServersConfig.java diff --git a/build.gradle b/build.gradle index b2faadc..ed3a2bd 100644 --- a/build.gradle +++ b/build.gradle @@ -65,6 +65,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-cache' implementation 'com.google.protobuf:protobuf-java:4.34.0-RC2' implementation "com.isums:proto-common:1.0-SNAPSHOT" + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2' testImplementation 'org.springframework.security:spring-security-test' compileOnly 'org.projectlombok:lombok' runtimeOnly 'org.postgresql:postgresql' diff --git a/src/main/java/com/isums/notificationservice/configurations/OpenApiConfig.java b/src/main/java/com/isums/notificationservice/configurations/OpenApiConfig.java new file mode 100644 index 0000000..89dc06a --- /dev/null +++ b/src/main/java/com/isums/notificationservice/configurations/OpenApiConfig.java @@ -0,0 +1,30 @@ +package com.isums.notificationservice.configurations; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + private static final String BEARER_SCHEME = "bearerAuth"; + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info() + .title("EContract Service API") + .version("v1") + .description("Có nhiều thứ rất là khó nói vậy nên là lá đò")) + .addSecurityItem(new SecurityRequirement().addList(BEARER_SCHEME)) + .components(new Components().addSecuritySchemes( + BEARER_SCHEME, + new SecurityScheme().name(BEARER_SCHEME).type(SecurityScheme.Type.HTTP) + .scheme("bearer").bearerFormat("JWT") + )); + } +} diff --git a/src/main/java/com/isums/notificationservice/configurations/OpenApiStripServersConfig.java b/src/main/java/com/isums/notificationservice/configurations/OpenApiStripServersConfig.java new file mode 100644 index 0000000..36bc4bf --- /dev/null +++ b/src/main/java/com/isums/notificationservice/configurations/OpenApiStripServersConfig.java @@ -0,0 +1,18 @@ +package com.isums.notificationservice.configurations; + +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class OpenApiStripServersConfig { + + @Bean + public OpenApiCustomizer relativeServers() { + return openApi -> openApi.setServers( + List.of(new io.swagger.v3.oas.models.servers.Server().url("")) + ); + } +} From 41ff1644fb675597cf0204fae1cedb2da04f1b3d Mon Sep 17 00:00:00 2001 From: hoangtuzami Date: Sat, 11 Apr 2026 11:21:03 +0700 Subject: [PATCH 07/11] Update security configuration to allow public access to Swagger UI and API documentation endpoints --- .../configurations/SecurityConfig.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/isums/notificationservice/configurations/SecurityConfig.java b/src/main/java/com/isums/notificationservice/configurations/SecurityConfig.java index e15c38b..97171a3 100644 --- a/src/main/java/com/isums/notificationservice/configurations/SecurityConfig.java +++ b/src/main/java/com/isums/notificationservice/configurations/SecurityConfig.java @@ -14,7 +14,17 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth - .requestMatchers("/actuator/prometheus").permitAll() + .requestMatchers( + "/actuator/prometheus", + "/api/notifications/v3/api-docs", + "/api/notifications/v3/api-docs/**", + "/api/notifications/swagger", + "/api/notifications/swagger/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs", + "/v3/api-docs/**" + ).permitAll() .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2 From 817e7042e4be66ed7cb8b3b99aa41a30d135f822 Mon Sep 17 00:00:00 2001 From: hoangtuzami Date: Mon, 13 Apr 2026 12:04:18 +0700 Subject: [PATCH 08/11] Add extensive unit tests for Kafka listeners, email services, and global exception handling; remove redundant custom `IllegalStateException`. --- .../exceptions/GlobalExceptionHandler.java | 4 +- .../exceptions/IllegalStateException.java | 7 - .../listeners/EContractEventListener.java | 8 +- .../listeners/PaymentEventListener.java | 2 +- .../EmailTemplateVersionRepository.java | 2 +- .../seeders/EmailTemplateSeeder.java | 90 +++++++- .../NotificationServiceApplicationTests.java | 3 +- .../ManagerNotificationControllerTest.java | 145 ++++++++++++ .../GlobalExceptionHandlerTest.java | 87 +++++++ .../Websockets/SseConnectionManagerTest.java | 56 +++++ .../kafka/ContractEventListenerTest.java | 121 ++++++++++ .../kafka/PaymentConsumerTest.java | 156 +++++++++++++ .../listeners/EContractEventListenerTest.java | 213 ++++++++++++++++++ .../listeners/PaymentEventListenerTest.java | 132 +++++++++++ .../listeners/UserEventListenerTest.java | 184 +++++++++++++++ .../services/EmailServiceImplTest.java | 116 ++++++++++ .../services/EmailTemplateServiceTest.java | 176 +++++++++++++++ .../ManagerNotificationServiceImplTest.java | 148 ++++++++++++ 18 files changed, 1624 insertions(+), 26 deletions(-) delete mode 100644 src/main/java/com/isums/notificationservice/exceptions/IllegalStateException.java create mode 100644 src/test/java/com/isums/notificationservice/controllers/ManagerNotificationControllerTest.java create mode 100644 src/test/java/com/isums/notificationservice/exceptions/GlobalExceptionHandlerTest.java create mode 100644 src/test/java/com/isums/notificationservice/infrastructures/Websockets/SseConnectionManagerTest.java create mode 100644 src/test/java/com/isums/notificationservice/infrastructures/kafka/ContractEventListenerTest.java create mode 100644 src/test/java/com/isums/notificationservice/infrastructures/kafka/PaymentConsumerTest.java create mode 100644 src/test/java/com/isums/notificationservice/infrastructures/listeners/EContractEventListenerTest.java create mode 100644 src/test/java/com/isums/notificationservice/infrastructures/listeners/PaymentEventListenerTest.java create mode 100644 src/test/java/com/isums/notificationservice/infrastructures/listeners/UserEventListenerTest.java create mode 100644 src/test/java/com/isums/notificationservice/services/EmailServiceImplTest.java create mode 100644 src/test/java/com/isums/notificationservice/services/EmailTemplateServiceTest.java create mode 100644 src/test/java/com/isums/notificationservice/services/ManagerNotificationServiceImplTest.java diff --git a/src/main/java/com/isums/notificationservice/exceptions/GlobalExceptionHandler.java b/src/main/java/com/isums/notificationservice/exceptions/GlobalExceptionHandler.java index 2065820..cfbe728 100644 --- a/src/main/java/com/isums/notificationservice/exceptions/GlobalExceptionHandler.java +++ b/src/main/java/com/isums/notificationservice/exceptions/GlobalExceptionHandler.java @@ -42,8 +42,8 @@ public ResponseEntity> handleNotFoundException(NotFoundExcepti .body(ApiResponses.fail(HttpStatus.NOT_FOUND, ex.getMessage())); } - @ExceptionHandler(IllegalStateException.class) - public ResponseEntity> handleIllegalStateException(IllegalStateException ex) { + @ExceptionHandler(java.lang.IllegalStateException.class) + public ResponseEntity> handleIllegalStateException(java.lang.IllegalStateException ex) { return ResponseEntity .status(HttpStatus.INTERNAL_SERVER_ERROR) .body(ApiResponses.fail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage())); diff --git a/src/main/java/com/isums/notificationservice/exceptions/IllegalStateException.java b/src/main/java/com/isums/notificationservice/exceptions/IllegalStateException.java deleted file mode 100644 index 7d39e3d..0000000 --- a/src/main/java/com/isums/notificationservice/exceptions/IllegalStateException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.isums.notificationservice.exceptions; - -public class IllegalStateException extends RuntimeException { - public IllegalStateException(String message) { - super(message); - } -} 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 d523dae..be2131c 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/listeners/EContractEventListener.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/listeners/EContractEventListener.java @@ -40,12 +40,14 @@ public class EContractEventListener { @KafkaListener(topics = "confirmAndSendToTenant-topic", groupId = "notification-group") public void handleConfirmAndSendToTenant(ConsumerRecord record, Acknowledgment ack) { - - ConfirmAndSendToTenantEvent event = objectMapper.readValue(record.value(), ConfirmAndSendToTenantEvent.class); - String messageId = event.getMessageId(); + String messageId = kafkaHelper.extractMessageId(record); kafkaHelper.setupMDC(record, messageId); try { + ConfirmAndSendToTenantEvent event = objectMapper.readValue( + record.value(), ConfirmAndSendToTenantEvent.class); + if (event.getMessageId() != null) messageId = event.getMessageId(); + if (idempotencyService.isDuplicate(messageId)) { log.warn("[EContract] Duplicate skipped messageId={}", messageId); ack.acknowledge(); 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 9c73dde..255633a 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/listeners/PaymentEventListener.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/listeners/PaymentEventListener.java @@ -100,7 +100,7 @@ private String translateType(String type) { private String formatVnd(Long amount) { if (amount == null) return "0 ₫"; - return NumberFormat.getNumberInstance(new Locale("vi", "VN")).format(amount) + " ₫"; + return NumberFormat.getNumberInstance(Locale.of("vi", "VN")).format(amount) + " ₫"; } private String safe(String s, String fb) { diff --git a/src/main/java/com/isums/notificationservice/infrastructures/repositories/EmailTemplateVersionRepository.java b/src/main/java/com/isums/notificationservice/infrastructures/repositories/EmailTemplateVersionRepository.java index ee6074e..f4d2246 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/repositories/EmailTemplateVersionRepository.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/repositories/EmailTemplateVersionRepository.java @@ -3,8 +3,8 @@ import com.isums.notificationservice.domains.entities.EmailTemplateVersion; import com.isums.notificationservice.domains.enums.LocaleType; import com.isums.notificationservice.domains.enums.TemplateStatus; -import io.lettuce.core.dynamic.annotation.Param; import jakarta.persistence.LockModeType; +import org.springframework.data.repository.query.Param; import jakarta.persistence.QueryHint; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; 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 c0d3414..36861fe 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/seeders/EmailTemplateSeeder.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/seeders/EmailTemplateSeeder.java @@ -40,22 +40,90 @@ public void seed(EmailTemplateRepository templateRepo, EmailTemplateVersionRepos templateRepo, versionRepo, "welcome", "ONBOARDING", "CUSTOMER", LocaleType.vi_VN, - "Chao mung {{name}} den voi ISUMS", + "Chào mừng {{name}} đến với ISUMS", """ - - -

Chao mung {{name}}!

-

Tai khoan cua ban da duoc kich hoat.

-

Bat dau tai: {{appUrl}}

-

Ho tro: {{supportEmail}}

- + + + + + Chào mừng đến với ISUMS + + +
+ Chào mừng {{name}}! Tài khoản ISUMS của bạn đã sẵn sàng. +
+ + + + + +
+ + + + + + + + + + + + + + +
+
+ 🎉 Chào mừng đến với ISUMS +
+
+ Hệ thống quản lý nhà trọ thông minh +
+
+

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

+

+ Tài khoản ISUMS của bạn đã được kích hoạt thành công. Bắt đầu hành trình + quản lý nhà trọ tiện lợi ngay hôm nay. +

+ + + + + +
+ + Truy cập ISUMS + +
+ +

+ 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:
+ {{appUrl}} +

+
+ Cần hỗ trợ? Liên hệ {{supportEmail}}
+ Email này được gửi tự động, vui lòng không trả lời. +
+
+ """, """ - Chao mung {{name}}! - Bat dau tai: {{appUrl}} - Ho tro: {{supportEmail}} + Xin chào {{name}}, + + Tài khoản ISUMS của bạn đã được kích hoạt thành công. + Truy cập ngay: {{appUrl}} + + Cần hỗ trợ? Liên hệ {{supportEmail}} """, List.of("name", "appUrl", "supportEmail"), "system" diff --git a/src/test/java/com/isums/notificationservice/NotificationServiceApplicationTests.java b/src/test/java/com/isums/notificationservice/NotificationServiceApplicationTests.java index b1643fb..5d69120 100644 --- a/src/test/java/com/isums/notificationservice/NotificationServiceApplicationTests.java +++ b/src/test/java/com/isums/notificationservice/NotificationServiceApplicationTests.java @@ -1,13 +1,14 @@ package com.isums.notificationservice; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest +@Disabled("Requires Postgres/Kafka/Redis/SMTP/gRPC infrastructure; run as integration test with Testcontainers") class NotificationServiceApplicationTests { @Test void contextLoads() { } - } diff --git a/src/test/java/com/isums/notificationservice/controllers/ManagerNotificationControllerTest.java b/src/test/java/com/isums/notificationservice/controllers/ManagerNotificationControllerTest.java new file mode 100644 index 0000000..3815388 --- /dev/null +++ b/src/test/java/com/isums/notificationservice/controllers/ManagerNotificationControllerTest.java @@ -0,0 +1,145 @@ +package com.isums.notificationservice.controllers; + +import com.isums.notificationservice.domains.dtos.NotificationDto; +import com.isums.notificationservice.domains.enums.NotificationCategory; +import com.isums.notificationservice.exceptions.GlobalExceptionHandler; +import com.isums.notificationservice.exceptions.NotFoundException; +import com.isums.notificationservice.infrastructures.Websockets.SseConnectionManager; +import com.isums.notificationservice.infrastructures.abstracts.ManagerNotificationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ManagerNotificationController") +class ManagerNotificationControllerTest { + + @Mock private ManagerNotificationService service; + @Mock private SseConnectionManager sseManager; + + @InjectMocks private ManagerNotificationController controller; + + private MockMvc mvc; + private UUID userId; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + Jwt jwt = Jwt.withTokenValue("t").header("alg", "none").subject(userId.toString()).build(); + + HandlerMethodArgumentResolver jwtResolver = new HandlerMethodArgumentResolver() { + @Override public boolean supportsParameter(MethodParameter p) { + return Jwt.class.equals(p.getParameterType()); + } + @Override public Object resolveArgument(MethodParameter p, ModelAndViewContainer m, + NativeWebRequest w, WebDataBinderFactory b) { return jwt; } + }; + + mvc = MockMvcBuilders.standaloneSetup(controller) + .setCustomArgumentResolvers(jwtResolver) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + @DisplayName("GET / delegates to service with caller UUID and PageRequest") + void list() throws Exception { + NotificationDto dto = NotificationDto.builder() + .id(UUID.randomUUID()).category("CONTRACT_EXPIRED") + .title("t").body("b").isRead(false).createdAt(Instant.now()).build(); + when(service.getByRecipient(any(UUID.class), any())) + .thenReturn(new PageImpl<>(List.of(dto))); + + // Spring Boot 4 no longer guarantees a stable JSON contract for Page in REST + // responses (known deprecation). We only assert the controller delegates correctly. + mvc.perform(get("/api/notifications/manager")); + + verify(service).getByRecipient(any(UUID.class), any()); + } + + @Test + @DisplayName("GET /unread-count returns {count: N}") + void unreadCount() throws Exception { + when(service.countUnread(userId)).thenReturn(3L); + + mvc.perform(get("/api/notifications/manager/unread-count")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.count").value(3)); + } + + @Test + @DisplayName("PUT /{id}/read marks notification read") + void markRead() throws Exception { + UUID id = UUID.randomUUID(); + + mvc.perform(put("/api/notifications/manager/{id}/read", id)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Marked as read")); + + verify(service).markRead(id, userId); + } + + @Test + @DisplayName("PUT /{id}/read returns 404 when notification missing") + void markReadNotFound() throws Exception { + UUID id = UUID.randomUUID(); + doThrow(new NotFoundException("not found")) + .when(service).markRead(id, userId); + + mvc.perform(put("/api/notifications/manager/{id}/read", id)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("PUT /read-all marks all notifications read") + void markAllRead() throws Exception { + mvc.perform(put("/api/notifications/manager/read-all")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("All marked as read")); + + verify(service).markAllRead(userId); + } + + @Test + @DisplayName("GET /stream subscribes via SSE and sends initial unread_count") + void stream() throws Exception { + SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); + when(sseManager.subscribe(userId)).thenReturn(emitter); + when(service.countUnread(userId)).thenReturn(5L); + + mvc.perform(get("/api/notifications/manager/stream")) + .andExpect(status().isOk()); + + verify(sseManager).subscribe(userId); + verify(service).countUnread(userId); + } +} diff --git a/src/test/java/com/isums/notificationservice/exceptions/GlobalExceptionHandlerTest.java b/src/test/java/com/isums/notificationservice/exceptions/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..503af9e --- /dev/null +++ b/src/test/java/com/isums/notificationservice/exceptions/GlobalExceptionHandlerTest.java @@ -0,0 +1,87 @@ +package com.isums.notificationservice.exceptions; + +import com.isums.notificationservice.domains.dtos.ApiResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataAccessException; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClientResponseException; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("GlobalExceptionHandler (notification-service)") +class GlobalExceptionHandlerTest { + + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); + + @Test + @DisplayName("handleNotFoundException returns 404") + void notFound() { + ResponseEntity> res = + handler.handleNotFoundException(new NotFoundException("missing")); + assertThat(res.getStatusCode().value()).isEqualTo(404); + assertThat(res.getBody().getMessage()).isEqualTo("missing"); + } + + @Test + @DisplayName("handleIllegalStateException returns 500 (fix: JDK class)") + void illegalState() { + ResponseEntity> res = + handler.handleIllegalStateException(new IllegalStateException("boom")); + assertThat(res.getStatusCode().value()).isEqualTo(500); + assertThat(res.getBody().getMessage()).isEqualTo("boom"); + } + + @Test + @DisplayName("handleBadRequest returns 400") + void badRequest() { + ResponseEntity> res = + handler.handleBadRequest(new IllegalArgumentException("bad")); + assertThat(res.getStatusCode().value()).isEqualTo(400); + assertThat(res.getBody().getErrors().get(0).getCode()).isEqualTo("BAD_REQUEST"); + } + + @Test + @DisplayName("handleConflict returns 409") + void conflict() { + ResponseEntity> res = + handler.handleConflict(new ConflictException("dup")); + assertThat(res.getStatusCode().value()).isEqualTo(409); + } + + @Test + @DisplayName("handleRestClient mirrors upstream status") + void restClient() { + RestClientResponseException ex = new HttpClientErrorException(HttpStatus.UNPROCESSABLE_ENTITY, "unp"); + ResponseEntity> res = handler.handleRestClient(ex); + assertThat(res.getStatusCode().value()).isEqualTo(422); + } + + @Test + @DisplayName("handleRestClient falls back to 502 for non-standard status") + void restClientFallback() { + RestClientResponseException ex = new RestClientResponseException( + "weird", HttpStatusCode.valueOf(599), "server", null, null, null); + assertThat(handler.handleRestClient(ex).getStatusCode().value()).isEqualTo(502); + } + + @Test + @DisplayName("handleDb returns 500 with DB_ERROR code") + void db() { + DataAccessException ex = new DataAccessException("outer", new RuntimeException("root")) {}; + ResponseEntity> res = handler.handleDb(ex); + assertThat(res.getStatusCode().value()).isEqualTo(500); + assertThat(res.getBody().getErrors().get(0).getMessage()).isEqualTo("root"); + } + + @Test + @DisplayName("handleGeneric returns 500 with sanitized message") + void generic() { + ResponseEntity> res = handler.handleGeneric(new Exception("sensitive")); + assertThat(res.getStatusCode().value()).isEqualTo(500); + assertThat(res.getBody().getMessage()).isEqualTo("Unexpected error"); + } +} diff --git a/src/test/java/com/isums/notificationservice/infrastructures/Websockets/SseConnectionManagerTest.java b/src/test/java/com/isums/notificationservice/infrastructures/Websockets/SseConnectionManagerTest.java new file mode 100644 index 0000000..3ec0b30 --- /dev/null +++ b/src/test/java/com/isums/notificationservice/infrastructures/Websockets/SseConnectionManagerTest.java @@ -0,0 +1,56 @@ +package com.isums.notificationservice.infrastructures.Websockets; + +import com.isums.notificationservice.domains.entities.ManagerNotification; +import com.isums.notificationservice.domains.enums.NotificationCategory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.time.Instant; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("SseConnectionManager") +class SseConnectionManagerTest { + + private final SseConnectionManager manager = new SseConnectionManager(); + + private ManagerNotification notif(UUID recipientId) { + return ManagerNotification.builder() + .id(UUID.randomUUID()).recipientId(recipientId) + .category(NotificationCategory.PAYMENT_OVERDUE) + .title("t").body("b") + .createdAt(Instant.now()).build(); + } + + @Test + @DisplayName("subscribe returns an emitter and push delivers to connected clients") + void subscribeAndPush() throws IOException { + UUID recipientId = UUID.randomUUID(); + SseEmitter emitter = manager.subscribe(recipientId); + + assertThat(emitter).isNotNull(); + // push should not throw; emitter receives the event quietly + manager.push(recipientId, notif(recipientId)); + } + + @Test + @DisplayName("push is no-op when recipient has no emitters") + void pushNoEmitters() { + UUID recipientId = UUID.randomUUID(); + manager.push(recipientId, notif(recipientId)); + } + + @Test + @DisplayName("each subscribe adds a separate emitter; push reaches all") + void multipleSubscribers() { + UUID recipientId = UUID.randomUUID(); + SseEmitter e1 = manager.subscribe(recipientId); + SseEmitter e2 = manager.subscribe(recipientId); + + assertThat(e1).isNotSameAs(e2); + manager.push(recipientId, notif(recipientId)); + } +} diff --git a/src/test/java/com/isums/notificationservice/infrastructures/kafka/ContractEventListenerTest.java b/src/test/java/com/isums/notificationservice/infrastructures/kafka/ContractEventListenerTest.java new file mode 100644 index 0000000..c12b3cc --- /dev/null +++ b/src/test/java/com/isums/notificationservice/infrastructures/kafka/ContractEventListenerTest.java @@ -0,0 +1,121 @@ +package com.isums.notificationservice.infrastructures.kafka; + +import com.isums.notificationservice.domains.enums.NotificationCategory; +import com.isums.notificationservice.domains.events.InspectionDoneNotifyEvent; +import com.isums.notificationservice.domains.events.InspectionScheduledEvent; +import com.isums.notificationservice.infrastructures.abstracts.ManagerNotificationService; +import common.kafkas.IdempotencyService; +import common.kafkas.KafkaListenerHelper; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.kafka.support.Acknowledgment; +import tools.jackson.databind.ObjectMapper; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ContractEventListener (notification-service)") +class ContractEventListenerTest { + + @Mock private ManagerNotificationService notificationService; + @Mock private ObjectMapper objectMapper; + @Mock private IdempotencyService idempotencyService; + @Mock private KafkaListenerHelper kafkaHelper; + @Mock private Acknowledgment ack; + + @InjectMocks private ContractEventListener listener; + + @Nested + @DisplayName("handleInspectionScheduled") + class Scheduled { + + private ConsumerRecord rec = + new ConsumerRecord<>("contract.inspection.scheduled", 0, 0L, "k", "v"); + + @Test + @DisplayName("sends CONTRACT_EXPIRED notification on happy path") + void happy() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + InspectionScheduledEvent event = new InspectionScheduledEvent( + UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), "Alice", "m1"); + when(objectMapper.readValue("v", InspectionScheduledEvent.class)).thenReturn(event); + + listener.handleInspectionScheduled(rec, ack); + + ArgumentCaptor cap = ArgumentCaptor.forClass(NotificationCategory.class); + verify(notificationService).send(eq(event.getManagerId()), cap.capture(), + anyString(), anyString(), anyString(), any(Map.class)); + assertThat(cap.getValue()).isEqualTo(NotificationCategory.CONTRACT_EXPIRED); + verify(ack).acknowledge(); + } + + @Test + @DisplayName("skips-and-acks when duplicate") + void duplicate() { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(true); + + listener.handleInspectionScheduled(rec, ack); + + verify(ack).acknowledge(); + verifyNoInteractions(notificationService); + } + + @Test + @DisplayName("rethrows for retry on failure") + void retry() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + when(objectMapper.readValue(any(String.class), eq(InspectionScheduledEvent.class))) + .thenThrow(new RuntimeException("bad")); + + assertThatThrownBy(() -> listener.handleInspectionScheduled(rec, ack)) + .isInstanceOf(RuntimeException.class); + verify(ack, never()).acknowledge(); + } + } + + @Nested + @DisplayName("handleInspectionDone") + class Done { + + private ConsumerRecord rec = + new ConsumerRecord<>("contract.inspection.done", 0, 0L, "k", "v"); + + @Test + @DisplayName("sends INSPECTION_DONE notification on happy path") + void happy() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + InspectionDoneNotifyEvent event = new InspectionDoneNotifyEvent( + UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), 100_000L, "m1"); + when(objectMapper.readValue("v", InspectionDoneNotifyEvent.class)).thenReturn(event); + + listener.handleInspectionDone(rec, ack); + + verify(notificationService).send(eq(event.getManagerId()), + eq(NotificationCategory.INSPECTION_DONE), + anyString(), anyString(), anyString(), any(Map.class)); + verify(ack).acknowledge(); + } + } +} diff --git a/src/test/java/com/isums/notificationservice/infrastructures/kafka/PaymentConsumerTest.java b/src/test/java/com/isums/notificationservice/infrastructures/kafka/PaymentConsumerTest.java new file mode 100644 index 0000000..c778ee2 --- /dev/null +++ b/src/test/java/com/isums/notificationservice/infrastructures/kafka/PaymentConsumerTest.java @@ -0,0 +1,156 @@ +package com.isums.notificationservice.infrastructures.kafka; + +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.domains.enums.NotificationCategory; +import com.isums.notificationservice.domains.events.OverdueTerminationRequestedEvent; +import com.isums.notificationservice.domains.events.PowerCutConfirmedEvent; +import com.isums.notificationservice.domains.events.PowerCutReviewRequestedEvent; +import com.isums.notificationservice.infrastructures.abstracts.EmailService; +import com.isums.notificationservice.infrastructures.abstracts.ManagerNotificationService; +import com.isums.notificationservice.infrastructures.grpcs.UserGrpcClient; +import com.isums.userservice.grpc.UserResponse; +import common.kafkas.IdempotencyService; +import common.kafkas.KafkaListenerHelper; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.kafka.support.Acknowledgment; +import tools.jackson.databind.ObjectMapper; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("PaymentConsumer") +class PaymentConsumerTest { + + @Mock private ManagerNotificationService notificationService; + @Mock private UserGrpcClient userGrpcClient; + @Mock private EmailService emailService; + @Mock private IdempotencyService idempotencyService; + @Mock private KafkaListenerHelper kafkaHelper; + @Mock private ObjectMapper objectMapper; + @Mock private Acknowledgment ack; + + @InjectMocks private PaymentConsumer consumer; + + @Nested + @DisplayName("handlePowerCutConfirmed") + class PowerCutConfirmed { + + private ConsumerRecord rec = + new ConsumerRecord<>("contract.power-cut-confirmed", 0, 0L, "k", "v"); + + @Test + @DisplayName("sends power_cut_warning_24h email") + void happy() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + PowerCutConfirmedEvent event = new PowerCutConfirmedEvent( + UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), + UUID.randomUUID(), Instant.now().plusSeconds(86400), "m1"); + when(objectMapper.readValue("v", PowerCutConfirmedEvent.class)).thenReturn(event); + UserResponse tenant = UserResponse.newBuilder() + .setId(event.getTenantId().toString()).setEmail("a@b.com").build(); + when(userGrpcClient.getUserById(event.getTenantId())).thenReturn(tenant); + + consumer.handlePowerCutConfirmed(rec, ack); + + verify(emailService).sendEmail(eq("a@b.com"), eq("power_cut_warning_24h"), + eq(LocaleType.vi_VN), any(Map.class)); + verify(ack).acknowledge(); + } + + @Test + @DisplayName("skips when duplicate") + void duplicate() { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(true); + + consumer.handlePowerCutConfirmed(rec, ack); + + verify(ack).acknowledge(); + verifyNoInteractions(emailService); + } + + @Test + @DisplayName("rethrows for retry on failure") + void retry() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + when(objectMapper.readValue(any(String.class), eq(PowerCutConfirmedEvent.class))) + .thenThrow(new RuntimeException("bad")); + + assertThatThrownBy(() -> consumer.handlePowerCutConfirmed(rec, ack)) + .isInstanceOf(RuntimeException.class); + verify(ack, never()).acknowledge(); + } + } + + @Nested + @DisplayName("handlePowerCutReviewRequested") + class ReviewRequested { + + private ConsumerRecord rec = + new ConsumerRecord<>("contract.power-cut-review-requested", 0, 0L, "k", "v"); + + @Test + @DisplayName("sends manager PAYMENT_OVERDUE notification") + void happy() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + PowerCutReviewRequestedEvent event = PowerCutReviewRequestedEvent.builder() + .contractId(UUID.randomUUID()).houseId(UUID.randomUUID()) + .managerId(UUID.randomUUID()).tenantName("Alice") + .daysLate(15).totalAmount(5_000_000L).messageId("m1").build(); + when(objectMapper.readValue("v", PowerCutReviewRequestedEvent.class)).thenReturn(event); + + consumer.handlePowerCutReviewRequested(rec, ack); + + verify(notificationService).send(eq(event.getManagerId()), + eq(NotificationCategory.PAYMENT_OVERDUE), + anyString(), anyString(), anyString(), any(Map.class)); + verify(ack).acknowledge(); + } + } + + @Nested + @DisplayName("handleOverdueTerminationRequested") + class Overdue { + + private ConsumerRecord rec = + new ConsumerRecord<>("contract.termination-overdue-requested", 0, 0L, "k", "v"); + + @Test + @DisplayName("sends manager PAYMENT_OVERDUE notification") + void happy() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + OverdueTerminationRequestedEvent event = new OverdueTerminationRequestedEvent( + UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), "Bob", "m1"); + when(objectMapper.readValue("v", OverdueTerminationRequestedEvent.class)).thenReturn(event); + + consumer.handleOverdueTerminationRequested(rec, ack); + + verify(notificationService).send(eq(event.getManagerId()), + eq(NotificationCategory.PAYMENT_OVERDUE), + anyString(), anyString(), anyString(), any(Map.class)); + verify(ack).acknowledge(); + } + } +} diff --git a/src/test/java/com/isums/notificationservice/infrastructures/listeners/EContractEventListenerTest.java b/src/test/java/com/isums/notificationservice/infrastructures/listeners/EContractEventListenerTest.java new file mode 100644 index 0000000..fbd547d --- /dev/null +++ b/src/test/java/com/isums/notificationservice/infrastructures/listeners/EContractEventListenerTest.java @@ -0,0 +1,213 @@ +package com.isums.notificationservice.infrastructures.listeners; + +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.domains.events.ConfirmAndSendToTenantEvent; +import com.isums.notificationservice.domains.events.RenewalReminderEvent; +import com.isums.notificationservice.infrastructures.abstracts.EmailService; +import com.isums.notificationservice.infrastructures.grpcs.UserGrpcClient; +import com.isums.userservice.grpc.UserResponse; +import common.kafkas.IdempotencyService; +import common.kafkas.KafkaListenerHelper; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.kafka.support.Acknowledgment; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +import java.time.Instant; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EContractEventListener") +class EContractEventListenerTest { + + @Mock private EmailService emailService; + @Mock private UserGrpcClient userGrpcClient; + @Mock private IdempotencyService idempotencyService; + @Mock private KafkaListenerHelper kafkaHelper; + @Mock private ObjectMapper objectMapper; + @Mock private Acknowledgment ack; + + @InjectMocks private EContractEventListener listener; + + @Nested + @DisplayName("handleConfirmAndSendToTenant") + class Confirm { + + private ConsumerRecord rec = + new ConsumerRecord<>("confirmAndSendToTenant-topic", 0, 0L, "k", "v"); + + private ConfirmAndSendToTenantEvent event() { + return ConfirmAndSendToTenantEvent.builder() + .messageId("m1") + .recipientUserId(UUID.randomUUID()) + .contractId(UUID.randomUUID()) + .contractName("HD") + .url("https://view.example/pdf") + .confirmUrl("https://confirm.example") + .startDate(Instant.now()) + .endDate(Instant.now().plusSeconds(86400 * 30)) + .build(); + } + + @Test + @DisplayName("sends econtract_view_confirm email on happy path") + void happy() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + ConfirmAndSendToTenantEvent event = event(); + when(objectMapper.readValue("v", ConfirmAndSendToTenantEvent.class)).thenReturn(event); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + UserResponse user = UserResponse.newBuilder() + .setId(event.getRecipientUserId().toString()) + .setEmail("alice@example.com").setName("Alice").build(); + when(userGrpcClient.getUserById(event.getRecipientUserId())).thenReturn(user); + + listener.handleConfirmAndSendToTenant(rec, ack); + + verify(emailService).sendEmail(eq("alice@example.com"), eq("econtract_view_confirm"), + eq(LocaleType.vi_VN), any()); + verify(ack).acknowledge(); + } + + @Test + @DisplayName("acks on JacksonException (poison pill — bug fix: deserialize is now inside try)") + void jackson() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(objectMapper.readValue(any(String.class), eq(ConfirmAndSendToTenantEvent.class))) + .thenThrow(new JacksonException("bad") {}); + + listener.handleConfirmAndSendToTenant(rec, ack); + + verify(ack).acknowledge(); + verifyNoInteractions(emailService); + } + + @Test + @DisplayName("skips-and-acks when duplicate") + void duplicate() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(objectMapper.readValue("v", ConfirmAndSendToTenantEvent.class)).thenReturn(event()); + when(idempotencyService.isDuplicate("m1")).thenReturn(true); + + listener.handleConfirmAndSendToTenant(rec, ack); + + verify(ack).acknowledge(); + verifyNoInteractions(userGrpcClient, emailService); + } + + @Test + @DisplayName("skips when recipientUserId null") + void noRecipient() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + ConfirmAndSendToTenantEvent e = event(); + e.setRecipientUserId(null); + when(objectMapper.readValue("v", ConfirmAndSendToTenantEvent.class)).thenReturn(e); + when(idempotencyService.isDuplicate(any())).thenReturn(false); + + listener.handleConfirmAndSendToTenant(rec, ack); + + verify(ack).acknowledge(); + verifyNoInteractions(userGrpcClient, emailService); + } + + @Test + @DisplayName("skips when url blank") + void noUrl() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + ConfirmAndSendToTenantEvent e = event(); + e.setUrl(""); + when(objectMapper.readValue("v", ConfirmAndSendToTenantEvent.class)).thenReturn(e); + when(idempotencyService.isDuplicate(any())).thenReturn(false); + + listener.handleConfirmAndSendToTenant(rec, ack); + + verify(ack).acknowledge(); + verifyNoInteractions(userGrpcClient, emailService); + } + + @Test + @DisplayName("skips when gRPC returns null user") + void userNull() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + ConfirmAndSendToTenantEvent e = event(); + when(objectMapper.readValue("v", ConfirmAndSendToTenantEvent.class)).thenReturn(e); + when(idempotencyService.isDuplicate(any())).thenReturn(false); + when(userGrpcClient.getUserById(any())).thenReturn(null); + + listener.handleConfirmAndSendToTenant(rec, ack); + + verify(ack).acknowledge(); + verifyNoInteractions(emailService); + } + + @Test + @DisplayName("rethrows RuntimeException for retry on unexpected error") + void retry() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + ConfirmAndSendToTenantEvent e = event(); + when(objectMapper.readValue("v", ConfirmAndSendToTenantEvent.class)).thenReturn(e); + when(idempotencyService.isDuplicate(any())).thenReturn(false); + when(userGrpcClient.getUserById(any())).thenThrow(new RuntimeException("grpc")); + + assertThatThrownBy(() -> listener.handleConfirmAndSendToTenant(rec, ack)) + .isInstanceOf(RuntimeException.class); + verify(ack, never()).acknowledge(); + } + } + + @Nested + @DisplayName("handleRenewalReminder") + class Renewal { + + private ConsumerRecord rec = + new ConsumerRecord<>("contract.renewal.reminder", 0, 0L, "k", "v"); + + @Test + @DisplayName("sends contract_renewal_reminder email on happy path") + void happy() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + RenewalReminderEvent event = new RenewalReminderEvent( + UUID.randomUUID(), UUID.randomUUID(), 14, + Instant.now().plusSeconds(86400 * 14), "m1"); + when(objectMapper.readValue("v", RenewalReminderEvent.class)).thenReturn(event); + UserResponse user = UserResponse.newBuilder() + .setId(event.getTenantId().toString()) + .setEmail("alice@example.com").setName("Alice").build(); + when(userGrpcClient.getUserById(event.getTenantId())).thenReturn(user); + + listener.handleRenewalReminder(rec, ack); + + verify(emailService).sendEmail(eq("alice@example.com"), + eq("contract_renewal_reminder"), eq(LocaleType.vi_VN), any()); + verify(ack).acknowledge(); + } + + @Test + @DisplayName("rethrows for retry on any failure") + void retry() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + when(objectMapper.readValue(any(String.class), eq(RenewalReminderEvent.class))) + .thenThrow(new RuntimeException("bad")); + + assertThatThrownBy(() -> listener.handleRenewalReminder(rec, ack)) + .isInstanceOf(RuntimeException.class); + verify(ack, never()).acknowledge(); + } + } +} diff --git a/src/test/java/com/isums/notificationservice/infrastructures/listeners/PaymentEventListenerTest.java b/src/test/java/com/isums/notificationservice/infrastructures/listeners/PaymentEventListenerTest.java new file mode 100644 index 0000000..167632b --- /dev/null +++ b/src/test/java/com/isums/notificationservice/infrastructures/listeners/PaymentEventListenerTest.java @@ -0,0 +1,132 @@ +package com.isums.notificationservice.infrastructures.listeners; + +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.domains.events.DepositPaidEvent; +import com.isums.notificationservice.infrastructures.abstracts.EmailService; +import com.isums.notificationservice.infrastructures.grpcs.UserGrpcClient; +import com.isums.userservice.grpc.UserResponse; +import common.kafkas.IdempotencyService; +import common.kafkas.KafkaListenerHelper; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.kafka.support.Acknowledgment; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +import java.time.Instant; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("PaymentEventListener (notification-service)") +class PaymentEventListenerTest { + + @Mock private EmailService emailService; + @Mock private UserGrpcClient userGrpcClient; + @Mock private IdempotencyService idempotencyService; + @Mock private KafkaListenerHelper kafkaHelper; + @Mock private ObjectMapper objectMapper; + @Mock private Acknowledgment ack; + + @InjectMocks private PaymentEventListener listener; + + private final ConsumerRecord rec = + new ConsumerRecord<>("payment-paid-topic", 0, 0L, "k", "v"); + + private DepositPaidEvent event(String type) { + UUID id = UUID.randomUUID(); + return DepositPaidEvent.builder() + .invoiceId(UUID.randomUUID()).contractId(UUID.randomUUID()) + .tenantId(id).houseId(UUID.randomUUID()) + .amount(5_000_000L).invoiceType(type).txnNo("TXN1") + .paidAt(Instant.now()).build(); + } + + @Test + @DisplayName("sends payment_receipt email on happy path") + void happy() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + DepositPaidEvent evt = event("MONTHLY_RENT"); + when(objectMapper.readValue("v", DepositPaidEvent.class)).thenReturn(evt); + UserResponse user = UserResponse.newBuilder() + .setId(evt.tenantId().toString()).setEmail("alice@example.com").setName("Alice").build(); + when(userGrpcClient.getUserById(evt.tenantId())).thenReturn(user); + + listener.handlePaymentPaid(rec, ack); + + verify(emailService).sendEmail(eq("alice@example.com"), eq("payment_receipt"), + eq(LocaleType.vi_VN), any()); + verify(ack).acknowledge(); + } + + @Test + @DisplayName("skips when duplicate") + void duplicate() { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(true); + + listener.handlePaymentPaid(rec, ack); + + verify(ack).acknowledge(); + verifyNoInteractions(emailService, userGrpcClient); + } + + @Test + @DisplayName("acks and skips when gRPC returns null user") + void userNull() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + DepositPaidEvent evt = event("DEPOSIT"); + when(objectMapper.readValue("v", DepositPaidEvent.class)).thenReturn(evt); + when(userGrpcClient.getUserById(any())).thenReturn(null); + + listener.handlePaymentPaid(rec, ack); + + verify(ack).acknowledge(); + verifyNoInteractions(emailService); + } + + @Test + @DisplayName("acks on JacksonException (poison pill)") + void jackson() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + when(objectMapper.readValue(any(String.class), eq(DepositPaidEvent.class))) + .thenThrow(new JacksonException("bad") {}); + + listener.handlePaymentPaid(rec, ack); + + verify(ack).acknowledge(); + } + + @Test + @DisplayName("rethrows for retry on email send failure") + void retry() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + DepositPaidEvent evt = event("UTILITY"); + when(objectMapper.readValue("v", DepositPaidEvent.class)).thenReturn(evt); + UserResponse user = UserResponse.newBuilder() + .setId(evt.tenantId().toString()).setEmail("a@b.com").build(); + when(userGrpcClient.getUserById(any())).thenReturn(user); + doThrow(new RuntimeException("smtp")).when(emailService).sendEmail(any(), any(), any(), any()); + + assertThatThrownBy(() -> listener.handlePaymentPaid(rec, ack)) + .isInstanceOf(RuntimeException.class); + verify(ack, never()).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 new file mode 100644 index 0000000..bfbec6e --- /dev/null +++ b/src/test/java/com/isums/notificationservice/infrastructures/listeners/UserEventListenerTest.java @@ -0,0 +1,184 @@ +package com.isums.notificationservice.infrastructures.listeners; + +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.domains.events.SendEmailEvent; +import com.isums.notificationservice.domains.events.UserActivatedEvent; +import com.isums.notificationservice.infrastructures.abstracts.EmailService; +import common.kafkas.IdempotencyService; +import common.kafkas.KafkaListenerHelper; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.kafka.support.Acknowledgment; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UserEventListener") +class UserEventListenerTest { + + @Mock private EmailService emailService; + @Mock private IdempotencyService idempotencyService; + @Mock private KafkaListenerHelper kafkaHelper; + @Mock private ObjectMapper objectMapper; + @Mock private Acknowledgment ack; + + @InjectMocks private UserEventListener listener; + + @Nested + @DisplayName("handleSendEmail") + class HandleSendEmail { + + private ConsumerRecord rec = new ConsumerRecord<>("notification-email", 0, 0L, "k", "v"); + + @Test + @DisplayName("dispatches email to EmailService on happy path") + void happy() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + SendEmailEvent event = new SendEmailEvent( + "alice@example.com", "WELCOME", Map.of("name", "Alice")); + when(objectMapper.readValue("v", SendEmailEvent.class)).thenReturn(event); + + listener.handleSendEmail(rec, ack); + + verify(emailService).sendEmail("alice@example.com", "welcome", + LocaleType.vi_VN, Map.of("name", "Alice")); + verify(ack).acknowledge(); + } + + @Test + @DisplayName("skips-and-acks when duplicate") + void duplicate() { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(true); + + listener.handleSendEmail(rec, ack); + + verify(ack).acknowledge(); + verifyNoInteractions(emailService); + } + + @Test + @DisplayName("acks and skips when 'to' is blank (invalid event)") + void missingTo() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + when(objectMapper.readValue("v", SendEmailEvent.class)) + .thenReturn(new SendEmailEvent(null, "x", Map.of())); + + listener.handleSendEmail(rec, ack); + + verify(ack).acknowledge(); + verifyNoInteractions(emailService); + } + + @Test + @DisplayName("acks on JacksonException (poison-pill handling)") + void jacksonException() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + when(objectMapper.readValue(any(String.class), eq(SendEmailEvent.class))) + .thenThrow(new JacksonException("bad") {}); + + listener.handleSendEmail(rec, ack); + + verify(ack).acknowledge(); + } + + @Test + @DisplayName("rethrows RuntimeException for retry on downstream failure") + void retry() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + SendEmailEvent event = new SendEmailEvent("a@b.com", "WELCOME", Map.of()); + when(objectMapper.readValue("v", SendEmailEvent.class)).thenReturn(event); + doThrow(new RuntimeException("smtp")) + .when(emailService).sendEmail(any(), any(), any(), any()); + + assertThatThrownBy(() -> listener.handleSendEmail(rec, ack)) + .isInstanceOf(RuntimeException.class); + verify(ack, never()).acknowledge(); + } + } + + @Nested + @DisplayName("handleOnUserActivated") + class HandleActivated { + + private ConsumerRecord rec = + new ConsumerRecord<>("user-activated-topic", 0, 0L, "k", "v"); + + private UserActivatedEvent eventWithInvoice(String paymentUrl) { + return UserActivatedEvent.builder() + .userId(UUID.randomUUID()) + .email("bob@example.com").name("Bob").tempPassword("Temp@123") + .firstRentPaymentUrl(paymentUrl) + .firstRentAmount(5_000_000L) + .firstRentDueDate(Instant.now().plusSeconds(86400)) + .build(); + } + + @Test + @DisplayName("sends user_activated email with invoice fields when firstRentPaymentUrl present") + void withInvoice() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + when(objectMapper.readValue("v", UserActivatedEvent.class)) + .thenReturn(eventWithInvoice("https://pay.example/1")); + + listener.handleOnUserActivated(rec, ack); + + verify(emailService).sendEmail(eq("bob@example.com"), eq("user_activated"), + eq(LocaleType.vi_VN), any()); + verify(ack).acknowledge(); + } + + @Test + @DisplayName("sends without invoice params when firstRentPaymentUrl null") + void withoutInvoice() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + when(objectMapper.readValue("v", UserActivatedEvent.class)) + .thenReturn(eventWithInvoice(null)); + + listener.handleOnUserActivated(rec, ack); + + verify(emailService).sendEmail(eq("bob@example.com"), eq("user_activated"), + eq(LocaleType.vi_VN), any()); + verify(ack).acknowledge(); + } + + @Test + @DisplayName("acks on JacksonException") + void jackson() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + when(objectMapper.readValue(any(String.class), eq(UserActivatedEvent.class))) + .thenThrow(new JacksonException("bad") {}); + + listener.handleOnUserActivated(rec, ack); + + verify(ack).acknowledge(); + verifyNoInteractions(emailService); + } + } +} diff --git a/src/test/java/com/isums/notificationservice/services/EmailServiceImplTest.java b/src/test/java/com/isums/notificationservice/services/EmailServiceImplTest.java new file mode 100644 index 0000000..bfd8bb0 --- /dev/null +++ b/src/test/java/com/isums/notificationservice/services/EmailServiceImplTest.java @@ -0,0 +1,116 @@ +package com.isums.notificationservice.services; + +import com.isums.notificationservice.domains.dtos.EmailTemplateCached; +import com.isums.notificationservice.domains.enums.LocaleType; +import io.github.resilience4j.ratelimiter.RateLimiter; +import io.github.resilience4j.retry.Retry; +import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mail.MailSendException; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Map; +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EmailServiceImpl") +class EmailServiceImplTest { + + @Mock private EmailTemplateService templateService; + @Mock private JavaMailSender mailSender; + + @InjectMocks private EmailServiceImpl service; + + private RateLimiter sesRateLimiter; + private Retry sesRetry; + + @BeforeEach + void setUp() { + // Use real resilience4j instances (no-op behaviour) to avoid mocking complex static behaviours + sesRateLimiter = RateLimiter.ofDefaults("test"); + sesRetry = Retry.ofDefaults("test"); + ReflectionTestUtils.setField(service, "sesRateLimiter", sesRateLimiter); + ReflectionTestUtils.setField(service, "sesRetry", sesRetry); + ReflectionTestUtils.setField(service, "from", "no-reply@isums.pro"); + } + + private EmailTemplateCached tpl() { + return new EmailTemplateCached( + 1, + "Xin chào {{name}}", + "

Xin chào {{name}}

", + "Xin chào {{name}}", + List.of("name")); + } + + @Nested + @DisplayName("sendEmail") + class Send { + + @Test + @DisplayName("renders subject/html/text and sends MimeMessage on happy path") + void happy() { + when(templateService.getActive("welcome", LocaleType.vi_VN)).thenReturn(tpl()); + MimeMessage mime = new MimeMessage((jakarta.mail.Session) null); + when(mailSender.createMimeMessage()).thenReturn(mime); + + service.sendEmail("alice@example.com", "welcome", LocaleType.vi_VN, + Map.of("name", "Alice")); + + verify(mailSender).send(mime); + } + + @Test + @DisplayName("throws IllegalArgumentException when variable not allowed") + void invalidVar() { + when(templateService.getActive("welcome", LocaleType.vi_VN)).thenReturn(tpl()); + + assertThatThrownBy(() -> service.sendEmail( + "a@b.com", "welcome", LocaleType.vi_VN, + Map.of("notAllowed", "x"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not allowed"); + } + + @Test + @DisplayName("propagates MailException from mailSender") + void mailException() { + when(templateService.getActive("welcome", LocaleType.vi_VN)).thenReturn(tpl()); + when(mailSender.createMimeMessage()).thenReturn(new MimeMessage((jakarta.mail.Session) null)); + doThrow(new MailSendException("smtp down")).when(mailSender).send(any(MimeMessage.class)); + + assertThatThrownBy(() -> service.sendEmail( + "a@b.com", "welcome", LocaleType.vi_VN, Map.of("name", "A"))) + .isInstanceOf(MailSendException.class); + } + + @Test + @DisplayName("allows any vars when template has no allowedVars restriction") + void noRestriction() { + EmailTemplateCached unrestricted = new EmailTemplateCached( + 1, "Subj", "

H

", null, List.of()); + when(templateService.getActive("open", LocaleType.vi_VN)).thenReturn(unrestricted); + when(mailSender.createMimeMessage()).thenReturn(new MimeMessage((jakarta.mail.Session) null)); + + service.sendEmail("a@b.com", "open", LocaleType.vi_VN, Map.of("anything", "ok")); + + verify(mailSender).send(any(MimeMessage.class)); + } + } +} diff --git a/src/test/java/com/isums/notificationservice/services/EmailTemplateServiceTest.java b/src/test/java/com/isums/notificationservice/services/EmailTemplateServiceTest.java new file mode 100644 index 0000000..ea49b17 --- /dev/null +++ b/src/test/java/com/isums/notificationservice/services/EmailTemplateServiceTest.java @@ -0,0 +1,176 @@ +package com.isums.notificationservice.services; + +import com.isums.notificationservice.domains.dtos.EmailTemplateCached; +import com.isums.notificationservice.domains.entities.EmailTemplate; +import com.isums.notificationservice.domains.entities.EmailTemplateVersion; +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.domains.enums.TemplateStatus; +import com.isums.notificationservice.infrastructures.repositories.EmailTemplateRepository; +import com.isums.notificationservice.infrastructures.repositories.EmailTemplateVersionRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EmailTemplateService") +class EmailTemplateServiceTest { + + @Mock private EmailTemplateRepository templateRepo; + @Mock private EmailTemplateVersionRepository versionRepo; + + @InjectMocks private EmailTemplateService service; + + private EmailTemplate template; + private UUID templateId; + + @BeforeEach + void setUp() { + templateId = UUID.randomUUID(); + template = EmailTemplate.builder() + .id(templateId).templateKey("welcome").build(); + } + + private EmailTemplateVersion versionV1() { + return EmailTemplateVersion.builder() + .template(template).locale(LocaleType.vi_VN).version(1) + .status(TemplateStatus.ACTIVE) + .subjectTpl("Hi {{name}}").htmlTpl("

{{name}}

").textTpl("Hi") + .allowedVars(List.of("name")).build(); + } + + @Nested + @DisplayName("getActive") + class GetActive { + + @Test + @DisplayName("returns cached projection from latest ACTIVE version") + void returnsCached() { + when(versionRepo.findFirstByTemplate_TemplateKeyAndLocaleAndStatusOrderByVersionDesc( + "welcome", LocaleType.vi_VN, TemplateStatus.ACTIVE)) + .thenReturn(Optional.of(versionV1())); + + EmailTemplateCached cached = service.getActive("welcome", LocaleType.vi_VN); + + assertThat(cached.version()).isEqualTo(1); + assertThat(cached.subjectTpl()).isEqualTo("Hi {{name}}"); + assertThat(cached.allowedVars()).containsExactly("name"); + } + + @Test + @DisplayName("throws IllegalStateException when no ACTIVE version") + void noActive() { + when(versionRepo.findFirstByTemplate_TemplateKeyAndLocaleAndStatusOrderByVersionDesc( + "welcome", LocaleType.vi_VN, TemplateStatus.ACTIVE)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getActive("welcome", LocaleType.vi_VN)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No ACTIVE template"); + } + } + + @Nested + @DisplayName("publishNewVersion") + class PublishNew { + + @Test + @DisplayName("creates v1 and saves ACTIVE when no prior version exists") + void createsV1() { + when(templateRepo.findByTemplateKey("welcome")).thenReturn(Optional.of(template)); + when(versionRepo.findLatestForUpdate(templateId, LocaleType.vi_VN)) + .thenReturn(Optional.empty()); + when(versionRepo.findActiveForUpdate(templateId, LocaleType.vi_VN)) + .thenReturn(Optional.empty()); + when(versionRepo.save(any(EmailTemplateVersion.class))).thenAnswer(a -> a.getArgument(0)); + + EmailTemplateVersion result = service.publishNewVersion( + "welcome", LocaleType.vi_VN, "subj", "

", "text", + List.of("name"), "admin"); + + assertThat(result.getVersion()).isEqualTo(1); + assertThat(result.getStatus()).isEqualTo(TemplateStatus.ACTIVE); + assertThat(result.getCreatedBy()).isEqualTo("admin"); + } + + @Test + @DisplayName("bumps to v2 and deprecates previous ACTIVE when exists") + void bumpsAndDeprecates() { + EmailTemplateVersion prev = versionV1(); + when(templateRepo.findByTemplateKey("welcome")).thenReturn(Optional.of(template)); + when(versionRepo.findLatestForUpdate(templateId, LocaleType.vi_VN)) + .thenReturn(Optional.of(prev)); + when(versionRepo.findActiveForUpdate(templateId, LocaleType.vi_VN)) + .thenReturn(Optional.of(prev)); + when(versionRepo.save(any(EmailTemplateVersion.class))).thenAnswer(a -> a.getArgument(0)); + + EmailTemplateVersion result = service.publishNewVersion( + "welcome", LocaleType.vi_VN, "s2", "

v2

", "t2", + List.of("name"), "admin"); + + assertThat(result.getVersion()).isEqualTo(2); + assertThat(prev.getStatus()).isEqualTo(TemplateStatus.DEPRECATED); + } + + @Test + @DisplayName("throws when template key missing") + void templateMissing() { + when(templateRepo.findByTemplateKey("missing")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.publishNewVersion( + "missing", LocaleType.vi_VN, "s", "h", "t", List.of(), "a")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Template not found"); + } + } + + @Nested + @DisplayName("updateActiveContent") + class UpdateActive { + + @Test + @DisplayName("patches subject/html/text/allowedVars on the ACTIVE row") + void patches() { + EmailTemplateVersion active = versionV1(); + when(templateRepo.findByTemplateKey("welcome")).thenReturn(Optional.of(template)); + when(versionRepo.findActiveForUpdate(templateId, LocaleType.vi_VN)) + .thenReturn(Optional.of(active)); + when(versionRepo.save(active)).thenReturn(active); + + service.updateActiveContent("welcome", LocaleType.vi_VN, + "new-subject", "

new

", "new-text", List.of("x"), "admin"); + + assertThat(active.getSubjectTpl()).isEqualTo("new-subject"); + assertThat(active.getHtmlTpl()).isEqualTo("

new

"); + assertThat(active.getAllowedVars()).containsExactly("x"); + assertThat(active.getUpdatedBy()).isEqualTo("admin"); + } + + @Test + @DisplayName("throws when no ACTIVE version") + void noActive() { + when(templateRepo.findByTemplateKey("welcome")).thenReturn(Optional.of(template)); + when(versionRepo.findActiveForUpdate(templateId, LocaleType.vi_VN)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.updateActiveContent( + "welcome", LocaleType.vi_VN, "s", "h", "t", List.of(), "a")) + .isInstanceOf(IllegalStateException.class); + } + } +} diff --git a/src/test/java/com/isums/notificationservice/services/ManagerNotificationServiceImplTest.java b/src/test/java/com/isums/notificationservice/services/ManagerNotificationServiceImplTest.java new file mode 100644 index 0000000..7db85f4 --- /dev/null +++ b/src/test/java/com/isums/notificationservice/services/ManagerNotificationServiceImplTest.java @@ -0,0 +1,148 @@ +package com.isums.notificationservice.services; + +import com.isums.notificationservice.domains.dtos.NotificationDto; +import com.isums.notificationservice.domains.entities.ManagerNotification; +import com.isums.notificationservice.domains.enums.NotificationCategory; +import com.isums.notificationservice.exceptions.NotFoundException; +import com.isums.notificationservice.infrastructures.Websockets.SseConnectionManager; +import com.isums.notificationservice.infrastructures.repositories.ManagerNotificationRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ManagerNotificationServiceImpl") +class ManagerNotificationServiceImplTest { + + @Mock private ManagerNotificationRepository repo; + @Mock private SseConnectionManager sseManager; + + @InjectMocks private ManagerNotificationServiceImpl service; + + private UUID recipientId; + + @BeforeEach + void setUp() { + recipientId = UUID.randomUUID(); + } + + @Nested + @DisplayName("send") + class Send { + + @Test + @DisplayName("persists notification and pushes SSE") + void sends() { + service.send(recipientId, NotificationCategory.PAYMENT_OVERDUE, + "Title", "Body", "/action", Map.of("k", "v")); + + ArgumentCaptor cap = ArgumentCaptor.forClass(ManagerNotification.class); + verify(repo).save(cap.capture()); + ManagerNotification saved = cap.getValue(); + assertThat(saved.getRecipientId()).isEqualTo(recipientId); + assertThat(saved.getTitle()).isEqualTo("Title"); + assertThat(saved.getCategory()).isEqualTo(NotificationCategory.PAYMENT_OVERDUE); + assertThat(saved.isRead()).isFalse(); + + verify(sseManager).push(recipientId, saved); + } + } + + @Nested + @DisplayName("getByRecipient") + class GetByRecipient { + + @Test + @DisplayName("maps entities to DTOs, desc by createdAt") + void returnsPage() { + ManagerNotification n = ManagerNotification.builder() + .id(UUID.randomUUID()).recipientId(recipientId) + .category(NotificationCategory.INSPECTION_DONE) + .title("t").body("b").createdAt(Instant.now()).build(); + Page page = new PageImpl<>(List.of(n)); + when(repo.findByRecipientIdOrderByCreatedAtDesc(any(UUID.class), any())) + .thenReturn(page); + + Page res = service.getByRecipient(recipientId, PageRequest.of(0, 10)); + assertThat(res.getContent()).hasSize(1); + assertThat(res.getContent().get(0).getCategory()).isEqualTo("INSPECTION_DONE"); + } + } + + @Nested + @DisplayName("countUnread") + class CountUnread { + + @Test + @DisplayName("delegates to repo") + void delegates() { + when(repo.countByRecipientIdAndIsReadFalse(recipientId)).thenReturn(7L); + assertThat(service.countUnread(recipientId)).isEqualTo(7L); + } + } + + @Nested + @DisplayName("markRead") + class MarkRead { + + @Test + @DisplayName("sets isRead and readAt and saves") + void marks() { + UUID notifId = UUID.randomUUID(); + ManagerNotification n = ManagerNotification.builder() + .id(notifId).recipientId(recipientId) + .category(NotificationCategory.CONTRACT_EXPIRED) + .title("t").body("b").isRead(false).build(); + when(repo.findByIdAndRecipientId(notifId, recipientId)).thenReturn(Optional.of(n)); + + service.markRead(notifId, recipientId); + + assertThat(n.isRead()).isTrue(); + assertThat(n.getReadAt()).isNotNull(); + verify(repo).save(n); + } + + @Test + @DisplayName("throws NotFoundException when notification missing") + void missing() { + UUID notifId = UUID.randomUUID(); + when(repo.findByIdAndRecipientId(notifId, recipientId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.markRead(notifId, recipientId)) + .isInstanceOf(NotFoundException.class); + } + } + + @Nested + @DisplayName("markAllRead") + class MarkAllRead { + + @Test + @DisplayName("delegates to repo with current timestamp") + void delegates() { + service.markAllRead(recipientId); + verify(repo).markAllReadByRecipientId(any(UUID.class), any(Instant.class)); + } + } +} From 2319777ececee34347bb1d249b3778976263509f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20=C4=90=E1=BB=A9c=20Hi=E1=BB=87u?= Date: Wed, 15 Apr 2026 02:36:23 +0700 Subject: [PATCH 09/11] Remove unused "user_activated" (vi_VN) email template from `EmailTemplateSeeder`. --- .../seeders/EmailTemplateSeeder.java | 181 ------------------ 1 file changed, 181 deletions(-) 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 36861fe..6ddb216 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/seeders/EmailTemplateSeeder.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/seeders/EmailTemplateSeeder.java @@ -278,187 +278,6 @@ public void seed(EmailTemplateRepository templateRepo, EmailTemplateVersionRepos "system" ); - // USER ACTIVATED (vi_VN) - upsertActiveV1( - templateRepo, versionRepo, - "user_activated", - "ONBOARDING", - "CUSTOMER", - LocaleType.vi_VN, - "Tài khoản của bạn đã được kích hoạt", - """ - - - - - - Tài khoản đã được kích hoạt - - -
- Tài khoản ISUMS của bạn đã sẵn sàng. Đây là thông tin đăng nhập tạm thời. -
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - -
-
- ISUMS -
-
- Hệ thống quản lý thuê trọ -
-
-
- - ✓  TÀI KHOẢN ĐÃ KÍCH HOẠT - -
-
-
- Xin chào, {{name}}! -
- -
- Tài khoản của bạn trên hệ thống ISUMS đã được tạo và kích hoạt thành công. - Dưới đây là thông tin đăng nhập tạm thời — vui lòng đổi mật khẩu ngay sau lần đăng nhập đầu tiên. -
- - - - - - - - - -
-
- Thông tin đăng nhập -
-
- - - - - - - - - - - - -
-
- Email -
-
-
- {{email}} -
-
-
-
-
- Mật khẩu tạm -
-
-
- {{password}} -
-
-
- - - - - - -
-
- ⚠️  Mật khẩu này chỉ dùng một lần. Vui lòng đổi mật khẩu ngay sau khi đăng nhập để bảo mật tài khoản. -
-
- -
- -
- Trân trọng,
- Đội ngũ ISUMS -
-
-
- Email này được gửi tự động. Vui lòng không trả lời trực tiếp. -
-
-
- - - """, - """ - Xin chào {{name}}, - - Tài khoản ISUMS của bạn đã được kích hoạt thành công. - - Thông tin đăng nhập: - - Email : {{email}} - - Mật khẩu: {{password}} - - Lưu ý: Đây là mật khẩu tạm thời, vui lòng đổi mật khẩu ngay sau khi đăng nhập. - - Trân trọng, - Đội ngũ ISUMS - """, - List.of("name", "email", "password"), - "system" - ); - upsertActiveV1( templateRepo, versionRepo, "payment_invoice", From 021c728d74ddd9cb494045a97c2b5ec262430915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20=C4=90=E1=BB=A9c=20Hi=E1=BB=87u?= Date: Thu, 7 May 2026 10:29:25 +0700 Subject: [PATCH 10/11] Add notification service domain models, repositories, and core logic for managing multi-channel alert dispatch, escalation, and template rendering. --- build.gradle | 47 +- docs/VOICE_NOTIFICATION_SETUP.md | 196 + .../NotificationServiceApplication.java | 2 + .../configurations/KafkaConsumerConfig.java | 35 +- .../configurations/OpenApiConfig.java | 3 +- .../configurations/SecurityConfig.java | 5 +- .../VoiceNotificationConfig.java | 77 + .../InternalDispatchController.java | 62 + .../ManagerNotificationController.java | 23 +- .../NotificationPreferencesController.java | 114 + .../NotificationSubscriptionController.java | 76 + .../StringeeAnswerUrlController.java | 179 + .../StringeeWebhookController.java | 121 + .../SubscriptionPlanController.java | 81 + .../TechnicianNotificationController.java | 79 + .../TenantNotificationController.java | 79 + .../controllers/TestVoiceController.java | 97 + .../VoiceCallHistoryController.java | 44 + .../domains/dtos/AlertDispatchRequest.java | 31 + .../domains/dtos/AlertDispatchResponse.java | 17 + .../domains/dtos/NotificationDto.java | 34 +- .../dtos/NotificationPreferencesDto.java | 31 + .../domains/dtos/SpeedSmsVoiceRequest.java | 44 + .../domains/dtos/SpeedSmsVoiceResponse.java | 8 + .../domains/dtos/SpeedSmsWebhookPayload.java | 17 + .../domains/dtos/SubscriptionDto.java | 20 + .../domains/dtos/SubscriptionPlanDto.java | 19 + .../dtos/UpdatePreferencesRequest.java | 50 + .../dtos/UpsertSubscriptionPlanRequest.java | 25 + .../domains/dtos/VoiceCallDto.java | 23 + .../domains/entities/ChannelTemplate.java | 57 + .../entities/ChannelTemplateVersion.java | 74 + .../domains/entities/ManagerNotification.java | 10 + .../entities/NotificationSubscription.java | 63 + .../domains/entities/SubscriptionPlan.java | 85 + .../entities/UserNotificationPreferences.java | 138 + .../domains/entities/VoiceAudioCache.java | 69 + .../domains/entities/VoiceCallEscalation.java | 40 + .../domains/entities/VoiceCallJob.java | 128 + .../domains/entities/VoiceConsentHistory.java | 72 + .../domains/enums/AlertEventType.java | 58 + .../domains/enums/AlertSeverity.java | 21 + .../domains/enums/EscalationReason.java | 7 + .../domains/enums/LocaleType.java | 7 +- .../domains/enums/NotificationCategory.java | 5 + .../domains/enums/NotificationChannel.java | 9 + .../domains/enums/RecipientRole.java | 7 + .../domains/enums/SubscriptionTier.java | 6 + .../domains/enums/VoiceCallStatus.java | 13 + .../domains/enums/VoiceGender.java | 6 + .../events/ConfirmAndSendToTenantEvent.java | 5 + .../ContractCancelledByTenantEvent.java | 22 + .../events/ContractCompletedEvent.java | 27 + ...ontractReadyForLandlordSignatureEvent.java | 20 + .../events/InspectionScheduledEvent.java | 1 + .../events/IssueQuoteSubmittedEvent.java | 22 + .../events/IssueWorkSlotAssignedEvent.java | 23 + .../PaymentSubscriptionActivatedEvent.java | 30 + .../domains/events/UserActivatedEvent.java | 7 +- .../events/UtilityThresholdExceededEvent.java | 39 + .../exceptions/GlobalExceptionHandler.java | 49 + .../Websockets/SseConnectionManager.java | 91 +- .../abstracts/SmsProvider.java | 16 + .../abstracts/TtsAudioSynthesizer.java | 25 + .../abstracts/VoiceProvider.java | 22 + .../PermanentEventFailureException.java | 12 + .../grpcs/HouseGrpcClient.java | 56 + .../infrastructures/grpcs/UserGrpcClient.java | 21 + .../kafka/ContractEventListener.java | 217 +- .../kafka/IssueNotificationEventListener.java | 207 + .../NotificationTranslationRequester.java | 110 + ...NotificationTranslationResultListener.java | 103 + .../kafka/PaymentConsumer.java | 49 +- .../listeners/EContractEventListener.java | 396 +- .../listeners/PaymentEventListener.java | 16 +- .../PaymentSubscriptionListener.java | 108 + .../listeners/UserEventListener.java | 9 +- .../listeners/UtilityAlertEventListener.java | 233 ++ .../ChannelTemplateRepository.java | 15 + .../ChannelTemplateVersionRepository.java | 19 + .../NotificationSubscriptionRepository.java | 18 + .../SubscriptionPlanRepository.java | 21 + ...UserNotificationPreferencesRepository.java | 12 + .../VoiceAudioCacheRepository.java | 14 + .../VoiceCallEscalationRepository.java | 11 + .../repositories/VoiceCallJobRepository.java | 24 + .../VoiceConsentHistoryRepository.java | 15 + .../seeders/EmailTemplateSeeder.java | 3683 ++++++++++------- .../seeders/UtilityAlertTemplateSeeder.java | 363 ++ .../seeders/VoiceAlertTemplateSeeder.java | 308 ++ .../services/AwsSnsClient.java | 183 + .../services/ChannelPolicy.java | 60 + .../services/ChannelTemplateRenderer.java | 76 + .../services/EmailServiceImpl.java | 13 +- .../services/EscalationService.java | 86 + .../ManagerNotificationServiceImpl.java | 21 + .../services/MonthlyQuotaResetScheduler.java | 27 + .../services/NotificationDispatchService.java | 501 +++ .../NotificationPreferenceService.java | 221 + .../services/NotificationQuotaService.java | 156 + .../NotificationRecipientResolver.java | 44 + .../NotificationSubscriptionService.java | 129 + .../services/PollyTtsSynthesizer.java | 167 + .../services/PremiumExpirationScheduler.java | 46 + .../services/QuietHoursPolicy.java | 46 + .../services/StringeeClientImpl.java | 295 ++ .../services/SubscriptionPlanService.java | 116 + .../services/TierQuotaPolicy.java | 37 + .../VoiceCallOrchestratorService.java | 198 + .../services/VoiceCallRetryScheduler.java | 79 + .../services/VoiceProviderRouter.java | 63 + .../services/VoiceWebhookHandler.java | 249 ++ ...nd_manager_notification_category_check.sql | 13 + ...ompleted_manager_notification_category.sql | 14 + ...fication_categories_for_contract_issue.sql | 17 + ...mplate_versions_locale_check_for_ja_JP.sql | 27 + ...0260425_0001__voice_notification_infra.sql | 249 ++ ...500__manager_notification_translations.sql | 12 + ...28_2300__voice_call_jobs_alert_context.sql | 14 + .../V20260429_0030__quiet_hours_enabled.sql | 5 + ...0260429_1100__voice_consent_compliance.sql | 27 + .../V20260429_1300__subscription_plans.sql | 51 + src/main/resources/logback-spring.xml | 23 + .../ManagerNotificationControllerTest.java | 9 +- .../Websockets/SseConnectionManagerTest.java | 20 + .../kafka/ContractEventListenerTest.java | 176 +- .../IssueNotificationEventListenerTest.java | 188 + .../listeners/UserEventListenerTest.java | 2 +- .../ManagerNotificationServiceImplTest.java | 9 + 129 files changed, 10788 insertions(+), 1834 deletions(-) create mode 100644 docs/VOICE_NOTIFICATION_SETUP.md create mode 100644 src/main/java/com/isums/notificationservice/configurations/VoiceNotificationConfig.java create mode 100644 src/main/java/com/isums/notificationservice/controllers/InternalDispatchController.java create mode 100644 src/main/java/com/isums/notificationservice/controllers/NotificationPreferencesController.java create mode 100644 src/main/java/com/isums/notificationservice/controllers/NotificationSubscriptionController.java create mode 100644 src/main/java/com/isums/notificationservice/controllers/StringeeAnswerUrlController.java create mode 100644 src/main/java/com/isums/notificationservice/controllers/StringeeWebhookController.java create mode 100644 src/main/java/com/isums/notificationservice/controllers/SubscriptionPlanController.java create mode 100644 src/main/java/com/isums/notificationservice/controllers/TechnicianNotificationController.java create mode 100644 src/main/java/com/isums/notificationservice/controllers/TenantNotificationController.java create mode 100644 src/main/java/com/isums/notificationservice/controllers/TestVoiceController.java create mode 100644 src/main/java/com/isums/notificationservice/controllers/VoiceCallHistoryController.java create mode 100644 src/main/java/com/isums/notificationservice/domains/dtos/AlertDispatchRequest.java create mode 100644 src/main/java/com/isums/notificationservice/domains/dtos/AlertDispatchResponse.java create mode 100644 src/main/java/com/isums/notificationservice/domains/dtos/NotificationPreferencesDto.java create mode 100644 src/main/java/com/isums/notificationservice/domains/dtos/SpeedSmsVoiceRequest.java create mode 100644 src/main/java/com/isums/notificationservice/domains/dtos/SpeedSmsVoiceResponse.java create mode 100644 src/main/java/com/isums/notificationservice/domains/dtos/SpeedSmsWebhookPayload.java create mode 100644 src/main/java/com/isums/notificationservice/domains/dtos/SubscriptionDto.java create mode 100644 src/main/java/com/isums/notificationservice/domains/dtos/SubscriptionPlanDto.java create mode 100644 src/main/java/com/isums/notificationservice/domains/dtos/UpdatePreferencesRequest.java create mode 100644 src/main/java/com/isums/notificationservice/domains/dtos/UpsertSubscriptionPlanRequest.java create mode 100644 src/main/java/com/isums/notificationservice/domains/dtos/VoiceCallDto.java create mode 100644 src/main/java/com/isums/notificationservice/domains/entities/ChannelTemplate.java create mode 100644 src/main/java/com/isums/notificationservice/domains/entities/ChannelTemplateVersion.java create mode 100644 src/main/java/com/isums/notificationservice/domains/entities/NotificationSubscription.java create mode 100644 src/main/java/com/isums/notificationservice/domains/entities/SubscriptionPlan.java create mode 100644 src/main/java/com/isums/notificationservice/domains/entities/UserNotificationPreferences.java create mode 100644 src/main/java/com/isums/notificationservice/domains/entities/VoiceAudioCache.java create mode 100644 src/main/java/com/isums/notificationservice/domains/entities/VoiceCallEscalation.java create mode 100644 src/main/java/com/isums/notificationservice/domains/entities/VoiceCallJob.java create mode 100644 src/main/java/com/isums/notificationservice/domains/entities/VoiceConsentHistory.java create mode 100644 src/main/java/com/isums/notificationservice/domains/enums/AlertEventType.java create mode 100644 src/main/java/com/isums/notificationservice/domains/enums/AlertSeverity.java create mode 100644 src/main/java/com/isums/notificationservice/domains/enums/EscalationReason.java create mode 100644 src/main/java/com/isums/notificationservice/domains/enums/NotificationChannel.java create mode 100644 src/main/java/com/isums/notificationservice/domains/enums/RecipientRole.java create mode 100644 src/main/java/com/isums/notificationservice/domains/enums/SubscriptionTier.java create mode 100644 src/main/java/com/isums/notificationservice/domains/enums/VoiceCallStatus.java create mode 100644 src/main/java/com/isums/notificationservice/domains/enums/VoiceGender.java create mode 100644 src/main/java/com/isums/notificationservice/domains/events/ContractCancelledByTenantEvent.java create mode 100644 src/main/java/com/isums/notificationservice/domains/events/ContractCompletedEvent.java create mode 100644 src/main/java/com/isums/notificationservice/domains/events/ContractReadyForLandlordSignatureEvent.java create mode 100644 src/main/java/com/isums/notificationservice/domains/events/IssueQuoteSubmittedEvent.java create mode 100644 src/main/java/com/isums/notificationservice/domains/events/IssueWorkSlotAssignedEvent.java create mode 100644 src/main/java/com/isums/notificationservice/domains/events/PaymentSubscriptionActivatedEvent.java create mode 100644 src/main/java/com/isums/notificationservice/domains/events/UtilityThresholdExceededEvent.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/abstracts/SmsProvider.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/abstracts/TtsAudioSynthesizer.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/abstracts/VoiceProvider.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/exceptions/PermanentEventFailureException.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/grpcs/HouseGrpcClient.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/kafka/IssueNotificationEventListener.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/kafka/NotificationTranslationRequester.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/kafka/NotificationTranslationResultListener.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/listeners/PaymentSubscriptionListener.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/listeners/UtilityAlertEventListener.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/repositories/ChannelTemplateRepository.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/repositories/ChannelTemplateVersionRepository.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/repositories/NotificationSubscriptionRepository.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/repositories/SubscriptionPlanRepository.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/repositories/UserNotificationPreferencesRepository.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/repositories/VoiceAudioCacheRepository.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/repositories/VoiceCallEscalationRepository.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/repositories/VoiceCallJobRepository.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/repositories/VoiceConsentHistoryRepository.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/seeders/UtilityAlertTemplateSeeder.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/seeders/VoiceAlertTemplateSeeder.java create mode 100644 src/main/java/com/isums/notificationservice/services/AwsSnsClient.java create mode 100644 src/main/java/com/isums/notificationservice/services/ChannelPolicy.java create mode 100644 src/main/java/com/isums/notificationservice/services/ChannelTemplateRenderer.java create mode 100644 src/main/java/com/isums/notificationservice/services/EscalationService.java create mode 100644 src/main/java/com/isums/notificationservice/services/MonthlyQuotaResetScheduler.java create mode 100644 src/main/java/com/isums/notificationservice/services/NotificationDispatchService.java create mode 100644 src/main/java/com/isums/notificationservice/services/NotificationPreferenceService.java create mode 100644 src/main/java/com/isums/notificationservice/services/NotificationQuotaService.java create mode 100644 src/main/java/com/isums/notificationservice/services/NotificationRecipientResolver.java create mode 100644 src/main/java/com/isums/notificationservice/services/NotificationSubscriptionService.java create mode 100644 src/main/java/com/isums/notificationservice/services/PollyTtsSynthesizer.java create mode 100644 src/main/java/com/isums/notificationservice/services/PremiumExpirationScheduler.java create mode 100644 src/main/java/com/isums/notificationservice/services/QuietHoursPolicy.java create mode 100644 src/main/java/com/isums/notificationservice/services/StringeeClientImpl.java create mode 100644 src/main/java/com/isums/notificationservice/services/SubscriptionPlanService.java create mode 100644 src/main/java/com/isums/notificationservice/services/TierQuotaPolicy.java create mode 100644 src/main/java/com/isums/notificationservice/services/VoiceCallOrchestratorService.java create mode 100644 src/main/java/com/isums/notificationservice/services/VoiceCallRetryScheduler.java create mode 100644 src/main/java/com/isums/notificationservice/services/VoiceProviderRouter.java create mode 100644 src/main/java/com/isums/notificationservice/services/VoiceWebhookHandler.java create mode 100644 src/main/resources/db/migration/V20260417_1205__extend_manager_notification_category_check.sql create mode 100644 src/main/resources/db/migration/V20260417_1335__add_contract_completed_manager_notification_category.sql create mode 100644 src/main/resources/db/migration/V20260417_1415__extend_manager_notification_categories_for_contract_issue.sql create mode 100644 src/main/resources/db/migration/V20260422_1600__extend_email_template_versions_locale_check_for_ja_JP.sql create mode 100644 src/main/resources/db/migration/V20260425_0001__voice_notification_infra.sql create mode 100644 src/main/resources/db/migration/V20260425_1500__manager_notification_translations.sql create mode 100644 src/main/resources/db/migration/V20260428_2300__voice_call_jobs_alert_context.sql create mode 100644 src/main/resources/db/migration/V20260429_0030__quiet_hours_enabled.sql create mode 100644 src/main/resources/db/migration/V20260429_1100__voice_consent_compliance.sql create mode 100644 src/main/resources/db/migration/V20260429_1300__subscription_plans.sql create mode 100644 src/main/resources/logback-spring.xml create mode 100644 src/test/java/com/isums/notificationservice/infrastructures/kafka/IssueNotificationEventListenerTest.java diff --git a/build.gradle b/build.gradle index ed3a2bd..bd4285f 100644 --- a/build.gradle +++ b/build.gradle @@ -19,6 +19,18 @@ configurations { extendsFrom annotationProcessor } } +configurations.configureEach { + resolutionStrategy { + force 'com.google.protobuf:protobuf-java:4.34.0' + force 'com.google.protobuf:protobuf-java-util:4.34.0' + eachDependency { details -> + if (details.requested.group == 'com.google.protobuf' && (details.requested.name == 'protobuf-java' || details.requested.name == 'protobuf-java-util')) { + details.useVersion '4.34.0' + details.because 'Keep protobuf runtime aligned with generated proto-common classes' + } + } + } +} repositories { mavenCentral() @@ -31,6 +43,14 @@ repositories { password = System.getenv("GITHUB_TOKEN") ?: "" } } + maven { + name = "GitHubPackagesObservability" + url = uri("https://maven.pkg.github.com/Management-System-for-Rental-SEP490/ISUMS_Observability-Common") + credentials { + username = System.getenv("GITHUB_ACTOR") ?: "" + password = System.getenv("GITHUB_TOKEN") ?: "" + } + } } ext { @@ -39,6 +59,12 @@ ext { } dependencies { + implementation 'net.logstash.logback:logstash-logback-encoder:8.1' + implementation 'io.opentelemetry:opentelemetry-exporter-otlp' + implementation 'io.micrometer:micrometer-tracing-bridge-otel' + implementation 'io.micrometer:micrometer-registry-prometheus' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'com.isums:isums-observability-common:1.0-SNAPSHOT' implementation 'org.springframework.boot:spring-boot-starter-kafka' implementation 'org.springframework.boot:spring-boot-starter-webmvc' implementation 'io.grpc:grpc-services' @@ -48,8 +74,22 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-security-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-database-postgresql' implementation(platform("io.awspring.cloud:spring-cloud-aws-dependencies:4.0.0")) implementation 'io.awspring.cloud:spring-cloud-aws-starter-ses' + // Polly for Japanese/English TTS pre-synth + S3 for audio cache + implementation 'software.amazon.awssdk:polly' + implementation 'software.amazon.awssdk:s3' + // SNS for transactional SMS (primary VN SMS path — works without + // brandname registration; in Sandbox mode requires per-destination + // phone-number verification, then auto-promoted out of Sandbox once + // AWS approves the production access request). + implementation 'software.amazon.awssdk:sns' + // Stringee REST API JWT auth (HS256 signing). nimbus-jose-jwt already + // pulled by spring-boot-starter-oauth2-resource-server, but pin here + // for clarity. + implementation 'com.nimbusds:nimbus-jose-jwt:9.40' implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' implementation "org.springframework.grpc:spring-grpc-client-spring-boot-starter" implementation "org.springframework.grpc:spring-grpc-spring-boot-starter" @@ -63,7 +103,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-cache' - implementation 'com.google.protobuf:protobuf-java:4.34.0-RC2' + implementation 'com.google.protobuf:protobuf-java:4.34.0' implementation "com.isums:proto-common:1.0-SNAPSHOT" implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2' testImplementation 'org.springframework.security:spring-security-test' @@ -90,3 +130,8 @@ tasks.named('bootBuildImage') { tasks.named('test') { useJUnitPlatform() } + + +tasks.withType(JavaCompile).configureEach { + options.encoding = "UTF-8" +} diff --git a/docs/VOICE_NOTIFICATION_SETUP.md b/docs/VOICE_NOTIFICATION_SETUP.md new file mode 100644 index 0000000..808e306 --- /dev/null +++ b/docs/VOICE_NOTIFICATION_SETUP.md @@ -0,0 +1,196 @@ +# Voice Notification — Setup & Operations + +Multi-channel alert delivery with per-user preferences, subscription +tier gating, TTS voice calls in **vi / en / ja**, retry + escalation, +DTMF acknowledgement, and a DRY_RUN mode for thesis demos. + +## What's new + +| Area | Added | +|---|---| +| Tables | `user_notification_preferences`, `notification_subscriptions`, `channel_templates`, `channel_template_versions`, `voice_call_jobs`, `voice_call_escalations`, `voice_audio_cache` | +| Enums | `NotificationChannel`, `SubscriptionTier`, `VoiceCallStatus`, `EscalationReason`, `VoiceGender`, `AlertEventType` | +| Services | `NotificationDispatchService`, `NotificationPreferenceService`, `NotificationSubscriptionService`, `NotificationQuotaService`, `VoiceCallOrchestratorService`, `VoiceWebhookHandler`, `EscalationService`, `ChannelTemplateRenderer`, `SpeedSmsClient`, `PollyTtsSynthesizer` | +| Schedulers | `VoiceCallRetryScheduler` (每分), `MonthlyQuotaResetScheduler` (1st month 00:05 VN), `PremiumExpirationScheduler` (nightly 02:15 VN) | +| REST | `/api/notifications/preferences/me` (GET/PUT), `/subscription`, `/quota`, `/test-voice`, `/calls/me`, `/subscriptions/admin/grant-premium`, `/voice/webhook`, `/internal/dispatch` | +| Kafka | Listens `payment.subscription-activated` | +| Seeders | `VoiceAlertTemplateSeeder` seeds 8 events × (vi/en/ja) × (VOICE + SMS for 4 critical) | + +## Environment variables + +Required for production; all optional in DRY_RUN mode (logging only). + +```bash +# Feature flag — flip to false once SpeedSMS credit is loaded +NOTIFICATION_VOICE_DRY_RUN=true + +# SpeedSMS (https://speedsms.vn) +SPEEDSMS_ACCESS_TOKEN= +SPEEDSMS_WEBHOOK_SECRET= +SPEEDSMS_BASE_URL=https://api.speedsms.vn +SPEEDSMS_VOICE_PATH=/api/voice/send +SPEEDSMS_SMS_PATH=/api/sms/send +SPEEDSMS_CALLER_ID=ISUMS + +# Shared secret between Notification-Service + IoT Lambda +INTERNAL_API_KEY= + +# Public base URL the webhook will arrive on +NOTIFICATION_PUBLIC_BASE_URL=https://api-dev.isums.pro + +# AWS Polly pre-synth target (only needed if non-VN users exist) +VOICE_AUDIO_BUCKET=isums-voice-tts +VOICE_AUDIO_PUBLIC_BASE=https://%s.s3.ap-southeast-1.amazonaws.com +``` + +## One-time setup + +1. **Flyway migration auto-applies** on startup + (`V20260425_0001__voice_notification_infra.sql`). Verify with: + ```sql + \dt user_notification_preferences + \dt voice_call_jobs + ``` + +2. **Template seed** runs from `VoiceAlertTemplateSeeder` on app start. + To skip (e.g. on repeated local runs): `app.seed.voice-templates=false`. + +3. **SpeedSMS account** + - Sign up at speedsms.vn → request Voice Call API enable (they enable + voice on request, OTP-only plan is default). + - Copy API key → `SPEEDSMS_ACCESS_TOKEN`. + - In their portal, set Webhook URL to + `${NOTIFICATION_PUBLIC_BASE_URL}/api/notifications/voice/webhook` + and generate an HMAC secret → `SPEEDSMS_WEBHOOK_SECRET`. + +4. **Polly + S3** (only for ja/en users) + - Create a bucket (suggestion: `isums-voice-tts`) with `public-read` + default ACL. + - The Notification-Service already has AWS credentials via + `spring.cloud.aws.credentials.*` (used by SES today); Polly + S3 + reuse them automatically. + +5. **Lambda side** — see `E:\ISUMS\tmp\lambdas\notif_dispatch_patch\`: + - Copy `_notification_client.py` into the Lambda package. + - Call `invoke_notification_service(...)` after each `_save_alert()` + in `esp32-threshold-checker` and `esp32-eif-score`. + - Set env on both Lambdas: + `NOTIFICATION_SERVICE_URL=https://api-dev.isums.pro` + `INTERNAL_API_KEY=` + +6. **Asset-Service denormalisation** — for the Lambda to know which + user to notify, `esp32_asset_map` needs a `tenantUserId` column. + Currently the map holds only `houseId/areaId`. Patch + `IoTDeviceServiceImpl.upsetToDynamoDB` to write `tenantUserId` at + node assignment time (the tenant can be looked up via contract-service + grpc: `findActiveTenantByHouseAndArea`). + Until this patch lands, the Lambda falls back to `landlordUserId` + on the map — voice calls go to the landlord instead of the tenant. + +## Flow: what happens when GAS_CRITICAL fires + +``` +ESP32 → MQTT → esp32-threshold-checker Lambda + → saves alert to esp32_alerts (DynamoDB) + → ws-broadcaster (existing flow, in-app push) + → POST /api/notifications/internal/dispatch (new) + └→ NotificationDispatchService + ├─ Email (via EmailService, template alert_gas_critical) + ├─ Push (handled by ws-broadcaster above — skipped) + ├─ SMS (only if PREMIUM + smsEnabled + phone present) + └─ Voice (only if PREMIUM + voiceEnabled + consent + !quiet_hours + + !rateLimit + !quotaExhausted) + └─ VoiceCallOrchestrator + ├─ render voice_gas_critical template (user.locale) + ├─ if ja → Polly.Tomoko → S3 → audio URL + │ else → SpeedSMS native TTS (Vietnamese) + ├─ SpeedSMS POST /voice/send + └─ Save voice_call_jobs row (DIALING) + +SpeedSMS → dials user phone → plays TTS 2× → user presses 1 → hangs up + → POST /api/notifications/voice/webhook {callId, status, dtmf} + └→ VoiceWebhookHandler + └─ dtmf=1 → ACKNOWLEDGED, stop retries + └─ dtmf=2 → record escalation, dispatch to landlord + └─ dtmf=9 → voiceEnabled=false (opt-out) + └─ NO_ANSWER → schedule retry via next_retry_at +``` + +## REST API cheat-sheet + +| Verb | Path | Auth | Purpose | +|---|---|---|---| +| GET | `/api/notifications/preferences/me` | JWT | Read my preferences | +| PUT | `/api/notifications/preferences/me` | JWT | Update (all fields optional) | +| GET | `/api/notifications/preferences/me/subscription` | JWT | My tier + quota | +| GET | `/api/notifications/preferences/me/quota` | JWT | Usage + rate-limit countdown | +| POST | `/api/notifications/preferences/me/test-voice` | JWT | Fire a test GAS_CRITICAL call (1/day) | +| GET | `/api/notifications/calls/me?page&size` | JWT | My call history | +| POST | `/api/notifications/subscriptions/admin/grant-premium` | LANDLORD/SYSTEM_ADMIN | `{userId, months}` — demo shortcut | +| POST | `/api/notifications/subscriptions/admin/downgrade` | LANDLORD/SYSTEM_ADMIN | `{userId}` | +| POST | `/api/notifications/subscriptions/me/upgrade` | JWT | Returns payment intent | +| POST | `/api/notifications/internal/dispatch` | `X-Internal-Key` | From Lambda | +| POST | `/api/notifications/voice/webhook` | `X-Signature` HMAC | From SpeedSMS | + +## Testing (DRY_RUN mode) + +1. Start service with `NOTIFICATION_VOICE_DRY_RUN=true` (default). +2. Grant yourself PREMIUM: + ```bash + curl -X POST \ + http://localhost:8085/api/notifications/subscriptions/admin/grant-premium \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"userId":"","months":1}' + ``` +3. Turn voice on + grant consent: + ```bash + curl -X PUT \ + http://localhost:8085/api/notifications/preferences/me \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"voiceEnabled":true,"voiceConsentGranted":true,"language":"ja_JP"}' + ``` +4. Fire test call: + ```bash + curl -X POST \ + http://localhost:8085/api/notifications/preferences/me/test-voice \ + -H 'Authorization: Bearer ' + ``` +5. Check the log — DRY_RUN prints the full rendered Japanese TTS text + that would have been spoken: + ``` + [SpeedSMS DRY_RUN] voice phone=+84... loop=2 text= + 緊急警報。エリアAで検出されたガス濃度が... + ``` +6. Query history: + ```bash + curl http://localhost:8085/api/notifications/calls/me \ + -H 'Authorization: Bearer ' + ``` + +## Tier & quota configuration + +Tier caps live in `TierQuotaPolicy.java` (not DB) so pricing tweaks +don't need a migration: + +| Tier | Voice/mo | SMS/mo | Retry max | Retry min interval | +|---------|----------|--------|-----------|---------------------| +| FREE | 0 | 0 | 0 | 120s | +| PREMIUM | 20 | 30 | 3 | 30s | + +At 19,000đ/month with PREMIUM × 20 voice calls × 30s average at +~800đ/minute = ~8,000đ provider cost, leaving ~11,000đ margin. + +## Future work (declared, not done) + +- **Brandname SMS** for Vietnamese carriers — currently SMS is gated + behind `SPEEDSMS_ACCESS_TOKEN` but without brandname registration, + delivery rate is poor. Use Zalo ZNS as an intermediate. +- **Asset-Service tenantUserId denorm** — see step 6 above. +- **Payment-Service VNPay/MoMo** — today `PaymentSubscriptionListener` + waits on Kafka events that nothing publishes. Admin grant endpoint + is the demo workaround. +- **Frontend UI** — preferences + upgrade CTA + call history view. +- **Speech-to-text ack** — more natural than DTMF but adds Polly+ + Transcribe cost. diff --git a/src/main/java/com/isums/notificationservice/NotificationServiceApplication.java b/src/main/java/com/isums/notificationservice/NotificationServiceApplication.java index b8ffe0a..b5525e5 100644 --- a/src/main/java/com/isums/notificationservice/NotificationServiceApplication.java +++ b/src/main/java/com/isums/notificationservice/NotificationServiceApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class NotificationServiceApplication { public static void main(String[] args) { diff --git a/src/main/java/com/isums/notificationservice/configurations/KafkaConsumerConfig.java b/src/main/java/com/isums/notificationservice/configurations/KafkaConsumerConfig.java index 9b672d7..12d42a0 100644 --- a/src/main/java/com/isums/notificationservice/configurations/KafkaConsumerConfig.java +++ b/src/main/java/com/isums/notificationservice/configurations/KafkaConsumerConfig.java @@ -1,7 +1,7 @@ package com.isums.notificationservice.configurations; +import com.isums.notificationservice.infrastructures.exceptions.PermanentEventFailureException; import org.apache.kafka.clients.producer.ProducerConfig; -import org.apache.kafka.common.serialization.ByteArraySerializer; import org.apache.kafka.common.serialization.StringSerializer; import org.apache.kafka.common.TopicPartition; import org.springframework.beans.factory.annotation.Value; @@ -21,18 +21,42 @@ public class KafkaConsumerConfig { @Value("${spring.kafka.bootstrap-servers}") private String bootstrapServers; + /** + * KafkaTemplate<String, Object> for components that publish typed + * Java events ({@link com.isums.notificationservice.infrastructures.kafka.NotificationTranslationRequester}). + * Uses Spring Kafka's JsonSerializer so any record/POJO is serialised to + * JSON on send — matches the consumer side which deserialises with + * Jackson into the appropriate event class. + */ @Bean - public KafkaTemplate dltKafkaTemplate() { + public KafkaTemplate objectKafkaTemplate() { Map props = Map.of( ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers, ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class, - ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class + ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + org.springframework.kafka.support.serializer.JsonSerializer.class ); return new KafkaTemplate<>(new DefaultKafkaProducerFactory<>(props)); } @Bean - public DefaultErrorHandler kafkaErrorHandler(KafkaTemplate dltKafkaTemplate) { + public KafkaTemplate dltKafkaTemplate() { + // All upstream consumers use StringDeserializer so ConsumerRecord.value() + // arrives as String. The DLT producer previously used ByteArraySerializer + // which choked with a ClassCastException (String → byte[]) whenever + // DeadLetterPublishingRecoverer tried to republish — causing the record + // to loop on retry instead of landing in the DLT. StringSerializer mirrors + // the consumer side so DLT publication lines up. + Map props = Map.of( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers, + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class, + ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class + ); + return new KafkaTemplate<>(new DefaultKafkaProducerFactory<>(props)); + } + + @Bean + public DefaultErrorHandler kafkaErrorHandler(KafkaTemplate dltKafkaTemplate) { DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer( dltKafkaTemplate, @@ -49,7 +73,8 @@ public DefaultErrorHandler kafkaErrorHandler(KafkaTemplate dltKa tools.jackson.databind.exc.InvalidDefinitionException.class, tools.jackson.databind.exc.UnrecognizedPropertyException.class, IllegalArgumentException.class, - org.springframework.messaging.converter.MessageConversionException.class + org.springframework.messaging.converter.MessageConversionException.class, + PermanentEventFailureException.class ); return handler; diff --git a/src/main/java/com/isums/notificationservice/configurations/OpenApiConfig.java b/src/main/java/com/isums/notificationservice/configurations/OpenApiConfig.java index 89dc06a..9e5c87b 100644 --- a/src/main/java/com/isums/notificationservice/configurations/OpenApiConfig.java +++ b/src/main/java/com/isums/notificationservice/configurations/OpenApiConfig.java @@ -19,7 +19,7 @@ public OpenAPI openAPI() { .info(new Info() .title("EContract Service API") .version("v1") - .description("Có nhiều thứ rất là khó nói vậy nên là lá đò")) + .description("ISUMS service API documentation")) .addSecurityItem(new SecurityRequirement().addList(BEARER_SCHEME)) .components(new Components().addSecuritySchemes( BEARER_SCHEME, @@ -28,3 +28,4 @@ public OpenAPI openAPI() { )); } } + diff --git a/src/main/java/com/isums/notificationservice/configurations/SecurityConfig.java b/src/main/java/com/isums/notificationservice/configurations/SecurityConfig.java index 97171a3..2c76395 100644 --- a/src/main/java/com/isums/notificationservice/configurations/SecurityConfig.java +++ b/src/main/java/com/isums/notificationservice/configurations/SecurityConfig.java @@ -23,7 +23,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs", - "/v3/api-docs/**" + "/v3/api-docs/**", + "/api/notifications/voice/stringee-webhook", + "/api/notifications/voice/stringee-answer-url", + "/api/notifications/internal/**" ).permitAll() .anyRequest().authenticated() ) diff --git a/src/main/java/com/isums/notificationservice/configurations/VoiceNotificationConfig.java b/src/main/java/com/isums/notificationservice/configurations/VoiceNotificationConfig.java new file mode 100644 index 0000000..12b1424 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/configurations/VoiceNotificationConfig.java @@ -0,0 +1,77 @@ +package com.isums.notificationservice.configurations; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.polly.PollyClient; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.sns.SnsClient; + +import java.time.Duration; + +/** + * Beans for the voice-notification stack: Stringee RestClient (voice TTS) + * and AWS clients (SNS for SMS, Polly + S3 for pre-synth audio). + * + *

All values default to safe dev-only values so the service boots in a + * clean checkout — real credentials go into environment variables. + */ +@Configuration +public class VoiceNotificationConfig { + + @Value("${app.notification.stringee.base-url:https://api.stringee.com}") + private String stringeeBaseUrl; + + @Value("${app.notification.aws.region:ap-southeast-1}") + private String awsRegion; + + @Bean + public RestClient stringeeRestClient() { + return RestClient.builder() + .baseUrl(stringeeBaseUrl) + .requestFactory(clientHttpRequestFactory()) + .build(); + } + + private static org.springframework.http.client.ClientHttpRequestFactory clientHttpRequestFactory() { + var factory = new org.springframework.http.client.SimpleClientHttpRequestFactory(); + factory.setConnectTimeout((int) Duration.ofSeconds(3).toMillis()); + factory.setReadTimeout((int) Duration.ofSeconds(8).toMillis()); + return factory; + } + + @Bean + public PollyClient pollyClient() { + return PollyClient.builder() + .region(Region.of(awsRegion)) + .build(); + } + + @Bean + public S3Client voiceAudioS3Client() { + return S3Client.builder() + .region(Region.of(awsRegion)) + .build(); + } + + /** + * AWS SNS client for transactional SMS. Uses the default credential + * provider chain (env vars / instance profile / shared credentials), + * same as Polly + S3. Region is shared with the rest of the AWS + * stack via {@code app.notification.aws.region} (default + * {@code ap-southeast-1}). + * + *

Activated lazily — bean is always defined but the + * {@link com.isums.notificationservice.services.AwsSnsClient} + * SmsProvider only routes traffic through it when + * {@code app.notification.sms.provider=AWS_SNS}. + */ + @Bean + public SnsClient snsClient() { + return SnsClient.builder() + .region(Region.of(awsRegion)) + .build(); + } +} diff --git a/src/main/java/com/isums/notificationservice/controllers/InternalDispatchController.java b/src/main/java/com/isums/notificationservice/controllers/InternalDispatchController.java new file mode 100644 index 0000000..8930846 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/controllers/InternalDispatchController.java @@ -0,0 +1,62 @@ +package com.isums.notificationservice.controllers; + +import com.isums.notificationservice.domains.dtos.AlertDispatchRequest; +import com.isums.notificationservice.domains.dtos.AlertDispatchResponse; +import com.isums.notificationservice.domains.dtos.ApiResponse; +import com.isums.notificationservice.domains.dtos.ApiResponses; +import com.isums.notificationservice.services.NotificationDispatchService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Called from the AWS Lambda tier (esp32-threshold-checker + esp32-eif-score) + * after an IoT alert crosses a threshold. Skips JWT auth because Lambda runs + * under AWS IAM, not Keycloak — uses a shared X-Internal-Key instead. + * + *

Also usable from curl/Postman in dev by passing the header manually. + */ +@RestController +@RequestMapping("/api/notifications/internal") +@RequiredArgsConstructor +@Slf4j +public class InternalDispatchController { + + private final NotificationDispatchService dispatchService; + + @Value("${app.notification.internal.api-key:}") + private String internalApiKey; + + @PostMapping("/dispatch") + public ResponseEntity> dispatch( + HttpServletRequest request, + @RequestBody AlertDispatchRequest req) { + if (!isAuthorised(request)) { + log.warn("[InternalDispatch] unauthorised POST from {}", request.getRemoteAddr()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiResponses.fail(HttpStatus.UNAUTHORIZED, "Invalid internal key")); + } + + log.info("[InternalDispatch] userId={} event={} alertId={}", + req.userId(), req.eventType(), req.alertId()); + + AlertDispatchResponse resp = dispatchService.dispatch(req); + return ResponseEntity.ok(ApiResponses.ok(resp, "Dispatched")); + } + + private boolean isAuthorised(HttpServletRequest request) { + if (internalApiKey == null || internalApiKey.isBlank()) { + log.error("[InternalDispatch] app.notification.internal.api-key not configured — rejecting all requests"); + return false; + } + String header = request.getHeader("X-Internal-Key"); + return internalApiKey.equals(header); + } +} diff --git a/src/main/java/com/isums/notificationservice/controllers/ManagerNotificationController.java b/src/main/java/com/isums/notificationservice/controllers/ManagerNotificationController.java index 7fa2a5a..685ec6c 100644 --- a/src/main/java/com/isums/notificationservice/controllers/ManagerNotificationController.java +++ b/src/main/java/com/isums/notificationservice/controllers/ManagerNotificationController.java @@ -8,7 +8,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.web.bind.annotation.*; @@ -25,25 +27,27 @@ public class ManagerNotificationController { private final ManagerNotificationService service; private final SseConnectionManager sseManager; - // SSE endpoint — web connect 1 lần khi login @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) -// @PreAuthorize("hasAnyRole('MANAGER', 'LANDLORD')") - public SseEmitter stream(@AuthenticationPrincipal Jwt jwt) { + + public ResponseEntity stream(@AuthenticationPrincipal Jwt jwt) { UUID userId = UUID.fromString(jwt.getSubject()); SseEmitter emitter = sseManager.subscribe(userId); - // Gửi unread count ngay khi connect try { emitter.send(SseEmitter.event() .name("unread_count") .data(Map.of("count", service.countUnread(userId)))); } catch (Exception ignored) {} - return emitter; + return ResponseEntity.ok() + .contentType(MediaType.TEXT_EVENT_STREAM) + .header(HttpHeaders.CACHE_CONTROL, "no-cache, no-transform") + .header("X-Accel-Buffering", "no") + .body(emitter); } @GetMapping -// @PreAuthorize("hasAnyRole('MANAGER', 'LANDLORD')") + public ApiResponse> list( @AuthenticationPrincipal Jwt jwt, @RequestParam(defaultValue = "0") int page, @@ -53,7 +57,7 @@ public ApiResponse> list( } @GetMapping("/unread-count") -// @PreAuthorize("hasAnyRole('MANAGER', 'LANDLORD')") + public ApiResponse> unreadCount(@AuthenticationPrincipal Jwt jwt) { UUID userId = UUID.fromString(jwt.getSubject()); return ApiResponses.ok( @@ -62,7 +66,7 @@ public ApiResponse> unreadCount(@AuthenticationPrincipal Jwt j } @PutMapping("/{id}/read") -// @PreAuthorize("hasAnyRole('MANAGER', 'LANDLORD')") + public ApiResponse markRead( @PathVariable UUID id, @AuthenticationPrincipal Jwt jwt) { @@ -71,9 +75,10 @@ public ApiResponse markRead( } @PutMapping("/read-all") -// @PreAuthorize("hasAnyRole('MANAGER', 'LANDLORD')") + public ApiResponse markAllRead(@AuthenticationPrincipal Jwt jwt) { service.markAllRead(UUID.fromString(jwt.getSubject())); return ApiResponses.ok(null, "All marked as read"); } } + diff --git a/src/main/java/com/isums/notificationservice/controllers/NotificationPreferencesController.java b/src/main/java/com/isums/notificationservice/controllers/NotificationPreferencesController.java new file mode 100644 index 0000000..5dc382a --- /dev/null +++ b/src/main/java/com/isums/notificationservice/controllers/NotificationPreferencesController.java @@ -0,0 +1,114 @@ +package com.isums.notificationservice.controllers; + +import com.isums.notificationservice.domains.dtos.ApiResponse; +import com.isums.notificationservice.domains.dtos.ApiResponses; +import com.isums.notificationservice.domains.dtos.NotificationPreferencesDto; +import com.isums.notificationservice.domains.dtos.SubscriptionDto; +import com.isums.notificationservice.domains.dtos.UpdatePreferencesRequest; +import com.isums.notificationservice.domains.entities.UserNotificationPreferences; +import com.isums.notificationservice.services.NotificationPreferenceService; +import com.isums.notificationservice.services.NotificationQuotaService; +import com.isums.notificationservice.services.NotificationSubscriptionService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/notifications/preferences") +@RequiredArgsConstructor +public class NotificationPreferencesController { + + private final NotificationPreferenceService preferenceService; + private final NotificationSubscriptionService subscriptionService; + private final NotificationQuotaService quotaService; + + @GetMapping("/me") + public ResponseEntity> getMyPreferences( + @AuthenticationPrincipal Jwt jwt) { + UUID userId = UUID.fromString(jwt.getSubject()); + UserNotificationPreferences p = preferenceService.getOrCreate(userId); + return ResponseEntity.ok(ApiResponses.ok(preferenceService.toDto(p), "OK")); + } + + @PutMapping("/me") + public ResponseEntity> updateMyPreferences( + @AuthenticationPrincipal Jwt jwt, + @Valid @RequestBody UpdatePreferencesRequest req, + HttpServletRequest httpReq) { + UUID userId = UUID.fromString(jwt.getSubject()); + // Tier check (PREMIUM gate on voice / SMS) only applies to TENANT. + // Landlord & manager voice are allowed by role, not subscription. + boolean tierExempt = hasAnyRealmRole(jwt, "LANDLORD", "MANAGER"); + // PDPL audit metadata — must be captured at request boundary + // because the service layer doesn't see the Servlet API directly. + String clientIp = resolveClientIp(httpReq); + String userAgent = httpReq.getHeader("User-Agent"); + UserNotificationPreferences p = preferenceService.update( + userId, req, tierExempt, clientIp, userAgent); + return ResponseEntity.ok(ApiResponses.ok(preferenceService.toDto(p), "Updated")); + } + + /** + * Trust X-Forwarded-For only if the request transited Cloudflare + * (its CF-Connecting-IP is the real client). For local dev hits we + * fall back to the Servlet remote address. + */ + private static String resolveClientIp(HttpServletRequest req) { + String cf = req.getHeader("CF-Connecting-IP"); + if (cf != null && !cf.isBlank()) return cf.trim(); + String fwd = req.getHeader("X-Forwarded-For"); + if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim(); + return req.getRemoteAddr(); + } + + @SuppressWarnings("unchecked") + private static boolean hasAnyRealmRole(Jwt jwt, String... roles) { + Object realmAccess = jwt.getClaims().get("realm_access"); + if (!(realmAccess instanceof Map map)) return false; + Object rolesObj = map.get("roles"); + if (!(rolesObj instanceof java.util.Collection col)) return false; + for (Object r : col) { + for (String want : roles) { + if (want.equalsIgnoreCase(String.valueOf(r))) return true; + } + } + return false; + } + + @GetMapping("/me/subscription") + public ResponseEntity> getMySubscription( + @AuthenticationPrincipal Jwt jwt) { + UUID userId = UUID.fromString(jwt.getSubject()); + SubscriptionDto dto = subscriptionService.toDto( + preferenceService.getSubscriptionOrCreate(userId)); + return ResponseEntity.ok(ApiResponses.ok(dto, "OK")); + } + + @GetMapping("/me/quota") + public ResponseEntity>> getMyQuota( + @AuthenticationPrincipal Jwt jwt) { + UUID userId = UUID.fromString(jwt.getSubject()); + SubscriptionDto sub = subscriptionService.toDto( + preferenceService.getSubscriptionOrCreate(userId)); + long cooldown = quotaService.remainingRateLimitSec(userId); + Map body = Map.of( + "tier", sub.tier(), + "voiceQuotaMonthly", sub.voiceQuotaMonthly(), + "voiceUsedThisMonth", sub.voiceUsedThisMonth(), + "voiceRemaining", sub.voiceRemaining(), + "smsQuotaMonthly", sub.smsQuotaMonthly(), + "smsUsedThisMonth", sub.smsUsedThisMonth(), + "smsRemaining", sub.smsRemaining(), + "voiceRateLimitRemainingSec", cooldown, + "currentMonthKey", NotificationQuotaService.currentMonthKey() + ); + return ResponseEntity.ok(ApiResponses.ok(body, "OK")); + } +} diff --git a/src/main/java/com/isums/notificationservice/controllers/NotificationSubscriptionController.java b/src/main/java/com/isums/notificationservice/controllers/NotificationSubscriptionController.java new file mode 100644 index 0000000..662e0e1 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/controllers/NotificationSubscriptionController.java @@ -0,0 +1,76 @@ +package com.isums.notificationservice.controllers; + +import com.isums.notificationservice.domains.dtos.ApiResponse; +import com.isums.notificationservice.domains.dtos.ApiResponses; +import com.isums.notificationservice.domains.dtos.SubscriptionDto; +import com.isums.notificationservice.services.NotificationSubscriptionService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; +import java.util.UUID; + +/** + * Subscription lifecycle. Self-serve upgrade requires a valid payment + * transaction id — real integration with Payment-Service is via Kafka + * {@code payment.subscription-activated}; this endpoint exists to let + * admins grant PREMIUM for thesis demo + QA without running a real + * VNPay/MoMo charge. + */ +@RestController +@RequestMapping("/api/notifications/subscriptions") +@RequiredArgsConstructor +public class NotificationSubscriptionController { + + private final NotificationSubscriptionService subscriptionService; + + /** Admin-only shortcut for demos / customer support refunds. */ + @PostMapping("/admin/grant-premium") + @PreAuthorize("hasAnyRole('LANDLORD', 'SYSTEM_ADMIN')") + public ResponseEntity> adminGrant( + @RequestBody AdminGrantRequest req) { + var sub = subscriptionService.activatePremium(req.userId(), req.months()); + return ResponseEntity.ok(ApiResponses.ok( + subscriptionService.toDto(sub), "Premium granted")); + } + + @PostMapping("/admin/downgrade") + @PreAuthorize("hasAnyRole('LANDLORD', 'SYSTEM_ADMIN')") + public ResponseEntity>> adminDowngrade( + @RequestBody DowngradeRequest req) { + subscriptionService.downgradeToFree(req.userId()); + return ResponseEntity.ok(ApiResponses.ok( + Map.of("userId", req.userId(), "tier", "FREE"), "Downgraded")); + } + + /** + * Self-upgrade entrypoint — returns payment reference; the actual + * tier switch happens when Kafka {@code payment.subscription-activated} + * is consumed. Kept here for API completeness. + */ + @PostMapping("/me/upgrade") + public ResponseEntity>> selfUpgrade( + @AuthenticationPrincipal Jwt jwt, + @RequestBody UpgradeRequest req) { + UUID userId = UUID.fromString(jwt.getSubject()); + // Returning a payment ref here would normally involve Payment-Service; + // for the thesis build, the admin grant endpoint above is the demo path. + return ResponseEntity.ok(ApiResponses.ok( + Map.of( + "userId", userId, + "months", req.months(), + "amountVnd", 19000 * req.months(), + "paymentProvider", "VNPAY", + "note", "Complete payment to activate — consume payment.subscription-activated Kafka event" + ), + "Payment intent created")); + } + + public record AdminGrantRequest(UUID userId, int months) {} + public record DowngradeRequest(UUID userId) {} + public record UpgradeRequest(int months) {} +} diff --git a/src/main/java/com/isums/notificationservice/controllers/StringeeAnswerUrlController.java b/src/main/java/com/isums/notificationservice/controllers/StringeeAnswerUrlController.java new file mode 100644 index 0000000..cfcfc6e --- /dev/null +++ b/src/main/java/com/isums/notificationservice/controllers/StringeeAnswerUrlController.java @@ -0,0 +1,179 @@ +package com.isums.notificationservice.controllers; + +import com.isums.notificationservice.domains.dtos.SpeedSmsWebhookPayload; +import com.isums.notificationservice.domains.entities.VoiceCallJob; +import com.isums.notificationservice.infrastructures.repositories.VoiceCallJobRepository; +import com.isums.notificationservice.services.VoiceWebhookHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Stringee project-level Answer URL endpoint. + * + *

Stringee trial accounts (observed empirically) do not honour the + * per-action {@code event_url} on inline gather actions — DTMF digits + * are dropped instead of POSTed. The only event channel that fires + * reliably is the project Answer URL configured on console.stringee.com. + * That URL receives BOTH: + *

    + *
  1. Initial fetch when the call connects → return [talk, gather] SCCO.
  2. + *
  3. Gather callback when the user presses a digit → request includes + * {@code digit}/{@code digits} param or body field. We route through + * {@link VoiceWebhookHandler} (same DTMF state machine as production) + * and return [talk("Đã chuyển..."), hangup] SCCO.
  4. + *
+ * + *

For paid Stringee plans where per-action event_url works, + * {@code /stringee-gather} is still wired — both paths converge on the + * same handler. + */ +@RestController +@RequestMapping("/api/notifications/voice") +@RequiredArgsConstructor +@Slf4j +public class StringeeAnswerUrlController { + + private final VoiceCallJobRepository voiceJobRepo; + private final VoiceWebhookHandler webhookHandler; + private final ObjectMapper objectMapper; + + @Value("${app.notification.stringee.voice-name:vietnam_female}") + private String voiceName; + + /** Public base for Stringee callbacks — must be reachable from Stringee servers. */ + @Value("${app.notification.stringee.answer-url-base:https://api-dev.isums.pro}") + private String answerUrlBase; + + @RequestMapping(value = "/stringee-answer-url", + method = {RequestMethod.GET, RequestMethod.POST}) + public ResponseEntity>> answerUrl( + @RequestParam(value = "jobId", required = false) String jobIdParam, + @RequestParam(value = "customField", required = false) String customField, + @RequestParam(value = "custom_data", required = false) String customData, + @RequestParam(value = "call_id", required = false) String callIdParam, + @RequestParam(value = "dtmf", required = false) String dtmfParam, + @RequestParam Map allParams, + @RequestBody(required = false) String rawBody) { + + log.info("[StringeeAnswerUrl] hit jobId={} customField={} customData={} callId={} dtmf={} params={} body={}", + jobIdParam, customField, customData, callIdParam, dtmfParam, allParams, rawBody); + + // Stringee `input` action POSTs JSON like: + // {"time":"...", "dtmf":"2", "call_id":"...", + // "customField":"...", "timeout":false} + // Project-level Answer URL (initial fetch) sends GET/POST with + // call_id in query/body but NO `dtmf`. We branch on dtmf presence. + String bodyCallId = ""; + String bodyDtmf = ""; + String bodyCustom = ""; + if (rawBody != null && !rawBody.isBlank()) { + try { + JsonNode json = objectMapper.readTree(rawBody); + bodyCallId = json.path("call_id").asString(""); + bodyDtmf = json.path("dtmf").asString(""); + bodyCustom = json.path("customField").asString(""); + } catch (Exception e) { + log.warn("[StringeeAnswerUrl] body parse failed: {}", e.getMessage()); + } + } + + String dtmf = firstNonBlank(dtmfParam, bodyDtmf); + String callId = firstNonBlank(callIdParam, bodyCallId); + + // ── Branch 1: input action callback (digit pressed) ─────────── + // Stringee's `input` action POSTs the DTMF digit here as a one-way + // notification — it does NOT consume our response body to alter + // the call flow (verified empirically: returning SCCO arrays here + // is ignored). The user-facing ack ("Đã ghi nhận...") is part of + // the ORIGINAL SCCO trailing-talk that runs after input completes. + // Our only job here is to fire the BE-side escalation/quota state + // machine and 200 OK so Stringee marks the input event delivered. + if (dtmf != null && !dtmf.isBlank()) { + log.info("[StringeeAnswerUrl] DTMF received callId={} digit={}", callId, dtmf); + try { + webhookHandler.handle(new SpeedSmsWebhookPayload( + callId, "ANSWERED", null, null, + dtmf, null, null, null)); + } catch (Exception e) { + log.error("[StringeeAnswerUrl] DTMF handler failed callId={} digit={}: {}", + callId, dtmf, e.getMessage(), e); + } + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body(List.of()); + } + + // ── Branch 2: Initial answer URL fetch — return alert SCCO ─── + // Project-level answer URL doesn't include our jobId query param; + // Stringee echoes the callout's customField instead. Prefer jobId + // (per-call answer URL with explicit param) and fall back to + // customField (or legacy custom_data) which we always set to the + // same UUID at callout time. + String resolvedId = firstNonBlank(jobIdParam, customField, bodyCustom, customData); + + String text = "Canh bao tu he thong ISUMS."; + if (resolvedId != null && !resolvedId.isBlank()) { + try { + VoiceCallJob job = voiceJobRepo.findById(UUID.fromString(resolvedId)).orElse(null); + if (job != null && job.getRenderedText() != null && !job.getRenderedText().isBlank()) { + text = job.getRenderedText(); + } + } catch (IllegalArgumentException e) { + log.warn("[StringeeAnswerUrl] invalid id={}", resolvedId); + } + } + + // SCCO: [talk(TTS), input, talk(ack), hangup]. The `input` action + // notifies eventUrl with the DTMF digit (BE fires escalation in + // Branch 1) but Stringee proceeds to the NEXT action regardless + // of what eventUrl response says. The trailing talk(ack) + hangup + // guarantees the user hears confirmation before the call ends. + List> scco = new java.util.ArrayList<>(); + + Map talk = new HashMap<>(); + talk.put("action", "talk"); + talk.put("text", text); + talk.put("voice", voiceName); + scco.add(talk); + + Map input = new HashMap<>(); + input.put("action", "input"); + input.put("maxDigits", 1); + input.put("timeOut", 30); + input.put("submitOnHash", false); + input.put("eventUrl", + answerUrlBase + "/api/notifications/voice/stringee-answer-url"); + scco.add(input); + + // No trailing talk — Stringee trial prepends the trial-account + // disclaimer before every talk action, so an ack talk would + // duplicate the disclaimer and stretch the call into billable + // dead air (see StringeeClientImpl.buildSccoBody for full notes). + // Hangup right after input keeps the call short and clean. + scco.add(Map.of("action", "hangup")); + + // Force JSON Content-Type so Stringee parses correctly regardless + // of what Accept header it sent. + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body(scco); + } + + + private static String firstNonBlank(String... values) { + if (values == null) return ""; + for (String v : values) if (v != null && !v.isBlank()) return v; + return ""; + } +} diff --git a/src/main/java/com/isums/notificationservice/controllers/StringeeWebhookController.java b/src/main/java/com/isums/notificationservice/controllers/StringeeWebhookController.java new file mode 100644 index 0000000..c7e63fc --- /dev/null +++ b/src/main/java/com/isums/notificationservice/controllers/StringeeWebhookController.java @@ -0,0 +1,121 @@ +package com.isums.notificationservice.controllers; + +import com.isums.notificationservice.domains.dtos.ApiResponse; +import com.isums.notificationservice.domains.dtos.ApiResponses; +import com.isums.notificationservice.domains.dtos.SpeedSmsWebhookPayload; +import com.isums.notificationservice.services.VoiceWebhookHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +import java.util.Map; + +/** + * Stringee event_url callback — Stringee posts call status events + * (started, ringing, answered, ended, dtmf) to whatever URL is set on + * the project in the Stringee Console. + * + *

Payload shape differs from SpeedSMS: + * {@code {"event":"answered","call_id":"...","duration":12,"dtmf":"1",...}} + * + *

We map the relevant subset onto the existing + * {@link VoiceWebhookHandler}'s state machine so retry / escalation / + * DTMF opt-out behave the same regardless of provider. + */ +@RestController +@RequestMapping("/api/notifications/voice") +@RequiredArgsConstructor +@Slf4j +public class StringeeWebhookController { + + private final VoiceWebhookHandler webhookHandler; + private final ObjectMapper objectMapper; + + @PostMapping(value = "/stringee-webhook", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity>> webhook(@RequestBody String rawBody) { + log.info("[StringeeWebhook] received body={}", rawBody); + try { + JsonNode json = objectMapper.readTree(rawBody); + + // Stringee event payload (observed in production traffic): + // {"call_status":"created"|"ringing"|"answered"|"ended"|"dtmf_received", + // "call_id":"...", + // "duration":43, + // "answerDuration":37, + // "endCallCause":"USER_END_CALL", + // "endedBy":"EXTERNAL", + // "digit":"1" // only on dtmf_received + // } + // tools.jackson `asString()` throws on MissingNode — use the + // overload with a default value so absent fields don't crash. + String status = firstNonBlank( + json.path("call_status").asString(""), + json.path("event").asString("")); + String callId = json.path("call_id").asString(""); + String dtmf = firstNonBlank( + json.path("digit").asString(""), + json.path("dtmf").asString(""), + json.path("digits").asString("")); + int duration = json.path("duration").asInt(0); + int answerDuration = json.path("answerDuration").asInt(0); + String endCause = json.path("endCallCause").asString(""); + + String mappedStatus = mapStatus(status, answerDuration, endCause); + + // Created/ringing → no DB state change (job already DIALING). + if ("DIALING".equals(mappedStatus) && (dtmf == null || dtmf.isBlank())) { + return ResponseEntity.ok(ApiResponses.ok( + Map.of("processed", false, + "noop", true, + "status", status, + "callId", callId), + "ignored")); + } + + SpeedSmsWebhookPayload mapped = new SpeedSmsWebhookPayload( + callId, mappedStatus, + duration > 0 ? duration : answerDuration, + null, dtmf, null, null, null); + var job = webhookHandler.handle(mapped); + + return ResponseEntity.ok(ApiResponses.ok( + Map.of("processed", job.isPresent(), + "status", status, + "mapped", mappedStatus, + "callId", callId, + "dtmf", dtmf == null ? "" : dtmf), + "OK")); + } catch (Exception e) { + log.error("[StringeeWebhook] handle failed: {}", e.getMessage(), e); + return ResponseEntity.ok(ApiResponses.ok( + Map.of("processed", false, "error", e.getMessage()), + "Logged")); + } + } + + /** + * Maps Stringee call_status onto our internal VoiceCallStatus enum. + * "ended" is special: distinguishes ANSWERED-then-completed vs + * NO_ANSWER (call rang but never picked up) using answerDuration. + */ + private static String mapStatus(String stringeeStatus, int answerDuration, String endCause) { + return switch (stringeeStatus == null ? "" : stringeeStatus.toLowerCase()) { + case "answered" -> "ANSWERED"; + case "dtmf_received" -> "ANSWERED"; + case "no_answer" -> "NO_ANSWER"; + case "busy" -> "BUSY"; + case "failed", "error" -> "FAILED"; + case "ended" -> answerDuration > 0 ? "ANSWERED" : "NO_ANSWER"; + default -> "DIALING"; + }; + } + + private static String firstNonBlank(String... values) { + for (String v : values) if (v != null && !v.isBlank()) return v; + return ""; + } +} diff --git a/src/main/java/com/isums/notificationservice/controllers/SubscriptionPlanController.java b/src/main/java/com/isums/notificationservice/controllers/SubscriptionPlanController.java new file mode 100644 index 0000000..b7a0b8b --- /dev/null +++ b/src/main/java/com/isums/notificationservice/controllers/SubscriptionPlanController.java @@ -0,0 +1,81 @@ +package com.isums.notificationservice.controllers; + +import com.isums.notificationservice.domains.dtos.ApiResponse; +import com.isums.notificationservice.domains.dtos.ApiResponses; +import com.isums.notificationservice.domains.dtos.SubscriptionPlanDto; +import com.isums.notificationservice.domains.dtos.UpsertSubscriptionPlanRequest; +import com.isums.notificationservice.services.SubscriptionPlanService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +/** + * Subscription plans REST. Reads are public-ish (any authenticated + * user — tenants need this to render the upgrade picker); writes are + * landlord/admin only. + */ +@RestController +@RequestMapping("/api/notifications/subscriptions/plans") +@RequiredArgsConstructor +public class SubscriptionPlanController { + + private final SubscriptionPlanService planService; + + /** Customer-facing list — only active plans, sorted by sort_order. */ + @GetMapping + public ResponseEntity>> list() { + return ResponseEntity.ok(ApiResponses.ok( + planService.listActiveForCustomers(), "OK")); + } + + /** Admin / landlord catalogue view — includes deactivated rows. */ + @GetMapping("/admin") + @PreAuthorize("hasAnyRole('LANDLORD', 'ADMIN', 'SYSTEM_ADMIN')") + public ResponseEntity>> listAdmin() { + return ResponseEntity.ok(ApiResponses.ok( + planService.listAllForAdmin(), "OK")); + } + + @GetMapping("/{id}") + public ResponseEntity> get(@PathVariable UUID id) { + return ResponseEntity.ok(ApiResponses.ok(planService.getById(id), "OK")); + } + + @PostMapping + @PreAuthorize("hasAnyRole('LANDLORD', 'ADMIN', 'SYSTEM_ADMIN')") + public ResponseEntity> create( + @AuthenticationPrincipal Jwt jwt, + @Valid @RequestBody UpsertSubscriptionPlanRequest req) { + UUID actor = UUID.fromString(jwt.getSubject()); + return ResponseEntity.ok(ApiResponses.ok( + planService.create(req, actor), "Plan created")); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('LANDLORD', 'ADMIN', 'SYSTEM_ADMIN')") + public ResponseEntity> update( + @AuthenticationPrincipal Jwt jwt, + @PathVariable UUID id, + @Valid @RequestBody UpsertSubscriptionPlanRequest req) { + UUID actor = UUID.fromString(jwt.getSubject()); + return ResponseEntity.ok(ApiResponses.ok( + planService.update(id, req, actor), "Plan updated")); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('LANDLORD', 'ADMIN', 'SYSTEM_ADMIN')") + public ResponseEntity> deactivate( + @AuthenticationPrincipal Jwt jwt, + @PathVariable UUID id) { + UUID actor = UUID.fromString(jwt.getSubject()); + planService.deactivate(id, actor); + return ResponseEntity.ok(ApiResponses.ok(null, "Plan deactivated")); + } +} diff --git a/src/main/java/com/isums/notificationservice/controllers/TechnicianNotificationController.java b/src/main/java/com/isums/notificationservice/controllers/TechnicianNotificationController.java new file mode 100644 index 0000000..1d6dfef --- /dev/null +++ b/src/main/java/com/isums/notificationservice/controllers/TechnicianNotificationController.java @@ -0,0 +1,79 @@ +package com.isums.notificationservice.controllers; + +import com.isums.notificationservice.domains.dtos.ApiResponse; +import com.isums.notificationservice.domains.dtos.ApiResponses; +import com.isums.notificationservice.domains.dtos.NotificationDto; +import com.isums.notificationservice.infrastructures.Websockets.SseConnectionManager; +import com.isums.notificationservice.infrastructures.abstracts.ManagerNotificationService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/notifications/technician") +@RequiredArgsConstructor +public class TechnicianNotificationController { + + private final ManagerNotificationService service; + private final SseConnectionManager sseManager; + + @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public ResponseEntity stream(@AuthenticationPrincipal org.springframework.security.oauth2.jwt.Jwt jwt) { + UUID userId = UUID.fromString(jwt.getSubject()); + SseEmitter emitter = sseManager.subscribe(userId); + + try { + emitter.send(SseEmitter.event() + .name("unread_count") + .data(Map.of("count", service.countUnread(userId)))); + } catch (Exception ignored) { + } + + return ResponseEntity.ok() + .contentType(MediaType.TEXT_EVENT_STREAM) + .header(HttpHeaders.CACHE_CONTROL, "no-cache, no-transform") + .header("X-Accel-Buffering", "no") + .body(emitter); + } + + @GetMapping + public ApiResponse> list( + @AuthenticationPrincipal org.springframework.security.oauth2.jwt.Jwt jwt, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + UUID userId = UUID.fromString(jwt.getSubject()); + return ApiResponses.ok(service.getByRecipient(userId, PageRequest.of(page, size)), "Success"); + } + + @GetMapping("/unread-count") + public ApiResponse> unreadCount(@AuthenticationPrincipal org.springframework.security.oauth2.jwt.Jwt jwt) { + UUID userId = UUID.fromString(jwt.getSubject()); + return ApiResponses.ok( + Map.of("count", service.countUnread(userId)), + "Success"); + } + + @PutMapping("/{id}/read") + public ApiResponse markRead( + @PathVariable UUID id, + @AuthenticationPrincipal org.springframework.security.oauth2.jwt.Jwt jwt) { + service.markRead(id, UUID.fromString(jwt.getSubject())); + return ApiResponses.ok(null, "Marked as read"); + } + + @PutMapping("/read-all") + public ApiResponse markAllRead(@AuthenticationPrincipal org.springframework.security.oauth2.jwt.Jwt jwt) { + service.markAllRead(UUID.fromString(jwt.getSubject())); + return ApiResponses.ok(null, "All marked as read"); + } +} + diff --git a/src/main/java/com/isums/notificationservice/controllers/TenantNotificationController.java b/src/main/java/com/isums/notificationservice/controllers/TenantNotificationController.java new file mode 100644 index 0000000..78d8a95 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/controllers/TenantNotificationController.java @@ -0,0 +1,79 @@ +package com.isums.notificationservice.controllers; + +import com.isums.notificationservice.domains.dtos.ApiResponse; +import com.isums.notificationservice.domains.dtos.ApiResponses; +import com.isums.notificationservice.domains.dtos.NotificationDto; +import com.isums.notificationservice.infrastructures.Websockets.SseConnectionManager; +import com.isums.notificationservice.infrastructures.abstracts.ManagerNotificationService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/notifications/tenant") +@RequiredArgsConstructor +public class TenantNotificationController { + + private final ManagerNotificationService service; + private final SseConnectionManager sseManager; + + @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public ResponseEntity stream(@AuthenticationPrincipal org.springframework.security.oauth2.jwt.Jwt jwt) { + UUID userId = UUID.fromString(jwt.getSubject()); + SseEmitter emitter = sseManager.subscribe(userId); + + try { + emitter.send(SseEmitter.event() + .name("unread_count") + .data(Map.of("count", service.countUnread(userId)))); + } catch (Exception ignored) { + } + + return ResponseEntity.ok() + .contentType(MediaType.TEXT_EVENT_STREAM) + .header(HttpHeaders.CACHE_CONTROL, "no-cache, no-transform") + .header("X-Accel-Buffering", "no") + .body(emitter); + } + + @GetMapping + public ApiResponse> list( + @AuthenticationPrincipal org.springframework.security.oauth2.jwt.Jwt jwt, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + UUID userId = UUID.fromString(jwt.getSubject()); + return ApiResponses.ok(service.getByRecipient(userId, PageRequest.of(page, size)), "Success"); + } + + @GetMapping("/unread-count") + public ApiResponse> unreadCount(@AuthenticationPrincipal org.springframework.security.oauth2.jwt.Jwt jwt) { + UUID userId = UUID.fromString(jwt.getSubject()); + return ApiResponses.ok( + Map.of("count", service.countUnread(userId)), + "Success"); + } + + @PutMapping("/{id}/read") + public ApiResponse markRead( + @PathVariable UUID id, + @AuthenticationPrincipal org.springframework.security.oauth2.jwt.Jwt jwt) { + service.markRead(id, UUID.fromString(jwt.getSubject())); + return ApiResponses.ok(null, "Marked as read"); + } + + @PutMapping("/read-all") + public ApiResponse markAllRead(@AuthenticationPrincipal org.springframework.security.oauth2.jwt.Jwt jwt) { + service.markAllRead(UUID.fromString(jwt.getSubject())); + return ApiResponses.ok(null, "All marked as read"); + } +} + diff --git a/src/main/java/com/isums/notificationservice/controllers/TestVoiceController.java b/src/main/java/com/isums/notificationservice/controllers/TestVoiceController.java new file mode 100644 index 0000000..c9752fd --- /dev/null +++ b/src/main/java/com/isums/notificationservice/controllers/TestVoiceController.java @@ -0,0 +1,97 @@ +package com.isums.notificationservice.controllers; + +import com.isums.notificationservice.domains.dtos.AlertDispatchRequest; +import com.isums.notificationservice.domains.dtos.AlertDispatchResponse; +import com.isums.notificationservice.domains.dtos.ApiResponse; +import com.isums.notificationservice.domains.dtos.ApiResponses; +import com.isums.notificationservice.domains.enums.AlertEventType; +import com.isums.notificationservice.infrastructures.grpcs.UserGrpcClient; +import com.isums.notificationservice.services.NotificationDispatchService; +import com.isums.notificationservice.services.NotificationQuotaService; +import com.isums.userservice.grpc.UserResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; + +import java.time.Duration; +import java.util.Map; +import java.util.UUID; + +/** + * Self-serve test call: triggers a GAS_CRITICAL alert for the signed-in + * user. Rate-limited to 1 per day (Redis key) so QA / thesis demos don't + * burn an arbitrary amount of credit. Still counts against the monthly + * quota like any other dispatch. + */ +@RestController +@RequestMapping("/api/notifications/preferences/me") +@RequiredArgsConstructor +@Slf4j +public class TestVoiceController { + + private final NotificationDispatchService dispatchService; + private final UserGrpcClient userGrpcClient; + private final StringRedisTemplate redis; + + @PostMapping("/test-voice") + public ResponseEntity> testVoice( + @AuthenticationPrincipal Jwt jwt) { + UUID userId = UUID.fromString(jwt.getSubject()); + + String key = "notif:test-voice:daily:" + userId; + Boolean firstToday = redis.opsForValue().setIfAbsent(key, "1", Duration.ofDays(1)); + if (!Boolean.TRUE.equals(firstToday)) { + Long ttl = redis.getExpire(key); + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body( + ApiResponses.fail(HttpStatus.TOO_MANY_REQUESTS, + "Test call already used today. Next available in " + ttl + "s.")); + } + + // Pull mainHouseId from user-service so escalation can resolve the + // region's manager when the user presses DTMF=2. Without it, the + // test call would dial the tenant successfully but escalation + // resolves to null (no houseId → no region → no manager) — which + // is fine for "did my phone ring" but breaks the "press 2 to + // forward" demo path. + String houseId = null; + try { + UserResponse u = userGrpcClient.getUserByKeycloakId(userId.toString()); + String mainHouseId = readOptionalString(u, "getMainHouseId"); + if (mainHouseId != null && !mainHouseId.isBlank()) { + houseId = mainHouseId; + } + } catch (Exception e) { + log.warn("[TestVoice] mainHouseId lookup failed userId={}: {}", userId, e.getMessage()); + } + + AlertDispatchRequest req = new AlertDispatchRequest( + userId, + "test-" + UUID.randomUUID(), + AlertEventType.GAS_CRITICAL, + houseId, null, "Test Area", + "test-thing", + "gas_ppm", 325.0, "ppm", + Map.of("testMode", true) + ); + + AlertDispatchResponse resp = dispatchService.dispatch(req); + return ResponseEntity.ok(ApiResponses.ok(resp, "Test dispatch triggered")); + } + + private static String readOptionalString(Object target, String getterName) { + if (target == null) { + return null; + } + try { + Object value = target.getClass().getMethod(getterName).invoke(target); + return value instanceof String s ? s : null; + } catch (ReflectiveOperationException ignored) { + return null; + } + } +} diff --git a/src/main/java/com/isums/notificationservice/controllers/VoiceCallHistoryController.java b/src/main/java/com/isums/notificationservice/controllers/VoiceCallHistoryController.java new file mode 100644 index 0000000..2abd7fc --- /dev/null +++ b/src/main/java/com/isums/notificationservice/controllers/VoiceCallHistoryController.java @@ -0,0 +1,44 @@ +package com.isums.notificationservice.controllers; + +import com.isums.notificationservice.domains.dtos.ApiResponse; +import com.isums.notificationservice.domains.dtos.ApiResponses; +import com.isums.notificationservice.domains.dtos.VoiceCallDto; +import com.isums.notificationservice.domains.entities.VoiceCallJob; +import com.isums.notificationservice.infrastructures.repositories.VoiceCallJobRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/notifications/calls") +@RequiredArgsConstructor +public class VoiceCallHistoryController { + + private final VoiceCallJobRepository voiceJobRepo; + + @GetMapping("/me") + public ResponseEntity>> listMyCalls( + @AuthenticationPrincipal Jwt jwt, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + UUID userId = UUID.fromString(jwt.getSubject()); + Page jobs = voiceJobRepo.findAllByUserIdOrderByCreatedAtDesc( + userId, PageRequest.of(page, size)); + Page mapped = jobs.map(this::toDto); + return ResponseEntity.ok(ApiResponses.ok(mapped, "OK")); + } + + private VoiceCallDto toDto(VoiceCallJob j) { + return new VoiceCallDto( + j.getId(), j.getUserId(), j.getAlertId(), j.getEventType(), + j.getLocale(), j.getStatus(), j.getDtmfReceived(), + j.getAcknowledgedAt(), j.getAttemptNumber(), j.getMaxAttempts(), + j.getDurationSec(), j.getCostVnd(), j.getCreatedAt()); + } +} diff --git a/src/main/java/com/isums/notificationservice/domains/dtos/AlertDispatchRequest.java b/src/main/java/com/isums/notificationservice/domains/dtos/AlertDispatchRequest.java new file mode 100644 index 0000000..7803528 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/dtos/AlertDispatchRequest.java @@ -0,0 +1,31 @@ +package com.isums.notificationservice.domains.dtos; + +import com.isums.notificationservice.domains.enums.AlertEventType; + +import java.util.Map; +import java.util.UUID; + +/** + * Payload the IoT Lambda tier (esp32-threshold-checker / esp32-eif-score) + * POSTs to {@code /api/notifications/internal/dispatch} for multi-channel + * delivery to the tenant + landlord. + * + *

{@code userId} is the tenant; the Notification-Service resolves + * landlord via HouseGrpc fallback when escalation_target_user_id is unset. + * + *

{@code templateVars} are merged with standard fields (areaName, value, + * unit) for Mustache interpolation. + */ +public record AlertDispatchRequest( + UUID userId, + String alertId, + AlertEventType eventType, + String houseId, + String areaId, + String areaName, + String thing, + String metric, + Double value, + String unit, + Map templateVars +) {} diff --git a/src/main/java/com/isums/notificationservice/domains/dtos/AlertDispatchResponse.java b/src/main/java/com/isums/notificationservice/domains/dtos/AlertDispatchResponse.java new file mode 100644 index 0000000..5492d6b --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/dtos/AlertDispatchResponse.java @@ -0,0 +1,17 @@ +package com.isums.notificationservice.domains.dtos; + +import java.util.List; +import java.util.UUID; + +public record AlertDispatchResponse( + boolean ok, + UUID userId, + List results +) { + public record ChannelDispatchResult( + String channel, + String status, // SENT | SKIPPED | FAILED + String reason, // only set when not SENT + UUID voiceJobId // only for VOICE channel + ) {} +} diff --git a/src/main/java/com/isums/notificationservice/domains/dtos/NotificationDto.java b/src/main/java/com/isums/notificationservice/domains/dtos/NotificationDto.java index 76a5534..494a317 100644 --- a/src/main/java/com/isums/notificationservice/domains/dtos/NotificationDto.java +++ b/src/main/java/com/isums/notificationservice/domains/dtos/NotificationDto.java @@ -1,5 +1,6 @@ package com.isums.notificationservice.domains.dtos; +import com.isums.common.i18n.TranslationMap; import com.isums.notificationservice.domains.entities.ManagerNotification; import lombok.AllArgsConstructor; import lombok.Builder; @@ -10,6 +11,16 @@ import java.util.Map; import java.util.UUID; +/** + * Notification DTO. Carries both source-language text ({@code title}/{@code body}) + * and the per-locale translation maps so the FE can either display the active + * locale ({@code resolveTitle()}) or render an editor with all locales visible. + * + *

{@link #from(ManagerNotification)} is the unresolved factory — it copies + * everything and lets callers (or the FE) decide which locale to show. + * {@link #from(ManagerNotification, String)} resolves once on the server side + * for callers that just want a flat string. + */ @Data @Builder @NoArgsConstructor @@ -18,7 +29,9 @@ public class NotificationDto { private UUID id; private String category; private String title; + private Map titleTranslations; private String body; + private Map bodyTranslations; private String actionUrl; private Map metadata; private boolean isRead; @@ -29,11 +42,30 @@ public static NotificationDto from(ManagerNotification n) { .id(n.getId()) .category(n.getCategory().name()) .title(n.getTitle()) + .titleTranslations(n.getTitleTranslations() == null ? null : n.getTitleTranslations().asMap()) .body(n.getBody()) + .bodyTranslations(n.getBodyTranslations() == null ? null : n.getBodyTranslations().asMap()) .actionUrl(n.getActionUrl()) .metadata(n.getMetadata()) .isRead(n.isRead()) .createdAt(n.getCreatedAt()) .build(); } -} \ No newline at end of file + + public static NotificationDto from(ManagerNotification n, String preferredLocale) { + NotificationDto dto = from(n); + if (preferredLocale != null) { + TranslationMap titleMap = n.getTitleTranslations(); + if (titleMap != null) { + String resolved = titleMap.resolve(preferredLocale); + if (resolved != null && !resolved.isBlank()) dto.setTitle(resolved); + } + TranslationMap bodyMap = n.getBodyTranslations(); + if (bodyMap != null) { + String resolved = bodyMap.resolve(preferredLocale); + if (resolved != null && !resolved.isBlank()) dto.setBody(resolved); + } + } + return dto; + } +} diff --git a/src/main/java/com/isums/notificationservice/domains/dtos/NotificationPreferencesDto.java b/src/main/java/com/isums/notificationservice/domains/dtos/NotificationPreferencesDto.java new file mode 100644 index 0000000..39e218d --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/dtos/NotificationPreferencesDto.java @@ -0,0 +1,31 @@ +package com.isums.notificationservice.domains.dtos; + +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.domains.enums.VoiceGender; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalTime; +import java.util.UUID; + +public record NotificationPreferencesDto( + UUID userId, + LocaleType language, + boolean emailEnabled, + boolean pushEnabled, + boolean smsEnabled, + boolean voiceEnabled, + boolean quietHoursEnabled, + LocalTime quietHoursStart, + LocalTime quietHoursEnd, + boolean quietHoursOverrideCritical, + int voiceMaxRetries, + int voiceRetryIntervalSec, + int voiceRateLimitSec, + VoiceGender voiceGender, + BigDecimal voiceSpeed, + boolean dtmfAckEnabled, + boolean escalationEnabled, + UUID escalationTargetUserId, + Instant voiceConsentGivenAt +) {} diff --git a/src/main/java/com/isums/notificationservice/domains/dtos/SpeedSmsVoiceRequest.java b/src/main/java/com/isums/notificationservice/domains/dtos/SpeedSmsVoiceRequest.java new file mode 100644 index 0000000..98696fd --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/dtos/SpeedSmsVoiceRequest.java @@ -0,0 +1,44 @@ +package com.isums.notificationservice.domains.dtos; + +import java.util.UUID; + +/** + * Generic voice-call request handed to a {@code VoiceProvider}. Named + * "SpeedSms*" for back-compat with the original SpeedSMS-only design; + * field semantics are provider-agnostic. + * + *

For SpeedSMS Voice OTP only digits in {@code tts} are read.
+ * For Stringee, the provider fetches {@code answerUrlBase + ?jobId=...} + * to retrieve the SCCO at call-connect time, so {@code jobId} is the + * canonical join key. + */ +public record SpeedSmsVoiceRequest( + String phone, + String tts, + String audioUrl, + int loop, + String callbackUrl, + String callerIdName, + UUID jobId, + String voiceName, + boolean interactive +) { + /** Back-compat constructor — assumes interactive=true (tenant flow). */ + public SpeedSmsVoiceRequest(String phone, String tts, String audioUrl, + int loop, String callbackUrl, String callerIdName) { + this(phone, tts, audioUrl, loop, callbackUrl, callerIdName, null, null, true); + } + + /** Constructor without explicit voice — provider picks default. */ + public SpeedSmsVoiceRequest(String phone, String tts, String audioUrl, + int loop, String callbackUrl, String callerIdName, UUID jobId) { + this(phone, tts, audioUrl, loop, callbackUrl, callerIdName, jobId, null, true); + } + + /** Constructor without explicit interactive flag — defaults to true. */ + public SpeedSmsVoiceRequest(String phone, String tts, String audioUrl, + int loop, String callbackUrl, String callerIdName, + UUID jobId, String voiceName) { + this(phone, tts, audioUrl, loop, callbackUrl, callerIdName, jobId, voiceName, true); + } +} diff --git a/src/main/java/com/isums/notificationservice/domains/dtos/SpeedSmsVoiceResponse.java b/src/main/java/com/isums/notificationservice/domains/dtos/SpeedSmsVoiceResponse.java new file mode 100644 index 0000000..4dbb706 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/dtos/SpeedSmsVoiceResponse.java @@ -0,0 +1,8 @@ +package com.isums.notificationservice.domains.dtos; + +public record SpeedSmsVoiceResponse( + boolean ok, + String callId, + String status, + String errorMessage +) {} diff --git a/src/main/java/com/isums/notificationservice/domains/dtos/SpeedSmsWebhookPayload.java b/src/main/java/com/isums/notificationservice/domains/dtos/SpeedSmsWebhookPayload.java new file mode 100644 index 0000000..c1bcbaa --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/dtos/SpeedSmsWebhookPayload.java @@ -0,0 +1,17 @@ +package com.isums.notificationservice.domains.dtos; + +/** + * Webhook body SpeedSMS posts after a voice call completes. The exact + * field names may differ slightly between SpeedSMS plans; the controller + * tolerates extras via Jackson {@code @JsonIgnoreProperties}. + */ +public record SpeedSmsWebhookPayload( + String callId, + String status, // ANSWERED | NO_ANSWER | BUSY | FAILED + Integer duration, // seconds + Integer cost, // VND + String dtmf, // digit user pressed + String recordingUrl, + String errorMessage, + Long occurredAt +) {} diff --git a/src/main/java/com/isums/notificationservice/domains/dtos/SubscriptionDto.java b/src/main/java/com/isums/notificationservice/domains/dtos/SubscriptionDto.java new file mode 100644 index 0000000..d03bbbe --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/dtos/SubscriptionDto.java @@ -0,0 +1,20 @@ +package com.isums.notificationservice.domains.dtos; + +import com.isums.notificationservice.domains.enums.SubscriptionTier; + +import java.time.Instant; +import java.util.UUID; + +public record SubscriptionDto( + UUID userId, + SubscriptionTier tier, + Instant premiumStartedAt, + Instant premiumUntil, + int voiceQuotaMonthly, + int voiceUsedThisMonth, + int voiceRemaining, + int smsQuotaMonthly, + int smsUsedThisMonth, + int smsRemaining, + Instant quotaResetAt +) {} diff --git a/src/main/java/com/isums/notificationservice/domains/dtos/SubscriptionPlanDto.java b/src/main/java/com/isums/notificationservice/domains/dtos/SubscriptionPlanDto.java new file mode 100644 index 0000000..1ba80fd --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/dtos/SubscriptionPlanDto.java @@ -0,0 +1,19 @@ +package com.isums.notificationservice.domains.dtos; + +import java.time.Instant; +import java.util.UUID; + +public record SubscriptionPlanDto( + UUID id, + String code, + String nameTranslations, // raw i18n JSON — FE picks based on locale + Integer durationDays, + Integer priceVnd, + Integer voiceQuotaMonthly, + Integer smsQuotaMonthly, + Integer sortOrder, + Boolean isActive, + Boolean isFeatured, + Instant createdAt, + Instant updatedAt +) {} diff --git a/src/main/java/com/isums/notificationservice/domains/dtos/UpdatePreferencesRequest.java b/src/main/java/com/isums/notificationservice/domains/dtos/UpdatePreferencesRequest.java new file mode 100644 index 0000000..765ed9d --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/dtos/UpdatePreferencesRequest.java @@ -0,0 +1,50 @@ +package com.isums.notificationservice.domains.dtos; + +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.domains.enums.VoiceGender; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import java.math.BigDecimal; +import java.time.LocalTime; +import java.util.UUID; + +/** + * Every field optional — null = keep existing. Ranges validated here so + * a malformed app request never writes junk into preferences. + */ +public record UpdatePreferencesRequest( + LocaleType language, + Boolean emailEnabled, + Boolean pushEnabled, + Boolean smsEnabled, + Boolean voiceEnabled, + Boolean quietHoursEnabled, + LocalTime quietHoursStart, + LocalTime quietHoursEnd, + Boolean quietHoursOverrideCritical, + + @Min(0) @Max(5) + Integer voiceMaxRetries, + + @Min(30) @Max(600) + Integer voiceRetryIntervalSec, + + @Min(60) @Max(3600) + Integer voiceRateLimitSec, + + VoiceGender voiceGender, + + @DecimalMin("0.80") @DecimalMax("1.20") + BigDecimal voiceSpeed, + + Boolean dtmfAckEnabled, + Boolean escalationEnabled, + UUID escalationTargetUserId, + + // Explicit boolean — true to grant consent now, false to revoke, + // null to leave unchanged. Revoking also turns voice_enabled off. + Boolean voiceConsentGranted +) {} diff --git a/src/main/java/com/isums/notificationservice/domains/dtos/UpsertSubscriptionPlanRequest.java b/src/main/java/com/isums/notificationservice/domains/dtos/UpsertSubscriptionPlanRequest.java new file mode 100644 index 0000000..ad8a511 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/dtos/UpsertSubscriptionPlanRequest.java @@ -0,0 +1,25 @@ +package com.isums.notificationservice.domains.dtos; + +import jakarta.validation.constraints.*; + +/** + * Body shape for create + update endpoints. {@code code} is immutable + * once set so payment intents can rely on it as a stable reference; + * to "rename", deactivate the row and create a new one. + */ +public record UpsertSubscriptionPlanRequest( + @NotBlank @Size(max = 40) String code, + @Size(max = 4000) String nameTranslations, + @NotNull @Min(1) @Max(3650) Integer durationDays, + // 10.000đ là sàn VNPay khuyến nghị; thẻ Sacombank/ACB... reject + // dưới mức này. Cap 50tr giữ cho landlord khỏi gõ nhầm 9 số 0. + @NotNull + @Min(value = 10_000, message = "Giá tối thiểu là 10.000đ (theo hạn mức ngân hàng VNPay)") + @Max(value = 50_000_000, message = "Giá tối đa là 50.000.000đ") + Integer priceVnd, + @Min(0) Integer voiceQuotaMonthly, + @Min(0) Integer smsQuotaMonthly, + Integer sortOrder, + Boolean isActive, + Boolean isFeatured +) {} diff --git a/src/main/java/com/isums/notificationservice/domains/dtos/VoiceCallDto.java b/src/main/java/com/isums/notificationservice/domains/dtos/VoiceCallDto.java new file mode 100644 index 0000000..fa76e34 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/dtos/VoiceCallDto.java @@ -0,0 +1,23 @@ +package com.isums.notificationservice.domains.dtos; + +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.domains.enums.VoiceCallStatus; + +import java.time.Instant; +import java.util.UUID; + +public record VoiceCallDto( + UUID id, + UUID userId, + String alertId, + String eventType, + LocaleType locale, + VoiceCallStatus status, + String dtmfReceived, + Instant acknowledgedAt, + int attemptNumber, + int maxAttempts, + Integer durationSec, + Integer costVnd, + Instant createdAt +) {} diff --git a/src/main/java/com/isums/notificationservice/domains/entities/ChannelTemplate.java b/src/main/java/com/isums/notificationservice/domains/entities/ChannelTemplate.java new file mode 100644 index 0000000..9ad4393 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/entities/ChannelTemplate.java @@ -0,0 +1,57 @@ +package com.isums.notificationservice.domains.entities; + +import com.isums.notificationservice.domains.enums.NotificationChannel; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "channel_templates", + uniqueConstraints = @UniqueConstraint( + name = "uq_channel_tpl_key_channel", + columnNames = {"template_key", "channel"})) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ChannelTemplate { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "template_key", nullable = false, length = 100) + private String templateKey; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private NotificationChannel channel; + + @Column(name = "event_type", length = 80) + private String eventType; + + @Column(length = 50) + private String category; + + @Column(name = "recipient_type", length = 50) + private String recipientType; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Column(name = "created_by", length = 100) + private String createdBy; + + @Column(name = "updated_by", length = 100) + private String updatedBy; +} diff --git a/src/main/java/com/isums/notificationservice/domains/entities/ChannelTemplateVersion.java b/src/main/java/com/isums/notificationservice/domains/entities/ChannelTemplateVersion.java new file mode 100644 index 0000000..8a1070c --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/entities/ChannelTemplateVersion.java @@ -0,0 +1,74 @@ +package com.isums.notificationservice.domains.entities; + +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.domains.enums.TemplateStatus; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.UpdateTimestamp; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "channel_template_versions", + uniqueConstraints = @UniqueConstraint( + name = "uq_ch_tpl_ver", + columnNames = {"template_id", "locale", "version"})) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ChannelTemplateVersion { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "template_id", nullable = false) + private ChannelTemplate template; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private LocaleType locale; + + @Column(nullable = false) + private int version; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @Builder.Default + private TemplateStatus status = TemplateStatus.DRAFT; + + @Column(nullable = false, columnDefinition = "text") + private String body; + + @Column(columnDefinition = "text") + private String ssml; + + @Column(length = 200) + private String title; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "allowed_vars", columnDefinition = "jsonb") + private List allowedVars; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Column(name = "created_by", length = 100) + private String createdBy; + + @Column(name = "updated_by", length = 100) + private String updatedBy; +} diff --git a/src/main/java/com/isums/notificationservice/domains/entities/ManagerNotification.java b/src/main/java/com/isums/notificationservice/domains/entities/ManagerNotification.java index febeee9..d0c166b 100644 --- a/src/main/java/com/isums/notificationservice/domains/entities/ManagerNotification.java +++ b/src/main/java/com/isums/notificationservice/domains/entities/ManagerNotification.java @@ -1,5 +1,7 @@ package com.isums.notificationservice.domains.entities; +import com.isums.common.i18n.TranslationMap; +import com.isums.common.i18n.TranslationMapConverter; import com.isums.notificationservice.domains.enums.NotificationCategory; import jakarta.persistence.*; import lombok.AllArgsConstructor; @@ -42,9 +44,17 @@ public class ManagerNotification { @Column(nullable = false) private String title; + @Column(name = "title_translations", columnDefinition = "text") + @Convert(converter = TranslationMapConverter.class) + private TranslationMap titleTranslations; + @Column(nullable = false, columnDefinition = "text") private String body; + @Column(name = "body_translations", columnDefinition = "text") + @Convert(converter = TranslationMapConverter.class) + private TranslationMap bodyTranslations; + private String actionUrl; @JdbcTypeCode(SqlTypes.JSON) diff --git a/src/main/java/com/isums/notificationservice/domains/entities/NotificationSubscription.java b/src/main/java/com/isums/notificationservice/domains/entities/NotificationSubscription.java new file mode 100644 index 0000000..089fb5f --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/entities/NotificationSubscription.java @@ -0,0 +1,63 @@ +package com.isums.notificationservice.domains.entities; + +import com.isums.notificationservice.domains.enums.SubscriptionTier; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "notification_subscriptions") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class NotificationSubscription { + + @Id + @Column(name = "user_id", columnDefinition = "uuid") + private UUID userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @Builder.Default + private SubscriptionTier tier = SubscriptionTier.FREE; + + @Column(name = "premium_started_at") + private Instant premiumStartedAt; + + @Column(name = "premium_until") + private Instant premiumUntil; + + @Column(name = "voice_quota_monthly", nullable = false) + @Builder.Default + private int voiceQuotaMonthly = 0; + + @Column(name = "voice_used_this_month", nullable = false) + @Builder.Default + private int voiceUsedThisMonth = 0; + + @Column(name = "sms_quota_monthly", nullable = false) + @Builder.Default + private int smsQuotaMonthly = 0; + + @Column(name = "sms_used_this_month", nullable = false) + @Builder.Default + private int smsUsedThisMonth = 0; + + @Column(name = "quota_reset_at", nullable = false) + @Builder.Default + private Instant quotaResetAt = Instant.now(); + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/src/main/java/com/isums/notificationservice/domains/entities/SubscriptionPlan.java b/src/main/java/com/isums/notificationservice/domains/entities/SubscriptionPlan.java new file mode 100644 index 0000000..4951dc9 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/entities/SubscriptionPlan.java @@ -0,0 +1,85 @@ +package com.isums.notificationservice.domains.entities; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.Instant; +import java.util.UUID; + +/** + * Landlord-editable plan catalogue. Each row defines the price + duration + * + bundled quotas for one PREMIUM tier (e.g. {@code PREMIUM_1M}, + * {@code PREMIUM_ANNUAL}, custom promo SKUs). + * + *

Why DAYS for duration? Lets the operator offer trial periods (7, + * 14 days), short-term promos, and standard month-aligned plans without + * branching. The activation logic translates {@code duration_days} to + * an {@code Instant} expiry by adding to {@code now()} or to the + * existing {@code premium_until} (top-up semantics). + * + *

{@code name_translations} stores the ISUMS i18n JSON blob (the + * same shape used by every other translatable column in the monorepo), + * so the FE can render the same plan in vi / en / ja without an extra + * lookup. + */ +@Entity +@Table(name = "subscription_plans") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SubscriptionPlan { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false, unique = true, length = 40) + private String code; + + @Column(name = "name_translations", columnDefinition = "text") + private String nameTranslations; + + @Column(name = "duration_days", nullable = false) + private Integer durationDays; + + @Column(name = "price_vnd", nullable = false) + private Integer priceVnd; + + @Column(name = "voice_quota_monthly", nullable = false) + @Builder.Default + private Integer voiceQuotaMonthly = 100; + + @Column(name = "sms_quota_monthly", nullable = false) + @Builder.Default + private Integer smsQuotaMonthly = 200; + + @Column(name = "sort_order", nullable = false) + @Builder.Default + private Integer sortOrder = 0; + + @Column(name = "is_active", nullable = false) + @Builder.Default + private Boolean isActive = true; + + @Column(name = "is_featured", nullable = false) + @Builder.Default + private Boolean isFeatured = false; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Column(name = "created_by") + private UUID createdBy; + + @Column(name = "updated_by") + private UUID updatedBy; +} diff --git a/src/main/java/com/isums/notificationservice/domains/entities/UserNotificationPreferences.java b/src/main/java/com/isums/notificationservice/domains/entities/UserNotificationPreferences.java new file mode 100644 index 0000000..a06e9d3 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/entities/UserNotificationPreferences.java @@ -0,0 +1,138 @@ +package com.isums.notificationservice.domains.entities; + +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.domains.enums.VoiceGender; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalTime; +import java.util.UUID; + +@Entity +@Table(name = "user_notification_preferences") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserNotificationPreferences { + + @Id + @Column(name = "user_id", columnDefinition = "uuid") + private UUID userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @Builder.Default + private LocaleType language = LocaleType.vi_VN; + + @Column(name = "email_enabled", nullable = false) + @Builder.Default + private boolean emailEnabled = true; + + @Column(name = "push_enabled", nullable = false) + @Builder.Default + private boolean pushEnabled = true; + + @Column(name = "sms_enabled", nullable = false) + @Builder.Default + private boolean smsEnabled = false; + + @Column(name = "voice_enabled", nullable = false) + @Builder.Default + private boolean voiceEnabled = false; + + /** + * Master switch for the quiet-hours window. When false, alerts ride + * through any time of day regardless of {@code quietHoursStart}/ + * {@code quietHoursEnd}. Defaults to true so existing behaviour + * stays the same after migration. + */ + @Column(name = "quiet_hours_enabled", nullable = false) + @Builder.Default + private boolean quietHoursEnabled = true; + + @Column(name = "quiet_hours_start", nullable = false) + @Builder.Default + private LocalTime quietHoursStart = LocalTime.of(22, 0); + + @Column(name = "quiet_hours_end", nullable = false) + @Builder.Default + private LocalTime quietHoursEnd = LocalTime.of(6, 0); + + @Column(name = "quiet_hours_override_critical", nullable = false) + @Builder.Default + private boolean quietHoursOverrideCritical = true; + + // voiceMaxRetries=1 → 2 total dial attempts (1 initial + 1 retry). + // After both fail to answer, VoiceWebhookHandler.scheduleRetryOrEscalate + // escalates to MANAGER with reason=NO_ANSWER_MAX_RETRIES. + @Column(name = "voice_max_retries", nullable = false) + @Builder.Default + private int voiceMaxRetries = 1; + + // Short retry interval — emergency alerts shouldn't wait 2 minutes + // between calls. 60s gives the user enough time to glance at the + // phone screen without making the system feel slow. + @Column(name = "voice_retry_interval_sec", nullable = false) + @Builder.Default + private int voiceRetryIntervalSec = 60; + + @Column(name = "voice_rate_limit_sec", nullable = false) + @Builder.Default + private int voiceRateLimitSec = 300; + + @Enumerated(EnumType.STRING) + @Column(name = "voice_gender", nullable = false, length = 10) + @Builder.Default + private VoiceGender voiceGender = VoiceGender.FEMALE; + + @Column(name = "voice_speed", nullable = false, precision = 3, scale = 2) + @Builder.Default + private BigDecimal voiceSpeed = new BigDecimal("1.00"); + + @Column(name = "dtmf_ack_enabled", nullable = false) + @Builder.Default + private boolean dtmfAckEnabled = true; + + @Column(name = "escalation_enabled", nullable = false) + @Builder.Default + private boolean escalationEnabled = true; + + @Column(name = "escalation_target_user_id") + private UUID escalationTargetUserId; + + @Column(name = "voice_consent_given_at") + private Instant voiceConsentGivenAt; + + /** + * Version of the T&C text the user agreed to. When the legal team + * publishes new wording, prior consents become "stale" and the user + * is re-prompted on next login. PDPL audit requirement. + */ + @Column(name = "voice_consent_text_version", length = 20) + private String voiceConsentTextVersion; + + /** + * IP that submitted the grant — required by PDPL Điều 11 for + * non-repudiation. Stored as PostgreSQL {@code inet} via String. + */ + @org.hibernate.annotations.JdbcTypeCode(org.hibernate.type.SqlTypes.INET) + @Column(name = "voice_consent_ip", columnDefinition = "inet") + private String voiceConsentIp; + + @Column(name = "voice_consent_user_agent", columnDefinition = "text") + private String voiceConsentUserAgent; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/src/main/java/com/isums/notificationservice/domains/entities/VoiceAudioCache.java b/src/main/java/com/isums/notificationservice/domains/entities/VoiceAudioCache.java new file mode 100644 index 0000000..322c91f --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/entities/VoiceAudioCache.java @@ -0,0 +1,69 @@ +package com.isums.notificationservice.domains.entities; + +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.domains.enums.VoiceGender; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "voice_audio_cache") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class VoiceAudioCache { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "cache_key", nullable = false, unique = true, length = 128) + private String cacheKey; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private LocaleType locale; + + @Enumerated(EnumType.STRING) + @Column(name = "voice_gender", nullable = false, length = 10) + private VoiceGender voiceGender; + + @Column(name = "voice_speed", nullable = false, precision = 3, scale = 2) + private BigDecimal voiceSpeed; + + @Column(name = "rendered_text", nullable = false, columnDefinition = "text") + private String renderedText; + + @Column(name = "s3_bucket", nullable = false, length = 200) + private String s3Bucket; + + @Column(name = "s3_key", nullable = false, length = 400) + private String s3Key; + + @Column(name = "public_url", nullable = false, columnDefinition = "text") + private String publicUrl; + + @Column(name = "duration_sec") + private Integer durationSec; + + @Column(name = "bytes") + private Integer bytes; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "last_used_at", nullable = false) + @Builder.Default + private Instant lastUsedAt = Instant.now(); + + @Column(name = "hit_count", nullable = false) + @Builder.Default + private int hitCount = 0; +} diff --git a/src/main/java/com/isums/notificationservice/domains/entities/VoiceCallEscalation.java b/src/main/java/com/isums/notificationservice/domains/entities/VoiceCallEscalation.java new file mode 100644 index 0000000..6ccaa97 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/entities/VoiceCallEscalation.java @@ -0,0 +1,40 @@ +package com.isums.notificationservice.domains.entities; + +import com.isums.notificationservice.domains.enums.EscalationReason; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "voice_call_escalations") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class VoiceCallEscalation { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "original_call_id", nullable = false, columnDefinition = "uuid") + private UUID originalCallId; + + @Column(name = "escalated_call_id", columnDefinition = "uuid") + private UUID escalatedCallId; + + @Column(name = "escalated_to_user_id", nullable = false, columnDefinition = "uuid") + private UUID escalatedToUserId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 40) + private EscalationReason reason; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; +} diff --git a/src/main/java/com/isums/notificationservice/domains/entities/VoiceCallJob.java b/src/main/java/com/isums/notificationservice/domains/entities/VoiceCallJob.java new file mode 100644 index 0000000..f3e3a0b --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/entities/VoiceCallJob.java @@ -0,0 +1,128 @@ +package com.isums.notificationservice.domains.entities; + +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.domains.enums.VoiceCallStatus; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "voice_call_jobs", indexes = { + @Index(name = "ix_voice_job_user_created", columnList = "user_id, created_at DESC"), + @Index(name = "ix_voice_job_status_retry", columnList = "status, next_retry_at"), + @Index(name = "ix_voice_job_provider_call", columnList = "provider_call_id"), + @Index(name = "ix_voice_job_alert", columnList = "alert_id") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class VoiceCallJob { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "user_id", nullable = false, columnDefinition = "uuid") + private UUID userId; + + @Column(name = "alert_id", length = 100) + private String alertId; + + @Column(name = "event_type", nullable = false, length = 80) + private String eventType; + + @Column(nullable = false, length = 40) + private String phone; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private LocaleType locale; + + @Column(name = "template_id", columnDefinition = "uuid") + private UUID templateId; + + @Column(name = "template_version_id", columnDefinition = "uuid") + private UUID templateVersionId; + + @Column(name = "rendered_text", nullable = false, columnDefinition = "text") + private String renderedText; + + // Denormalised alert context — used by the webhook handler to + // re-dispatch the same alert to landlord / manager on DTMF=2 or + // NO_ANSWER_MAX_RETRIES without re-querying DynamoDB. + @Column(name = "house_id", length = 64) + private String houseId; + + @Column(name = "area_id", length = 64) + private String areaId; + + @Column(name = "area_name", length = 200) + private String areaName; + + @Column(length = 100) + private String thing; + + @Column(length = 40) + private String metric; + + @Column(name = "alert_value", precision = 10, scale = 2) + private java.math.BigDecimal alertValue; + + @Column(name = "alert_unit", length = 20) + private String alertUnit; + + @Column(nullable = false, length = 20) + @Builder.Default + private String provider = "SPEEDSMS"; + + @Column(name = "provider_call_id", length = 100) + private String providerCallId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @Builder.Default + private VoiceCallStatus status = VoiceCallStatus.PENDING; + + @Column(name = "dtmf_received", length = 10) + private String dtmfReceived; + + @Column(name = "acknowledged_at") + private Instant acknowledgedAt; + + @Column(name = "attempt_number", nullable = false) + @Builder.Default + private int attemptNumber = 1; + + @Column(name = "max_attempts", nullable = false) + @Builder.Default + private int maxAttempts = 3; + + @Column(name = "next_retry_at") + private Instant nextRetryAt; + + @Column(name = "duration_sec") + private Integer durationSec; + + @Column(name = "cost_vnd") + private Integer costVnd; + + @Column(name = "recording_url", columnDefinition = "text") + private String recordingUrl; + + @Column(name = "error_message", columnDefinition = "text") + private String errorMessage; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/src/main/java/com/isums/notificationservice/domains/entities/VoiceConsentHistory.java b/src/main/java/com/isums/notificationservice/domains/entities/VoiceConsentHistory.java new file mode 100644 index 0000000..6cb9d25 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/entities/VoiceConsentHistory.java @@ -0,0 +1,72 @@ +package com.isums.notificationservice.domains.entities; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; +import java.util.UUID; + +/** + * Append-only audit row — one entry per voice consent state transition. + * + *

Mandated by PDPL (Nghị định 13/2023/NĐ-CP, Điều 11–13) and Thông + * tư 22/2021/TT-BTTTT: every grant or revoke must be reproducible from + * audit data, including the exact text version the user agreed to, the + * IP address they submitted from, and their user agent. + * + *

Retention: 5 years (telecom compliance window). Rows are NEVER + * deleted — admin downgrades or auto-expiry produce a new row with + * action=REVOKED|EXPIRED instead. + */ +@Entity +@Table(name = "voice_consent_history") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class VoiceConsentHistory { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "user_id", nullable = false) + private UUID userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private Action action; + + @Column(name = "text_version", length = 20) + private String textVersion; + + /** PostgreSQL inet — store as string in JPA for portability. */ + @JdbcTypeCode(SqlTypes.INET) + @Column(columnDefinition = "inet") + private String ip; + + @Column(name = "user_agent", columnDefinition = "text") + private String userAgent; + + @Enumerated(EnumType.STRING) + @Column(name = "initiated_by", nullable = false, length = 20) + @Builder.Default + private InitiatedBy initiatedBy = InitiatedBy.USER; + + @Column(name = "initiated_by_id") + private UUID initiatedById; + + @Column(columnDefinition = "text") + private String note; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + public enum Action { GRANTED, REVOKED, EXPIRED } + public enum InitiatedBy { USER, ADMIN, SYSTEM } +} diff --git a/src/main/java/com/isums/notificationservice/domains/enums/AlertEventType.java b/src/main/java/com/isums/notificationservice/domains/enums/AlertEventType.java new file mode 100644 index 0000000..cac26e5 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/enums/AlertEventType.java @@ -0,0 +1,58 @@ +package com.isums.notificationservice.domains.enums; + +/** + * Event types dispatched through the multi-channel notification pipeline. + * Must match the {@code event_type} string used in template seeding and + * the payload from the esp32 Lambda tier. + * + *

Severity hint: *_CRITICAL events bypass quiet hours when the user's + * {@code quiet_hours_override_critical} is TRUE (default). + */ +public enum AlertEventType { + // Critical — safety / immediate action + GAS_CRITICAL, // MQ2 over 300 ppm + FIRE_CRITICAL, // temperature > 55°C + POWER_LOST, // controller reports PZEM outage + WATER_LEAK_SUSPECTED, // > 10 min continuous flow + + // Warning — attention needed but not immediate + GAS_WARNING, + TEMPERATURE_HIGH, + HUMIDITY_HIGH, + HUMIDITY_LOW, + VOLTAGE_ABNORMAL, + CURRENT_HIGH, + POWER_HIGH, + FREQUENCY_ABNORMAL, + WATER_FLOW_HIGH, + + // Utility monthly threshold alerts from Asset-Service + UTILITY_ELECTRICITY_WARNING, + UTILITY_WATER_WARNING, + UTILITY_ELECTRICITY_CRITICAL, + UTILITY_WATER_CRITICAL, + + // Info — good news + POWER_RESTORED, + + // AI-derived anomaly + EIF_ANOMALY_POWER, + EIF_ANOMALY_WATER; + + public boolean isCritical() { + return severity() == AlertSeverity.CRITICAL; + } + + /** + * Maps the event type to its three-level severity used by the + * routing matrix in {@code NotificationDispatchService}. + */ + public AlertSeverity severity() { + return switch (this) { + case GAS_CRITICAL, FIRE_CRITICAL, POWER_LOST, WATER_LEAK_SUSPECTED, + UTILITY_ELECTRICITY_CRITICAL, UTILITY_WATER_CRITICAL -> AlertSeverity.CRITICAL; + case POWER_RESTORED -> AlertSeverity.INFO; + default -> AlertSeverity.WARNING; + }; + } +} diff --git a/src/main/java/com/isums/notificationservice/domains/enums/AlertSeverity.java b/src/main/java/com/isums/notificationservice/domains/enums/AlertSeverity.java new file mode 100644 index 0000000..bd571bd --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/enums/AlertSeverity.java @@ -0,0 +1,21 @@ +package com.isums.notificationservice.domains.enums; + +/** + * Three-level severity classification used by the routing matrix. + * + *

    + *
  • {@code CRITICAL} — immediate safety risk (gas leak, fire, power + * lost, water leak). Voice + SMS to tenant AND landlord; manager + * gets SMS (voice as fallback if landlord doesn't ack).
  • + *
  • {@code WARNING} — attention needed but not immediate (high temp, + * gas warning, EIF anomaly). Voice tenant, SMS landlord, email + * digest manager.
  • + *
  • {@code INFO} — good news / status (power restored). Push + + * email tenant only; landlord/manager skip.
  • + *
+ */ +public enum AlertSeverity { + CRITICAL, + WARNING, + INFO +} diff --git a/src/main/java/com/isums/notificationservice/domains/enums/EscalationReason.java b/src/main/java/com/isums/notificationservice/domains/enums/EscalationReason.java new file mode 100644 index 0000000..6a733ef --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/enums/EscalationReason.java @@ -0,0 +1,7 @@ +package com.isums.notificationservice.domains.enums; + +public enum EscalationReason { + NO_ANSWER_MAX_RETRIES, + DTMF_KEY_2, + MANUAL +} diff --git a/src/main/java/com/isums/notificationservice/domains/enums/LocaleType.java b/src/main/java/com/isums/notificationservice/domains/enums/LocaleType.java index f815065..e57676a 100644 --- a/src/main/java/com/isums/notificationservice/domains/enums/LocaleType.java +++ b/src/main/java/com/isums/notificationservice/domains/enums/LocaleType.java @@ -1,6 +1,11 @@ package com.isums.notificationservice.domains.enums; +/** + * Locale codes used as the template folder suffix, e.g. templates are + * named {baseKey}_vi_VN, {baseKey}_en_US, {baseKey}_ja_JP. + */ public enum LocaleType { vi_VN, - en_US + en_US, + ja_JP } diff --git a/src/main/java/com/isums/notificationservice/domains/enums/NotificationCategory.java b/src/main/java/com/isums/notificationservice/domains/enums/NotificationCategory.java index b18ac1c..24fb731 100644 --- a/src/main/java/com/isums/notificationservice/domains/enums/NotificationCategory.java +++ b/src/main/java/com/isums/notificationservice/domains/enums/NotificationCategory.java @@ -3,6 +3,11 @@ public enum NotificationCategory { CONTRACT_EXPIRED, INSPECTION_DONE, + CONTRACT_READY_FOR_LANDLORD_SIGNATURE, + CONTRACT_COMPLETED, + CONTRACT_CANCELLED_BY_TENANT, + ISSUE_WORK_SLOT_CREATED, + ISSUE_QUOTE_WAITING_MANAGER_APPROVAL, RENEWAL_REQUEST, PAYMENT_OVERDUE, DEPOSIT_REFUND_CONFIRM diff --git a/src/main/java/com/isums/notificationservice/domains/enums/NotificationChannel.java b/src/main/java/com/isums/notificationservice/domains/enums/NotificationChannel.java new file mode 100644 index 0000000..274c202 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/enums/NotificationChannel.java @@ -0,0 +1,9 @@ +package com.isums.notificationservice.domains.enums; + +public enum NotificationChannel { + EMAIL, + PUSH, + SMS, + VOICE, + ZNS +} diff --git a/src/main/java/com/isums/notificationservice/domains/enums/RecipientRole.java b/src/main/java/com/isums/notificationservice/domains/enums/RecipientRole.java new file mode 100644 index 0000000..e9f4493 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/enums/RecipientRole.java @@ -0,0 +1,7 @@ +package com.isums.notificationservice.domains.enums; + +public enum RecipientRole { + TENANT, + LANDLORD, + MANAGER +} diff --git a/src/main/java/com/isums/notificationservice/domains/enums/SubscriptionTier.java b/src/main/java/com/isums/notificationservice/domains/enums/SubscriptionTier.java new file mode 100644 index 0000000..c326c3e --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/enums/SubscriptionTier.java @@ -0,0 +1,6 @@ +package com.isums.notificationservice.domains.enums; + +public enum SubscriptionTier { + FREE, + PREMIUM +} diff --git a/src/main/java/com/isums/notificationservice/domains/enums/VoiceCallStatus.java b/src/main/java/com/isums/notificationservice/domains/enums/VoiceCallStatus.java new file mode 100644 index 0000000..dc55dcb --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/enums/VoiceCallStatus.java @@ -0,0 +1,13 @@ +package com.isums.notificationservice.domains.enums; + +public enum VoiceCallStatus { + PENDING, + DIALING, + ANSWERED, + NO_ANSWER, + BUSY, + FAILED, + ACKNOWLEDGED, + ESCALATED, + SKIPPED +} diff --git a/src/main/java/com/isums/notificationservice/domains/enums/VoiceGender.java b/src/main/java/com/isums/notificationservice/domains/enums/VoiceGender.java new file mode 100644 index 0000000..b015933 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/enums/VoiceGender.java @@ -0,0 +1,6 @@ +package com.isums.notificationservice.domains.enums; + +public enum VoiceGender { + MALE, + FEMALE +} 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 eebbc4c..c47752e 100644 --- a/src/main/java/com/isums/notificationservice/domains/events/ConfirmAndSendToTenantEvent.java +++ b/src/main/java/com/isums/notificationservice/domains/events/ConfirmAndSendToTenantEvent.java @@ -21,4 +21,9 @@ public class ConfirmAndSendToTenantEvent { private String confirmUrl; private Instant startDate; private Instant endDate; + + // Contract language code emitted by econtract-service so Notification + // 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/ContractCancelledByTenantEvent.java b/src/main/java/com/isums/notificationservice/domains/events/ContractCancelledByTenantEvent.java new file mode 100644 index 0000000..9254612 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/events/ContractCancelledByTenantEvent.java @@ -0,0 +1,22 @@ +package com.isums.notificationservice.domains.events; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ContractCancelledByTenantEvent { + private String messageId; + private UUID contractId; + private UUID houseId; + private UUID tenantId; + private String tenantName; + private String reason; + private Instant cancelledAt; + private UUID initiatedByUserId; +} diff --git a/src/main/java/com/isums/notificationservice/domains/events/ContractCompletedEvent.java b/src/main/java/com/isums/notificationservice/domains/events/ContractCompletedEvent.java new file mode 100644 index 0000000..a96bc57 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/events/ContractCompletedEvent.java @@ -0,0 +1,27 @@ +package com.isums.notificationservice.domains.events; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ContractCompletedEvent { + private UUID contractId; + private UUID tenantId; + private String tenantEmail; + private Boolean isNewAccount; + private UUID houseId; + private UUID landlordId; + private Long depositAmount; + private Long rentAmount; + private Integer payDate; + private Instant startAt; + private Instant endAt; + private Instant completedAt; + private String signedPdfUrl; +} diff --git a/src/main/java/com/isums/notificationservice/domains/events/ContractReadyForLandlordSignatureEvent.java b/src/main/java/com/isums/notificationservice/domains/events/ContractReadyForLandlordSignatureEvent.java new file mode 100644 index 0000000..aaa7915 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/events/ContractReadyForLandlordSignatureEvent.java @@ -0,0 +1,20 @@ +package com.isums.notificationservice.domains.events; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ContractReadyForLandlordSignatureEvent { + private String messageId; + private UUID contractId; + private UUID recipientUserId; + private UUID tenantId; + private String tenantName; + private String contractName; + private String documentId; +} diff --git a/src/main/java/com/isums/notificationservice/domains/events/InspectionScheduledEvent.java b/src/main/java/com/isums/notificationservice/domains/events/InspectionScheduledEvent.java index 062fd2c..455b825 100644 --- a/src/main/java/com/isums/notificationservice/domains/events/InspectionScheduledEvent.java +++ b/src/main/java/com/isums/notificationservice/domains/events/InspectionScheduledEvent.java @@ -12,6 +12,7 @@ public class InspectionScheduledEvent { private UUID contractId; private UUID inspectionId; + private UUID houseId; private UUID managerId; private String tenantName; private String messageId; diff --git a/src/main/java/com/isums/notificationservice/domains/events/IssueQuoteSubmittedEvent.java b/src/main/java/com/isums/notificationservice/domains/events/IssueQuoteSubmittedEvent.java new file mode 100644 index 0000000..8cb61c1 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/events/IssueQuoteSubmittedEvent.java @@ -0,0 +1,22 @@ +package com.isums.notificationservice.domains.events; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class IssueQuoteSubmittedEvent { + private String messageId; + private UUID issueId; + private UUID quoteId; + private UUID houseId; + private UUID staffId; + private BigDecimal totalPrice; + private Instant submittedAt; +} diff --git a/src/main/java/com/isums/notificationservice/domains/events/IssueWorkSlotAssignedEvent.java b/src/main/java/com/isums/notificationservice/domains/events/IssueWorkSlotAssignedEvent.java new file mode 100644 index 0000000..5d22624 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/events/IssueWorkSlotAssignedEvent.java @@ -0,0 +1,23 @@ +package com.isums.notificationservice.domains.events; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class IssueWorkSlotAssignedEvent { + private UUID referenceId; + private UUID tenantId; + private UUID houseId; + private UUID slotId; + private UUID staffId; + private String referenceType; + private LocalDateTime startTime; + private LocalDateTime endTime; + private String action; +} diff --git a/src/main/java/com/isums/notificationservice/domains/events/PaymentSubscriptionActivatedEvent.java b/src/main/java/com/isums/notificationservice/domains/events/PaymentSubscriptionActivatedEvent.java new file mode 100644 index 0000000..c4d64ab --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/events/PaymentSubscriptionActivatedEvent.java @@ -0,0 +1,30 @@ +package com.isums.notificationservice.domains.events; + +import java.time.Instant; +import java.util.UUID; + +/** + * Emitted by Payment-Service after a successful VNPay IPN for a + * {@code SUBSCRIPTION} payment. Field names mirror the Map + * the producer puts on {@code payment.subscription-activated} — change + * either side and you must update the other in lockstep. + * + *

{@code months} is intentionally absent — Payment-Service moved to + * {@code durationDays} so a 7-day trial doesn't have to round up to a + * full month. Boxed wrappers everywhere so a missing field deserialises + * to null instead of crashing the consumer (we saw this in prod when the + * old primitive {@code int months} blew up on a {@code null} field). + */ +public record PaymentSubscriptionActivatedEvent( + String intentId, + UUID userId, + String purpose, + Integer durationDays, + String planCode, + String planId, + Long amountVnd, + String provider, + String txnRef, + String txnNo, + Instant paidAt +) {} 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 02b6207..1504dca 100644 --- a/src/main/java/com/isums/notificationservice/domains/events/UserActivatedEvent.java +++ b/src/main/java/com/isums/notificationservice/domains/events/UserActivatedEvent.java @@ -5,12 +5,17 @@ import java.time.Instant; import java.util.UUID; +/** + * Tenant account activated. Carries the plaintext temp password so the + * welcome email can show it — tenant logs in with it, Keycloak forces + * password change on first login. + */ @Builder public record UserActivatedEvent( UUID userId, String email, String name, - String tempPassword, + String password, String firstRentPaymentUrl, Long firstRentAmount, Instant firstRentDueDate diff --git a/src/main/java/com/isums/notificationservice/domains/events/UtilityThresholdExceededEvent.java b/src/main/java/com/isums/notificationservice/domains/events/UtilityThresholdExceededEvent.java new file mode 100644 index 0000000..177df09 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/events/UtilityThresholdExceededEvent.java @@ -0,0 +1,39 @@ +package com.isums.notificationservice.domains.events; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Local copy of the payload emitted by asset-service on topic + * {@code utility.consumption.alert}. Keeping this class mirrored in + * each service (rather than importing a shared jar) follows the + * project convention already used for DepositPaidEvent and + * PowerCutConfirmedEvent — lets each service deserialise with Jackson + * without pulling asset-service's classpath. + * + *

Schema evolution: add fields at the end (nullable) and enable + * {@code FAIL_ON_UNKNOWN_PROPERTIES=false} on the ObjectMapper. If a + * field must be removed, bump the topic name to *.v2 on both ends. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class UtilityThresholdExceededEvent { + private String eventId; + private String houseId; + private String houseName; + private String landlordUserId; // legacy field; older publishers stored the renter here + private String tenantUserId; + private String metric; // ELECTRICITY | WATER + private String previousStatus; // GOOD | WARNING | CRITICAL | NO_DATA + private String currentStatus; // WARNING | CRITICAL + private Double currentUsage; + private Double monthlyLimit; + private Double usagePercent; + private String unit; + private String month; // yyyy-MM + private Long occurredAt; // epoch millis +} diff --git a/src/main/java/com/isums/notificationservice/exceptions/GlobalExceptionHandler.java b/src/main/java/com/isums/notificationservice/exceptions/GlobalExceptionHandler.java index cfbe728..bd9f448 100644 --- a/src/main/java/com/isums/notificationservice/exceptions/GlobalExceptionHandler.java +++ b/src/main/java/com/isums/notificationservice/exceptions/GlobalExceptionHandler.java @@ -4,20 +4,69 @@ import com.isums.notificationservice.domains.dtos.ApiResponse; import com.isums.notificationservice.domains.dtos.ApiResponses; import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; import org.springframework.dao.DataAccessException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.ErrorResponseException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.context.request.async.AsyncRequestNotUsableException; +import org.springframework.web.context.request.async.AsyncRequestTimeoutException; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.resource.NoResourceFoundException; import java.util.List; @RestControllerAdvice +@Order(Ordered.LOWEST_PRECEDENCE) @Slf4j public class GlobalExceptionHandler { + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity> handleNoResource(NoResourceFoundException ex) { + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body(ApiResponses.fail(HttpStatus.NOT_FOUND, "Resource not found: " + ex.getResourcePath())); + } + + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity> handleResponseStatus(ResponseStatusException ex) { + HttpStatus status = HttpStatus.resolve(ex.getStatusCode().value()); + if (status == null) status = HttpStatus.INTERNAL_SERVER_ERROR; + return ResponseEntity.status(status) + .body(ApiResponses.fail(status, ex.getReason() != null ? ex.getReason() : status.getReasonPhrase())); + } + + @ExceptionHandler(ErrorResponseException.class) + public ResponseEntity> handleErrorResponse(ErrorResponseException ex) { + HttpStatus status = HttpStatus.resolve(ex.getStatusCode().value()); + if (status == null) status = HttpStatus.INTERNAL_SERVER_ERROR; + return ResponseEntity.status(status) + .body(ApiResponses.fail(status, ex.getBody().getDetail() != null ? ex.getBody().getDetail() : status.getReasonPhrase())); + } + + // Thrown when an SSE client disconnects mid-stream. Do not log as ERROR, + // and do not wrap in a 500 response (the response is already closed). + @ExceptionHandler(AsyncRequestNotUsableException.class) + public void handleAsyncClientGone(AsyncRequestNotUsableException ex) { + log.debug("[SSE] Client disconnected: {}", ex.getMessage()); + } + + // Thrown when an SSE / long-polling endpoint times out waiting for a + // DeferredResult. The response is already committed with + // Content-Type: text/event-stream, so attempting to return an + // ApiResponse JSON body triggers a secondary HttpMessageNotWritableException. + // Return a void response — the client simply sees the stream close, + // reconnects, and we stop spamming ERROR logs on every idle timeout. + @ExceptionHandler(AsyncRequestTimeoutException.class) + public void handleAsyncTimeout(AsyncRequestTimeoutException ex) { + log.debug("[SSE] Request timed out (client should auto-reconnect)"); + } + @ExceptionHandler(DataAccessException.class) public ResponseEntity> handleDb(DataAccessException ex) { ex.getMostSpecificCause(); diff --git a/src/main/java/com/isums/notificationservice/infrastructures/Websockets/SseConnectionManager.java b/src/main/java/com/isums/notificationservice/infrastructures/Websockets/SseConnectionManager.java index 4689f9a..2f8dc92 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/Websockets/SseConnectionManager.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/Websockets/SseConnectionManager.java @@ -2,6 +2,8 @@ import com.isums.notificationservice.domains.dtos.NotificationDto; import com.isums.notificationservice.domains.entities.ManagerNotification; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -10,24 +12,65 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; @Component @Slf4j public class SseConnectionManager { + // Must be shorter than the gateway's spring.http.clients.read-timeout (10s). + // Otherwise the gateway's Apache HttpClient times out the upstream read, + // closes the connection, and the client has to reconnect. + private static final long HEARTBEAT_INTERVAL_SECONDS = 7L; + // Finite emitter timeout forces clients to cycle connections periodically, + // which lets the upstream proxy / gateway release pool slots held by + // abandoned streams (dead laptops, closed tabs, reloaded HMR modules). + private static final long EMITTER_TIMEOUT_MS = 30L * 60L * 1000L; + private final Map> emitters = new ConcurrentHashMap<>(); + private final ScheduledExecutorService heartbeatExecutor = + Executors.newSingleThreadScheduledExecutor(task -> { + Thread thread = new Thread(task, "notification-sse-heartbeat"); + thread.setDaemon(true); + return thread; + }); + + @PostConstruct + void startHeartbeat() { + heartbeatExecutor.scheduleAtFixedRate( + this::sendHeartbeats, + HEARTBEAT_INTERVAL_SECONDS, + HEARTBEAT_INTERVAL_SECONDS, + TimeUnit.SECONDS); + } + + @PreDestroy + void stopHeartbeat() { + heartbeatExecutor.shutdown(); + try { + if (!heartbeatExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + heartbeatExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + heartbeatExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + emitters.values().forEach(list -> list.forEach(SseEmitter::complete)); + emitters.clear(); + } public SseEmitter subscribe(UUID recipientId) { - SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); + return subscribe(recipientId, new SseEmitter(EMITTER_TIMEOUT_MS)); + } + SseEmitter subscribe(UUID recipientId, SseEmitter emitter) { emitters.computeIfAbsent(recipientId, k -> new CopyOnWriteArrayList<>()) .add(emitter); - Runnable remove = () -> { - CopyOnWriteArrayList list = emitters.get(recipientId); - if (list != null) list.remove(emitter); - }; + Runnable remove = () -> remove(recipientId, emitter); emitter.onCompletion(remove); emitter.onTimeout(remove); @@ -41,9 +84,14 @@ public SseEmitter subscribe(UUID recipientId) { public void push(UUID recipientId, ManagerNotification notification) { CopyOnWriteArrayList list = emitters.get(recipientId); - if (list == null || list.isEmpty()) return; + if (list == null || list.isEmpty()) { + log.info("[SSE] Push skipped recipientId={} notificationId={} reason=no_active_connection", + recipientId, notification.getId()); + return; + } NotificationDto dto = NotificationDto.from(notification); + int before = list.size(); list.forEach(emitter -> { try { @@ -53,8 +101,37 @@ public void push(UUID recipientId, ManagerNotification notification) { .data(dto)); } catch (Exception e) { log.warn("[SSE] Push failed recipientId={}: {}", recipientId, e.getMessage()); - list.remove(emitter); + remove(recipientId, emitter); } }); + int after = connectionCount(recipientId); + log.info("[SSE] Push attempted recipientId={} notificationId={} before={} after={}", + recipientId, notification.getId(), before, after); + } + + void sendHeartbeats() { + emitters.forEach((recipientId, list) -> list.forEach(emitter -> { + try { + emitter.send(SseEmitter.event().comment("heartbeat")); + } catch (Exception e) { + log.warn("[SSE] Heartbeat failed recipientId={}: {}", recipientId, e.getMessage()); + remove(recipientId, emitter); + } + })); + } + + int connectionCount(UUID recipientId) { + CopyOnWriteArrayList list = emitters.get(recipientId); + return list == null ? 0 : list.size(); + } + + private void remove(UUID recipientId, SseEmitter emitter) { + CopyOnWriteArrayList list = emitters.get(recipientId); + if (list == null) return; + + list.remove(emitter); + if (list.isEmpty()) { + emitters.remove(recipientId, list); + } } } diff --git a/src/main/java/com/isums/notificationservice/infrastructures/abstracts/SmsProvider.java b/src/main/java/com/isums/notificationservice/infrastructures/abstracts/SmsProvider.java new file mode 100644 index 0000000..3e5eb8b --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/abstracts/SmsProvider.java @@ -0,0 +1,16 @@ +package com.isums.notificationservice.infrastructures.abstracts; + +import com.isums.notificationservice.domains.dtos.SpeedSmsVoiceResponse; + +/** + * SMS-only provider. Split from {@link VoiceProvider} so voice keeps + * going through Stringee while SMS goes through AWS SNS. + */ +public interface SmsProvider { + + /** Provider id for routing / metrics: "AWS_SNS". */ + String providerId(); + + /** Send a transactional SMS. */ + SpeedSmsVoiceResponse sendSms(String phone, String text); +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/abstracts/TtsAudioSynthesizer.java b/src/main/java/com/isums/notificationservice/infrastructures/abstracts/TtsAudioSynthesizer.java new file mode 100644 index 0000000..87be5b2 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/abstracts/TtsAudioSynthesizer.java @@ -0,0 +1,25 @@ +package com.isums.notificationservice.infrastructures.abstracts; + +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.domains.enums.VoiceGender; + +import java.math.BigDecimal; + +public interface TtsAudioSynthesizer { + + /** + * Returns a publicly-reachable audio URL (MP3) speaking the given text + * in the given locale/gender/speed. Cached by (rendered_text, locale, + * gender, speed) hash — duplicate texts reuse the same S3 object. + * + *

Used for {@code ja_JP} (and optionally {@code en_US}) where the + * VN SpeedSMS TTS engine either doesn't support the language or + * pronounces it poorly. For {@code vi_VN}, callers should prefer + * SpeedSMS native TTS (cheaper + lower latency) and skip this path. + */ + String synthesizeAndCache( + String text, + LocaleType locale, + VoiceGender gender, + BigDecimal speed); +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/abstracts/VoiceProvider.java b/src/main/java/com/isums/notificationservice/infrastructures/abstracts/VoiceProvider.java new file mode 100644 index 0000000..6776c7c --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/abstracts/VoiceProvider.java @@ -0,0 +1,22 @@ +package com.isums.notificationservice.infrastructures.abstracts; + +import com.isums.notificationservice.domains.dtos.SpeedSmsVoiceRequest; +import com.isums.notificationservice.domains.dtos.SpeedSmsVoiceResponse; + +/** + * Voice-call abstraction implemented by Stringee. Kept as an interface + * so swapping vendors later only touches the impl + the router. DTO + * names retain the legacy {@code SpeedSms*} prefix from the previous + * provider — semantics are generic. + */ +public interface VoiceProvider { + + /** Provider id for routing / metrics: "STRINGEE". */ + String providerId(); + + /** Dial + speak. Returns provider callId on success. */ + SpeedSmsVoiceResponse sendVoiceCall(SpeedSmsVoiceRequest request); + + /** Verify webhook payload (HMAC / JWT depending on provider). */ + boolean verifyWebhookSignature(String rawBody, String signature); +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/exceptions/PermanentEventFailureException.java b/src/main/java/com/isums/notificationservice/infrastructures/exceptions/PermanentEventFailureException.java new file mode 100644 index 0000000..6e0ce52 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/exceptions/PermanentEventFailureException.java @@ -0,0 +1,12 @@ +package com.isums.notificationservice.infrastructures.exceptions; + +public class PermanentEventFailureException extends RuntimeException { + + public PermanentEventFailureException(String message, Throwable cause) { + super(message, cause); + } + + public PermanentEventFailureException(String message) { + super(message); + } +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/grpcs/HouseGrpcClient.java b/src/main/java/com/isums/notificationservice/infrastructures/grpcs/HouseGrpcClient.java new file mode 100644 index 0000000..8fff0b9 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/grpcs/HouseGrpcClient.java @@ -0,0 +1,56 @@ +package com.isums.notificationservice.infrastructures.grpcs; + +import com.isums.houseservice.grpc.GetHouseRequest; +import com.isums.houseservice.grpc.GetRegionResponse; +import com.isums.houseservice.grpc.HouseResponse; +import com.isums.houseservice.grpc.HouseServiceGrpc; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class HouseGrpcClient { + + private final HouseServiceGrpc.HouseServiceBlockingStub houseStub; + + public UUID getLandlordIdByHouseId(UUID houseId) { + HouseResponse response = houseStub.getHouseById( + GetHouseRequest.newBuilder().setHouseId(houseId.toString()).build()); + + if (response.getUserRentalId().isBlank()) { + return null; + } + return UUID.fromString(response.getUserRentalId()); + } + + public UUID getManagerIdByHouseId(UUID houseId) { + GetRegionResponse response = houseStub.getRegionIdByHouseId( + GetHouseRequest.newBuilder().setHouseId(houseId.toString()).build()); + + if (response.getManagerId().isBlank()) { + return null; + } + return UUID.fromString(response.getManagerId()); + } + + /** + * Friendly house name (e.g. "Nhà Quận 1", "Vinhomes Central Park"). + * Used in alert TTS / email so the recipient knows WHICH house the + * incident is at — tenants can rent multiple houses, managers + * supervise multiple regions, so the houseId UUID alone is useless + * to a human listener. Returns "" if the house is missing or has + * no name set. + */ + public String getHouseNameByHouseId(UUID houseId) { + try { + HouseResponse response = houseStub.getHouseById( + GetHouseRequest.newBuilder().setHouseId(houseId.toString()).build()); + String name = response.getName(); + return name == null ? "" : name; + } catch (Exception e) { + return ""; + } + } +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/grpcs/UserGrpcClient.java b/src/main/java/com/isums/notificationservice/infrastructures/grpcs/UserGrpcClient.java index 0b27255..8c2bcb9 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/grpcs/UserGrpcClient.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/grpcs/UserGrpcClient.java @@ -1,6 +1,7 @@ package com.isums.notificationservice.infrastructures.grpcs; import com.isums.userservice.grpc.GetUserByIdRequest; +import com.isums.userservice.grpc.GetUserIdAndRoleByKeyCloakIdRequest; import com.isums.userservice.grpc.UserResponse; import com.isums.userservice.grpc.UserServiceGrpc; import lombok.RequiredArgsConstructor; @@ -14,10 +15,30 @@ public class UserGrpcClient { private final UserServiceGrpc.UserServiceBlockingStub userStub; + /** + * Lookup by INTERNAL user UUID (the {@code users.id} primary key). + * Use this when the caller already holds an internal ID — typically + * an escalation target picked from the {@code /api/users/managers} + * REST endpoint, which returns internal IDs. + */ public UserResponse getUserById(UUID userId) { GetUserByIdRequest req = GetUserByIdRequest.newBuilder().setUserId(String.valueOf(userId)).build(); return userStub.getUserById(req); } + + /** + * Lookup by Keycloak {@code sub} claim — the value mobile/web JWTs + * carry. Use this whenever the caller derived the ID from a JWT + * (which is most controller paths). Returns the same UserResponse + * shape so call-sites stay symmetric with {@link #getUserById}. + */ + public UserResponse getUserByKeycloakId(String keycloakId) { + GetUserIdAndRoleByKeyCloakIdRequest req = + GetUserIdAndRoleByKeyCloakIdRequest.newBuilder() + .setKeycloakId(keycloakId) + .build(); + return userStub.getUserIdAndRoleByKeyCloakId(req); + } } 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 0c7456c..6dcdaed 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/kafka/ContractEventListener.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/kafka/ContractEventListener.java @@ -1,9 +1,13 @@ package com.isums.notificationservice.infrastructures.kafka; import com.isums.notificationservice.domains.enums.NotificationCategory; +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.InspectionDoneNotifyEvent; import com.isums.notificationservice.domains.events.InspectionScheduledEvent; import com.isums.notificationservice.infrastructures.abstracts.ManagerNotificationService; +import com.isums.notificationservice.services.NotificationRecipientResolver; import common.kafkas.IdempotencyService; import common.kafkas.KafkaListenerHelper; import lombok.RequiredArgsConstructor; @@ -14,7 +18,10 @@ import org.springframework.stereotype.Component; import tools.jackson.databind.ObjectMapper; +import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.UUID; @Component @RequiredArgsConstructor @@ -22,11 +29,11 @@ public class ContractEventListener { private final ManagerNotificationService notificationService; + private final NotificationRecipientResolver recipientResolver; private final ObjectMapper objectMapper; private final IdempotencyService idempotencyService; private final KafkaListenerHelper kafkaHelper; - // Hợp đồng hết hạn → phân công nhân viên kiểm tra @KafkaListener(topics = "contract.inspection.scheduled", groupId = "notification-group") public void handleInspectionScheduled( @@ -42,19 +49,28 @@ public void handleInspectionScheduled( InspectionScheduledEvent event = objectMapper.readValue( record.value(), InspectionScheduledEvent.class); - notificationService.send( - event.getManagerId(), - NotificationCategory.CONTRACT_EXPIRED, - "Hợp đồng hết hạn — Đã lên lịch kiểm tra nhà", - "Hợp đồng #" + event.getContractId().toString().substring(0, 8).toUpperCase() - + " của khách " + event.getTenantName() - + " đã hết hạn. Nhân viên đã được phân công kiểm tra.", - "/contracts/" + event.getContractId() + "/termination", - Map.of( - "contractId", event.getContractId().toString(), - "inspectionId", event.getInspectionId().toString() - ) - ); + Map metadata = new HashMap<>(); + metadata.put("contractId", event.getContractId().toString()); + metadata.put("inspectionId", event.getInspectionId().toString()); + metadata.put("status", "PENDING_TERMINATION"); + if (event.getHouseId() != null) { + metadata.put("houseId", event.getHouseId().toString()); + } + + List recipientIds = recipientResolver.resolveLandlordAndManager( + event.getHouseId(), event.getManagerId()); + for (UUID recipientId : recipientIds) { + notificationService.send( + recipientId, + NotificationCategory.CONTRACT_EXPIRED, + "Hợp đồng hết hạn — đã lên lịch kiểm tra nhà", + "Hợp đồng #" + event.getContractId().toString().substring(0, 8).toUpperCase() + + " của khách thuê " + event.getTenantName() + + " đã hết hạn. Đã phân công nhân viên đi kiểm tra.", + "/contracts/" + event.getContractId() + "/termination", + metadata + ); + } idempotencyService.markProcessed(messageId); ack.acknowledge(); @@ -80,10 +96,10 @@ public void handleInspectionDone( InspectionDoneNotifyEvent event = objectMapper.readValue(record.value(), InspectionDoneNotifyEvent.class); notificationService.send(event.getManagerId(), NotificationCategory.INSPECTION_DONE, - "Kiểm tra nhà hoàn tất — Cần xác nhận hoàn cọc", + "Đã kiểm tra nhà xong — chờ xác nhận hoàn cọc", "Nhân viên đã kiểm tra xong hợp đồng #" + event.getContractId().toString().substring(0, 8).toUpperCase() - + ". Vui lòng xem và xác nhận số tiền hoàn cọc.", + + ". Vui lòng xem lại và xác nhận số tiền cọc hoàn lại.", "/contracts/" + event.getContractId() + "/deposit-refund", Map.of( "contractId", event.getContractId().toString(), @@ -100,4 +116,171 @@ public void handleInspectionDone( throw new RuntimeException(e); } } -} \ No newline at end of file + + @KafkaListener(topics = "contract.ready-for-landlord-signature", + groupId = "notification-group") + public void handleReadyForLandlordSignature( + ConsumerRecord record, Acknowledgment ack) { + + String messageId = kafkaHelper.extractMessageId(record); + try { + if (idempotencyService.isDuplicate(messageId)) { + ack.acknowledge(); + return; + } + + ContractReadyForLandlordSignatureEvent event = objectMapper.readValue( + record.value(), ContractReadyForLandlordSignatureEvent.class); + + String contractLabel = event.getContractName() != null && !event.getContractName().isBlank() + ? event.getContractName() + : "#" + event.getContractId().toString().substring(0, 8).toUpperCase(); + String tenantLabel = event.getTenantName() != null && !event.getTenantName().isBlank() + ? event.getTenantName() + : "khach thue"; + + Map metadata = new HashMap<>(); + metadata.put("contractId", event.getContractId().toString()); + metadata.put("status", "READY"); + if (event.getTenantId() != null) { + metadata.put("tenantId", event.getTenantId().toString()); + } + if (event.getDocumentId() != null && !event.getDocumentId().isBlank()) { + metadata.put("documentId", event.getDocumentId()); + } + + 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ý.", + "/contracts/" + event.getContractId(), + metadata + ); + + idempotencyService.markProcessed(messageId); + ack.acknowledge(); + log.info("[Notification] handleReadyForLandlordSignature done messageId={}", messageId); + } catch (Exception e) { + log.error("[Notification] handleReadyForLandlordSignature failed: {}", e.getMessage(), e); + throw new RuntimeException(e); + } + } + + @KafkaListener(topics = "contract-completed-topic", + groupId = "notification-group") + public void handleContractCompleted( + ConsumerRecord record, Acknowledgment ack) { + + String messageId = kafkaHelper.extractMessageId(record); + try { + if (idempotencyService.isDuplicate(messageId)) { + ack.acknowledge(); + return; + } + + ContractCompletedEvent event = objectMapper.readValue( + record.value(), ContractCompletedEvent.class); + + String contractLabel = "#" + event.getContractId().toString().substring(0, 8).toUpperCase(); + String tenantLabel = event.getTenantEmail() != null && !event.getTenantEmail().isBlank() + ? event.getTenantEmail() + : "khach thue"; + + Map metadata = new HashMap<>(); + metadata.put("contractId", event.getContractId().toString()); + metadata.put("status", "COMPLETED"); + if (event.getTenantId() != null) { + metadata.put("tenantId", event.getTenantId().toString()); + } + if (event.getHouseId() != null) { + metadata.put("houseId", event.getHouseId().toString()); + } + if (event.getCompletedAt() != null) { + metadata.put("completedAt", event.getCompletedAt().toString()); + } + if (event.getSignedPdfUrl() != null && !event.getSignedPdfUrl().isBlank()) { + metadata.put("signedPdfUrl", event.getSignedPdfUrl()); + } + + List recipientIds = recipientResolver.resolveLandlordAndManager( + event.getHouseId(), event.getLandlordId()); + for (UUID recipientId : recipientIds) { + notificationService.send( + recipientId, + NotificationCategory.CONTRACT_COMPLETED, + "Hợp đồng được ký thành công", + "Người thuê nhà " + tenantLabel + " đã hoàn tất việc ký hợp đồng " + contractLabel + ".", + "/contracts/" + event.getContractId(), + metadata + ); + } + + idempotencyService.markProcessed(messageId); + ack.acknowledge(); + log.info("[Notification] handleContractCompleted done messageId={}", messageId); + } catch (Exception e) { + log.error("[Notification] handleContractCompleted failed: {}", e.getMessage(), e); + throw new RuntimeException(e); + } + } + + @KafkaListener(topics = "contract.cancelled-by-tenant", + groupId = "notification-group") + public void handleContractCancelledByTenant( + ConsumerRecord record, Acknowledgment ack) { + + String messageId = kafkaHelper.extractMessageId(record); + try { + if (idempotencyService.isDuplicate(messageId)) { + ack.acknowledge(); + return; + } + + ContractCancelledByTenantEvent event = objectMapper.readValue( + record.value(), ContractCancelledByTenantEvent.class); + + String tenantLabel = event.getTenantName() != null && !event.getTenantName().isBlank() + ? event.getTenantName() + : "khach thue"; + + Map metadata = new HashMap<>(); + metadata.put("contractId", event.getContractId().toString()); + metadata.put("status", "CANCELLED_BY_TENANT"); + if (event.getHouseId() != null) { + metadata.put("houseId", event.getHouseId().toString()); + } + if (event.getTenantId() != null) { + metadata.put("tenantId", event.getTenantId().toString()); + } + if (event.getCancelledAt() != null) { + metadata.put("cancelledAt", event.getCancelledAt().toString()); + } + if (event.getReason() != null && !event.getReason().isBlank()) { + metadata.put("reason", event.getReason()); + } + + List recipientIds = recipientResolver.resolveLandlordAndManager( + event.getHouseId(), event.getInitiatedByUserId()); + for (UUID recipientId : recipientIds) { + notificationService.send( + recipientId, + NotificationCategory.CONTRACT_CANCELLED_BY_TENANT, + "Khách thuê đã huỷ ký hợp đồng", + "Khách thuê " + tenantLabel + " đã huỷ ký hợp đồng #" + + event.getContractId().toString().substring(0, 8).toUpperCase() + ".", + "/contracts/" + event.getContractId(), + metadata + ); + } + + idempotencyService.markProcessed(messageId); + ack.acknowledge(); + log.info("[Notification] handleContractCancelledByTenant done messageId={}", messageId); + } catch (Exception e) { + log.error("[Notification] handleContractCancelledByTenant failed: {}", e.getMessage(), e); + throw new RuntimeException(e); + } + } +} + diff --git a/src/main/java/com/isums/notificationservice/infrastructures/kafka/IssueNotificationEventListener.java b/src/main/java/com/isums/notificationservice/infrastructures/kafka/IssueNotificationEventListener.java new file mode 100644 index 0000000..ab2f2ff --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/kafka/IssueNotificationEventListener.java @@ -0,0 +1,207 @@ +package com.isums.notificationservice.infrastructures.kafka; + +import com.isums.notificationservice.domains.enums.NotificationCategory; +import com.isums.notificationservice.domains.events.IssueQuoteSubmittedEvent; +import com.isums.notificationservice.domains.events.IssueWorkSlotAssignedEvent; +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 lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +@Slf4j +public class IssueNotificationEventListener { + + private final ManagerNotificationService notificationService; + private final NotificationRecipientResolver recipientResolver; + private final UserGrpcClient userGrpcClient; + private final ObjectMapper objectMapper; + private final IdempotencyService idempotencyService; + private final KafkaListenerHelper kafkaHelper; + + @KafkaListener(topics = "job.assigned", groupId = "notification-group") + public void handleIssueWorkSlotAssigned( + ConsumerRecord record, Acknowledgment ack) { + + String messageId = kafkaHelper.extractMessageId(record); + try { + if (idempotencyService.isDuplicate(messageId)) { + ack.acknowledge(); + return; + } + + IssueWorkSlotAssignedEvent event = objectMapper.readValue( + record.value(), IssueWorkSlotAssignedEvent.class); + + if (!"ISSUE".equalsIgnoreCase(event.getReferenceType()) + || !"JOB_ASSIGNED".equalsIgnoreCase(event.getAction())) { + ack.acknowledge(); + return; + } + + String staffName = resolveUserName(event.getStaffId(), "staff"); + List recipientIds = recipientResolver.resolveLandlordAndManager(event.getHouseId(), event.getStaffId()); + + Map metadata = new HashMap<>(); + metadata.put("issueId", event.getReferenceId().toString()); + metadata.put("slotId", event.getSlotId().toString()); + metadata.put("houseId", event.getHouseId().toString()); + if (event.getStaffId() != null) { + metadata.put("staffId", event.getStaffId().toString()); + } + metadata.put("status", "SCHEDULED"); + + for (UUID recipientId : recipientIds) { + notificationService.send( + recipientId, + NotificationCategory.ISSUE_WORK_SLOT_CREATED, + "Work slot created for issue", + "Issue #" + shortId(event.getReferenceId()) + + " has had a work slot scheduled with " + staffName + ".", + "/issues/" + event.getReferenceId(), + metadata + ); + } + + idempotencyService.markProcessed(messageId); + ack.acknowledge(); + log.info("[Notification] handleIssueWorkSlotAssigned done messageId={}", messageId); + } catch (Exception e) { + log.error("[Notification] handleIssueWorkSlotAssigned failed: {}", e.getMessage(), e); + throw new RuntimeException(e); + } + } + + @KafkaListener(topics = "job.created", groupId = "notification-group") + public void handleIssueCreated( + ConsumerRecord record, Acknowledgment ack) { + + String messageId = kafkaHelper.extractMessageId(record); + try { + if (idempotencyService.isDuplicate(messageId)) { + ack.acknowledge(); + return; + } + + IssueWorkSlotAssignedEvent event = objectMapper.readValue( + record.value(), IssueWorkSlotAssignedEvent.class); + + if (!"ISSUE".equalsIgnoreCase(event.getReferenceType()) + || !"JOB_CREATED".equalsIgnoreCase(event.getAction()) + || event.getTenantId() == null + || event.getReferenceId() == null) { + ack.acknowledge(); + return; + } + + String actorName = resolveUserName(event.getTenantId(), "tenant"); + Map metadata = new HashMap<>(); + metadata.put("issueId", event.getReferenceId().toString()); + metadata.put("houseId", event.getHouseId().toString()); + metadata.put("tenantId", event.getTenantId().toString()); + metadata.put("status", "CREATED"); + + notificationService.send( + event.getTenantId(), + NotificationCategory.ISSUE_WORK_SLOT_CREATED, + "Issue created", + "Issue #" + shortId(event.getReferenceId()) + + " has been created by " + actorName + ".", + "/issues/" + event.getReferenceId(), + metadata + ); + + idempotencyService.markProcessed(messageId); + ack.acknowledge(); + log.info("[Notification] handleIssueCreated done messageId={}", messageId); + } catch (Exception e) { + log.error("[Notification] handleIssueCreated failed: {}", e.getMessage(), e); + throw new RuntimeException(e); + } + } + + @KafkaListener(topics = "issue.quote.submitted", groupId = "notification-group") + public void handleIssueQuoteSubmitted( + ConsumerRecord record, Acknowledgment ack) { + + String messageId = kafkaHelper.extractMessageId(record); + try { + if (idempotencyService.isDuplicate(messageId)) { + ack.acknowledge(); + return; + } + + IssueQuoteSubmittedEvent event = objectMapper.readValue( + record.value(), IssueQuoteSubmittedEvent.class); + + String staffName = resolveUserName(event.getStaffId(), "staff"); + List recipientIds = recipientResolver.resolveLandlordAndManager(event.getHouseId()); + + Map metadata = new HashMap<>(); + metadata.put("issueId", event.getIssueId().toString()); + metadata.put("quoteId", event.getQuoteId().toString()); + metadata.put("houseId", event.getHouseId().toString()); + if (event.getStaffId() != null) { + metadata.put("staffId", event.getStaffId().toString()); + } + metadata.put("status", "WAITING_MANAGER_APPROVAL_QUOTE"); + if (event.getTotalPrice() != null) { + metadata.put("totalPrice", event.getTotalPrice().toPlainString()); + } + + for (UUID recipientId : recipientIds) { + notificationService.send( + recipientId, + NotificationCategory.ISSUE_QUOTE_WAITING_MANAGER_APPROVAL, + "Staff submitted a quote", + "Issue #" + shortId(event.getIssueId()) + + " is awaiting quote approval from " + staffName + ".", + "/issues/" + event.getIssueId(), + metadata + ); + } + + idempotencyService.markProcessed(messageId); + ack.acknowledge(); + log.info("[Notification] handleIssueQuoteSubmitted done messageId={}", messageId); + } catch (Exception e) { + log.error("[Notification] handleIssueQuoteSubmitted failed: {}", e.getMessage(), e); + throw new RuntimeException(e); + } + } + + private String resolveUserName(UUID userId, String fallback) { + if (userId == null) { + return fallback; + } + try { + UserResponse user = userGrpcClient.getUserById(userId); + if (user != null && !user.getName().isBlank()) { + return user.getName(); + } + } catch (Exception e) { + log.warn("[Notification] resolveUserName failed userId={}: {}", userId, e.getMessage()); + } + return fallback; + } + + private String shortId(UUID id) { + return id.toString().substring(0, 8).toUpperCase(); + } +} + diff --git a/src/main/java/com/isums/notificationservice/infrastructures/kafka/NotificationTranslationRequester.java b/src/main/java/com/isums/notificationservice/infrastructures/kafka/NotificationTranslationRequester.java new file mode 100644 index 0000000..7aa3bd8 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/kafka/NotificationTranslationRequester.java @@ -0,0 +1,110 @@ +package com.isums.notificationservice.infrastructures.kafka; + +import com.isums.common.i18n.SupportedLocales; +import com.isums.common.i18n.TranslationMap; +import com.isums.common.i18n.events.TextTranslationRequestedEvent; +import com.isums.common.i18n.events.TranslationIntent; +import com.isums.notificationservice.domains.entities.ManagerNotification; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +/** + * Publishes {@link TextTranslationRequestedEvent} for each translatable field + * on a notification that is missing locales. Caller (the service layer) invokes + * {@link #requestMissing(ManagerNotification, String)} after persisting. + * + *

Disabled via {@code isums.i18n.notification.auto-translate=false} for + * rollback. {@code isums.i18n.notification.required-locales} controls which + * locales we expect every notification to have (default: vi,en,ja). + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class NotificationTranslationRequester { + + static final String CALLBACK_TOPIC = "text.translation.result.notification"; + + private final KafkaTemplate kafkaTemplate; + + @Value("${isums.i18n.notification.auto-translate:true}") + private boolean autoTranslate; + + @Value("${isums.i18n.notification.required-locales:vi,en,ja}") + private String requiredLocalesCsv; + + @Value("${isums.i18n.notification.default-source:en}") + private String defaultSourceLanguage; + + public void requestMissing(ManagerNotification n, String sourceLanguageOverride) { + if (!autoTranslate || n == null || n.getId() == null) return; + Set required = parseLocales(); + String source = sourceLanguageOverride != null ? sourceLanguageOverride : defaultSourceLanguage; + + if (n.getTitle() != null && !n.getTitle().isBlank()) { + List missing = computeMissing(n.getTitleTranslations(), required, source); + if (!missing.isEmpty()) { + publish(n.getId(), "notification.title", "title", n.getTitle(), source, missing); + } + } + if (n.getBody() != null && !n.getBody().isBlank()) { + List missing = computeMissing(n.getBodyTranslations(), required, source); + if (!missing.isEmpty()) { + publish(n.getId(), "notification.body", "body", n.getBody(), source, missing); + } + } + } + + private Set parseLocales() { + Set out = new java.util.LinkedHashSet<>(); + for (String raw : requiredLocalesCsv.split(",")) { + String code = TranslationMap.normalizeLanguage(raw); + if (code != null && SupportedLocales.isSupported(code)) out.add(code); + } + if (out.isEmpty()) out.addAll(SupportedLocales.ALL); + return out; + } + + private List computeMissing(TranslationMap existing, Set required, String source) { + Set have = existing == null ? Set.of() : existing.languagesPresent(); + List missing = new ArrayList<>(); + for (String locale : required) { + // Source already exists in main column, no need to translate to itself + if (locale.equals(source)) continue; + if (!have.contains(locale)) missing.add(locale); + } + return missing; + } + + private void publish(UUID resourceId, String resourceType, String fieldName, + String text, String source, List targets) { + TextTranslationRequestedEvent event = new TextTranslationRequestedEvent( + UUID.randomUUID(), + resourceType, + resourceId, + fieldName, + text, + source, + targets, + TranslationIntent.CUSTOMER_FACING_UI, + Boolean.TRUE, + Instant.now(), + CALLBACK_TOPIC); + try { + kafkaTemplate.send(TextTranslationRequestedEvent.TOPIC, resourceId.toString(), event); + log.debug("Requested translation resourceType={} resourceId={} targets={}", + resourceType, resourceId, targets); + } catch (Exception ex) { + log.warn("Failed to publish translation request resourceType={} resourceId={}: {}", + resourceType, resourceId, ex.toString()); + } + } +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/kafka/NotificationTranslationResultListener.java b/src/main/java/com/isums/notificationservice/infrastructures/kafka/NotificationTranslationResultListener.java new file mode 100644 index 0000000..be4e191 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/kafka/NotificationTranslationResultListener.java @@ -0,0 +1,103 @@ +package com.isums.notificationservice.infrastructures.kafka; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.isums.common.i18n.TranslationMap; +import com.isums.common.i18n.events.TextTranslationResultEvent; +import com.isums.notificationservice.domains.entities.ManagerNotification; +import com.isums.notificationservice.infrastructures.Websockets.SseConnectionManager; +import com.isums.notificationservice.infrastructures.repositories.ManagerNotificationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +/** + * Consumes {@link TextTranslationResultEvent} from AI-Service and merges the + * translated text into {@code titleTranslations} / {@code bodyTranslations} + * for the matching {@link ManagerNotification}. Results arrive one per target + * locale; we never overwrite a value that's already present (preserves manual + * edits via {@link TranslationMap#mergeAutoFilled}). + * + *

FAILED results are logged and dropped; the FE shows them as missing and + * the user can manually translate via the sync endpoint. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class NotificationTranslationResultListener { + + private final ObjectMapper objectMapper; + private final ManagerNotificationRepository repository; + private final SseConnectionManager sseManager; + + @KafkaListener(topics = NotificationTranslationRequester.CALLBACK_TOPIC, + groupId = "notification-translation-result") + @Transactional + public void onResult(String payload, Acknowledgment ack) { + try { + TextTranslationResultEvent event = objectMapper.readValue(payload, TextTranslationResultEvent.class); + if (!TextTranslationResultEvent.STATUS_DONE.equals(event.status()) + || event.translatedText() == null + || event.translatedText().isBlank()) { + log.debug("Skipping non-DONE translation result requestId={} status={}", + event.requestId(), event.status()); + ack.acknowledge(); + return; + } + apply(event); + ack.acknowledge(); + } catch (Exception ex) { + log.error("Failed to apply translation result, payload={}", payload, ex); + ack.acknowledge(); // do not retry on parse errors + } + } + + private void apply(TextTranslationResultEvent event) { + UUID id = event.resourceId(); + Optional opt = repository.findById(id); + if (opt.isEmpty()) { + log.debug("Notification {} not found for translation result; likely deleted", id); + return; + } + ManagerNotification n = opt.get(); + Map patch = new LinkedHashMap<>(); + patch.put(event.targetLanguage(), event.translatedText()); + + boolean isTitle = "notification.title".equals(event.resourceType()); + boolean isBody = "notification.body".equals(event.resourceType()); + + if (isTitle) { + TranslationMap before = n.getTitleTranslations() == null ? TranslationMap.empty() : n.getTitleTranslations(); + n.setTitleTranslations(before.mergeAutoFilled(patch)); + } else if (isBody) { + TranslationMap before = n.getBodyTranslations() == null ? TranslationMap.empty() : n.getBodyTranslations(); + n.setBodyTranslations(before.mergeAutoFilled(patch)); + } else { + log.warn("Unknown resourceType for notification translation: {}", event.resourceType()); + return; + } + ManagerNotification persisted = repository.save(n); + log.debug("Applied translation resourceType={} resourceId={} target={}", + event.resourceType(), id, event.targetLanguage()); + + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + sseManager.push(persisted.getRecipientId(), persisted); + } + }); + } else { + sseManager.push(persisted.getRecipientId(), persisted); + } + } +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/kafka/PaymentConsumer.java b/src/main/java/com/isums/notificationservice/infrastructures/kafka/PaymentConsumer.java index a4768d0..9b0d018 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/kafka/PaymentConsumer.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/kafka/PaymentConsumer.java @@ -94,10 +94,10 @@ public void handlePowerCutReviewRequested( record.value(), PowerCutReviewRequestedEvent.class); notificationService.send(event.getManagerId(), NotificationCategory.PAYMENT_OVERDUE, - "Khách " + event.getTenantName() - + " trễ " + event.getDaysLate() + " ngày — Xem xét cắt điện", - "Tổng tiền cần thu: " + formatVnd(event.getTotalAmount()) - + ". Vào hệ thống để xác nhận cắt điện nếu cần.", + "Tenant " + event.getTenantName() + + " overdue by " + event.getDaysLate() + " days — consider power-cut", + "Total amount due: " + formatVnd(event.getTotalAmount()) + + ". Open the system to confirm power-cut if needed.", "/contracts/" + event.getContractId() + "/power-cut", Map.of( "contractId", event.getContractId().toString(), @@ -133,8 +133,8 @@ public void handleOverdueTerminationRequested( record.value(), OverdueTerminationRequestedEvent.class); notificationService.send(event.getManagerId(), NotificationCategory.PAYMENT_OVERDUE, - "Khách " + event.getTenantName() + " trễ tiền thuê 30 ngày", - "Khách đã chậm thanh toán 30 ngày. Vui lòng xem xét chấm dứt hợp đồng.", + "Tenant " + event.getTenantName() + " rent overdue 30 days", + "The tenant is 30 days late on payment. Please consider terminating the contract.", "/contracts/" + event.getContractId() + "/termination", Map.of("contractId", event.getContractId().toString()) ); @@ -150,43 +150,8 @@ public void handleOverdueTerminationRequested( } } -// @KafkaListener(topics = "payment.power-cut-requested", groupId = "notification-group") -// public void handlePowerCutRequest( -// ConsumerRecord record, Acknowledgment ack) { -// -// String messageId = kafkaHelper.extractMessageId(record); -// try { -// if (idempotencyService.isDuplicate(messageId)) { -// ack.acknowledge(); -// return; -// } -// -// PowerCutRequestEvent event = objectMapper.readValue( -// record.value(), PowerCutRequestEvent.class); -// -// notificationService.send(event.getContractId(), -// NotificationCategory.PAYMENT_OVERDUE, -// "Tenant trễ tiền thuê 14 ngày — Xem xét cắt điện", -// "Khách thuê đã chậm thanh toán " + event.getDaysLate() -// + " ngày. Tổng tiền: " + formatVnd(event.getTotalAmount()) -// + ". Bấm xác nhận nếu muốn cắt điện.", -// "/contracts/" + event.getContractId() + "/power-cut", -// Map.of( -// "contractId", event.getContractId().toString(), -// "invoiceId", event.getInvoiceId().toString(), -// "daysLate", String.valueOf(event.getDaysLate()) -// ) -// ); -// -// idempotencyService.markProcessed(messageId); -// ack.acknowledge(); -// } catch (Exception e) { -// log.error("[Notification] handlePowerCutRequest failed: {}", e.getMessage(), e); -// throw new RuntimeException(e); -// } -// } - private String formatVnd(Long amount) { return NumberFormat.getNumberInstance(Locale.of("vi", "VN")).format(amount) + " ₫"; } } + 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 be2131c..4bd709d 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/listeners/EContractEventListener.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/listeners/EContractEventListener.java @@ -1,160 +1,236 @@ -package com.isums.notificationservice.infrastructures.listeners; - -import com.isums.notificationservice.domains.events.RenewalReminderEvent; -import tools.jackson.core.JacksonException; -import tools.jackson.databind.ObjectMapper; -import com.isums.notificationservice.domains.events.ConfirmAndSendToTenantEvent; -import com.isums.notificationservice.domains.enums.LocaleType; -import com.isums.notificationservice.infrastructures.abstracts.EmailService; -import com.isums.notificationservice.infrastructures.grpcs.UserGrpcClient; -import com.isums.userservice.grpc.UserResponse; -import common.kafkas.IdempotencyService; -import common.kafkas.KafkaListenerHelper; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.kafka.clients.consumer.ConsumerRecord; -import org.springframework.kafka.annotation.KafkaListener; -import org.springframework.kafka.support.Acknowledgment; -import org.springframework.stereotype.Component; - -import java.time.Instant; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.HashMap; -import java.util.Map; - -@Slf4j -@Component -@RequiredArgsConstructor -public class EContractEventListener { - - private final EmailService emailService; - private final UserGrpcClient userGrpcClient; - private final IdempotencyService idempotencyService; - private final KafkaListenerHelper kafkaHelper; - private final ObjectMapper objectMapper; - - private static final DateTimeFormatter DMY = DateTimeFormatter - .ofPattern("dd/MM/yyyy") - .withZone(ZoneId.of("Asia/Ho_Chi_Minh")); - - @KafkaListener(topics = "confirmAndSendToTenant-topic", groupId = "notification-group") - public void handleConfirmAndSendToTenant(ConsumerRecord record, Acknowledgment ack) { - String messageId = kafkaHelper.extractMessageId(record); - kafkaHelper.setupMDC(record, messageId); - - try { - ConfirmAndSendToTenantEvent event = objectMapper.readValue( - record.value(), ConfirmAndSendToTenantEvent.class); - if (event.getMessageId() != null) messageId = event.getMessageId(); - - if (idempotencyService.isDuplicate(messageId)) { - log.warn("[EContract] Duplicate skipped messageId={}", messageId); - ack.acknowledge(); - return; - } - - if (event.getRecipientUserId() == null) { - log.error("[EContract] recipientUserId null, skip. contractId={}", event.getContractId()); - ack.acknowledge(); - return; - } - if (event.getUrl() == null || event.getUrl().isBlank()) { - log.error("[EContract] url null/blank, skip. contractId={}", event.getContractId()); - ack.acknowledge(); - return; - } - - UserResponse user = userGrpcClient.getUserById(event.getRecipientUserId()); - if (user == null) { - log.error("[EContract] User not found userId={} contractId={}", - event.getRecipientUserId(), event.getContractId()); - ack.acknowledge(); - return; - } - - Map vars = new HashMap<>(); - vars.put("tenantName", safe(user.getName(), "bạn")); - vars.put("contractName", safe(event.getContractName(), "Hợp đồng thuê nhà")); - vars.put("contractNo", shortId(event.getContractId())); - vars.put("propertyAddress", "N/A"); - vars.put("startDate", formatDate(event.getStartDate())); - vars.put("endDate", formatDate(event.getEndDate())); - vars.put("viewUrl", event.getUrl()); - vars.put("confirmUrl", safe(event.getConfirmUrl(), event.getUrl())); - vars.put("expiresIn", "24 giờ"); - vars.put("landlordName", "Chủ nhà"); - - emailService.sendEmail(user.getEmail(), "econtract_view_confirm", LocaleType.vi_VN, vars); - - idempotencyService.markProcessed(messageId); - ack.acknowledge(); - - log.info("[EContract] Email sent messageId={} to={} contractId={}", - messageId, user.getEmail(), event.getContractId()); - - } catch (JacksonException e) { - log.error("[EContract] Deserialization failed messageId={} raw={}: {}", - messageId, record.value(), e.getMessage()); - ack.acknowledge(); - } catch (Exception e) { - log.error("[EContract] Processing failed messageId={}, will retry: {}", - messageId, e.getMessage(), e); - throw new RuntimeException(e); - } finally { - kafkaHelper.clearMDC(); - } - } - - @KafkaListener(topics = "contract.renewal.reminder", groupId = "notification-group") - public void handleRenewalReminder( - ConsumerRecord record, Acknowledgment ack) { - - String messageId = kafkaHelper.extractMessageId(record); - try { - if (idempotencyService.isDuplicate(messageId)) { - ack.acknowledge(); - return; - } - - RenewalReminderEvent event = objectMapper.readValue(record.value(), RenewalReminderEvent.class); - - UserResponse tenant = userGrpcClient.getUserById(event.getTenantId()); - - emailService.sendEmail( - tenant.getEmail(), - "contract_renewal_reminder", - LocaleType.vi_VN, - Map.of( - "tenantName", tenant.getName(), - "contractId", event.getContractId().toString() - .substring(0, 8).toUpperCase(), - "daysRemaining", String.valueOf(event.getDaysRemaining()), - "endDate", DMY.format(event.getEndDate()), - "openForNew", event.getDaysRemaining() == 0 - ) - ); - - idempotencyService.markProcessed(messageId); - ack.acknowledge(); - log.info("[Notification] RenewalReminder sent tenantId={} daysRemaining={}", - event.getTenantId(), event.getDaysRemaining()); - - } catch (Exception e) { - log.error("[Notification] handleRenewalReminder failed: {}", e.getMessage(), e); - throw new RuntimeException(e); - } - } - - private String safe(String s, String fallback) { - return (s != null && !s.isBlank()) ? s.trim() : fallback; - } - - private String formatDate(Instant instant) { - return instant != null ? DMY.format(instant) : "N/A"; - } - - private String shortId(java.util.UUID id) { - return id != null ? id.toString().substring(0, 8).toUpperCase() : "N/A"; - } -} \ No newline at end of file +package com.isums.notificationservice.infrastructures.listeners; + +import com.isums.notificationservice.domains.events.RenewalReminderEvent; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; +import com.isums.notificationservice.domains.events.ConfirmAndSendToTenantEvent; +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.infrastructures.abstracts.EmailService; +import com.isums.notificationservice.infrastructures.grpcs.UserGrpcClient; +import com.isums.userservice.grpc.UserResponse; +import common.kafkas.IdempotencyService; +import common.kafkas.KafkaListenerHelper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class EContractEventListener { + + private final EmailService emailService; + private final UserGrpcClient userGrpcClient; + private final IdempotencyService idempotencyService; + private final KafkaListenerHelper kafkaHelper; + private final ObjectMapper objectMapper; + + private static final DateTimeFormatter DMY = DateTimeFormatter + .ofPattern("dd/MM/yyyy") + .withZone(ZoneId.of("Asia/Ho_Chi_Minh")); + + @KafkaListener(topics = "confirmAndSendToTenant-topic", groupId = "notification-group") + public void handleConfirmAndSendToTenant(ConsumerRecord record, Acknowledgment ack) { + String messageId = kafkaHelper.extractMessageId(record); + kafkaHelper.setupMDC(record, messageId); + + try { + ConfirmAndSendToTenantEvent event = objectMapper.readValue( + record.value(), ConfirmAndSendToTenantEvent.class); + if (event.getMessageId() != null) messageId = event.getMessageId(); + + if (idempotencyService.isDuplicate(messageId)) { + log.warn("[EContract] Duplicate skipped messageId={}", messageId); + ack.acknowledge(); + return; + } + + if (event.getRecipientUserId() == null) { + log.error("[EContract] recipientUserId null, skip. contractId={}", event.getContractId()); + ack.acknowledge(); + return; + } + if (event.getUrl() == null || event.getUrl().isBlank()) { + log.error("[EContract] url null/blank, skip. contractId={}", event.getContractId()); + ack.acknowledge(); + return; + } + + UserResponse user = userGrpcClient.getUserById(event.getRecipientUserId()); + if (user == null) { + log.error("[EContract] User not found userId={} contractId={}", + event.getRecipientUserId(), event.getContractId()); + ack.acknowledge(); + return; + } + + LocaleType locale = mapLocale(event.getContractLanguage()); + + Map vars = new HashMap<>(); + vars.put("tenantName", safe(user.getName(), fallbackTenantName(locale))); + vars.put("contractName", safe(event.getContractName(), fallbackContractName(locale))); + vars.put("contractNo", shortId(event.getContractId())); + vars.put("propertyAddress", "N/A"); + vars.put("startDate", formatDate(event.getStartDate())); + vars.put("endDate", formatDate(event.getEndDate())); + vars.put("viewUrl", event.getUrl()); + vars.put("confirmUrl", safe(event.getConfirmUrl(), event.getUrl())); + vars.put("expiresIn", expiresIn(locale)); + vars.put("landlordName", fallbackLandlordName(locale)); + + emailService.sendEmail(user.getEmail(), "econtract_view_confirm", locale, vars); + + idempotencyService.markProcessed(messageId); + ack.acknowledge(); + + log.info("[EContract] Email sent messageId={} to={} contractId={}", + messageId, user.getEmail(), event.getContractId()); + + } catch (JacksonException e) { + log.error("[EContract] Deserialization failed messageId={} raw={}: {}", + messageId, record.value(), e.getMessage()); + ack.acknowledge(); + } catch (StatusRuntimeException e) { + if (isPermanentGrpcFailure(e)) { + log.warn("[EContract] Permanent gRPC failure code={} messageId={}: {} — ack and skip", + e.getStatus().getCode(), messageId, e.getMessage()); + idempotencyService.markProcessed(messageId); + ack.acknowledge(); + } else { + log.error("[EContract] Transient gRPC failure code={} messageId={}, will retry: {}", + e.getStatus().getCode(), messageId, e.getMessage()); + throw e; + } + } catch (Exception e) { + log.error("[EContract] Processing failed messageId={}, will retry: {}", + messageId, e.getMessage(), e); + throw new RuntimeException(e); + } finally { + kafkaHelper.clearMDC(); + } + } + + 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 = "contract.renewal.reminder", groupId = "notification-group") + public void handleRenewalReminder( + ConsumerRecord record, Acknowledgment ack) { + + String messageId = kafkaHelper.extractMessageId(record); + try { + if (idempotencyService.isDuplicate(messageId)) { + ack.acknowledge(); + return; + } + + RenewalReminderEvent event = objectMapper.readValue(record.value(), RenewalReminderEvent.class); + + UserResponse tenant = userGrpcClient.getUserById(event.getTenantId()); + + emailService.sendEmail( + tenant.getEmail(), + "contract_renewal_reminder", + LocaleType.vi_VN, + Map.of( + "tenantName", tenant.getName(), + "contractId", event.getContractId().toString() + .substring(0, 8).toUpperCase(), + "daysRemaining", String.valueOf(event.getDaysRemaining()), + "endDate", DMY.format(event.getEndDate()), + "openForNew", event.getDaysRemaining() == 0 + ) + ); + + idempotencyService.markProcessed(messageId); + ack.acknowledge(); + log.info("[Notification] RenewalReminder sent tenantId={} daysRemaining={}", + event.getTenantId(), event.getDaysRemaining()); + + } catch (StatusRuntimeException e) { + if (isPermanentGrpcFailure(e)) { + log.warn("[Notification] RenewalReminder permanent gRPC failure code={}: {} — ack and skip", + e.getStatus().getCode(), e.getMessage()); + idempotencyService.markProcessed(messageId); + ack.acknowledge(); + } else { + log.error("[Notification] RenewalReminder transient gRPC failure code={}, will retry: {}", + e.getStatus().getCode(), e.getMessage()); + throw e; + } + } catch (Exception e) { + log.error("[Notification] handleRenewalReminder failed: {}", e.getMessage(), e); + throw new RuntimeException(e); + } + } + + private String safe(String s, String fallback) { + return (s != null && !s.isBlank()) ? s.trim() : fallback; + } + + private String formatDate(Instant instant) { + return instant != null ? DMY.format(instant) : "N/A"; + } + + private String shortId(java.util.UUID id) { + return id != null ? id.toString().substring(0, 8).toUpperCase() : "N/A"; + } + + private static LocaleType mapLocale(String contractLanguage) { + if (contractLanguage == null) return LocaleType.vi_VN; + return switch (contractLanguage) { + case "VI_EN" -> LocaleType.en_US; + case "VI_JA" -> LocaleType.ja_JP; + default -> LocaleType.vi_VN; + }; + } + + private static String fallbackTenantName(LocaleType l) { + return switch (l) { + case en_US -> "you"; + case ja_JP -> "お客様"; + default -> "you"; + }; + } + + private static String fallbackContractName(LocaleType l) { + return switch (l) { + case en_US -> "Lease contract"; + case ja_JP -> "賃貸借契約"; + default -> "House lease contract"; + }; + } + + private static String fallbackLandlordName(LocaleType l) { + return switch (l) { + case en_US -> "Landlord"; + case ja_JP -> "家主"; + default -> "Landlord"; + }; + } + + private static String expiresIn(LocaleType l) { + return switch (l) { + case en_US -> "24 hours"; + case ja_JP -> "24時間"; + default -> "24 hours"; + }; + } +} 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 255633a..00e1b61 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/listeners/PaymentEventListener.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/listeners/PaymentEventListener.java @@ -61,7 +61,7 @@ public void handlePaymentPaid(ConsumerRecord record, Acknowledgm } Map vars = new HashMap<>(); - vars.put("tenantName", safe(user.getName(), "bạn")); + vars.put("tenantName", safe(user.getName(), "you")); vars.put("invoiceType", translateType(event.invoiceType())); vars.put("amount", formatVnd(event.amount())); vars.put("txnNo", event.txnNo()); @@ -89,12 +89,12 @@ public void handlePaymentPaid(ConsumerRecord record, Acknowledgm private String translateType(String type) { return switch (type) { - case "DEPOSIT" -> "Tiền cọc"; - case "MONTHLY_RENT" -> "Tiền thuê tháng"; - case "MAINTENANCE" -> "Phí sửa chữa"; - case "UTILITY" -> "Phí tiện ích"; - case "PENALTY" -> "Tiền phạt"; - default -> "Hóa đơn"; + case "DEPOSIT" -> "Deposit"; + case "MONTHLY_RENT" -> "Monthly rent"; + case "MAINTENANCE" -> "Repair fee"; + case "UTILITY" -> "Utility fee"; + case "PENALTY" -> "Penalty"; + default -> "Invoice"; }; } @@ -106,4 +106,4 @@ private String formatVnd(Long amount) { private String safe(String s, String fb) { return (s != null && !s.isBlank()) ? s.trim() : fb; } -} \ No newline at end of file +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/listeners/PaymentSubscriptionListener.java b/src/main/java/com/isums/notificationservice/infrastructures/listeners/PaymentSubscriptionListener.java new file mode 100644 index 0000000..409a680 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/listeners/PaymentSubscriptionListener.java @@ -0,0 +1,108 @@ +package com.isums.notificationservice.infrastructures.listeners; + +import com.isums.notificationservice.domains.entities.SubscriptionPlan; +import com.isums.notificationservice.domains.events.PaymentSubscriptionActivatedEvent; +import com.isums.notificationservice.infrastructures.repositories.SubscriptionPlanRepository; +import com.isums.notificationservice.services.NotificationSubscriptionService; +import common.kafkas.IdempotencyService; +import common.kafkas.KafkaListenerHelper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +import java.util.UUID; + +/** + * Activates PREMIUM + resets quotas when Payment-Service publishes a + * successful subscription charge. Acts as the production path; admins + * can also use {@code /api/notifications/subscriptions/admin/grant-premium} + * to skip the payment loop during thesis demos. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PaymentSubscriptionListener { + + private final NotificationSubscriptionService subscriptionService; + private final SubscriptionPlanRepository planRepo; + private final IdempotencyService idempotencyService; + private final KafkaListenerHelper kafkaHelper; + private final ObjectMapper objectMapper; + + @KafkaListener( + topics = "payment.subscription-activated", + groupId = "notification-subscription-group") + public void onActivated(ConsumerRecord record, Acknowledgment ack) { + String messageId = kafkaHelper.extractMessageId(record); + kafkaHelper.setupMDC(record, messageId); + + try { + if (idempotencyService.isDuplicate(messageId)) { + ack.acknowledge(); + return; + } + + PaymentSubscriptionActivatedEvent event = + objectMapper.readValue(record.value(), PaymentSubscriptionActivatedEvent.class); + + if (event.userId() == null) { + log.warn("[SubscriptionActivated] missing userId messageId={}", messageId); + ack.acknowledge(); + return; + } + + // Defensive defaults: Payment-Service guarantees durationDays in + // the new shape, but a redelivery from before the schema change + // could still be in flight (older Kafka offsets). Fall back to + // 30 days so an empty / legacy payload still grants something + // reasonable instead of silently no-oping the activation. + int days = event.durationDays() != null && event.durationDays() > 0 + ? event.durationDays() + : 30; + + // Plan-driven quotas: PREMIUM_1M = 100 voice / 200 SMS, while + // TRIAL_7D ships with smaller caps. Without this lookup the + // user would land on the legacy TierQuotaPolicy floor (20/30) + // regardless of which plan they paid for. PlanId comes through + // as a string in the Map Kafka payload — parse + // best-effort and fall back to the policy default if missing. + int voiceQuota = -1; + int smsQuota = -1; + String planIdStr = event.planId(); + if (planIdStr != null && !planIdStr.isBlank()) { + try { + SubscriptionPlan plan = planRepo.findById(UUID.fromString(planIdStr)).orElse(null); + if (plan != null) { + voiceQuota = plan.getVoiceQuotaMonthly(); + smsQuota = plan.getSmsQuotaMonthly(); + } + } catch (IllegalArgumentException ex) { + log.warn("[SubscriptionActivated] invalid planId={} messageId={} — falling back to tier policy", + planIdStr, messageId); + } + } + + if (voiceQuota >= 0 && smsQuota >= 0) { + subscriptionService.activatePremiumByDays(event.userId(), days, voiceQuota, smsQuota); + } else { + // Legacy / missing plan path — service uses TierQuotaPolicy. + subscriptionService.activatePremiumByDays(event.userId(), days); + } + idempotencyService.markProcessed(messageId); + ack.acknowledge(); + + log.info("[SubscriptionActivated] user={} plan={} days={} voice={} sms={} txnRef={}", + event.userId(), event.planCode(), days, voiceQuota, smsQuota, event.txnRef()); + } catch (Exception e) { + log.error("[SubscriptionActivated] failed messageId={}: {}", + messageId, e.getMessage(), e); + throw new RuntimeException(e); + } finally { + kafkaHelper.clearMDC(); + } + } +} 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 fececb4..bb2999d 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/listeners/UserEventListener.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/listeners/UserEventListener.java @@ -87,11 +87,16 @@ public void handleOnUserActivated(ConsumerRecord record, Acknowl Map params = new HashMap<>(); params.put("name", event.name()); params.put("email", event.email()); - params.put("password", event.tempPassword()); + + params.put("password", event.password() != null ? event.password() : ""); params.put("hasInvoice", event.firstRentPaymentUrl() != null); if (event.firstRentPaymentUrl() != null) { params.put("invoiceType", "Tiền thuê tháng đầu"); + params.put("invoiceTypeVi", "Tiền thuê tháng đầu"); + params.put("invoiceTypeEn", "First-month rent"); + params.put("invoiceTypeJa", "初月家賃"); + params.put("invoiceTypeCode", "MONTHLY_RENT"); params.put("invoiceAmount", formatVnd(event.firstRentAmount())); params.put("invoiceDueDate", DMY.format(event.firstRentDueDate())); params.put("invoicePaymentUrl", event.firstRentPaymentUrl()); @@ -118,4 +123,4 @@ private String formatVnd(Long amount) { if (amount == null) return "0 ₫"; return NumberFormat.getNumberInstance(Locale.of("vi", "VN")).format(amount) + " ₫"; } -} \ No newline at end of file +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/listeners/UtilityAlertEventListener.java b/src/main/java/com/isums/notificationservice/infrastructures/listeners/UtilityAlertEventListener.java new file mode 100644 index 0000000..d744d81 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/listeners/UtilityAlertEventListener.java @@ -0,0 +1,233 @@ +package com.isums.notificationservice.infrastructures.listeners; + +import com.isums.notificationservice.domains.dtos.AlertDispatchRequest; +import com.isums.notificationservice.domains.dtos.AlertDispatchResponse; +import com.isums.notificationservice.domains.events.UtilityThresholdExceededEvent; +import com.isums.notificationservice.domains.enums.AlertEventType; +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.infrastructures.abstracts.EmailService; +import com.isums.notificationservice.infrastructures.grpcs.UserGrpcClient; +import com.isums.notificationservice.services.NotificationDispatchService; +import com.isums.userservice.grpc.UserResponse; +import common.kafkas.IdempotencyService; +import common.kafkas.KafkaListenerHelper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +import java.text.NumberFormat; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UtilityAlertEventListener { + + private final EmailService emailService; + private final UserGrpcClient userGrpcClient; + private final IdempotencyService idempotencyService; + private final KafkaListenerHelper kafkaHelper; + private final ObjectMapper objectMapper; + private final NotificationDispatchService dispatchService; + + private static final DateTimeFormatter DMY = DateTimeFormatter + .ofPattern("dd/MM/yyyy HH:mm") + .withZone(ZoneId.of("Asia/Ho_Chi_Minh")); + + @KafkaListener(topics = "utility.consumption.alert", groupId = "notification-group") + public void onThresholdExceeded(ConsumerRecord record, Acknowledgment ack) { + String messageId = kafkaHelper.extractMessageId(record); + kafkaHelper.setupMDC(record, messageId); + + try { + if (idempotencyService.isDuplicate(messageId)) { + log.warn("[UtilityAlert] duplicate skipped messageId={}", messageId); + ack.acknowledge(); + return; + } + + UtilityThresholdExceededEvent event = + objectMapper.readValue(record.value(), UtilityThresholdExceededEvent.class); + + if (hasText(event.getTenantUserId())) { + dispatchTenantAlert(event, event.getTenantUserId()); + } else { + // Older asset-service builds put the current renter in + // landlordUserId. Keep that path alive for in-flight Kafka + // records, then let the new tenantUserId field take over. + log.warn("[UtilityAlert] tenantUserId missing eventId={}, falling back to legacy landlordUserId", + event.getEventId()); + dispatchTenantAlert(event, event.getLandlordUserId()); + sendLegacyEmail(event); + } + + idempotencyService.markProcessed(messageId); + ack.acknowledge(); + + log.info("[UtilityAlert] processed messageId={} house={} metric={} {}→{} tenantUserId={}", + messageId, event.getHouseName(), event.getMetric(), + event.getPreviousStatus(), event.getCurrentStatus(), event.getTenantUserId()); + + } catch (JacksonException e) { + log.error("[UtilityAlert] deserialize failed messageId={}: {}", messageId, e.getMessage()); + ack.acknowledge(); + } catch (Exception e) { + log.error("[UtilityAlert] processing failed messageId={}, will retry: {}", + messageId, e.getMessage(), e); + throw new RuntimeException(e); + } finally { + kafkaHelper.clearMDC(); + } + } + + private void dispatchTenantAlert(UtilityThresholdExceededEvent event, String tenantUserId) { + if (!hasText(tenantUserId)) { + log.warn("[UtilityAlert] no tenant user id eventId={} houseId={}", + event.getEventId(), event.getHouseId()); + return; + } + + UserResponse tenant; + try { + tenant = userGrpcClient.getUserById(UUID.fromString(tenantUserId)); + } catch (Exception e) { + log.error("[UtilityAlert] tenant lookup failed userId={} eventId={}: {}", + tenantUserId, event.getEventId(), e.getMessage()); + return; + } + if (tenant == null || !hasText(tenant.getKeycloakId())) { + log.warn("[UtilityAlert] tenant has no keycloakId userId={} eventId={}", + tenantUserId, event.getEventId()); + return; + } + + UUID tenantKeycloakId; + try { + tenantKeycloakId = UUID.fromString(tenant.getKeycloakId()); + } catch (Exception e) { + log.error("[UtilityAlert] bad tenant keycloakId={} userId={} eventId={}", + tenant.getKeycloakId(), tenantUserId, event.getEventId()); + return; + } + + AlertEventType eventType = mapEventType(event); + AlertDispatchRequest req = new AlertDispatchRequest( + tenantKeycloakId, + safe(event.getEventId(), "utility-" + UUID.randomUUID()), + eventType, + event.getHouseId(), + null, + safe(event.getHouseName(), "Utility"), + "utility-threshold", + safe(event.getMetric(), "utility").toLowerCase(Locale.ROOT), + event.getUsagePercent(), + "%", + dispatchVars(event) + ); + + AlertDispatchResponse resp = dispatchService.dispatch(req); + log.info("[UtilityAlert] dispatched eventId={} tenantUserId={} tenantKeycloakId={} eventType={} results={}", + event.getEventId(), tenantUserId, tenantKeycloakId, eventType, + resp == null ? 0 : resp.results().size()); + } + + private void sendLegacyEmail(UtilityThresholdExceededEvent event) { + if (!hasText(event.getLandlordUserId())) { + return; + } + UserResponse recipient; + try { + recipient = userGrpcClient.getUserById(UUID.fromString(event.getLandlordUserId())); + } catch (Exception e) { + log.error("[UtilityAlert] legacy email lookup failed userId={}: {}", + event.getLandlordUserId(), e.getMessage()); + return; + } + if (recipient == null || !hasText(recipient.getEmail())) { + log.warn("[UtilityAlert] no email for legacy recipient userId={}", event.getLandlordUserId()); + return; + } + + Map vars = dispatchVars(event); + vars.put("landlordName", safe(recipient.getName(), "you")); + try { + emailService.sendEmail(recipient.getEmail(), "utility_threshold_exceeded", LocaleType.vi_VN, vars); + } catch (Exception e) { + log.warn("[UtilityAlert] legacy email skipped userId={} eventId={}: {}", + event.getLandlordUserId(), event.getEventId(), e.getMessage()); + } + } + + private Map dispatchVars(UtilityThresholdExceededEvent event) { + LocaleType locale = LocaleType.vi_VN; + Map vars = new HashMap<>(); + vars.put("houseName", safe(event.getHouseName(), event.getHouseId())); + vars.put("metricLabel", metricLabel(event.getMetric(), locale)); + vars.put("currentUsage", formatNum(event.getCurrentUsage())); + vars.put("monthlyLimit", formatNum(event.getMonthlyLimit())); + vars.put("usagePercent", event.getUsagePercent() == null ? "—" : String.format("%.1f", event.getUsagePercent())); + vars.put("unit", safe(event.getUnit(), "")); + vars.put("month", safe(event.getMonth(), "")); + vars.put("severity", severityLabel(event.getCurrentStatus(), locale)); + vars.put("occurredAt", event.getOccurredAt() != null + ? DMY.format(Instant.ofEpochMilli(event.getOccurredAt())) + : "—"); + return vars; + } + + private static AlertEventType mapEventType(UtilityThresholdExceededEvent event) { + boolean critical = "CRITICAL".equalsIgnoreCase(event.getCurrentStatus()); + boolean electricity = "ELECTRICITY".equalsIgnoreCase(event.getMetric()); + if (electricity) { + return critical + ? AlertEventType.UTILITY_ELECTRICITY_CRITICAL + : AlertEventType.UTILITY_ELECTRICITY_WARNING; + } + return critical + ? AlertEventType.UTILITY_WATER_CRITICAL + : AlertEventType.UTILITY_WATER_WARNING; + } + + private static String metricLabel(String metric, LocaleType locale) { + if (metric == null) return ""; + boolean electricity = "ELECTRICITY".equalsIgnoreCase(metric); + return switch (locale) { + case en_US -> electricity ? "electricity" : "water"; + case ja_JP -> electricity ? "電気" : "水道"; + default -> electricity ? "electricity" : "water"; + }; + } + + private static String severityLabel(String status, LocaleType locale) { + boolean critical = "CRITICAL".equalsIgnoreCase(status); + return switch (locale) { + case en_US -> critical ? "CRITICAL" : "WARNING"; + case ja_JP -> critical ? "重大" : "警告"; + default -> critical ? "CRITICAL" : "WARNING"; + }; + } + + private static String formatNum(Double v) { + if (v == null) return "—"; + return NumberFormat.getNumberInstance(Locale.of("vi", "VN")).format(v); + } + + private static String safe(String s, String fb) { + return (s != null && !s.isBlank()) ? s.trim() : fb; + } + + private static boolean hasText(String s) { + return s != null && !s.isBlank(); + } +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/repositories/ChannelTemplateRepository.java b/src/main/java/com/isums/notificationservice/infrastructures/repositories/ChannelTemplateRepository.java new file mode 100644 index 0000000..459e70f --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/repositories/ChannelTemplateRepository.java @@ -0,0 +1,15 @@ +package com.isums.notificationservice.infrastructures.repositories; + +import com.isums.notificationservice.domains.entities.ChannelTemplate; +import com.isums.notificationservice.domains.enums.NotificationChannel; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface ChannelTemplateRepository extends JpaRepository { + + Optional findByTemplateKeyAndChannel(String templateKey, NotificationChannel channel); +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/repositories/ChannelTemplateVersionRepository.java b/src/main/java/com/isums/notificationservice/infrastructures/repositories/ChannelTemplateVersionRepository.java new file mode 100644 index 0000000..8450905 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/repositories/ChannelTemplateVersionRepository.java @@ -0,0 +1,19 @@ +package com.isums.notificationservice.infrastructures.repositories; + +import com.isums.notificationservice.domains.entities.ChannelTemplateVersion; +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.domains.enums.NotificationChannel; +import com.isums.notificationservice.domains.enums.TemplateStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface ChannelTemplateVersionRepository extends JpaRepository { + + Optional + findFirstByTemplate_TemplateKeyAndTemplate_ChannelAndLocaleAndStatusOrderByVersionDesc( + String templateKey, NotificationChannel channel, LocaleType locale, TemplateStatus status); +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/repositories/NotificationSubscriptionRepository.java b/src/main/java/com/isums/notificationservice/infrastructures/repositories/NotificationSubscriptionRepository.java new file mode 100644 index 0000000..d98fe8a --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/repositories/NotificationSubscriptionRepository.java @@ -0,0 +1,18 @@ +package com.isums.notificationservice.infrastructures.repositories; + +import com.isums.notificationservice.domains.entities.NotificationSubscription; +import com.isums.notificationservice.domains.enums.SubscriptionTier; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Repository +public interface NotificationSubscriptionRepository + extends JpaRepository { + + List findAllByTierAndPremiumUntilBefore( + SubscriptionTier tier, Instant cutoff); +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/repositories/SubscriptionPlanRepository.java b/src/main/java/com/isums/notificationservice/infrastructures/repositories/SubscriptionPlanRepository.java new file mode 100644 index 0000000..c8f34f0 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/repositories/SubscriptionPlanRepository.java @@ -0,0 +1,21 @@ +package com.isums.notificationservice.infrastructures.repositories; + +import com.isums.notificationservice.domains.entities.SubscriptionPlan; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface SubscriptionPlanRepository extends JpaRepository { + + /** Active plans for the FE picker — sorted by curator's preferred order. */ + List findByIsActiveTrueOrderBySortOrderAscPriceVndAsc(); + + /** All plans (active + retired) — admin / landlord-only catalogue view. */ + List findAllByOrderBySortOrderAscPriceVndAsc(); + + Optional findByCode(String code); +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/repositories/UserNotificationPreferencesRepository.java b/src/main/java/com/isums/notificationservice/infrastructures/repositories/UserNotificationPreferencesRepository.java new file mode 100644 index 0000000..0fad915 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/repositories/UserNotificationPreferencesRepository.java @@ -0,0 +1,12 @@ +package com.isums.notificationservice.infrastructures.repositories; + +import com.isums.notificationservice.domains.entities.UserNotificationPreferences; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface UserNotificationPreferencesRepository + extends JpaRepository { +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/repositories/VoiceAudioCacheRepository.java b/src/main/java/com/isums/notificationservice/infrastructures/repositories/VoiceAudioCacheRepository.java new file mode 100644 index 0000000..c9fe8bc --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/repositories/VoiceAudioCacheRepository.java @@ -0,0 +1,14 @@ +package com.isums.notificationservice.infrastructures.repositories; + +import com.isums.notificationservice.domains.entities.VoiceAudioCache; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface VoiceAudioCacheRepository extends JpaRepository { + + Optional findByCacheKey(String cacheKey); +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/repositories/VoiceCallEscalationRepository.java b/src/main/java/com/isums/notificationservice/infrastructures/repositories/VoiceCallEscalationRepository.java new file mode 100644 index 0000000..7dc5380 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/repositories/VoiceCallEscalationRepository.java @@ -0,0 +1,11 @@ +package com.isums.notificationservice.infrastructures.repositories; + +import com.isums.notificationservice.domains.entities.VoiceCallEscalation; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface VoiceCallEscalationRepository extends JpaRepository { +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/repositories/VoiceCallJobRepository.java b/src/main/java/com/isums/notificationservice/infrastructures/repositories/VoiceCallJobRepository.java new file mode 100644 index 0000000..9142444 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/repositories/VoiceCallJobRepository.java @@ -0,0 +1,24 @@ +package com.isums.notificationservice.infrastructures.repositories; + +import com.isums.notificationservice.domains.entities.VoiceCallJob; +import com.isums.notificationservice.domains.enums.VoiceCallStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface VoiceCallJobRepository extends JpaRepository { + + Optional findByProviderCallId(String providerCallId); + + List findAllByStatusInAndNextRetryAtBefore( + List statuses, Instant cutoff); + + Page findAllByUserIdOrderByCreatedAtDesc(UUID userId, Pageable pageable); +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/repositories/VoiceConsentHistoryRepository.java b/src/main/java/com/isums/notificationservice/infrastructures/repositories/VoiceConsentHistoryRepository.java new file mode 100644 index 0000000..db5f5d3 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/repositories/VoiceConsentHistoryRepository.java @@ -0,0 +1,15 @@ +package com.isums.notificationservice.infrastructures.repositories; + +import com.isums.notificationservice.domains.entities.VoiceConsentHistory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface VoiceConsentHistoryRepository extends JpaRepository { + /** Latest consent rows for a user — used by /preferences/me/consent-history. */ + Page findByUserIdOrderByCreatedAtDesc(UUID userId, Pageable pageable); +} 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 6ddb216..64a9828 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/seeders/EmailTemplateSeeder.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/seeders/EmailTemplateSeeder.java @@ -1,1572 +1,2111 @@ -package com.isums.notificationservice.infrastructures.seeders; - -import com.isums.notificationservice.domains.entities.EmailTemplate; -import com.isums.notificationservice.domains.entities.EmailTemplateVersion; -import com.isums.notificationservice.domains.enums.LocaleType; -import com.isums.notificationservice.domains.enums.TemplateStatus; -import com.isums.notificationservice.infrastructures.repositories.EmailTemplateRepository; -import com.isums.notificationservice.infrastructures.repositories.EmailTemplateVersionRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.ApplicationRunner; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Configuration -@RequiredArgsConstructor -public class EmailTemplateSeeder { - - @Value("${app.seed.email-templates:true}") - private boolean enabled; - - @Bean - @Transactional - ApplicationRunner seedEmailTemplatesRunner(EmailTemplateRepository templateRepo, - EmailTemplateVersionRepository versionRepo) { - return args -> { - if (!enabled) return; - seed(templateRepo, versionRepo); - }; - } - - // WELCOME USER (vi_VN) - @Transactional - public void seed(EmailTemplateRepository templateRepo, EmailTemplateVersionRepository versionRepo) { - - upsertActiveV1( - templateRepo, versionRepo, - "welcome", "ONBOARDING", "CUSTOMER", - LocaleType.vi_VN, - "Chào mừng {{name}} đến với ISUMS", - """ - - - - - - Chào mừng đến với ISUMS - - -

- Chào mừng {{name}}! Tài khoản ISUMS của bạn đã sẵn sàng. -
- - - - - -
- - - - - - - - - - - - - - -
-
- 🎉 Chào mừng đến với ISUMS -
-
- Hệ thống quản lý nhà trọ thông minh -
-
-

- Xin chào {{name}}, -

-

- Tài khoản ISUMS của bạn đã được kích hoạt thành công. Bắt đầu hành trình - quản lý nhà trọ tiện lợi ngay hôm nay. -

- - - - - -
- - Truy cập ISUMS - -
- -

- 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:
- {{appUrl}} -

-
- Cần hỗ trợ? Liên hệ {{supportEmail}}
- Email này được gửi tự động, vui lòng không trả lời. -
-
- - - """, - """ - Xin chào {{name}}, - - Tài khoản ISUMS của bạn đã được kích hoạt thành công. - Truy cập ngay: {{appUrl}} - - Cần hỗ trợ? Liên hệ {{supportEmail}} - """, - List.of("name", "appUrl", "supportEmail"), - "system" - ); - - // E-CONTRACT VIEW + CONFIRM (vi_VN) - upsertActiveV1( - templateRepo, versionRepo, - "econtract_view_confirm", - "CONTRACT", - "TENANT", - LocaleType.vi_VN, - "Vui lòng xem và xác nhận hợp đồng {{contractNo}}", - """ - - - - - - Xác nhận hợp đồng thuê nhà - - -
- Bạn có một hợp đồng thuê nhà cần xem và xác nhận. -
- - - - - -
- - - - - - - - - - - - - - -
-
- ISUMS • Hợp đồng thuê nhà -
-
- Vui lòng xem và xác nhận hợp đồng -
-
-
- Xin chào {{tenantName}}, -
- -
- Bạn vừa nhận được hợp đồng thuê nhà từ {{landlordName}}. - Vui lòng nhấn Xem hợp đồng để đọc nội dung và tiến hành xác nhận nếu đồng ý. -
- - - - - -
-
-
Mã hợp đồng: {{contractNo}}
-
Tên hợp đồng: {{contractName}}
-
Địa chỉ: {{propertyAddress}}
-
Thời hạn: {{startDate}} – {{endDate}}
-
-
- - - - - - - -
- - Xem hợp đồng - - - - Tôi đồng ý (Xác nhận) - -
- -
- Nếu bạn không bấm được nút, hãy copy & paste link sau vào trình duyệt: -
- Xem hợp đồng: {{viewUrl}}
- Xác nhận: {{confirmUrl}} -
-
- -
- Vì lý do bảo mật, đường dẫn xác nhận có thể hết hạn sau {{expiresIn}}. - Nếu bạn không yêu cầu email này, vui lòng bỏ qua. -
- -
- -
- Trân trọng,
- Đội ngũ ISUMS -
-
-
- 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 {{tenantName}} - - Bạn vừa nhận được hợp đồng thuê nhà từ {{landlordName}}. - - Mã hợp đồng: {{contractNo}} - Tên hợp đồng: {{contractName}} - Địa chỉ: {{propertyAddress}} - Thời hạn: {{startDate}} - {{endDate}} - - Xem hợp đồng: {{viewUrl}} - Xác nhận đồng ý: {{confirmUrl}} - - Lưu ý: Link xác nhận có thể hết hạn sau {{expiresIn}}. - """, - List.of( - "tenantName", - "landlordName", - "contractNo", - "contractName", - "propertyAddress", - "startDate", - "endDate", - "viewUrl", - "confirmUrl", - "expiresIn" - ), - "system" - ); - - upsertActiveV1( - templateRepo, versionRepo, - "payment_invoice", - "PAYMENT", - "TENANT", - LocaleType.vi_VN, - "Hóa đơn {{invoiceType}} cần thanh toán trước {{dueDate}}", - """ - - - - - - Hóa đơn thanh toán - - -
- Bạn có hóa đơn {{invoiceType}} cần thanh toán trước {{dueDate}}. -
- - - - -
- - - - - - - - - - - - - - -
-
💳 Hóa đơn thanh toán
-
ISUMS — Hệ thống quản lý nhà trọ
-
-
- Xin chào,
- Bạn có một hóa đơn cần thanh toán. Vui lòng thanh toán trước hạn để tránh phát sinh phí trễ hạn. -
- - - - - - - - - - - -
-
- Loại hóa đơn
-
{{invoiceType}}
-
-
- Số tiền
-
{{amount}}
-
-
- Hạn thanh toán
-
{{dueDate}}
-
- - - - - -
- - Thanh toán ngay - -
- -
- Nếu không bấm được nút, hãy copy link sau vào trình duyệt:
-
{{paymentUrl}}
-
- -
- Link thanh toán hợp lệ trong {{expiresIn}}. - Nếu bạn không yêu cầu email này, vui lòng bỏ qua. -
- -
-
- Trân trọng,
Đội ngũ ISUMS -
-
-
- Email này được gửi tự động. Vui lòng không trả lời trực tiếp. -
-
-
- - - """, - // ── TEXT ────────────────────────────────────────────────────── - """ - Xin chào, - - Bạn có hóa đơn {{invoiceType}} cần thanh toán. - - Số tiền: {{amount}} - Hạn nộp: {{dueDate}} - - Thanh toán tại: {{paymentUrl}} - - Link có hiệu lực trong {{expiresIn}}. - - Trân trọng, - Đội ngũ ISUMS - """, - List.of("invoiceType", "amount", "dueDate", "paymentUrl", "expiresIn"), - "system" - ); - - upsertActiveV1( - templateRepo, versionRepo, - "payment_receipt", - "PAYMENT", - "TENANT", - LocaleType.vi_VN, - "Xác nhận thanh toán {{invoiceType}} thành công", - """ - - - Xác nhận thanh toán - - - -
- - - - - - - - - - - - - - -
-
- ✅ Thanh toán thành công -
-
- ISUMS — Hệ thống quản lý nhà trọ -
-
-
- Xin chào {{tenantName}},
- Hệ thống đã ghi nhận thanh toán của bạn. -
- - - - - - - - - - - - - - -
-
Loại thanh toán
-
- {{invoiceType}} -
-
-
Số tiền
-
- {{amount}} -
-
-
Mã giao dịch
-
- {{txnNo}} -
-
-
Thời gian
-
- {{paidAt}} -
-
- -
- Vui lòng lưu lại email này như biên nhận thanh toán. - Nếu có thắc mắc, liên hệ chủ nhà hoặc hỗ trợ ISUMS. -
- -
-
- Trân trọng,
Đội ngũ ISUMS -
-
-
- Email này được gửi tự động. Vui lòng không trả lời trực tiếp. -
-
-
- - - """, - """ - Xin chào {{tenantName}}, - - Hệ thống đã ghi nhận thanh toán: - - Loại: {{invoiceType}} - - Số tiền: {{amount}} - - Mã GD: {{txnNo}} - - Thời gian: {{paidAt}} - - Vui lòng lưu lại email này như biên nhận. - - Trân trọng, - Đội ngũ ISUMS - """, - List.of("tenantName", "invoiceType", "amount", "txnNo", "paidAt"), - "system" - ); - - upsertActiveV1( - templateRepo, versionRepo, - "user_activated", - "ONBOARDING", - "TENANT", - LocaleType.vi_VN, - "Chào mừng {{name}} — Tài khoản đã sẵn sàng", - """ - - - - Tài khoản đã kích hoạt - - - -
- - - - - - - - - - - - - - - - - -
-
- ISUMS · Quản lý nhà trọ -
-
- Chào mừng bạn! 🎉 -
-
- Tài khoản của bạn đã được kích hoạt thành công -
-
-
- Xin chào {{name}},
- Chủ nhà đã kích hoạt tài khoản ISUMS cho bạn. - Dưới đây là thông tin đăng nhập tạm thời — vui lòng đổi mật khẩu ngay sau khi đăng nhập. -
- - - - - - - - - - - - -
-
- Thông tin đăng nhập -
-
-
Email
-
{{email}}
-
-
Mật khẩu tạm thời
-
{{password}}
-
- - - {{#hasInvoice}} -
-
- ⚡ Khoản cần thanh toán ngay -
- - - - - - - - - - - - - -
-
Loại hóa đơn
-
{{invoiceType}}
-
-
Số tiền
-
{{invoiceAmount}}
-
-
Hạn thanh toán
-
{{invoiceDueDate}}
-
- - Thanh toán ngay → - -
-
- {{/hasInvoice}} - - -
-
- 💡 Sau khi đăng nhập lần đầu, hệ thống sẽ yêu cầu bạn đổi mật khẩu mới.
- Mọi hóa đơn và lịch sử thanh toán có thể xem trong ứng dụng ISUMS. -
-
- -
-
- Trân trọng,
Đội ngũ ISUMS -
-
-
- Email này được gửi tự động. Vui lòng không trả lời trực tiếp. -
-
-
- - - """, - """ - Xin chào {{name}}, - - Tài khoản ISUMS của bạn đã được kích hoạt. - - Thông tin đăng nhập: - - Email : {{email}} - - Mật khẩu: {{password}} - - {{#hasInvoice}} - Khoản cần thanh toán: - - Loại : {{invoiceType}} - - Số tiền : {{invoiceAmount}} - - Hạn TT : {{invoiceDueDate}} - - Link : {{invoicePaymentUrl}} - {{/hasInvoice}} - - Vui lòng đổi mật khẩu sau khi đăng nhập lần đầu. - - Trân trọng, - Đội ngũ ISUMS - """, - List.of("name", "email", "password", "hasInvoice", - "invoiceType", "invoiceAmount", "invoiceDueDate", "invoicePaymentUrl"), - "system" - ); - - upsertActiveV1( - templateRepo, versionRepo, - "contract_completed", "CONTRACT", "TENANT", LocaleType.vi_VN, - "Hợp đồng đã ký thành công — Tải về tại đây", - """ - - - - - - -
- - - - - - - - - - -
-
- ISUMS · Quản lý nhà trọ -
-
- Hợp đồng đã hoàn tất ✅ -
-
- Cả hai bên đã ký điện tử thành công -
-
-
- Hợp đồng mã {{contractId}} - đã được ký bởi tất cả các bên và có hiệu lực pháp lý.
- Bạn có thể tải về bản gốc có chữ ký số tại đây: -
- -
-
- ✅ Hợp đồng này có giá trị pháp lý tương đương bản giấy theo quy định.
- 📎 Link tải sẽ hết hạn sau 7 ngày. Vui lòng lưu lại file PDF. -
-
-
-
- Trân trọng,
Đội ngũ ISUMS -
-
-
- Email này được gửi tự động. Vui lòng không trả lời trực tiếp. -
-
-
- - - """, - """ - Hợp đồng {{contractId}} đã được ký hoàn tất. - Tải về tại: {{signedPdfUrl}} - Link hết hạn sau 7 ngày. - """, - List.of("contractId", "signedPdfUrl"), - "system" - ); - - - // ── INSPECTION DONE REVIEW (manager) ────────────────────────────── - upsertActiveV1( - templateRepo, versionRepo, - "inspection_done_review", "CONTRACT", "MANAGER", - LocaleType.vi_VN, - "Kiểm tra nhà hoàn tất — Hợp đồng #{{contractId}}", - """ - - - - - -
- - - - - - - - - - -
-
- ✅ Kiểm tra nhà hoàn tất -
-
-

- Kính gửi {{managerName}}, -

-

- Nhân viên đã hoàn thành kiểm tra nhà cho hợp đồng - #{{contractId}}. -

- - - - - - - - - - - - - -
- Mã kiểm tra - - {{inspectionId}} -
- Số tiền khấu trừ đề xuất - - {{deductionAmount}} -
- Ghi chú - - {{notes}} -
-

- Vui lòng đăng nhập hệ thống để xem chi tiết và xác nhận - số tiền hoàn cọc cho khách. -

-
-
- Email này được gửi tự động. Vui lòng không trả lời trực tiếp. -
-
-
- - - """, - """ - Kính gửi {{managerName}}, - - Nhân viên đã hoàn thành kiểm tra nhà cho hợp đồng #{{contractId}}. - - Mã kiểm tra: {{inspectionId}} - Số tiền khấu trừ đề xuất: {{deductionAmount}} - Ghi chú: {{notes}} - - Vui lòng đăng nhập hệ thống để xác nhận hoàn cọc. - """, - List.of("managerName", "contractId", "inspectionId", - "houseId", "deductionAmount", "notes"), - "system" - ); - -// ── CONTRACT EXPIRED INSPECTION SCHEDULED (manager) ─────────────── - upsertActiveV1( - templateRepo, versionRepo, - "contract_expired_inspection_scheduled", "CONTRACT", "MANAGER", - LocaleType.vi_VN, - "Hợp đồng #{{contractId}} đã hết hạn — Đã lên lịch kiểm tra nhà", - """ - - - - - -
- - - - - - - - - - -
-
- 🔔 Hợp đồng hết hạn — Đã phân công kiểm tra nhà -
-
-

- Kính gửi {{managerName}}, -

-

- Hợp đồng #{{contractId}} của khách - {{tenantName}} đã hết hạn. -

-

- Hệ thống đã tự động tạo lịch kiểm tra nhà và phân công - nhân viên phụ trách. -

- - - - - - - - - -
- Mã kiểm tra - - {{inspectionId}} -
- Khách thuê - - {{tenantName}} -
-

- Vui lòng theo dõi tiến trình kiểm tra trên hệ thống. -

-
-
- Email này được gửi tự động. Vui lòng không trả lời trực tiếp. -
-
-
- - - """, - """ - Kính gửi {{managerName}}, - - Hợp đồng #{{contractId}} của khách {{tenantName}} đã hết hạn. - - Mã kiểm tra: {{inspectionId}} - - Hệ thống đã tự động phân công nhân viên kiểm tra nhà. - Vui lòng theo dõi tiến trình trên hệ thống. - """, - List.of("managerName", "contractId", "tenantName", - "houseId", "inspectionId"), - "system" - ); - - // ── CONTRACT RENEWAL REMINDER (tenant) ──────────────────────────── - upsertActiveV1( - templateRepo, versionRepo, - "contract_renewal_reminder", "CONTRACT", "TENANT", - LocaleType.vi_VN, - "Hợp đồng của bạn còn {{daysRemaining}} ngày — Bạn có muốn gia hạn?", - """ - - - - - - -
- - - - - - - - - - -
-
- ⏰ Hợp đồng sắp hết hạn -
-
-

- Kính gửi {{tenantName}}, -

-

- Hợp đồng thuê nhà #{{contractId}} của bạn - {{#openForNew}} - đã hết hạn hôm nay. Phòng đã được mở cho khách mới đặt cọc. - {{/openForNew}} - {{^openForNew}} - còn {{daysRemaining}} ngày nữa sẽ hết hạn vào - {{endDate}}. - {{/openForNew}} -

-

- Nếu bạn muốn tiếp tục thuê, vui lòng liên hệ quản lý hoặc - bấm nút Gia hạn trong ứng dụng ISUMS. -

-
-
- Email này được gửi tự động. Vui lòng không trả lời trực tiếp. -
-
-
- - - """, - """ - Kính gửi {{tenantName}}, - - Hợp đồng #{{contractId}} của bạn còn {{daysRemaining}} ngày (hết hạn {{endDate}}). - - Nếu muốn gia hạn, vui lòng liên hệ quản lý hoặc bấm Gia hạn trong app ISUMS. - """, - List.of("tenantName", "contractId", "daysRemaining", "endDate", "openForNew"), - "system" - ); - -// ── RENEWAL REQUEST RECEIVED (manager) ──────────────────────────── - upsertActiveV1( - templateRepo, versionRepo, - "renewal_request_received", "CONTRACT", "MANAGER", - LocaleType.vi_VN, - "Khách {{tenantName}} muốn gia hạn hợp đồng #{{contractId}}", - """ - - - - - - -
- - - - - - - - - - -
-
- 🔔 Yêu cầu gia hạn hợp đồng -
-
-

- Kính gửi {{managerName}}, -

-

- Khách {{tenantName}} vừa gửi yêu cầu gia hạn - hợp đồng #{{contractId}}. -

- - - - - - - - - -
- Tình trạng cạnh tranh - - {{hasCompetingDeposit}} -
- Ghi chú của khách - - {{note}} -
-

- Vui lòng đăng nhập hệ thống để liên hệ khách và soạn hợp đồng mới nếu đồng ý. -

-
-
- Email này được gửi tự động. Vui lòng không trả lời trực tiếp. -
-
-
- - - """, - """ - Kính gửi {{managerName}}, - - Khách {{tenantName}} vừa gửi yêu cầu gia hạn hợp đồng #{{contractId}}. - - Tình trạng cạnh tranh: {{hasCompetingDeposit}} - Ghi chú: {{note}} - - Vui lòng đăng nhập hệ thống để xử lý. - """, - List.of("managerName", "tenantName", "contractId", "hasCompetingDeposit", "note"), - "system" - ); - -// ── RENEWAL DECLINED (tenant) ────────────────────────────────────── - upsertActiveV1( - templateRepo, versionRepo, - "renewal_declined", "CONTRACT", "TENANT", - LocaleType.vi_VN, - "Yêu cầu gia hạn hợp đồng #{{contractId}} không được chấp thuận", - """ - - - - - - -
- - - - - - - - - - -
-
- ❌ Yêu cầu gia hạn không được chấp thuận -
-
-

- Kính gửi {{tenantName}}, -

-

- Rất tiếc, yêu cầu gia hạn hợp đồng #{{contractId}} - của bạn không được chấp thuận. -

- - - - - -
- Lý do - - {{reason}} -
-

- Nếu có thắc mắc, vui lòng liên hệ quản lý để được hỗ trợ. -

-
-
- Email này được gửi tự động. Vui lòng không trả lời trực tiếp. -
-
-
- - - """, - """ - Kính gửi {{tenantName}}, - - Yêu cầu gia hạn hợp đồng #{{contractId}} của bạn không được chấp thuận. - - Lý do: {{reason}} - - Nếu có thắc mắc, vui lòng liên hệ quản lý. - """, - List.of("tenantName", "contractId", "reason"), - "system" - ); - - // late_payment_reminder_day0 - upsertActiveV1(templateRepo, versionRepo, - "late_payment_reminder_day0", "PAYMENT", "TENANT", LocaleType.vi_VN, - "Nhắc nhở: Hóa đơn tiền thuê đến hạn hôm nay", - """ - - - - -
- - - - -
-
- 💳 Hóa đơn tiền thuê đến hạn -
-
-

- Hóa đơn tiền thuê tháng này đến hạn thanh toán hôm nay - ({{dueDate}}). -

-

- Số tiền: {{totalAmount}} -

-

- Vui lòng thanh toán đúng hạn để tránh phát sinh phí phạt. -

-
-
- Email này được gửi tự động. Vui lòng không trả lời trực tiếp. -
-
-
- - """, - "Hóa đơn tiền thuê tháng này đến hạn hôm nay ({{dueDate}}).\nSố tiền: {{totalAmount}}\nVui lòng thanh toán đúng hạn.", - List.of("totalAmount", "dueDate", "daysLate"), - "system" - ); - -// late_payment_reminder_day1 - upsertActiveV1(templateRepo, versionRepo, - "late_payment_reminder_day1", "PAYMENT", "TENANT", LocaleType.vi_VN, - "Nhắc lần 2: Hóa đơn tiền thuê quá hạn 1 ngày", - """ - - - - -
- - - - -
-
- ⚠️ Hóa đơn quá hạn 1 ngày -
-
-

- Hóa đơn tiền thuê của bạn đã quá hạn 1 ngày. - Số tiền cần thanh toán: {{totalAmount}}. -

-

- Sau 3 ngày quá hạn, hệ thống sẽ tự động áp dụng phí phạt trễ thanh toán. -

-
-
Email này được gửi tự động.
-
-
- - """, - "Hóa đơn tiền thuê quá hạn 1 ngày. Số tiền: {{totalAmount}}. Thanh toán ngay để tránh phạt.", - List.of("totalAmount", "dueDate", "daysLate"), - "system" - ); - -// late_payment_reminder_day2 - upsertActiveV1(templateRepo, versionRepo, - "late_payment_reminder_day2", "PAYMENT", "TENANT", LocaleType.vi_VN, - "Cảnh báo: Hóa đơn tiền thuê quá hạn 2 ngày — còn 1 ngày trước khi bị phạt", - """ - - - - -
- - - - -
-
- 🚨 Còn 1 ngày trước khi bị phạt trễ thanh toán -
-
-

- Hóa đơn tiền thuê của bạn đã quá hạn 2 ngày. - Số tiền: {{totalAmount}}. -

-

- Nếu chưa thanh toán sau ngày mai, hệ thống sẽ áp dụng phí phạt 5% tiền thuê tháng. -

-
-
Email này được gửi tự động.
-
-
- - """, - "CẢNH BÁO: Hóa đơn quá hạn 2 ngày. Còn 1 ngày trước khi bị phạt 5%. Số tiền: {{totalAmount}}.", - List.of("totalAmount", "dueDate", "daysLate"), - "system" - ); - -// late_payment_penalty_applied - upsertActiveV1(templateRepo, versionRepo, - "late_payment_penalty_applied", "PAYMENT", "TENANT", LocaleType.vi_VN, - "Thông báo: Áp dụng phí phạt trễ thanh toán {{penaltyPercent}}%", - """ - - - - -
- - - - -
-
- 💸 Phí phạt trễ thanh toán đã được áp dụng -
-
-

- Do thanh toán trễ {{daysLate}} ngày, phí phạt - {{penaltyPercent}}% đã được áp dụng vào hóa đơn của bạn. -

- - - - - - - - - -
Phí phạt{{penaltyAmount}}
Tổng cần thanh toán{{totalAmount}}
-

- Vui lòng thanh toán ngay để tránh phát sinh thêm phí phạt. -

-
-
Email này được gửi tự động.
-
-
- - """, - "Phí phạt {{penaltyPercent}}% đã được áp dụng do trễ {{daysLate}} ngày.\nPhí phạt: {{penaltyAmount}}\nTổng cần thanh toán: {{totalAmount}}", - List.of("penaltyPercent", "penaltyAmount", "totalAmount", "daysLate"), - "system" - ); - -// late_payment_formal_warning - upsertActiveV1(templateRepo, versionRepo, - "late_payment_formal_warning", "PAYMENT", "TENANT", LocaleType.vi_VN, - "Cảnh báo chính thức: Hóa đơn tiền thuê quá hạn 7 ngày — Tính năng app bị hạn chế", - """ - - - - -
- - - - -
-
- 🔒 Cảnh báo chính thức — Tài khoản bị hạn chế -
-
-

- Hóa đơn tiền thuê của bạn đã quá hạn 7 ngày. - Tổng số tiền cần thanh toán: {{totalAmount}}. -

-

- Tính năng ứng dụng của bạn đã bị hạn chế cho đến khi hoàn tất thanh toán. -

-

- Nếu không thanh toán trong thời gian sớm, chủ nhà có quyền thực hiện - các biện pháp mạnh hơn theo quy định hợp đồng. -

-
-
Email này được gửi tự động.
-
-
- - """, - "CẢNH BÁO CHÍNH THỨC: Hóa đơn quá hạn 7 ngày. Tài khoản bị hạn chế.\nTổng tiền: {{totalAmount}}\nVui lòng thanh toán ngay.", - List.of("totalAmount", "dueDate", "daysLate"), - "system" - ); - -// power_cut_warning_24h - 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ê", - """ - - - - -
- - - - -
-
- ⚡ Cảnh báo cắt điện sau 24 giờ -
-
-

- Do chưa thanh toán tiền thuê, chủ nhà đã xác nhận cắt điện. - Điện sẽ bị cắt vào lúc {{executeAt}}. -

-

- Vui lòng thanh toán ngay để tránh bị cắt điện. -

-

- Đây là thông báo bắt buộc theo quy định hợp đồng thuê nhà. -

-
-
Email này được gửi tự động.
-
-
- - """, - "CẢNH BÁO: Điện sẽ bị cắt vào {{executeAt}} do chưa thanh toán tiền thuê.\nVui lòng thanh toán ngay để tránh bị cắt điện.", - List.of("executeAt"), - "system" - ); - -// overdue_termination_notice - upsertActiveV1(templateRepo, versionRepo, - "overdue_termination_notice", "PAYMENT", "MANAGER", LocaleType.vi_VN, - "Thông báo: Khách {{tenantName}} trễ tiền thuê 30 ngày — Xem xét chấm dứt hợp đồng", - """ - - - - -
- - - - -
-
- 📋 Khách trễ tiền thuê 30 ngày -
-
-

- Kính gửi {{managerName}}, -

-

- Khách {{tenantName}} (Hợp đồng #{{contractId}}) đã - chậm thanh toán tiền thuê 30 ngày. -

-

- Theo Luật Nhà ở 2023, bạn có quyền khởi động thủ tục chấm dứt hợp đồng. - Vui lòng đăng nhập hệ thống để xem xét và quyết định. -

-
-
Email này được gửi tự động.
-
-
- - """, - "Kính gửi {{managerName}},\nKhách {{tenantName}} (HĐ #{{contractId}}) đã trễ tiền thuê 30 ngày.\nVui lòng đăng nhập hệ thống để xem xét chấm dứt hợp đồng.", - List.of("managerName", "tenantName", "contractId", "daysLate"), - "system" - ); - - } - - private void upsertActiveV1( - EmailTemplateRepository templateRepo, - EmailTemplateVersionRepository versionRepo, - String templateKey, - String category, - String recipientType, - LocaleType locale, - String subjectTpl, - String htmlTpl, - String textTpl, - List allowedVars, - String actor - ) { - EmailTemplate tpl = templateRepo.findByTemplateKey(templateKey) - .orElseGet(() -> templateRepo.save( - EmailTemplate.builder() - .templateKey(templateKey) - .category(category) - .recipientType(recipientType) - .createdBy(actor) - .updatedBy(actor) - .build() - )); - - boolean hasActive = versionRepo - .findFirstByTemplate_TemplateKeyAndLocaleAndStatusOrderByVersionDesc( - templateKey, locale, TemplateStatus.ACTIVE - ).isPresent(); - - if (hasActive) return; - - EmailTemplateVersion v1 = EmailTemplateVersion.builder() - .template(tpl) - .locale(locale) - .version(1) - .status(TemplateStatus.ACTIVE) - .subjectTpl(subjectTpl) - .htmlTpl(htmlTpl) - .textTpl(textTpl) - .allowedVars(allowedVars) - .createdBy(actor) - .updatedBy(actor) - .build(); - - versionRepo.save(v1); - } -} +package com.isums.notificationservice.infrastructures.seeders; + +import com.isums.notificationservice.domains.entities.EmailTemplate; +import com.isums.notificationservice.domains.entities.EmailTemplateVersion; +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.domains.enums.TemplateStatus; +import com.isums.notificationservice.infrastructures.repositories.EmailTemplateRepository; +import com.isums.notificationservice.infrastructures.repositories.EmailTemplateVersionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class EmailTemplateSeeder { + + @Value("${app.seed.email-templates:true}") + private boolean enabled; + + @Bean + @Transactional + ApplicationRunner seedEmailTemplatesRunner(EmailTemplateRepository templateRepo, + EmailTemplateVersionRepository versionRepo) { + return args -> { + if (!enabled) return; + seed(templateRepo, versionRepo); + }; + } + + @Transactional + public void seed(EmailTemplateRepository templateRepo, EmailTemplateVersionRepository versionRepo) { + + upsertActiveV1( + templateRepo, versionRepo, + "welcome", "ONBOARDING", "CUSTOMER", + LocaleType.vi_VN, + "Chào mừng {{name}} đến với ISUMS", + """ + + + + + + Chào mừng đến với ISUMS + + +
+ Chào mừng {{name}}! Tài khoản ISUMS của bạn đã sẵn sàng. +
+ + + + + +
+ + + + + + + + + + + + + + +
+
+ 🎉 Chào mừng đến với ISUMS +
+
+ Hệ thống quản lý nhà nguyên căn thông minh +
+
+

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

+

+ Tài khoản ISUMS của bạn đã được kích hoạt thành công. Bắt đầu hành trình + quản lý nhà nguyên căn tiện lợi ngay hôm nay. +

+ + + + + +
+ + Truy cập ISUMS + +
+ +

+ 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:
+ {{appUrl}} +

+
+ Cần hỗ trợ? Liên hệ {{supportEmail}}
+ Email này được gửi tự động, vui lòng không trả lời. +
+
+ + + """, + """ + Xin chào {{name}}, + + Tài khoản ISUMS của bạn đã được kích hoạt thành công. + Truy cập ngay: {{appUrl}} + + Cần hỗ trợ? Liên hệ {{supportEmail}} + """, + List.of("name", "appUrl", "supportEmail"), + "system" + ); + + upsertActiveV1( + templateRepo, versionRepo, + "econtract_view_confirm", + "CONTRACT", + "TENANT", + LocaleType.vi_VN, + "Vui lòng xem và xác nhận hợp đồng {{contractNo}}", + """ + + + + + + Xác nhận hợp đồng thuê nhà + + +
+ Bạn có một hợp đồng thuê nhà cần xem và xác nhận. +
+ + + + + +
+ + + + + + + + + + + + + + +
+
+ ISUMS • Hợp đồng thuê nhà +
+
+ Vui lòng xem và xác nhận hợp đồng +
+
+
+ Xin chào {{tenantName}}, +
+ +
+ Bạn vừa nhận được hợp đồng thuê nhà từ {{landlordName}}. + Vui lòng nhấn Xem hợp đồng để đọc nội dung và tiến hành xác nhận nếu đồng ý. +
+ + + + + +
+
+
Mã hợp đồng: {{contractNo}}
+
Tên hợp đồng: {{contractName}}
+
Địa chỉ: {{propertyAddress}}
+
Thời hạn: {{startDate}} – {{endDate}}
+
+
+ + + + + + + +
+ + Xem hợp đồng + + + + Tôi đồng ý (Xác nhận) + +
+ +
+ Nếu bạn không bấm được nút, hãy copy & paste link sau vào trình duyệt: +
+ Xem hợp đồng: {{viewUrl}}
+ Xác nhận: {{confirmUrl}} +
+
+ +
+ Vì lý do bảo mật, đường dẫn xác nhận có thể hết hạn sau {{expiresIn}}. + Nếu bạn không yêu cầu email này, vui lòng bỏ qua. +
+ +
+ +
+ Trân trọng,
+ Đội ngũ ISUMS +
+
+
+ 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 {{tenantName}} + + Bạn vừa nhận được hợp đồng thuê nhà từ {{landlordName}}. + + Mã hợp đồng: {{contractNo}} + Tên hợp đồng: {{contractName}} + Địa chỉ: {{propertyAddress}} + Thời hạn: {{startDate}} - {{endDate}} + + Xem hợp đồng: {{viewUrl}} + Xác nhận đồng ý: {{confirmUrl}} + + Lưu ý: Link xác nhận có thể hết hạn sau {{expiresIn}}. + """, + List.of( + "tenantName", + "landlordName", + "contractNo", + "contractName", + "propertyAddress", + "startDate", + "endDate", + "viewUrl", + "confirmUrl", + "expiresIn" + ), + "system" + ); + + upsertActiveV1( + templateRepo, versionRepo, + "econtract_view_confirm", + "CONTRACT", + "TENANT", + LocaleType.en_US, + "Please review and confirm your lease contract {{contractNo}}", + """ + + + + + + Confirm your lease contract + + +
+ You have a lease contract to review and confirm. +
+ + + + + +
+ + + + + + + + + + + + + + +
+
+ ISUMS • Lease contract +
+
+ Please review and confirm your contract +
+
+
+ Hello {{tenantName}}, +
+ +
+ You have received a lease contract from {{landlordName}}. + Please click View contract to read the details and confirm if you agree. +
+ + + + + +
+
+
Contract no.: {{contractNo}}
+
Contract name: {{contractName}}
+
Address: {{propertyAddress}}
+
Term: {{startDate}} – {{endDate}}
+
+
+ + + + + + + +
+ + View contract + + + + I agree (Confirm) + +
+ +
+ If the buttons don't work, copy & paste these links into your browser: +
+ View contract: {{viewUrl}}
+ Confirm: {{confirmUrl}} +
+
+ +
+ For security reasons, the confirmation link may expire after {{expiresIn}}. + If you did not request this email, please ignore it. +
+ +
+ +
+ Best regards,
+ The ISUMS team +
+
+
+ This email is sent automatically. Please do not reply to this address. +
+
+
+ + + """, + """ + Hello {{tenantName}} + + You have received a lease contract from {{landlordName}}. + + Contract no.: {{contractNo}} + Contract name: {{contractName}} + Address: {{propertyAddress}} + Term: {{startDate}} - {{endDate}} + + View contract: {{viewUrl}} + Confirm: {{confirmUrl}} + + Note: the confirmation link may expire after {{expiresIn}}. + """, + List.of( + "tenantName", + "landlordName", + "contractNo", + "contractName", + "propertyAddress", + "startDate", + "endDate", + "viewUrl", + "confirmUrl", + "expiresIn" + ), + "system" + ); + + upsertActiveV1( + templateRepo, versionRepo, + "econtract_view_confirm", + "CONTRACT", + "TENANT", + LocaleType.ja_JP, + "賃貸借契約 {{contractNo}} のご確認のお願い", + """ + + + + + + 賃貸借契約のご確認 + + +
+ ご確認いただきたい賃貸借契約があります。 +
+ + + + + +
+ + + + + + + + + + + + + + +
+
+ ISUMS • 賃貸借契約 +
+
+ 契約内容をご確認ください +
+
+
+ {{tenantName}} 様 +
+ +
+ {{landlordName}} より賃貸借契約が届きました。 + 契約書を表示 をクリックし、内容をご確認のうえ、ご同意いただける場合は確認ボタンを押してください。 +
+ + + + + +
+
+
契約番号: {{contractNo}}
+
契約名: {{contractName}}
+
住所: {{propertyAddress}}
+
契約期間: {{startDate}} – {{endDate}}
+
+
+ + + + + + + +
+ + 契約書を表示 + + + + 同意して確認 + +
+ +
+ ボタンが動作しない場合は、以下のリンクをブラウザにコピーしてください。 +
+ 契約書を表示: {{viewUrl}}
+ 確認: {{confirmUrl}} +
+
+ +
+ セキュリティのため、確認リンクは {{expiresIn}} 後に失効する場合があります。 + 本メールにお心当たりがない場合は、破棄してください。 +
+ +
+ +
+ 敬具
+ ISUMS チーム +
+
+
+ 本メールは自動送信されています。ご返信いただかないようお願いいたします。 +
+
+
+ + + """, + """ + {{tenantName}} 様 + + {{landlordName}} より賃貸借契約が届きました。 + + 契約番号: {{contractNo}} + 契約名: {{contractName}} + 住所: {{propertyAddress}} + 契約期間: {{startDate}} - {{endDate}} + + 契約書を表示: {{viewUrl}} + 同意して確認: {{confirmUrl}} + + ※確認リンクは {{expiresIn}} 後に失効する場合があります。 + """, + List.of( + "tenantName", + "landlordName", + "contractNo", + "contractName", + "propertyAddress", + "startDate", + "endDate", + "viewUrl", + "confirmUrl", + "expiresIn" + ), + "system" + ); + + upsertActiveV1( + templateRepo, versionRepo, + "payment_invoice", + "PAYMENT", + "TENANT", + LocaleType.vi_VN, + "Hóa đơn {{invoiceType}} cần thanh toán trước {{dueDate}}", + """ + + + + + + Hóa đơn thanh toán + + +
+ Bạn có hóa đơn {{invoiceType}} cần thanh toán trước {{dueDate}}. +
+ + + + +
+ + + + + + + + + + + + + + +
+
💳 Hóa đơn thanh toán
+
ISUMS — Hệ thống quản lý nhà nguyên căn
+
+
+ Xin chào,
+ Bạn có một hóa đơn cần thanh toán. Vui lòng thanh toán trước hạn để tránh phát sinh phí trễ hạn. +
+ + + + + + + + + + + +
+
+ Loại hóa đơn
+
{{invoiceType}}
+
+
+ Số tiền
+
{{amount}}
+
+
+ Hạn thanh toán
+
{{dueDate}}
+
+ + + + + +
+ + Thanh toán ngay + +
+ +
+ Nếu không bấm được nút, hãy copy link sau vào trình duyệt:
+
{{paymentUrl}}
+
+ +
+ Link thanh toán hợp lệ trong {{expiresIn}}. + Nếu bạn không yêu cầu email này, vui lòng bỏ qua. +
+ +
+
+ Trân trọng,
Đội ngũ ISUMS +
+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + + """, + + """ + Xin chào, + + Bạn có hóa đơn {{invoiceType}} cần thanh toán. + + Số tiền: {{amount}} + Hạn nộp: {{dueDate}} + + Thanh toán tại: {{paymentUrl}} + + Link có hiệu lực trong {{expiresIn}}. + + Trân trọng, + Đội ngũ ISUMS + """, + List.of("invoiceType", "invoiceTypeVi", "invoiceTypeEn", "invoiceTypeJa", "invoiceTypeCode", + "amount", "dueDate", "paymentUrl", "expiresIn"), + "system" + ); + + upsertActiveV1( + templateRepo, versionRepo, + "payment_receipt", + "PAYMENT", + "TENANT", + LocaleType.vi_VN, + "Xác nhận thanh toán {{invoiceType}} thành công", + """ + + + Xác nhận thanh toán + + + +
+ + + + + + + + + + + + + + +
+
+ ✅ Thanh toán thành công +
+
+ ISUMS — Hệ thống quản lý nhà nguyên căn +
+
+
+ Xin chào {{tenantName}},
+ Hệ thống đã ghi nhận thanh toán của bạn. +
+ + + + + + + + + + + + + + +
+
Loại thanh toán
+
+ {{invoiceType}} +
+
+
Số tiền
+
+ {{amount}} +
+
+
Mã giao dịch
+
+ {{txnNo}} +
+
+
Thời gian
+
+ {{paidAt}} +
+
+ +
+ Vui lòng lưu lại email này như biên nhận thanh toán. + Nếu có thắc mắc, liên hệ chủ nhà hoặc hỗ trợ ISUMS. +
+ +
+
+ Trân trọng,
Đội ngũ ISUMS +
+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + + """, + """ + Xin chào {{tenantName}}, + + Hệ thống đã ghi nhận thanh toán: + - Loại: {{invoiceType}} + - Số tiền: {{amount}} + - Mã GD: {{txnNo}} + - Thời gian: {{paidAt}} + + Vui lòng lưu lại email này như biên nhận. + + Trân trọng, + Đội ngũ ISUMS + """, + List.of("tenantName", "invoiceType", "amount", "txnNo", "paidAt"), + "system" + ); + + upsertActiveV1( + templateRepo, versionRepo, + "user_activated", + "ONBOARDING", + "TENANT", + LocaleType.vi_VN, + "Chào mừng {{name}} — Tài khoản đã sẵn sàng", + """ + + + + Tài khoản đã kích hoạt + + + +
+ + + + + + + + + + + + + + + + + +
+
+ ISUMS · Quản lý nhà nguyên căn +
+
+ Chào mừng bạn! 🎉 +
+
+ Tài khoản của bạn đã được kích hoạt thành công +
+
+
+ Xin chào {{name}},
+ Chủ nhà đã kích hoạt tài khoản ISUMS cho bạn. + Dưới đây là thông tin đăng nhập tạm thời — vui lòng đổi mật khẩu ngay sau khi đăng nhập. +
+ + + + + + + + + + + + +
+
+ Thông tin đăng nhập +
+
+
Email
+
{{email}}
+
+
Mật khẩu tạm
+
{{password}}
+
Vui lòng đổi mật khẩu ngay sau khi đăng nhập lần đầu.
+
+ + + {{#hasInvoice}} +
+
+ ⚡ Khoản cần thanh toán ngay +
+ + + + + + + + + + + + + +
+
Loại hóa đơn
+
{{invoiceType}}
+
+
Số tiền
+
{{invoiceAmount}}
+
+
Hạn thanh toán
+
{{invoiceDueDate}}
+
+ + Thanh toán ngay → + +
+
+ {{/hasInvoice}} + + +
+
+ 💡 Sau khi đăng nhập lần đầu, hệ thống sẽ yêu cầu bạn đổi mật khẩu mới.
+ Mọi hóa đơn và lịch sử thanh toán có thể xem trong ứng dụng ISUMS. +
+
+ +
+
+ Trân trọng,
Đội ngũ ISUMS +
+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + + """, + """ + Xin chào {{name}}, + + Tài khoản ISUMS của bạn đã được kích hoạt. + + Thông tin đăng nhập: + - Email : {{email}} + - Mật khẩu tạm : {{password}} (đổi ngay sau khi đăng nhập lần đầu) + + {{#hasInvoice}} + Khoản cần thanh toán: + - Loại : {{invoiceType}} + - Số tiền : {{invoiceAmount}} + - Hạn TT : {{invoiceDueDate}} + - Link : {{invoicePaymentUrl}} + {{/hasInvoice}} + + Trân trọng, + Đội ngũ ISUMS + """, + List.of("name", "email", "password", "hasInvoice", + "invoiceType", "invoiceTypeVi", "invoiceTypeEn", "invoiceTypeJa", "invoiceTypeCode", + "invoiceAmount", "invoiceDueDate", "invoicePaymentUrl"), + "system" + ); + + upsertActiveV1( + templateRepo, versionRepo, + "contract_completed", "CONTRACT", "TENANT", LocaleType.vi_VN, + "Hợp đồng đã ký thành công — Tải về tại đây", + """ + + + + + + +
+ + + + + + + + + + +
+
+ ISUMS · Quản lý nhà nguyên căn +
+
+ Hợp đồng đã hoàn tất ✅ +
+
+ Cả hai bên đã ký điện tử thành công +
+
+
+ Hợp đồng mã {{contractId}} + đã được ký bởi tất cả các bên và có hiệu lực pháp lý.
+ Bạn có thể tải về bản gốc có chữ ký số tại đây: +
+ +
+
+ ✅ Hợp đồng này có giá trị pháp lý tương đương bản giấy theo quy định.
+ 📎 Link tải sẽ hết hạn sau 7 ngày. Vui lòng lưu lại file PDF. +
+
+
+
+ Trân trọng,
Đội ngũ ISUMS +
+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + + """, + """ + Hợp đồng {{contractId}} đã được ký hoàn tất. + Tải về tại: {{signedPdfUrl}} + Link hết hạn sau 7 ngày. + """, + List.of("contractId", "signedPdfUrl", "depositAmount", "depositDeadline"), + "system" + ); + + upsertActiveV1( + templateRepo, versionRepo, + "inspection_done_review", "CONTRACT", "MANAGER", + LocaleType.vi_VN, + "Kiểm tra nhà hoàn tất — Hợp đồng #{{contractId}}", + """ + + + + + +
+ + + + + + + + + + +
+
+ ✅ Kiểm tra nhà hoàn tất +
+
+

+ Kính gửi {{managerName}}, +

+

+ Nhân viên đã hoàn thành kiểm tra nhà cho hợp đồng + #{{contractId}}. +

+ + + + + + + + + + + + + +
+ Mã kiểm tra + + {{inspectionId}} +
+ Số tiền khấu trừ đề xuất + + {{deductionAmount}} +
+ Ghi chú + + {{notes}} +
+

+ Vui lòng đăng nhập hệ thống để xem chi tiết và xác nhận + số tiền hoàn cọc cho khách. +

+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + + """, + """ + Kính gửi {{managerName}}, + + Nhân viên đã hoàn thành kiểm tra nhà cho hợp đồng #{{contractId}}. + + Mã kiểm tra: {{inspectionId}} + Số tiền khấu trừ đề xuất: {{deductionAmount}} + Ghi chú: {{notes}} + + Vui lòng đăng nhập hệ thống để xác nhận hoàn cọc. + """, + List.of("managerName", "contractId", "inspectionId", + "houseId", "deductionAmount", "notes"), + "system" + ); + + upsertActiveV1( + templateRepo, versionRepo, + "contract_expired_inspection_scheduled", "CONTRACT", "MANAGER", + LocaleType.vi_VN, + "Hợp đồng #{{contractId}} đã hết hạn — Đã lên lịch kiểm tra nhà", + """ + + + + + +
+ + + + + + + + + + +
+
+ 🔔 Hợp đồng hết hạn — Đã phân công kiểm tra nhà +
+
+

+ Kính gửi {{managerName}}, +

+

+ Hợp đồng #{{contractId}} của khách + {{tenantName}} đã hết hạn. +

+

+ Hệ thống đã tự động tạo lịch kiểm tra nhà và phân công + nhân viên phụ trách. +

+ + + + + + + + + +
+ Mã kiểm tra + + {{inspectionId}} +
+ Khách thuê + + {{tenantName}} +
+

+ Vui lòng theo dõi tiến trình kiểm tra trên hệ thống. +

+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + + """, + """ + Kính gửi {{managerName}}, + + Hợp đồng #{{contractId}} của khách {{tenantName}} đã hết hạn. + + Mã kiểm tra: {{inspectionId}} + + Hệ thống đã tự động phân công nhân viên kiểm tra nhà. + Vui lòng theo dõi tiến trình trên hệ thống. + """, + List.of("managerName", "contractId", "tenantName", + "houseId", "inspectionId"), + "system" + ); + + upsertActiveV1( + templateRepo, versionRepo, + "contract_renewal_reminder", "CONTRACT", "TENANT", + LocaleType.vi_VN, + "Hợp đồng của bạn còn {{daysRemaining}} ngày — Bạn có muốn gia hạn?", + """ + + + + + + +
+ + + + + + + + + + +
+
+ ⏰ Hợp đồng sắp hết hạn +
+
+

+ Kính gửi {{tenantName}}, +

+

+ Hợp đồng thuê nhà #{{contractId}} của bạn + {{#openForNew}} + đã hết hạn hôm nay. Phòng đã được mở cho khách mới đặt cọc. + {{/openForNew}} + {{^openForNew}} + còn {{daysRemaining}} ngày nữa sẽ hết hạn vào + {{endDate}}. + {{/openForNew}} +

+

+ Nếu bạn muốn tiếp tục thuê, vui lòng liên hệ quản lý hoặc + bấm nút Gia hạn trong ứng dụng ISUMS. +

+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + + """, + """ + Kính gửi {{tenantName}}, + + Hợp đồng #{{contractId}} của bạn còn {{daysRemaining}} ngày (hết hạn {{endDate}}). + + Nếu muốn gia hạn, vui lòng liên hệ quản lý hoặc bấm Gia hạn trong app ISUMS. + """, + List.of("tenantName", "contractId", "daysRemaining", "endDate", "openForNew"), + "system" + ); + + upsertActiveV1( + templateRepo, versionRepo, + "renewal_request_received", "CONTRACT", "MANAGER", + LocaleType.vi_VN, + "Khách {{tenantName}} muốn gia hạn hợp đồng #{{contractId}}", + """ + + + + + + +
+ + + + + + + + + + +
+
+ 🔔 Yêu cầu gia hạn hợp đồng +
+
+

+ Kính gửi {{managerName}}, +

+

+ Khách {{tenantName}} vừa gửi yêu cầu gia hạn + hợp đồng #{{contractId}}. +

+ + + + + + + + + +
+ Tình trạng cạnh tranh + + {{hasCompetingDeposit}} +
+ Ghi chú của khách + + {{note}} +
+

+ Vui lòng đăng nhập hệ thống để liên hệ khách và soạn hợp đồng mới nếu đồng ý. +

+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + + """, + """ + Kính gửi {{managerName}}, + + Khách {{tenantName}} vừa gửi yêu cầu gia hạn hợp đồng #{{contractId}}. + + Tình trạng cạnh tranh: {{hasCompetingDeposit}} + Ghi chú: {{note}} + + Vui lòng đăng nhập hệ thống để xử lý. + """, + List.of("managerName", "tenantName", "contractId", "hasCompetingDeposit", "note"), + "system" + ); + + upsertActiveV1( + templateRepo, versionRepo, + "renewal_declined", "CONTRACT", "TENANT", + LocaleType.vi_VN, + "Yêu cầu gia hạn hợp đồng #{{contractId}} không được chấp thuận", + """ + + + + + + +
+ + + + + + + + + + +
+
+ ❌ Yêu cầu gia hạn không được chấp thuận +
+
+

+ Kính gửi {{tenantName}}, +

+

+ Rất tiếc, yêu cầu gia hạn hợp đồng #{{contractId}} + của bạn không được chấp thuận. +

+ + + + + +
+ Lý do + + {{reason}} +
+

+ Nếu có thắc mắc, vui lòng liên hệ quản lý để được hỗ trợ. +

+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + + """, + """ + Kính gửi {{tenantName}}, + + Yêu cầu gia hạn hợp đồng #{{contractId}} của bạn không được chấp thuận. + + Lý do: {{reason}} + + Nếu có thắc mắc, vui lòng liên hệ quản lý. + """, + List.of("tenantName", "contractId", "reason"), + "system" + ); + + upsertActiveV1(templateRepo, versionRepo, + "late_payment_reminder_day0", "PAYMENT", "TENANT", LocaleType.vi_VN, + "Nhắc nhở: Hóa đơn tiền thuê đến hạn hôm nay", + """ + + + + +
+ + + + +
+
+ 💳 Hóa đơn tiền thuê đến hạn +
+
+

+ Hóa đơn tiền thuê tháng này đến hạn thanh toán hôm nay + ({{dueDate}}). +

+

+ Số tiền: {{totalAmount}} +

+

+ Vui lòng thanh toán đúng hạn để tránh phát sinh phí phạt. +

+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + """, + "Hóa đơn tiền thuê tháng này đến hạn hôm nay ({{dueDate}}).\nSố tiền: {{totalAmount}}\nVui lòng thanh toán đúng hạn.", + List.of("totalAmount", "dueDate", "daysLate"), + "system" + ); + + upsertActiveV1(templateRepo, versionRepo, + "late_payment_reminder_day1", "PAYMENT", "TENANT", LocaleType.vi_VN, + "Nhắc lần 2: Hóa đơn tiền thuê quá hạn 1 ngày", + """ + + + + +
+ + + + +
+
+ ⚠️ Hóa đơn quá hạn 1 ngày +
+
+

+ Hóa đơn tiền thuê của bạn đã quá hạn 1 ngày. + Số tiền cần thanh toán: {{totalAmount}}. +

+

+ Sau 3 ngày quá hạn, hệ thống sẽ tự động áp dụng phí phạt trễ thanh toán. +

+
+
Email này được gửi tự động.
+
+
+ + """, + "Hóa đơn tiền thuê quá hạn 1 ngày. Số tiền: {{totalAmount}}. Thanh toán ngay để tránh phạt.", + List.of("totalAmount", "dueDate", "daysLate"), + "system" + ); + + upsertActiveV1(templateRepo, versionRepo, + "late_payment_reminder_day2", "PAYMENT", "TENANT", LocaleType.vi_VN, + "Cảnh báo: Hóa đơn tiền thuê quá hạn 2 ngày — còn 1 ngày trước khi bị phạt", + """ + + + + +
+ + + + +
+
+ 🚨 Còn 1 ngày trước khi bị phạt trễ thanh toán +
+
+

+ Hóa đơn tiền thuê của bạn đã quá hạn 2 ngày. + Số tiền: {{totalAmount}}. +

+

+ Nếu chưa thanh toán sau ngày mai, hệ thống sẽ áp dụng phí phạt 5% tiền thuê tháng. +

+
+
Email này được gửi tự động.
+
+
+ + """, + "CẢNH BÁO: Hóa đơn quá hạn 2 ngày. Còn 1 ngày trước khi bị phạt 5%. Số tiền: {{totalAmount}}.", + List.of("totalAmount", "dueDate", "daysLate"), + "system" + ); + + upsertActiveV1(templateRepo, versionRepo, + "late_payment_penalty_applied", "PAYMENT", "TENANT", LocaleType.vi_VN, + "Thông báo: Áp dụng phí phạt trễ thanh toán {{penaltyPercent}}%", + """ + + + + +
+ + + + +
+
+ 💸 Phí phạt trễ thanh toán đã được áp dụng +
+
+

+ Do thanh toán trễ {{daysLate}} ngày, phí phạt + {{penaltyPercent}}% đã được áp dụng vào hóa đơn của bạn. +

+ + + + + + + + + +
Phí phạt{{penaltyAmount}}
Tổng cần thanh toán{{totalAmount}}
+

+ Vui lòng thanh toán ngay để tránh phát sinh thêm phí phạt. +

+
+
Email này được gửi tự động.
+
+
+ + """, + "Phí phạt {{penaltyPercent}}% đã được áp dụng do trễ {{daysLate}} ngày.\nPhí phạt: {{penaltyAmount}}\nTổng cần thanh toán: {{totalAmount}}", + List.of("penaltyPercent", "penaltyAmount", "totalAmount", "daysLate"), + "system" + ); + + upsertActiveV1(templateRepo, versionRepo, + "late_payment_formal_warning", "PAYMENT", "TENANT", LocaleType.vi_VN, + "Cảnh báo chính thức: Hóa đơn tiền thuê quá hạn 7 ngày — Tính năng app bị hạn chế", + """ + + + + +
+ + + + +
+
+ 🔒 Cảnh báo chính thức — Tài khoản bị hạn chế +
+
+

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

+

+ Tính năng ứng dụng của bạn đã bị hạn chế cho đến khi hoàn tất thanh toán. +

+

+ Nếu không thanh toán trong thời gian sớm, chủ nhà có quyền thực hiện + các biện pháp mạnh hơn theo quy định hợp đồng. +

+
+
Email này được gửi tự động.
+
+
+ + """, + "CẢNH BÁO CHÍNH THỨC: Hóa đơn quá hạn 7 ngày. Tài khoản bị hạn chế.\nTổng tiền: {{totalAmount}}\nVui lòng thanh toán ngay.", + 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ê", + """ + + + + +
+ + + + +
+
+ ⚡ Cảnh báo cắt điện sau 24 giờ +
+
+

+ Do chưa thanh toán tiền thuê, chủ nhà đã xác nhận cắt điện. + Điện sẽ bị cắt vào lúc {{executeAt}}. +

+

+ Vui lòng thanh toán ngay để tránh bị cắt điện. +

+

+ Đây là thông báo bắt buộc theo quy định hợp đồng thuê nhà. +

+
+
Email này được gửi tự động.
+
+
+ + """, + "CẢNH BÁO: Điện sẽ bị cắt vào {{executeAt}} do chưa thanh toán tiền thuê.\nVui lòng thanh toán ngay để tránh bị cắt điện.", + List.of("executeAt"), + "system" + ); + + upsertActiveV1(templateRepo, versionRepo, + "overdue_termination_notice", "PAYMENT", "MANAGER", LocaleType.vi_VN, + "Thông báo: Khách {{tenantName}} trễ tiền thuê 30 ngày — Xem xét chấm dứt hợp đồng", + """ + + + + +
+ + + + +
+
+ 📋 Khách trễ tiền thuê 30 ngày +
+
+

+ Kính gửi {{managerName}}, +

+

+ Khách {{tenantName}} (Hợp đồng #{{contractId}}) đã + chậm thanh toán tiền thuê 30 ngày. +

+

+ Theo Luật Nhà ở 2023, bạn có quyền khởi động thủ tục chấm dứt hợp đồng. + Vui lòng đăng nhập hệ thống để xem xét và quyết định. +

+
+
Email này được gửi tự động.
+
+
+ + """, + "Kính gửi {{managerName}},\nKhách {{tenantName}} (HĐ #{{contractId}}) đã trễ tiền thuê 30 ngày.\nVui lòng đăng nhập hệ thống để xem xét chấm dứt hợp đồng.", + List.of("managerName", "tenantName", "contractId", "daysLate"), + "system" + ); + + upsertActiveV1( + templateRepo, versionRepo, + "contract_deposit_transferred", "CONTRACT", "TENANT", LocaleType.vi_VN, + "Cọc đã chuyển sang nhà mới — Hợp đồng #{{contractId}}", + """ + + + + + +
+ + + + + + + + + + +
+
+ ISUMS · Quản lý nhà nguyên căn +
+
+ Cọc đã chuyển sang nhà mới ✅ +
+
+ Hợp đồng mới đã được kích hoạt +
+
+
+ Tiền cọc của hợp đồng cũ đã được chuyển sang hợp đồng mới + #{{contractId}}. +
+ + + +
+
Cọc gốc
+
{{depositAmount}}
+
+
Số tiền đã chuyển
+
{{transferredAmount}}
+
+
+
+ ✅ Hợp đồng mới đã có hiệu lực — bạn không cần nộp thêm tiền cọc. +
+
+
+
+ Trân trọng,
Đội ngũ ISUMS +
+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + """, + """ + Tiền cọc {{transferredAmount}} đã được chuyển sang hợp đồng mới #{{contractId}}. + Cọc gốc: {{depositAmount}}. + Bạn không cần nộp thêm tiền cọc. + """, + List.of("contractId", "depositAmount", "transferredAmount"), + "system" + ); + + upsertActiveV1( + templateRepo, versionRepo, + "contract_deposit_increase", "CONTRACT", "TENANT", LocaleType.vi_VN, + "Cần nộp thêm cọc {{additionalAmount}} cho nhà mới", + """ + + + + + +
+ + + + + + + + + + +
+
+ ISUMS · Quản lý nhà nguyên căn +
+
+ Cần nộp thêm tiền cọc 💰 +
+
+ Cọc nhà mới cao hơn cọc nhà cũ +
+
+
+ Cọc của hợp đồng cũ đã được chuyển sang hợp đồng mới + #{{contractId}}, tuy nhiên cọc nhà mới cao hơn + nên bạn cần nộp thêm phần chênh lệch. +
+ + + + +
+
Cọc gốc
+
{{originalAmount}}
+
+
Đã chuyển
+
{{transferredAmount}}
+
+
Cần nộp thêm
+
{{additionalAmount}}
+
+
+
+ ⏰ Hạn thanh toán: {{dueDate}} +
+
+
+
+ Trân trọng,
Đội ngũ ISUMS +
+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + """, + """ + Cọc đã chuyển sang hợp đồng mới #{{contractId}}. + Cọc gốc: {{originalAmount}} + Đã chuyển: {{transferredAmount}} + Cần nộp thêm: {{additionalAmount}} (hạn {{dueDate}}). + """, + List.of("contractId", "originalAmount", "transferredAmount", "additionalAmount", "dueDate"), + "system" + ); + + upsertActiveV1( + templateRepo, versionRepo, + "contract_deposit_refund", "CONTRACT", "TENANT", LocaleType.vi_VN, + "Hoàn lại {{refundAmount}} chênh lệch cọc — Hợp đồng #{{contractId}}", + """ + + + + + +
+ + + + + + + + + + +
+
+ ISUMS · Quản lý nhà nguyên căn +
+
+ Hoàn tiền chênh lệch cọc 💸 +
+
+ Cọc nhà mới thấp hơn cọc nhà cũ +
+
+
+ Cọc của hợp đồng cũ đã được chuyển sang hợp đồng mới + #{{contractId}} và phần chênh lệch sẽ được hoàn lại cho bạn. +
+ + + + +
+
Cọc gốc
+
{{originalAmount}}
+
+
Đã chuyển sang nhà mới
+
{{transferredAmount}}
+
+
Hoàn lại
+
{{refundAmount}}
+
+
+
+ 💸 Phương thức hoàn: {{refundMethod}}
+ Tiền sẽ về tài khoản của bạn trong 1–3 ngày làm việc. +
+
+
+
+ Trân trọng,
Đội ngũ ISUMS +
+
+
+ Email này được gửi tự động. Vui lòng không trả lời trực tiếp. +
+
+
+ + """, + """ + Cọc gốc: {{originalAmount}} + Đã chuyển sang hợp đồng mới #{{contractId}}: {{transferredAmount}} + Hoàn lại: {{refundAmount}} qua {{refundMethod}}. + """, + List.of("contractId", "originalAmount", "transferredAmount", "refundAmount", "refundMethod"), + "system" + ); + + } + + private void upsertActiveV1( + EmailTemplateRepository templateRepo, + EmailTemplateVersionRepository versionRepo, + String templateKey, + String category, + String recipientType, + LocaleType locale, + String subjectTpl, + String htmlTpl, + String textTpl, + List allowedVars, + String actor + ) { + EmailTemplate tpl = templateRepo.findByTemplateKey(templateKey) + .orElseGet(() -> templateRepo.save( + EmailTemplate.builder() + .templateKey(templateKey) + .category(category) + .recipientType(recipientType) + .createdBy(actor) + .updatedBy(actor) + .build() + )); + + EmailTemplateVersion existing = versionRepo + .findFirstByTemplate_TemplateKeyAndLocaleAndStatusOrderByVersionDesc( + templateKey, locale, TemplateStatus.ACTIVE + ).orElse(null); + + if (existing != null) { + boolean changed = !equalsSafe(existing.getSubjectTpl(), subjectTpl) + || !equalsSafe(existing.getHtmlTpl(), htmlTpl) + || !equalsSafe(existing.getTextTpl(), textTpl); + if (!changed) return; + existing.setSubjectTpl(subjectTpl); + existing.setHtmlTpl(htmlTpl); + existing.setTextTpl(textTpl); + existing.setAllowedVars(allowedVars); + existing.setUpdatedBy(actor); + versionRepo.save(existing); + return; + } + + EmailTemplateVersion v1 = EmailTemplateVersion.builder() + .template(tpl) + .locale(locale) + .version(1) + .status(TemplateStatus.ACTIVE) + .subjectTpl(subjectTpl) + .htmlTpl(htmlTpl) + .textTpl(textTpl) + .allowedVars(allowedVars) + .createdBy(actor) + .updatedBy(actor) + .build(); + + versionRepo.save(v1); + } + + private static boolean equalsSafe(String a, String b) { + if (a == null) return b == null; + return a.equals(b); + } +} + diff --git a/src/main/java/com/isums/notificationservice/infrastructures/seeders/UtilityAlertTemplateSeeder.java b/src/main/java/com/isums/notificationservice/infrastructures/seeders/UtilityAlertTemplateSeeder.java new file mode 100644 index 0000000..5535ee7 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/seeders/UtilityAlertTemplateSeeder.java @@ -0,0 +1,363 @@ +package com.isums.notificationservice.infrastructures.seeders; + +import com.isums.notificationservice.domains.entities.EmailTemplate; +import com.isums.notificationservice.domains.entities.EmailTemplateVersion; +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.domains.enums.TemplateStatus; +import com.isums.notificationservice.infrastructures.repositories.EmailTemplateRepository; +import com.isums.notificationservice.infrastructures.repositories.EmailTemplateVersionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +@Slf4j +public class UtilityAlertTemplateSeeder { + + private static final String TEMPLATE_KEY = "utility_threshold_exceeded"; + private static final String CATEGORY = "ALERT"; + private static final String RECIPIENT_TYPE = "LANDLORD"; + private static final String ACTOR = "system"; + private static final List ALLOWED_VARS = List.of( + "landlordName", "userName", "houseName", "metricLabel", "currentUsage", + "monthlyLimit", "unit", "usagePercent", "month", "severity", "occurredAt" + ); + + @Value("${app.seed.email-templates:true}") + private boolean enabled; + + @Bean + ApplicationRunner seedUtilityAlertTemplatesRunner( + EmailTemplateRepository templateRepo, + EmailTemplateVersionRepository versionRepo) { + return args -> { + if (!enabled) return; + try { + seed(templateRepo, versionRepo); + } catch (Exception e) { + + log.error("[UtilityAlert] template seed failed: {}", e.getMessage(), e); + } + }; + } + + @Transactional + public void seed(EmailTemplateRepository templateRepo, EmailTemplateVersionRepository versionRepo) { + upsertIfAbsent(templateRepo, versionRepo, + LocaleType.vi_VN, + "Cảnh báo {{severity}}: {{houseName}} vượt ngưỡng {{metricLabel}} ({{usagePercent}}%)", + htmlVi(), + """ + Xin chào {{landlordName}}, + + Nhà {{houseName}} đã vượt ngưỡng tiêu thụ {{metricLabel}} tháng {{month}}. + - Đã dùng: {{currentUsage}} {{unit}} + - Hạn mức: {{monthlyLimit}} {{unit}} + - Mức: {{usagePercent}}% + - Mức độ: {{severity}} + + Hãy kiểm tra nhà hoặc liên hệ người thuê để xử lý. + """ + ); + upsertIfAbsent(templateRepo, versionRepo, + LocaleType.en_US, + "{{severity}}: {{houseName}} over {{metricLabel}} limit ({{usagePercent}}%)", + htmlEn(), + """ + Hello {{landlordName}}, + + {{houseName}} has crossed its {{metricLabel}} consumption limit for {{month}}. + - Used: {{currentUsage}} {{unit}} + - Limit: {{monthlyLimit}} {{unit}} + - Ratio: {{usagePercent}}% + - Level: {{severity}} + + Please check the property or contact the tenant. + """ + ); + upsertIfAbsent(templateRepo, versionRepo, + LocaleType.ja_JP, + "【{{severity}}】{{houseName}}の{{metricLabel}}使用量がしきい値を超過({{usagePercent}}%)", + htmlJa(), + """ + {{landlordName}}様、 + + {{month}}の{{houseName}}における{{metricLabel}}使用量がしきい値を超過しました。 + - 使用量: {{currentUsage}} {{unit}} + - 上限: {{monthlyLimit}} {{unit}} + - 割合: {{usagePercent}}% + - 区分: {{severity}} + + 物件をご確認いただくか、テナントにご連絡をお願いいたします。 + """ + ); + seedDispatchAlias(templateRepo, versionRepo, "alert_utility_electricity_warning", + "Cảnh báo điện: {{houseName}} đã dùng {{usagePercent}}% hạn mức", + "Electricity warning: {{houseName}} used {{usagePercent}}% of its limit", + "電力警告: {{houseName}}は上限の{{usagePercent}}%を使用"); + seedDispatchAlias(templateRepo, versionRepo, "alert_utility_water_warning", + "Cảnh báo nước: {{houseName}} đã dùng {{usagePercent}}% hạn mức", + "Water warning: {{houseName}} used {{usagePercent}}% of its limit", + "水道警告: {{houseName}}は上限の{{usagePercent}}%を使用"); + seedDispatchAlias(templateRepo, versionRepo, "alert_utility_electricity_critical", + "Khẩn cấp điện: {{houseName}} đã vượt hạn mức", + "Critical electricity alert: {{houseName}} exceeded its limit", + "緊急電力警報: {{houseName}}が上限を超過"); + seedDispatchAlias(templateRepo, versionRepo, "alert_utility_water_critical", + "Khẩn cấp nước: {{houseName}} đã vượt hạn mức", + "Critical water alert: {{houseName}} exceeded its limit", + "緊急水道警報: {{houseName}}が上限を超過"); + } + + private void upsertIfAbsent( + EmailTemplateRepository templateRepo, + EmailTemplateVersionRepository versionRepo, + LocaleType locale, String subject, String html, String text) { + + EmailTemplate tpl = templateRepo.findByTemplateKey(TEMPLATE_KEY) + .orElseGet(() -> templateRepo.save( + EmailTemplate.builder() + .templateKey(TEMPLATE_KEY) + .category(CATEGORY) + .recipientType(RECIPIENT_TYPE) + .createdBy(ACTOR) + .updatedBy(ACTOR) + .build())); + + boolean hasActive = versionRepo + .findFirstByTemplate_TemplateKeyAndLocaleAndStatusOrderByVersionDesc( + TEMPLATE_KEY, locale, TemplateStatus.ACTIVE + ).isPresent(); + if (hasActive) return; + + EmailTemplateVersion v1 = EmailTemplateVersion.builder() + .template(tpl) + .locale(locale) + .version(1) + .status(TemplateStatus.ACTIVE) + .subjectTpl(subject) + .htmlTpl(html) + .textTpl(text) + .allowedVars(ALLOWED_VARS) + .createdBy(ACTOR) + .updatedBy(ACTOR) + .build(); + versionRepo.save(v1); + log.info("[UtilityAlert] seeded template {} locale={}", TEMPLATE_KEY, locale); + } + + private void seedDispatchAlias( + EmailTemplateRepository templateRepo, + EmailTemplateVersionRepository versionRepo, + String templateKey, + String subjectVi, + String subjectEn, + String subjectJa) { + upsertIfAbsent(templateRepo, versionRepo, templateKey, "TENANT", LocaleType.vi_VN, + subjectVi, dispatchHtml("Xin chào {{userName}},", "Nhà {{houseName}} đang ở mức {{severity}} về {{metricLabel}} trong tháng {{month}}."), + dispatchText("Xin chào {{userName}},", "Nhà {{houseName}} đang ở mức {{severity}} về {{metricLabel}} trong tháng {{month}}.")); + upsertIfAbsent(templateRepo, versionRepo, templateKey, "TENANT", LocaleType.en_US, + subjectEn, dispatchHtml("Hello {{userName}},", "{{houseName}} is at {{severity}} level for {{metricLabel}} consumption in {{month}}."), + dispatchText("Hello {{userName}},", "{{houseName}} is at {{severity}} level for {{metricLabel}} consumption in {{month}}.")); + upsertIfAbsent(templateRepo, versionRepo, templateKey, "TENANT", LocaleType.ja_JP, + subjectJa, dispatchHtml("{{userName}}様、", "{{month}}の{{houseName}}における{{metricLabel}}使用量が{{severity}}状態です。"), + dispatchText("{{userName}}様、", "{{month}}の{{houseName}}における{{metricLabel}}使用量が{{severity}}状態です。")); + } + + private void upsertIfAbsent( + EmailTemplateRepository templateRepo, + EmailTemplateVersionRepository versionRepo, + String templateKey, String recipientType, + LocaleType locale, String subject, String html, String text) { + + EmailTemplate tpl = templateRepo.findByTemplateKey(templateKey) + .orElseGet(() -> templateRepo.save( + EmailTemplate.builder() + .templateKey(templateKey) + .category(CATEGORY) + .recipientType(recipientType) + .createdBy(ACTOR) + .updatedBy(ACTOR) + .build())); + + boolean hasActive = versionRepo + .findFirstByTemplate_TemplateKeyAndLocaleAndStatusOrderByVersionDesc( + templateKey, locale, TemplateStatus.ACTIVE + ).isPresent(); + if (hasActive) return; + + EmailTemplateVersion v1 = EmailTemplateVersion.builder() + .template(tpl) + .locale(locale) + .version(1) + .status(TemplateStatus.ACTIVE) + .subjectTpl(subject) + .htmlTpl(html) + .textTpl(text) + .allowedVars(ALLOWED_VARS) + .createdBy(ACTOR) + .updatedBy(ACTOR) + .build(); + versionRepo.save(v1); + log.info("[UtilityAlert] seeded template {} locale={}", templateKey, locale); + } + + private static String dispatchText(String greeting, String lead) { + return """ + %s + + %s + - Đã dùng / Used: {{currentUsage}} {{unit}} + - Hạn mức / Limit: {{monthlyLimit}} {{unit}} + - Tỷ lệ / Ratio: {{usagePercent}}%% + - Thời điểm / Time: {{occurredAt}} + """.formatted(greeting, lead); + } + + private static String dispatchHtml(String greeting, String lead) { + return """ + + + + +
+ + + +
+
ISUMS UTILITY ALERT
+
{{houseName}} - {{usagePercent}}%%
+
+

%s

+

%s

+ + + + + +
Used{{currentUsage}} {{unit}}
Limit{{monthlyLimit}} {{unit}}
Ratio{{usagePercent}}%%
Time{{occurredAt}}
+
+
+ + """.formatted(greeting, lead); + } + + private static String htmlVi() { + return """ + + + + +
+ + + + +
+
CẢNH BÁO TIÊU THỤ TIỆN ÍCH
+
{{houseName}} — {{metricLabel}} {{usagePercent}}%
+
+

Xin chào {{landlordName}},

+

+ Nhà {{houseName}} đang {{severity}} về tiêu thụ + {{metricLabel}} trong tháng {{month}}. + Vui lòng xem lại hoặc liên hệ người thuê để tránh phát sinh vượt hạn mức hợp đồng. +

+ + + + + + +
Đã sử dụng{{currentUsage}} {{unit}}
Hạn mức tháng{{monthlyLimit}} {{unit}}
Mức sử dụng{{usagePercent}}%
Mức độ{{severity}}
Thời điểm{{occurredAt}}
+
+
Email gửi tự động từ hệ thống ISUMS. Vui lòng đăng nhập vào dashboard để xem chi tiết và xử lý.
+
+
+ + """; + } + + private static String htmlEn() { + return """ + + + + +
+ + + + +
+
UTILITY THRESHOLD ALERT
+
{{houseName}} — {{metricLabel}} {{usagePercent}}%
+
+

Hello {{landlordName}},

+

+ {{houseName}} is in {{severity}} state for + {{metricLabel}} consumption in {{month}}. + Please review or reach out to the tenant before the monthly cap is breached. +

+ + + + + + +
Used{{currentUsage}} {{unit}}
Monthly limit{{monthlyLimit}} {{unit}}
Usage ratio{{usagePercent}}%
Level{{severity}}
Occurred at{{occurredAt}}
+
+
Sent automatically by ISUMS. Sign in to the dashboard for full detail and actions.
+
+
+ + """; + } + + private static String htmlJa() { + return """ + + + + +
+ + + + +
+
ユーティリティ警報
+
{{houseName}} — {{metricLabel}} {{usagePercent}}%
+
+

{{landlordName}}様、

+

+ {{month}}{{houseName}}における + {{metricLabel}}使用量が{{severity}}状態です。 + 契約上限を超過する前に、物件の確認またはテナントへの連絡をお願いいたします。 +

+ + + + + + +
使用量{{currentUsage}} {{unit}}
月間上限{{monthlyLimit}} {{unit}}
使用率{{usagePercent}}%
区分{{severity}}
検出時刻{{occurredAt}}
+
+
ISUMSより自動送信。詳細はダッシュボードにログインしてご確認ください。
+
+
+ + """; + } +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/seeders/VoiceAlertTemplateSeeder.java b/src/main/java/com/isums/notificationservice/infrastructures/seeders/VoiceAlertTemplateSeeder.java new file mode 100644 index 0000000..85a5607 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/seeders/VoiceAlertTemplateSeeder.java @@ -0,0 +1,308 @@ +package com.isums.notificationservice.infrastructures.seeders; + +import com.isums.notificationservice.domains.entities.ChannelTemplate; +import com.isums.notificationservice.domains.entities.ChannelTemplateVersion; +import com.isums.notificationservice.domains.enums.AlertEventType; +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.domains.enums.NotificationChannel; +import com.isums.notificationservice.domains.enums.TemplateStatus; +import com.isums.notificationservice.infrastructures.repositories.ChannelTemplateRepository; +import com.isums.notificationservice.infrastructures.repositories.ChannelTemplateVersionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Configuration +@RequiredArgsConstructor +@Slf4j +public class VoiceAlertTemplateSeeder { + + private static final String ACTOR = "system"; + private static final List ALLOWED_VARS = List.of( + "userName", "houseId", "areaId", "areaName", "thing", + "metric", "metricLabel", "value", "unit", "eventType", "occurredAt", "level", + "houseName", "currentUsage", "monthlyLimit", "usagePercent", "month", "severity" + ); + + @Value("${app.seed.voice-templates:true}") + private boolean enabled; + + @Bean + ApplicationRunner seedVoiceAlertTemplatesRunner( + ChannelTemplateRepository templateRepo, + ChannelTemplateVersionRepository versionRepo) { + return args -> { + if (!enabled) return; + try { + seedAll(templateRepo, versionRepo); + } catch (Exception e) { + log.error("[VoiceAlertSeed] failed: {}", e.getMessage(), e); + } + }; + } + + @Transactional + public void seedAll(ChannelTemplateRepository templateRepo, + ChannelTemplateVersionRepository versionRepo) { + seedVoice(templateRepo, versionRepo, AlertEventType.GAS_CRITICAL, + "Cảnh báo khẩn cấp. Phát hiện khí gas vượt ngưỡng nguy hiểm tại {{areaName}}, " + + "{{value}} {{unit}}. Vui lòng kiểm tra ngay. " + + "Nhấn 1 để xác nhận đã nghe, nhấn 2 để chuyển cho chủ nhà, " + + "nhấn 9 để tắt cảnh báo gọi.", + "Emergency alert. Gas concentration has exceeded the critical threshold at " + + "{{areaName}}, {{value}} {{unit}}. Please check immediately. " + + "Press 1 to acknowledge, press 2 to escalate to your landlord, " + + "or press 9 to opt out of voice alerts.", + "緊急警報。{{areaName}}で検出されたガス濃度が危険な閾値を超えました。" + + "{{value}}{{unit}}。直ちに確認してください。" + + "確認するには1を、大家さんに転送するには2を、音声通知を無効にするには9を押してください。" + ); + + seedVoice(templateRepo, versionRepo, AlertEventType.FIRE_CRITICAL, + "Cảnh báo khẩn cấp. Nhiệt độ tại {{areaName}} là {{value}} độ C, vượt ngưỡng an toàn. " + + "Nghi ngờ có cháy. Vui lòng kiểm tra ngay. " + + "Nhấn 1 để xác nhận, nhấn 2 để chuyển cho chủ nhà.", + "Emergency alert. Temperature at {{areaName}} is {{value}} degrees Celsius, " + + "exceeding the safety threshold. Possible fire. Please check immediately. " + + "Press 1 to acknowledge, press 2 to escalate.", + "緊急警報。{{areaName}}の温度は{{value}}度で、安全閾値を超えています。" + + "火災の可能性があります。直ちに確認してください。" + + "確認するには1を、転送するには2を押してください。" + ); + + seedVoice(templateRepo, versionRepo, AlertEventType.POWER_LOST, + "Thông báo. Khu vực {{areaName}} đã mất điện. " + + "Nhấn 1 để xác nhận.", + "Notification. Power has been lost at {{areaName}}. Press 1 to acknowledge.", + "お知らせ。{{areaName}}で停電が発生しました。確認するには1を押してください。" + ); + + seedVoice(templateRepo, versionRepo, AlertEventType.POWER_RESTORED, + "Thông báo. Khu vực {{areaName}} đã có điện trở lại.", + "Notification. Power has been restored at {{areaName}}.", + "お知らせ。{{areaName}}の電力が復旧しました。" + ); + + seedVoice(templateRepo, versionRepo, AlertEventType.WATER_LEAK_SUSPECTED, + "Cảnh báo. Nghi ngờ rò rỉ nước tại {{areaName}}. " + + "Dòng nước chảy liên tục {{value}} {{unit}}. " + + "Vui lòng kiểm tra. Nhấn 1 để xác nhận, nhấn 2 để chuyển cho chủ nhà.", + "Warning. Suspected water leak at {{areaName}}. " + + "Continuous flow {{value}} {{unit}}. Please check. " + + "Press 1 to acknowledge, press 2 to escalate.", + "警告。{{areaName}}で水漏れの可能性があります。" + + "連続流量{{value}}{{unit}}。ご確認ください。" + + "確認するには1を、転送するには2を押してください。" + ); + + seedVoice(templateRepo, versionRepo, AlertEventType.GAS_WARNING, + "Cảnh báo. Nồng độ gas tại {{areaName}} là {{value}} {{unit}}, vượt ngưỡng khuyến nghị. " + + "Vui lòng thông gió khu vực. Nhấn 1 để xác nhận.", + "Warning. Gas concentration at {{areaName}} is {{value}} {{unit}}, " + + "above recommended level. Please ventilate. Press 1 to acknowledge.", + "警告。{{areaName}}のガス濃度は{{value}}{{unit}}で、推奨レベルを超えています。" + + "換気してください。確認するには1を押してください。" + ); + + seedVoice(templateRepo, versionRepo, AlertEventType.EIF_ANOMALY_POWER, + "Thông báo từ hệ thống. Mức tiêu thụ điện bất thường tại {{areaName}}. " + + "Vui lòng kiểm tra thiết bị đang sử dụng. Nhấn 1 để xác nhận.", + "System notification. Abnormal power consumption at {{areaName}}. " + + "Please review running appliances. Press 1 to acknowledge.", + "システム通知。{{areaName}}で異常な電力消費を検出しました。" + + "使用中の機器を確認してください。確認するには1を押してください。" + ); + + seedVoice(templateRepo, versionRepo, AlertEventType.EIF_ANOMALY_WATER, + "Thông báo từ hệ thống. Mức dùng nước bất thường tại {{areaName}}. " + + "Vui lòng kiểm tra đường ống. Nhấn 1 để xác nhận.", + "System notification. Abnormal water usage at {{areaName}}. " + + "Please check plumbing. Press 1 to acknowledge.", + "システム通知。{{areaName}}で異常な水の使用を検出しました。" + + "配管を確認してください。確認するには1を押してください。" + ); + + seedVoice(templateRepo, versionRepo, AlertEventType.UTILITY_ELECTRICITY_WARNING, + "Cảnh báo. Nhà {{houseName}} đã dùng {{usagePercent}} phần trăm hạn mức điện tháng {{month}}, " + + "tương đương {{currentUsage}} trên {{monthlyLimit}} {{unit}}. Vui lòng kiểm tra. Nhấn 1 để xác nhận.", + "Warning. {{houseName}} has used {{usagePercent}} percent of the electricity limit for {{month}}, " + + "{{currentUsage}} of {{monthlyLimit}} {{unit}}. Please review. Press 1 to acknowledge.", + "警告。{{houseName}}の{{month}}電力使用量は上限の{{usagePercent}}パーセント、" + + "{{currentUsage}}/{{monthlyLimit}}{{unit}}です。確認するには1を押してください。" + ); + + seedVoice(templateRepo, versionRepo, AlertEventType.UTILITY_WATER_WARNING, + "Cảnh báo. Nhà {{houseName}} đã dùng {{usagePercent}} phần trăm hạn mức nước tháng {{month}}, " + + "tương đương {{currentUsage}} trên {{monthlyLimit}} {{unit}}. Vui lòng kiểm tra. Nhấn 1 để xác nhận.", + "Warning. {{houseName}} has used {{usagePercent}} percent of the water limit for {{month}}, " + + "{{currentUsage}} of {{monthlyLimit}} {{unit}}. Please review. Press 1 to acknowledge.", + "警告。{{houseName}}の{{month}}水道使用量は上限の{{usagePercent}}パーセント、" + + "{{currentUsage}}/{{monthlyLimit}}{{unit}}です。確認するには1を押してください。" + ); + + seedVoice(templateRepo, versionRepo, AlertEventType.UTILITY_ELECTRICITY_CRITICAL, + "Cảnh báo khẩn cấp. Nhà {{houseName}} đã vượt hạn mức điện tháng {{month}}, " + + "{{currentUsage}} trên {{monthlyLimit}} {{unit}}, đạt {{usagePercent}} phần trăm. " + + "Vui lòng xử lý ngay. Nhấn 1 để xác nhận, nhấn 2 để chuyển cho chủ nhà.", + "Critical alert. {{houseName}} has exceeded the electricity limit for {{month}}, " + + "{{currentUsage}} of {{monthlyLimit}} {{unit}}, reaching {{usagePercent}} percent. " + + "Please act now. Press 1 to acknowledge, press 2 to escalate.", + "緊急警報。{{houseName}}の{{month}}電力使用量が上限を超えました。" + + "{{currentUsage}}/{{monthlyLimit}}{{unit}}、{{usagePercent}}パーセントです。" + + "確認するには1を、転送するには2を押してください。" + ); + + seedVoice(templateRepo, versionRepo, AlertEventType.UTILITY_WATER_CRITICAL, + "Cảnh báo khẩn cấp. Nhà {{houseName}} đã vượt hạn mức nước tháng {{month}}, " + + "{{currentUsage}} trên {{monthlyLimit}} {{unit}}, đạt {{usagePercent}} phần trăm. " + + "Vui lòng xử lý ngay. Nhấn 1 để xác nhận, nhấn 2 để chuyển cho chủ nhà.", + "Critical alert. {{houseName}} has exceeded the water limit for {{month}}, " + + "{{currentUsage}} of {{monthlyLimit}} {{unit}}, reaching {{usagePercent}} percent. " + + "Please act now. Press 1 to acknowledge, press 2 to escalate.", + "緊急警報。{{houseName}}の{{month}}水道使用量が上限を超えました。" + + "{{currentUsage}}/{{monthlyLimit}}{{unit}}、{{usagePercent}}パーセントです。" + + "確認するには1を、転送するには2を押してください。" + ); + + seedSms(templateRepo, versionRepo, AlertEventType.GAS_CRITICAL, + "[ISUMS] KHAN CAP: Gas vuot nguong nguy hiem tai {{areaName}} ({{value}} {{unit}}). Hay kiem tra ngay.", + "[ISUMS] EMERGENCY: Gas at {{areaName}} above critical ({{value}} {{unit}}). Check immediately.", + "[ISUMS] 緊急: {{areaName}}のガス濃度が危険({{value}}{{unit}})。至急確認を。" + ); + + seedSms(templateRepo, versionRepo, AlertEventType.FIRE_CRITICAL, + "[ISUMS] KHAN CAP: Nhiet do cao tai {{areaName}} ({{value}}C). Nghi co chay.", + "[ISUMS] EMERGENCY: High temperature at {{areaName}} ({{value}}C). Possible fire.", + "[ISUMS] 緊急: {{areaName}}高温({{value}}C)。火災の可能性。" + ); + + seedSms(templateRepo, versionRepo, AlertEventType.POWER_LOST, + "[ISUMS] Mat dien tai {{areaName}}.", + "[ISUMS] Power lost at {{areaName}}.", + "[ISUMS] {{areaName}}で停電。" + ); + + seedSms(templateRepo, versionRepo, AlertEventType.WATER_LEAK_SUSPECTED, + "[ISUMS] Nghi ro ri nuoc tai {{areaName}} ({{value}} {{unit}}). Kiem tra giup.", + "[ISUMS] Suspected water leak at {{areaName}} ({{value}} {{unit}}). Please check.", + "[ISUMS] {{areaName}}水漏れ疑い({{value}}{{unit}})。ご確認を。" + ); + + seedSms(templateRepo, versionRepo, AlertEventType.GAS_WARNING, + "[ISUMS] Canh bao: Gas tai {{areaName}} dat {{value}} {{unit}}. Hay thong gio va kiem tra.", + "[ISUMS] Warning: Gas at {{areaName}} reached {{value}} {{unit}}. Ventilate and check.", + "[ISUMS] 警告: {{areaName}}のガス濃度{{value}}{{unit}}。換気して確認してください。" + ); + + seedSms(templateRepo, versionRepo, AlertEventType.EIF_ANOMALY_POWER, + "[ISUMS] Canh bao: Dien nang tieu thu bat thuong tai {{areaName}}. Hay kiem tra thiet bi.", + "[ISUMS] Warning: Abnormal power usage at {{areaName}}. Please check appliances.", + "[ISUMS] 警告: {{areaName}}で異常な電力使用。機器を確認してください。" + ); + + seedSms(templateRepo, versionRepo, AlertEventType.EIF_ANOMALY_WATER, + "[ISUMS] Canh bao: Nuoc tieu thu bat thuong tai {{areaName}}. Hay kiem tra duong ong.", + "[ISUMS] Warning: Abnormal water usage at {{areaName}}. Please check plumbing.", + "[ISUMS] 警告: {{areaName}}で異常な水使用。配管を確認してください。" + ); + + seedSms(templateRepo, versionRepo, AlertEventType.UTILITY_ELECTRICITY_WARNING, + "[ISUMS] Canh bao dien: {{houseName}} da dung {{usagePercent}}% han muc thang {{month}} ({{currentUsage}}/{{monthlyLimit}} {{unit}}).", + "[ISUMS] Electricity warning: {{houseName}} used {{usagePercent}}% of {{month}} limit ({{currentUsage}}/{{monthlyLimit}} {{unit}}).", + "[ISUMS] 電力警告: {{houseName}}は{{month}}上限の{{usagePercent}}%を使用({{currentUsage}}/{{monthlyLimit}}{{unit}})。" + ); + + seedSms(templateRepo, versionRepo, AlertEventType.UTILITY_WATER_WARNING, + "[ISUMS] Canh bao nuoc: {{houseName}} da dung {{usagePercent}}% han muc thang {{month}} ({{currentUsage}}/{{monthlyLimit}} {{unit}}).", + "[ISUMS] Water warning: {{houseName}} used {{usagePercent}}% of {{month}} limit ({{currentUsage}}/{{monthlyLimit}} {{unit}}).", + "[ISUMS] 水道警告: {{houseName}}は{{month}}上限の{{usagePercent}}%を使用({{currentUsage}}/{{monthlyLimit}}{{unit}})。" + ); + + seedSms(templateRepo, versionRepo, AlertEventType.UTILITY_ELECTRICITY_CRITICAL, + "[ISUMS] KHAN CAP dien: {{houseName}} vuot han muc thang {{month}} ({{usagePercent}}%, {{currentUsage}}/{{monthlyLimit}} {{unit}}).", + "[ISUMS] CRITICAL electricity: {{houseName}} exceeded {{month}} limit ({{usagePercent}}%, {{currentUsage}}/{{monthlyLimit}} {{unit}}).", + "[ISUMS] 緊急 電力: {{houseName}}は{{month}}上限超過({{usagePercent}}%, {{currentUsage}}/{{monthlyLimit}}{{unit}})。" + ); + + seedSms(templateRepo, versionRepo, AlertEventType.UTILITY_WATER_CRITICAL, + "[ISUMS] KHAN CAP nuoc: {{houseName}} vuot han muc thang {{month}} ({{usagePercent}}%, {{currentUsage}}/{{monthlyLimit}} {{unit}}).", + "[ISUMS] CRITICAL water: {{houseName}} exceeded {{month}} limit ({{usagePercent}}%, {{currentUsage}}/{{monthlyLimit}} {{unit}}).", + "[ISUMS] 緊急 水道: {{houseName}}は{{month}}上限超過({{usagePercent}}%, {{currentUsage}}/{{monthlyLimit}}{{unit}})。" + ); + } + + private void seedVoice(ChannelTemplateRepository templateRepo, + ChannelTemplateVersionRepository versionRepo, + AlertEventType event, + String viBody, String enBody, String jaBody) { + String key = "voice_" + event.name().toLowerCase(); + upsertIfAbsent(templateRepo, versionRepo, + key, NotificationChannel.VOICE, event.name(), + LocaleType.vi_VN, viBody, null); + upsertIfAbsent(templateRepo, versionRepo, + key, NotificationChannel.VOICE, event.name(), + LocaleType.en_US, enBody, null); + upsertIfAbsent(templateRepo, versionRepo, + key, NotificationChannel.VOICE, event.name(), + LocaleType.ja_JP, jaBody, null); + } + + private void seedSms(ChannelTemplateRepository templateRepo, + ChannelTemplateVersionRepository versionRepo, + AlertEventType event, + String viBody, String enBody, String jaBody) { + String key = "sms_" + event.name().toLowerCase(); + upsertIfAbsent(templateRepo, versionRepo, + key, NotificationChannel.SMS, event.name(), + LocaleType.vi_VN, viBody, "ISUMS Alert"); + upsertIfAbsent(templateRepo, versionRepo, + key, NotificationChannel.SMS, event.name(), + LocaleType.en_US, enBody, "ISUMS Alert"); + upsertIfAbsent(templateRepo, versionRepo, + key, NotificationChannel.SMS, event.name(), + LocaleType.ja_JP, jaBody, "ISUMS Alert"); + } + + private void upsertIfAbsent(ChannelTemplateRepository templateRepo, + ChannelTemplateVersionRepository versionRepo, + String templateKey, NotificationChannel channel, String eventType, + LocaleType locale, String body, String title) { + ChannelTemplate tpl = templateRepo.findByTemplateKeyAndChannel(templateKey, channel) + .orElseGet(() -> templateRepo.save( + ChannelTemplate.builder() + .templateKey(templateKey) + .channel(channel) + .eventType(eventType) + .category("ALERT") + .recipientType("TENANT_OR_LANDLORD") + .createdBy(ACTOR) + .updatedBy(ACTOR) + .build())); + + Optional existing = versionRepo + .findFirstByTemplate_TemplateKeyAndTemplate_ChannelAndLocaleAndStatusOrderByVersionDesc( + templateKey, channel, locale, TemplateStatus.ACTIVE); + if (existing.isPresent()) return; + + ChannelTemplateVersion v1 = ChannelTemplateVersion.builder() + .template(tpl) + .locale(locale) + .version(1) + .status(TemplateStatus.ACTIVE) + .body(body) + .title(title) + .allowedVars(ALLOWED_VARS) + .createdBy(ACTOR) + .updatedBy(ACTOR) + .build(); + versionRepo.save(v1); + log.info("[VoiceAlertSeed] seeded {} channel={} locale={}", templateKey, channel, locale); + } +} diff --git a/src/main/java/com/isums/notificationservice/services/AwsSnsClient.java b/src/main/java/com/isums/notificationservice/services/AwsSnsClient.java new file mode 100644 index 0000000..31564ad --- /dev/null +++ b/src/main/java/com/isums/notificationservice/services/AwsSnsClient.java @@ -0,0 +1,183 @@ +package com.isums.notificationservice.services; + +import com.isums.notificationservice.domains.dtos.SpeedSmsVoiceResponse; +import com.isums.notificationservice.infrastructures.abstracts.SmsProvider; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.sns.SnsClient; +import software.amazon.awssdk.services.sns.model.MessageAttributeValue; +import software.amazon.awssdk.services.sns.model.PublishRequest; +import software.amazon.awssdk.services.sns.model.PublishResponse; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * AWS SNS SMS provider — the only SMS path. + * + *

Flow: + *

    + *
  1. Normalise phone to E.164 with leading {@code +} (e.g. + * {@code 0326336224} → {@code +84326336224}). Stringee voice + * expects no leading {@code +}, so the format diverges by channel.
  2. + *
  3. Set {@code AWS.SNS.SMS.SMSType=Transactional} attribute so SNS + * picks the higher-priority delivery path (alerts, not marketing).
  4. + *
  5. Set {@code AWS.SNS.SMS.SenderID} attribute (best-effort — + * VN MNOs ignore it but US/EU accept).
  6. + *
  7. {@link SnsClient#publish(PublishRequest)} returns a messageId on + * acceptance. Successful API call ≠ guaranteed handset delivery — + * sandbox accounts must verify each destination phone first; + * production accounts (after AWS approves the Use-Case form) can + * send to any number.
  8. + *
+ * + *

Activated by {@code app.notification.sms.provider=AWS_SNS} (the + * default). {@link Primary} avoids ambiguity should another + * {@link SmsProvider} bean ever be reintroduced. + */ +@Service +@Primary +@ConditionalOnProperty(name = "app.notification.sms.provider", havingValue = "AWS_SNS") +@Slf4j +public class AwsSnsClient implements SmsProvider { + + private final SnsClient snsClient; + + /** + * Optional SenderID — appears as the SMS sender on supported countries + * (US, AU, EU). VN MNOs ignore it and substitute their own short-code, + * but setting it doesn't hurt. Max 11 alphanumeric characters. + */ + @Value("${app.notification.aws.sns.sender-id:ISUMS}") + private String senderId; + + /** + * Default-CC fallback for phones that come in as 0xxx (VN domestic). + * Should match the country whose MNO numbers we support. + */ + @Value("${app.notification.aws.sns.default-cc:84}") + private String defaultCc; + + @Value("${app.notification.voice.dry-run:false}") + private boolean dryRun; + + public AwsSnsClient(SnsClient snsClient) { + this.snsClient = snsClient; + } + + @jakarta.annotation.PostConstruct + void logConfig() { + log.info("[AWS SNS init] senderId={} defaultCc=+{} dryRun={}", + senderId, defaultCc, dryRun); + } + + @Override + public String providerId() { + return "AWS_SNS"; + } + + @Override + public SpeedSmsVoiceResponse sendSms(String phone, String text) { + if (dryRun) { + log.info("[AWS SNS DRY_RUN] phone={} text={}", phone, text); + return new SpeedSmsVoiceResponse(true, + "dry-sns-" + UUID.randomUUID(), + "SENT", null); + } + + String e164 = toE164Plus(phone, defaultCc); + if (e164.isBlank()) { + return new SpeedSmsVoiceResponse(false, null, "FAILED", + "Phone number is empty / cannot be normalised to E.164"); + } + + try { + Map attrs = new HashMap<>(); + attrs.put("AWS.SNS.SMS.SMSType", + MessageAttributeValue.builder() + .dataType("String") + .stringValue("Transactional") + .build()); + if (senderId != null && !senderId.isBlank()) { + attrs.put("AWS.SNS.SMS.SenderID", + MessageAttributeValue.builder() + .dataType("String") + .stringValue(senderId) + .build()); + } + + // SNS limits: GSM-7 ≤ 160 chars or UCS-2 ≤ 70 chars per segment. + // Vietnamese diacritics force UCS-2 → keep texts short. Truncating + // here is a last-resort safety; alert templates are already short. + String body = text == null ? "" : text; + if (body.length() > 600) { + log.warn("[AWS SNS] truncating body from {} chars to 600 (3 UCS-2 segments)", body.length()); + body = body.substring(0, 600); + } + + PublishRequest req = PublishRequest.builder() + .phoneNumber(e164) + .message(body) + .messageAttributes(attrs) + .build(); + + PublishResponse resp = snsClient.publish(req); + String messageId = resp.messageId(); + + log.info("[AWS SNS] sms sent phone={} messageId={} bytes={}", + e164, messageId, body.length()); + + return new SpeedSmsVoiceResponse(true, + messageId == null ? ("sns-" + System.currentTimeMillis()) : messageId, + "SENT", null); + } catch (software.amazon.awssdk.services.sns.model.InvalidParameterException e) { + // Common Sandbox failure: destination phone not verified yet. + log.error("[AWS SNS] invalid parameter phone={} msg={}", e164, e.awsErrorDetails().errorMessage()); + return new SpeedSmsVoiceResponse(false, null, "FAILED", + "AWS SNS rejected: " + e.awsErrorDetails().errorMessage() + + " (Sandbox accounts must verify the destination phone first)"); + } catch (Exception e) { + log.error("[AWS SNS] publish failed phone={}: {}", e164, e.getMessage(), e); + return new SpeedSmsVoiceResponse(false, null, "FAILED", e.getMessage()); + } + } + + /** + * Normalises a raw phone string to AWS-SNS-friendly E.164 with a + * leading {@code +}. Handles {@code 0xxx}, {@code 84xxx}, + * {@code +84xxx}, {@code 0084xxx}, with or without spaces / dashes. + * + *

Examples (defaultCc = "84"): + *

    + *
  • {@code 0326336224} → {@code +84326336224}
  • + *
  • {@code 84326336224} → {@code +84326336224}
  • + *
  • {@code +84326336224} → {@code +84326336224}
  • + *
  • {@code 032 633-6224} → {@code +84326336224}
  • + *
  • {@code 0084326336224} → {@code +84326336224}
  • + *
+ * + *

Different from {@link StringeeClientImpl#normalizeE164} which + * returns the country-code-prefixed digits WITHOUT the leading + * {@code +} (Stringee REST quirk). AWS SNS requires the {@code +}. + */ + static String toE164Plus(String phone, String defaultCc) { + if (phone == null) return ""; + String digits = phone.replaceAll("[\\s\\-()]+", "").trim(); + if (digits.isEmpty()) return ""; + if (digits.startsWith("+")) { + return digits; + } + if (digits.startsWith("00")) { + return "+" + digits.substring(2); + } + if (digits.startsWith("0")) { + return "+" + defaultCc + digits.substring(1); + } + // Already CC-prefixed (84xxx) but missing +. + return "+" + digits; + } +} diff --git a/src/main/java/com/isums/notificationservice/services/ChannelPolicy.java b/src/main/java/com/isums/notificationservice/services/ChannelPolicy.java new file mode 100644 index 0000000..270df61 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/services/ChannelPolicy.java @@ -0,0 +1,60 @@ +package com.isums.notificationservice.services; + +import com.isums.notificationservice.domains.enums.AlertSeverity; +import com.isums.notificationservice.domains.enums.RecipientRole; + +/** + * The production channel-routing matrix. Driven by (severity × role) — + * tenant gets the loudest treatment for safety events, landlord gets + * SMS for non-critical, manager defaults to digest-only to avoid + * fatigue at scale. + * + *

Channel flags are PERMISSION GRANTS — actual delivery still + * respects user preferences (consent, opt-out, quiet hours) and + * subscription tier. The policy can never RAISE delivery beyond a + * user's settings; it can only SUPPRESS. + * + *

For non-tenant roles (landlord / manager), tier checks are skipped + * because they're a business cost of the property owner, not a + * subscription product. The landlord's own tier still gates calls + * placed TO the landlord's number, but voice/SMS to the landlord on a + * tenant alert isn't tier-gated. + */ +public record ChannelPolicy( + boolean push, + boolean email, + boolean sms, + boolean voice +) { + public static ChannelPolicy forSeverityRole(AlertSeverity severity, RecipientRole role) { + return switch (severity) { + case CRITICAL -> switch (role) { + // Tenant: first responder — every channel + case TENANT -> new ChannelPolicy(true, true, true, true); + // Landlord: EMAIL ONLY. Landlord pays the manager to be + // on-call; bothering them at 2am for sensor blips burns + // the relationship. They get visibility via email. + case LANDLORD -> new ChannelPolicy(false, true, false, false); + // Manager: full ops contact — voice + SMS + push + email. + // This is who actually drives to the site. + case MANAGER -> new ChannelPolicy(true, true, true, true); + }; + case WARNING -> switch (role) { + // Tenant: voice + SMS for actionable warnings when the user opted in. + case TENANT -> new ChannelPolicy(true, true, true, true); + // Landlord: email only (no SMS noise) + case LANDLORD -> new ChannelPolicy(false, true, false, false); + // Manager: SMS + push + email; voice reserved for CRITICAL + case MANAGER -> new ChannelPolicy(true, true, true, false); + }; + case INFO -> switch (role) { + // Tenant: lightweight push + email + case TENANT -> new ChannelPolicy(true, true, false, false); + // Landlord: skip entirely — INFO doesn't need owner attention + case LANDLORD -> new ChannelPolicy(false, false, false, false); + // Manager: email only (operational signal for daily review) + case MANAGER -> new ChannelPolicy(false, true, false, false); + }; + }; + } +} diff --git a/src/main/java/com/isums/notificationservice/services/ChannelTemplateRenderer.java b/src/main/java/com/isums/notificationservice/services/ChannelTemplateRenderer.java new file mode 100644 index 0000000..4125945 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/services/ChannelTemplateRenderer.java @@ -0,0 +1,76 @@ +package com.isums.notificationservice.services; + +import com.github.mustachejava.DefaultMustacheFactory; +import com.github.mustachejava.MustacheFactory; +import com.isums.notificationservice.domains.entities.ChannelTemplateVersion; +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.domains.enums.NotificationChannel; +import com.isums.notificationservice.domains.enums.TemplateStatus; +import com.isums.notificationservice.exceptions.NotFoundException; +import com.isums.notificationservice.infrastructures.repositories.ChannelTemplateVersionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.io.StringReader; +import java.util.Map; + +/** + * Loads an ACTIVE channel template version and Mustache-renders body + + * title (+ SSML when set). Missing template on the requested locale + * falls back to {@link LocaleType#vi_VN} — matches the email template + * service behaviour. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class ChannelTemplateRenderer { + + private final ChannelTemplateVersionRepository versionRepo; + private final MustacheFactory mf = new DefaultMustacheFactory(); + + public record RenderedTemplate( + String body, + String title, + String ssml, + ChannelTemplateVersion version + ) {} + + public RenderedTemplate render(String templateKey, NotificationChannel channel, + LocaleType locale, Map vars) { + ChannelTemplateVersion version = versionRepo + .findFirstByTemplate_TemplateKeyAndTemplate_ChannelAndLocaleAndStatusOrderByVersionDesc( + templateKey, channel, locale, TemplateStatus.ACTIVE) + .or(() -> { + if (locale != LocaleType.vi_VN) { + log.warn("Template {} for {} locale={} missing — falling back vi_VN", + templateKey, channel, locale); + return versionRepo + .findFirstByTemplate_TemplateKeyAndTemplate_ChannelAndLocaleAndStatusOrderByVersionDesc( + templateKey, channel, LocaleType.vi_VN, TemplateStatus.ACTIVE); + } + return java.util.Optional.empty(); + }) + .orElseThrow(() -> new NotFoundException( + "No ACTIVE template for key=" + templateKey + + " channel=" + channel + " locale=" + locale)); + + String body = renderOne(version.getBody(), vars, "body"); + String title = version.getTitle() == null ? null + : renderOne(version.getTitle(), vars, "title"); + String ssml = version.getSsml() == null ? null + : renderOne(version.getSsml(), vars, "ssml"); + + return new RenderedTemplate(body, title, ssml, version); + } + + private String renderOne(String tpl, Map vars, String label) { + try (var sw = new java.io.StringWriter()) { + var mustache = mf.compile(new StringReader(tpl), label); + mustache.execute(sw, vars).flush(); + return sw.toString(); + } catch (Exception e) { + throw new RuntimeException("render " + label + " failed: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/isums/notificationservice/services/EmailServiceImpl.java b/src/main/java/com/isums/notificationservice/services/EmailServiceImpl.java index 2ba5dfb..632f1a1 100644 --- a/src/main/java/com/isums/notificationservice/services/EmailServiceImpl.java +++ b/src/main/java/com/isums/notificationservice/services/EmailServiceImpl.java @@ -80,11 +80,22 @@ private String render(String tpl, Map vars) { } } + /** + * Log extra vars that publishers sent but the template's allowedVars + * doesn't declare. Used to throw — which broke every email whenever a + * publisher and the template seeder drifted (e.g. `hasPdf` added to the + * payment-service publisher months after the template was first seeded). + * Mustache silently ignores unused variables, so the email still renders + * correctly; the only cost of unexpected vars is slightly noisy logs, + * which beats losing the email + retry-looping + DLT-stuck altogether. + * Keep the check as a DEV signal so the seeder can be updated, but + * never fail the send. + */ private void validateVars(EmailTemplateCached tpl, Map vars) { if (tpl.allowedVars() == null || tpl.allowedVars().isEmpty()) return; for (String k : vars.keySet()) { if (!tpl.allowedVars().contains(k)) { - throw new IllegalArgumentException("Variable not allowed: " + k); + log.warn("Extra template variable (not in allowedVars, will be ignored by Mustache): {}", k); } } } diff --git a/src/main/java/com/isums/notificationservice/services/EscalationService.java b/src/main/java/com/isums/notificationservice/services/EscalationService.java new file mode 100644 index 0000000..dac6abf --- /dev/null +++ b/src/main/java/com/isums/notificationservice/services/EscalationService.java @@ -0,0 +1,86 @@ +package com.isums.notificationservice.services; + +import com.isums.notificationservice.domains.entities.UserNotificationPreferences; +import com.isums.notificationservice.domains.entities.VoiceCallEscalation; +import com.isums.notificationservice.domains.enums.EscalationReason; +import com.isums.notificationservice.infrastructures.grpcs.HouseGrpcClient; +import com.isums.notificationservice.infrastructures.repositories.VoiceCallEscalationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class EscalationService { + + private final HouseGrpcClient houseGrpcClient; + private final VoiceCallEscalationRepository escalationRepo; + + /** + * Decides who to escalate to. Priority: + * 1. user-specified {@code escalation_target_user_id} in preferences + * 2. MANAGER of the alert's house's region (HouseGrpc) — operations + * contact, paid by the landlord to handle on-call incidents + * 3. landlord, only as a final fallback when the region has no + * assigned manager (rare — usually a setup gap) + * 4. null → nothing to escalate to, caller drops the escalation + * + *

Why manager over landlord? B2B reality: the landlord OWNS the + * property and pays for the service, but the MANAGER is the on-call + * operations contact. Calling the landlord at 2am for every gas + * sensor blip would burn the relationship — landlord just wants + * email visibility (see ChannelPolicy LANDLORD = email-only). + */ + public UUID resolveEscalationTarget(UUID originalUserId, + UserNotificationPreferences prefs, + String houseIdOrNull) { + if (prefs.getEscalationTargetUserId() != null) { + return prefs.getEscalationTargetUserId(); + } + if (houseIdOrNull == null || houseIdOrNull.isBlank()) { + return null; + } + UUID houseUuid; + try { houseUuid = UUID.fromString(houseIdOrNull); } + catch (IllegalArgumentException e) { return null; } + + // Primary path: region manager. + try { + UUID manager = houseGrpcClient.getManagerIdByHouseId(houseUuid); + if (manager != null && !manager.equals(originalUserId)) { + return manager; + } + } catch (Exception e) { + log.warn("[Escalation] manager lookup failed houseId={}: {}", + houseIdOrNull, e.getMessage()); + } + // Fallback: landlord (only if region has no manager assigned). + try { + UUID landlord = houseGrpcClient.getLandlordIdByHouseId(houseUuid); + if (landlord != null && !landlord.equals(originalUserId)) { + log.info("[Escalation] no manager for houseId={}, falling back to landlord", houseIdOrNull); + return landlord; + } + } catch (Exception e) { + log.warn("[Escalation] landlord lookup failed houseId={}: {}", + houseIdOrNull, e.getMessage()); + } + return null; + } + + @Transactional + public VoiceCallEscalation record(UUID originalCallId, UUID escalatedCallId, + UUID escalatedToUserId, EscalationReason reason) { + VoiceCallEscalation row = VoiceCallEscalation.builder() + .originalCallId(originalCallId) + .escalatedCallId(escalatedCallId) + .escalatedToUserId(escalatedToUserId) + .reason(reason) + .build(); + return escalationRepo.save(row); + } +} diff --git a/src/main/java/com/isums/notificationservice/services/ManagerNotificationServiceImpl.java b/src/main/java/com/isums/notificationservice/services/ManagerNotificationServiceImpl.java index 2029759..d6ba639 100644 --- a/src/main/java/com/isums/notificationservice/services/ManagerNotificationServiceImpl.java +++ b/src/main/java/com/isums/notificationservice/services/ManagerNotificationServiceImpl.java @@ -1,20 +1,24 @@ package com.isums.notificationservice.services; +import com.isums.common.i18n.TranslationMap; import com.isums.notificationservice.domains.dtos.NotificationDto; import com.isums.notificationservice.domains.entities.ManagerNotification; import com.isums.notificationservice.domains.enums.NotificationCategory; import com.isums.notificationservice.exceptions.NotFoundException; import com.isums.notificationservice.infrastructures.Websockets.SseConnectionManager; import com.isums.notificationservice.infrastructures.abstracts.ManagerNotificationService; +import com.isums.notificationservice.infrastructures.kafka.NotificationTranslationRequester; import com.isums.notificationservice.infrastructures.repositories.ManagerNotificationRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Instant; +import java.util.LinkedHashMap; import java.util.Map; import java.util.UUID; @@ -25,6 +29,10 @@ public class ManagerNotificationServiceImpl implements ManagerNotificationServic private final ManagerNotificationRepository repo; private final SseConnectionManager sseManager; + private final NotificationTranslationRequester translationRequester; + + @Value("${isums.i18n.notification.source-language:en}") + private String sourceLanguage = "en"; @Override @Transactional @@ -36,7 +44,9 @@ public void send(UUID recipientId, NotificationCategory category, .recipientId(recipientId) .category(category) .title(title) + .titleTranslations(sourceMap(title)) .body(body) + .bodyTranslations(sourceMap(body)) .actionUrl(actionUrl) .metadata(metadata) .isRead(false) @@ -44,10 +54,21 @@ public void send(UUID recipientId, NotificationCategory category, repo.save(n); sseManager.push(recipientId, n); + translationRequester.requestMissing(n, sourceLanguage); log.info("[Notification] Sent recipientId={} category={}", recipientId, category); } + private TranslationMap sourceMap(String text) { + String code = TranslationMap.normalizeLanguage(sourceLanguage); + if (code == null || code.isBlank()) code = "en"; + if (text == null || text.isBlank()) return TranslationMap.empty(); + Map source = new LinkedHashMap<>(); + source.put(code, text); + source.put("_source", code); + return new TranslationMap(source); + } + @Override public Page getByRecipient(UUID recipientId, Pageable pageable) { return repo.findByRecipientIdOrderByCreatedAtDesc(recipientId, pageable) diff --git a/src/main/java/com/isums/notificationservice/services/MonthlyQuotaResetScheduler.java b/src/main/java/com/isums/notificationservice/services/MonthlyQuotaResetScheduler.java new file mode 100644 index 0000000..59f6c69 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/services/MonthlyQuotaResetScheduler.java @@ -0,0 +1,27 @@ +package com.isums.notificationservice.services; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class MonthlyQuotaResetScheduler { + + private final NotificationQuotaService quotaService; + + // 00:05 on the 1st of every month (VN time). The 5-minute delay is + // insurance against clock skew between the scheduler host and Redis's + // TTL expiry, which runs on the Redis-cloud server in a different zone. + @Scheduled(cron = "0 5 0 1 * *", zone = "Asia/Ho_Chi_Minh") + public void resetMonthly() { + log.info("[QuotaReset] triggered"); + try { + quotaService.resetAllUsageCounters(); + } catch (Exception e) { + log.error("[QuotaReset] failed: {}", e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/isums/notificationservice/services/NotificationDispatchService.java b/src/main/java/com/isums/notificationservice/services/NotificationDispatchService.java new file mode 100644 index 0000000..d8c9f09 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/services/NotificationDispatchService.java @@ -0,0 +1,501 @@ +package com.isums.notificationservice.services; + +import com.isums.notificationservice.domains.dtos.AlertDispatchRequest; +import com.isums.notificationservice.domains.dtos.AlertDispatchResponse; +import com.isums.notificationservice.domains.dtos.AlertDispatchResponse.ChannelDispatchResult; +import com.isums.notificationservice.domains.entities.NotificationSubscription; +import com.isums.notificationservice.domains.entities.UserNotificationPreferences; +import com.isums.notificationservice.domains.entities.VoiceCallJob; +import com.isums.notificationservice.domains.enums.AlertSeverity; +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.domains.enums.NotificationChannel; +import com.isums.notificationservice.domains.enums.RecipientRole; +import com.isums.notificationservice.domains.enums.SubscriptionTier; +import com.isums.notificationservice.infrastructures.abstracts.EmailService; +import com.isums.notificationservice.infrastructures.abstracts.SmsProvider; +import com.isums.notificationservice.infrastructures.grpcs.HouseGrpcClient; +import com.isums.notificationservice.infrastructures.grpcs.UserGrpcClient; +import com.isums.userservice.grpc.UserResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Production routing entrypoint. One alert fans out to up to three + * recipients — tenant + landlord + manager — using a (severity × role) + * channel matrix ({@link ChannelPolicy}). Each recipient gets their + * own delivery in their OWN locale (vi/en/ja from their User profile). + * + *

Hard rules (defence-in-depth): + *

    + *
  • User preferences are a HARD CEILING — policy can suppress but + * never raise beyond what the user has opted in to.
  • + *
  • Tenant pays for voice/SMS via PREMIUM tier; landlord and + * manager are business recipients on the property owner's bill, + * no tier check.
  • + *
  • Quiet hours bypass only for CRITICAL events when the user has + * opted into the override (default true).
  • + *
  • Per-recipient rate limit + monthly quota — landlord receiving + * voice calls for 5 different houses still hits their own limit + * independently.
  • + *
+ * + *

Best-effort: a single-channel failure never blocks the rest. Every + * outcome lands in the response so Lambda can log a summary. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class NotificationDispatchService { + + private final NotificationPreferenceService preferenceService; + private final NotificationQuotaService quotaService; + private final VoiceCallOrchestratorService voiceOrchestrator; + private final EmailService emailService; + private final VoiceProviderRouter providerRouter; + private final SmsProvider smsProvider; + private final UserGrpcClient userGrpcClient; + private final HouseGrpcClient houseGrpcClient; + private final ChannelTemplateRenderer templateRenderer; + + /** + * Globally disables the SMS path. Trial Stringee accounts ship without + * any registered brandname — every {@code POST /v1/sms} returns + * {@code "From number invalid"}. Flip to {@code true} (the default + * for production) once you've registered a brandname with the + * carrier through Stringee Console. + */ + @org.springframework.beans.factory.annotation.Value("${app.notification.sms.enabled:false}") + private boolean smsGloballyEnabled; + + public AlertDispatchResponse dispatch(AlertDispatchRequest req) { + List results = new ArrayList<>(); + AlertSeverity severity = req.eventType().severity(); + + // Test calls flagged via templateVars["testMode"]=true. The flag + // means "tenant is verifying their own setup" — fanning out to + // landlord/manager would spam business contacts every time the + // tenant tries the in-app test button. Real IoT alerts (gas, fire) + // still broadcast normally; only the self-test path is restricted. + boolean testMode = req.templateVars() != null + && Boolean.TRUE.equals(req.templateVars().get("testMode")); + + log.info("[Dispatch] alertId={} eventType={} severity={} tenant={} houseId={} testMode={}", + req.alertId(), req.eventType(), severity, req.userId(), req.houseId(), testMode); + + // -- 1. Tenant (primary recipient) -- + dispatchToRecipient(req, req.userId(), RecipientRole.TENANT, severity, results); + + // -- 2. Landlord + manager (only if non-INFO and houseId resolvable) -- + // Skipped entirely in test mode — manager is reached only when the + // tenant explicitly presses DTMF=2 (escalation flow in + // VoiceWebhookHandler.applyDtmf), not on the initial test ping. + if (!testMode && severity != AlertSeverity.INFO && nonBlank(req.houseId())) { + UUID houseUuid; + try { + houseUuid = UUID.fromString(req.houseId()); + } catch (IllegalArgumentException e) { + log.warn("[Dispatch] invalid houseId={} — skipping landlord/manager fan-out", + req.houseId()); + return new AlertDispatchResponse(true, req.userId(), results); + } + + UUID landlordId = lookupSafely(() -> houseGrpcClient.getLandlordIdByHouseId(houseUuid), + "landlord", req.houseId()); + UUID managerId = lookupSafely(() -> houseGrpcClient.getManagerIdByHouseId(houseUuid), + "manager", req.houseId()); + + if (landlordId != null && !landlordId.equals(req.userId())) { + dispatchToRecipient(req, landlordId, RecipientRole.LANDLORD, severity, results); + } + if (managerId != null + && !managerId.equals(req.userId()) + && !managerId.equals(landlordId)) { + dispatchToRecipient(req, managerId, RecipientRole.MANAGER, severity, results); + } + } + + return new AlertDispatchResponse(true, req.userId(), results); + } + + /** + * Direct dispatch to a specific recipient — used by the webhook + * handler for escalation re-dispatch (DTMF=2 or NO_ANSWER_MAX_RETRIES). + * Skips the fan-out so we don't recurse the original tenant. + * Backward-compat overload — pass null reason. + */ + public AlertDispatchResponse dispatchDirect(AlertDispatchRequest req, + UUID targetUserId, + RecipientRole targetRole) { + return dispatchDirect(req, targetUserId, targetRole, null); + } + + /** + * Same as the no-reason overload but lets the caller propagate the + * {@code EscalationReason} so the voice template picker can choose + * a manager script that matches reality (DTMF forwarded vs + * tenant-didn't-answer auto-escalation). + */ + public AlertDispatchResponse dispatchDirect(AlertDispatchRequest req, + UUID targetUserId, + RecipientRole targetRole, + com.isums.notificationservice.domains.enums.EscalationReason reason) { + List results = new ArrayList<>(); + AlertSeverity severity = req.eventType().severity(); + log.info("[Dispatch] DIRECT escalation to userId={} role={} reason={} alertId={}", + targetUserId, targetRole, reason, req.alertId()); + dispatchToRecipient(req, targetUserId, targetRole, severity, results, reason); + return new AlertDispatchResponse(true, targetUserId, results); + } + + // ─── Per-recipient delivery ──────────────────────────────────────── + + private void dispatchToRecipient(AlertDispatchRequest req, + UUID userId, + RecipientRole role, + AlertSeverity severity, + List results) { + dispatchToRecipient(req, userId, role, severity, results, null); + } + + private void dispatchToRecipient(AlertDispatchRequest req, + UUID userId, + RecipientRole role, + AlertSeverity severity, + List results, + com.isums.notificationservice.domains.enums.EscalationReason reason) { + + String prefix = role.name() + "/"; + + // ID semantics across the dispatch graph: + // - TENANT : userId normally arrives from JWT.sub → Keycloak UUID. + // Some IoT Lambdas still pass the internal users.id from + // esp32_asset_map; resolveTenantUser handles both. + // - LANDLORD/MANAGER : userId arrives from house-grpc landlord/manager + // resolution → INTERNAL users.id UUID. + // We branch the gRPC lookup so each side uses the right rpc, then + // canonicalise downstream lookups (prefs / sub) on the Keycloak ID + // returned in the response — Notification-Service's tables are + // keyed by JWT.sub, so a mismatch here would surface as "user not + // found" / always-FREE tier (which is exactly the bug we hit). + UserResponse user; + try { + user = (role == RecipientRole.TENANT) + ? resolveTenantUser(userId) + : userGrpcClient.getUserById(userId); + } catch (Exception e) { + log.error("[Dispatch] user lookup failed userId={} role={}: {}", + userId, role, e.getMessage()); + results.add(new ChannelDispatchResult(prefix + "ALL", "FAILED", "user_lookup_failed", null)); + return; + } + + // Canonicalise to Keycloak ID for prefs / subscription lookup, + // regardless of how the caller passed the user in. + UUID keycloakUuid; + try { + keycloakUuid = UUID.fromString(user.getKeycloakId()); + } catch (Exception e) { + log.error("[Dispatch] user has malformed keycloakId={} role={}", + user.getKeycloakId(), role); + results.add(new ChannelDispatchResult(prefix + "ALL", "FAILED", "bad_keycloak_id", null)); + return; + } + + UserNotificationPreferences prefs = preferenceService.getOrCreate(keycloakUuid); + NotificationSubscription sub = preferenceService.getSubscriptionOrCreate(keycloakUuid); + + // Locale resolution: User-Service profile language is the system-wide + // source of truth. Notification prefs used to carry a separate language, + // but the web UI no longer edits it; treating the stale/default prefs + // row as authoritative makes notifications ignore /users/me.language. + LocaleType locale = resolveLocale(prefs, user); + Map vars = buildTemplateVars(req, user); + + ChannelPolicy policy = ChannelPolicy.forSeverityRole(severity, role); + log.info("[Dispatch] {} userId={} locale={} policy=[email={},push={},sms={},voice={}]", + prefix, userId, locale, + policy.email(), policy.push(), policy.sms(), policy.voice()); + + ChannelDispatchResult emailRes = deliverEmail(prefix, policy, prefs, user, req, vars, locale); + ChannelDispatchResult pushRes = deliverPush(prefix, policy); + ChannelDispatchResult smsRes = deliverSms(prefix, policy, prefs, sub, user, req, vars, locale, role); + ChannelDispatchResult voiceRes = policy.voice() + ? deliverVoice(prefix, prefs, sub, user, req, vars, locale, role, reason) + : new ChannelDispatchResult(prefix + "VOICE", "SKIPPED", "policy_off_for_role", null); + + results.add(emailRes); + results.add(pushRes); + results.add(smsRes); + results.add(voiceRes); + + log.info("[Dispatch] {} outcome email={}({}) push={}({}) sms={}({}) voice={}({})", + prefix, + emailRes.status(), emailRes.reason(), + pushRes.status(), pushRes.reason(), + smsRes.status(), smsRes.reason(), + voiceRes.status(), voiceRes.reason()); + } + + private UserResponse resolveTenantUser(UUID userId) { + try { + return userGrpcClient.getUserByKeycloakId(userId.toString()); + } catch (Exception keycloakLookupFailed) { + log.warn("[Dispatch] tenant keycloak lookup failed userId={}, trying internal user id: {}", + userId, keycloakLookupFailed.getMessage()); + return userGrpcClient.getUserById(userId); + } + } + + private ChannelDispatchResult deliverEmail(String prefix, ChannelPolicy policy, + UserNotificationPreferences prefs, + UserResponse user, + AlertDispatchRequest req, + Map vars, + LocaleType locale) { + if (!policy.email()) { + return new ChannelDispatchResult(prefix + "EMAIL", "SKIPPED", "policy_off_for_role", null); + } + if (!prefs.isEmailEnabled()) { + return new ChannelDispatchResult(prefix + "EMAIL", "SKIPPED", "user_disabled", null); + } + if (!nonBlank(user.getEmail())) { + return new ChannelDispatchResult(prefix + "EMAIL", "SKIPPED", "no_email", null); + } + try { + String emailTemplateKey = "alert_" + req.eventType().name().toLowerCase(); + emailService.sendEmail(user.getEmail(), emailTemplateKey, locale, vars); + return new ChannelDispatchResult(prefix + "EMAIL", "SENT", null, null); + } catch (Exception e) { + log.warn("[Dispatch] {}email send failed: {}", prefix, e.getMessage()); + return new ChannelDispatchResult(prefix + "EMAIL", "SKIPPED", e.getMessage(), null); + } + } + + private ChannelDispatchResult deliverPush(String prefix, ChannelPolicy policy) { + if (!policy.push()) { + return new ChannelDispatchResult(prefix + "PUSH", "SKIPPED", "policy_off_for_role", null); + } + // The IoT Lambda tier already invokes ws-broadcaster on alert ingest; + // duplicating here would double-send. Reported as SKIPPED for clarity + // — the user STILL gets the in-app push, just from upstream. + return new ChannelDispatchResult(prefix + "PUSH", "SKIPPED", + "handled_by_ws_broadcaster_lambda", null); + } + + private ChannelDispatchResult deliverSms(String prefix, ChannelPolicy policy, + UserNotificationPreferences prefs, + NotificationSubscription sub, + UserResponse user, + AlertDispatchRequest req, + Map vars, + LocaleType locale, + RecipientRole role) { + if (!smsGloballyEnabled) { + return new ChannelDispatchResult(prefix + "SMS", "SKIPPED", + "sms_provider_unconfigured (no brandname)", null); + } + if (!policy.sms()) { + return new ChannelDispatchResult(prefix + "SMS", "SKIPPED", "policy_off_for_role", null); + } + if (!prefs.isSmsEnabled()) { + return new ChannelDispatchResult(prefix + "SMS", "SKIPPED", "user_disabled", null); + } + // Tenant pays via PREMIUM tier; landlord/manager are business recipients. + if (role == RecipientRole.TENANT && sub.getTier() != SubscriptionTier.PREMIUM) { + return new ChannelDispatchResult(prefix + "SMS", "SKIPPED", "tier_free", null); + } + if (!nonBlank(user.getPhoneNumber())) { + return new ChannelDispatchResult(prefix + "SMS", "SKIPPED", "no_phone", null); + } + try { + var rendered = templateRenderer.render( + "sms_" + req.eventType().name().toLowerCase(), + NotificationChannel.SMS, locale, vars); + var resp = smsProvider.sendSms(user.getPhoneNumber(), rendered.body()); + if (!resp.ok()) { + return new ChannelDispatchResult(prefix + "SMS", "FAILED", + resp.errorMessage(), null); + } + return new ChannelDispatchResult(prefix + "SMS", "SENT", null, null); + } catch (Exception e) { + log.warn("[Dispatch] {}sms send failed: {}", prefix, e.getMessage()); + return new ChannelDispatchResult(prefix + "SMS", "SKIPPED", e.getMessage(), null); + } + } + + private ChannelDispatchResult deliverVoice(String prefix, + UserNotificationPreferences prefs, + NotificationSubscription sub, + UserResponse user, + AlertDispatchRequest req, + Map vars, + LocaleType locale, + RecipientRole role, + com.isums.notificationservice.domains.enums.EscalationReason reason) { + // Quota / rate-limit / orchestrator job rows all key off the same + // identifier the prefs + subscription tables use → Keycloak ID. + // Using `user.getId()` (internal users.id) here would build Redis + // keys / look up subscription rows under a UUID that the rest of + // Notification-Service never writes to → false "quota exceeded" + // and orphaned voice_call_jobs. + UUID keycloakUuid = UUID.fromString(user.getKeycloakId()); + boolean criticalSafety = req.eventType().severity() == AlertSeverity.CRITICAL; + + if (!prefs.isVoiceEnabled() && !criticalSafety) { + return new ChannelDispatchResult(prefix + "VOICE", "SKIPPED", "user_disabled", null); + } + if (role == RecipientRole.TENANT + && sub.getTier() != SubscriptionTier.PREMIUM + && !criticalSafety) { + return new ChannelDispatchResult(prefix + "VOICE", "SKIPPED", "tier_free", null); + } + if (role == RecipientRole.TENANT + && prefs.getVoiceConsentGivenAt() == null + && !criticalSafety) { + return new ChannelDispatchResult(prefix + "VOICE", "SKIPPED", "no_consent", null); + } + if (!nonBlank(user.getPhoneNumber())) { + return new ChannelDispatchResult(prefix + "VOICE", "SKIPPED", "no_phone", null); + } + if (QuietHoursPolicy.shouldSuppress(prefs, req.eventType()) && !criticalSafety) { + return new ChannelDispatchResult(prefix + "VOICE", "SKIPPED", "quiet_hours", null); + } + if (!quotaService.tryAcquireVoiceRateLimit(keycloakUuid, prefs.getVoiceRateLimitSec())) { + long remaining = quotaService.remainingRateLimitSec(keycloakUuid); + return new ChannelDispatchResult(prefix + "VOICE", "SKIPPED", + "rate_limited_remaining_sec=" + remaining, null); + } + // Tier-quota only applies to TENANT (the paying customer). MANAGER / + // LANDLORD are operations roles — landlord pays for the service so + // their on-call staff always get voice. Without this bypass a fresh + // manager subscription row (tier=FREE, quota=0) silently swallows + // every escalation call → user complaint "manager's phone never + // rings". Tenant path still gates on quota above the tier check. + if (role == RecipientRole.TENANT + && !quotaService.tryConsumeVoiceQuota(keycloakUuid)) { + return new ChannelDispatchResult(prefix + "VOICE", "SKIPPED", + "monthly_quota_exceeded", null); + } + + try { + UserNotificationPreferences effectivePrefs = prefs.getLanguage() == locale + ? prefs + : cloneWithLocale(prefs, locale); + + VoiceCallJob job = voiceOrchestrator.enqueueFirstAttempt( + keycloakUuid, user.getPhoneNumber(), effectivePrefs, req, vars, role, reason); + return new ChannelDispatchResult(prefix + "VOICE", "SENT", null, job.getId()); + } catch (Exception e) { + // Only refund if we actually consumed (TENANT path). Non-TENANT + // bypassed the quota debit so refund would underflow the row. + if (role == RecipientRole.TENANT) { + quotaService.refundVoiceQuota(keycloakUuid); + } + log.error("[Dispatch] {}voice dispatch failed userId={}: {}", + prefix, keycloakUuid, e.getMessage(), e); + return new ChannelDispatchResult(prefix + "VOICE", "FAILED", e.getMessage(), null); + } + } + + // ─── Helpers ─────────────────────────────────────────────────────── + + private static LocaleType resolveLocale(UserNotificationPreferences prefs, UserResponse user) { + if (user != null && user.getLanguage() != null && !user.getLanguage().isBlank()) { + return NotificationPreferenceService.mapProtoLanguageToLocale(user.getLanguage()); + } + if (prefs != null && prefs.getLanguage() != null) { + return prefs.getLanguage(); + } + return LocaleType.vi_VN; + } + + private interface UuidLookup { + UUID call(); + } + + private UUID lookupSafely(UuidLookup lookup, String label, String houseId) { + try { + return lookup.call(); + } catch (Exception e) { + log.warn("[Dispatch] {} lookup failed houseId={}: {}", label, houseId, e.getMessage()); + return null; + } + } + + private static UserNotificationPreferences cloneWithLocale( + UserNotificationPreferences p, LocaleType locale) { + return UserNotificationPreferences.builder() + .userId(p.getUserId()) + .language(locale) + .emailEnabled(p.isEmailEnabled()) + .pushEnabled(p.isPushEnabled()) + .smsEnabled(p.isSmsEnabled()) + .voiceEnabled(p.isVoiceEnabled()) + .quietHoursStart(p.getQuietHoursStart()) + .quietHoursEnd(p.getQuietHoursEnd()) + .quietHoursOverrideCritical(p.isQuietHoursOverrideCritical()) + .voiceMaxRetries(p.getVoiceMaxRetries()) + .voiceRetryIntervalSec(p.getVoiceRetryIntervalSec()) + .voiceRateLimitSec(p.getVoiceRateLimitSec()) + .voiceGender(p.getVoiceGender()) + .voiceSpeed(p.getVoiceSpeed()) + .dtmfAckEnabled(p.isDtmfAckEnabled()) + .escalationEnabled(p.isEscalationEnabled()) + .escalationTargetUserId(p.getEscalationTargetUserId()) + .voiceConsentGivenAt(p.getVoiceConsentGivenAt()) + .build(); + } + + private Map buildTemplateVars(AlertDispatchRequest req, UserResponse user) { + Map vars = new HashMap<>(); + vars.put("userName", user.getName() == null ? "" : user.getName()); + vars.put("houseId", nz(req.houseId())); + // Human-friendly house name — both tenants and managers can have + // ties to multiple houses, so an alert that says only "tại Phòng + // khách" is ambiguous. Resolved via house-grpc on every dispatch + // (cheap call, ~5ms; House-Service caches at the entity layer). + // Falls back to empty string when houseId is unset (test calls + // before mainHouseId was wired) so the template renders cleanly. + vars.put("houseName", nonBlank(req.houseId()) + ? safeHouseName(req.houseId()) : ""); + vars.put("areaId", nz(req.areaId())); + vars.put("areaName", nz(req.areaName())); + vars.put("thing", nz(req.thing())); + vars.put("metric", nz(req.metric())); + vars.put("value", req.value() == null ? "" : String.format("%.1f", req.value())); + vars.put("unit", nz(req.unit())); + vars.put("eventType", req.eventType().name()); + vars.put("occurredAt", Instant.now().toString()); + if (req.templateVars() != null) { + vars.putAll(req.templateVars()); + } + return vars; + } + + /** Best-effort house name lookup — never throws to the dispatch path. */ + private String safeHouseName(String houseIdStr) { + try { + return houseGrpcClient.getHouseNameByHouseId(UUID.fromString(houseIdStr)); + } catch (Exception e) { + log.warn("[Dispatch] house-name lookup failed houseId={}: {}", + houseIdStr, e.getMessage()); + return ""; + } + } + + private static String nz(String v) { + return v == null ? "" : v; + } + + private static boolean nonBlank(String s) { + return s != null && !s.isBlank(); + } +} diff --git a/src/main/java/com/isums/notificationservice/services/NotificationPreferenceService.java b/src/main/java/com/isums/notificationservice/services/NotificationPreferenceService.java new file mode 100644 index 0000000..d0e8c48 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/services/NotificationPreferenceService.java @@ -0,0 +1,221 @@ +package com.isums.notificationservice.services; + +import com.isums.notificationservice.domains.dtos.NotificationPreferencesDto; +import com.isums.notificationservice.domains.dtos.UpdatePreferencesRequest; +import com.isums.notificationservice.domains.entities.NotificationSubscription; +import com.isums.notificationservice.domains.entities.UserNotificationPreferences; +import com.isums.notificationservice.domains.entities.VoiceConsentHistory; +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.domains.enums.SubscriptionTier; +import com.isums.notificationservice.exceptions.ConflictException; +import com.isums.notificationservice.infrastructures.repositories.NotificationSubscriptionRepository; +import com.isums.notificationservice.infrastructures.repositories.UserNotificationPreferencesRepository; +import com.isums.notificationservice.infrastructures.repositories.VoiceConsentHistoryRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class NotificationPreferenceService { + + private final UserNotificationPreferencesRepository prefsRepo; + private final NotificationSubscriptionRepository subsRepo; + private final VoiceConsentHistoryRepository consentHistoryRepo; + + /** + * Current published version of the voice consent T&C text. When + * legal updates the wording, bump this — existing consents stay + * valid for already-active users but new grants record the new + * version. Source of truth: {@code docs/legal/voice-consent-vi.md}. + */ + private static final String CURRENT_CONSENT_VERSION = "v1.0-2026-04"; + + @Transactional + public UserNotificationPreferences getOrCreate(UUID userId) { + return prefsRepo.findById(userId) + .orElseGet(() -> { + try { + return prefsRepo.saveAndFlush( + UserNotificationPreferences.builder().userId(userId).build()); + } catch (DataIntegrityViolationException race) { + // Concurrent request created the row first (e.g. two + // SSE subscribes from the same user, two browser tabs). + // Retry the read instead of bubbling the unique-key + // violation up to the user. + log.debug("[Prefs] race on getOrCreate userId={} — re-reading", userId); + return prefsRepo.findById(userId) + .orElseThrow(() -> race); + } + }); + } + + @Transactional + public NotificationSubscription getSubscriptionOrCreate(UUID userId) { + return subsRepo.findById(userId) + .orElseGet(() -> { + try { + return subsRepo.saveAndFlush( + NotificationSubscription.builder() + .userId(userId) + .tier(SubscriptionTier.FREE) + .voiceQuotaMonthly(TierQuotaPolicy.voiceQuotaFor(SubscriptionTier.FREE)) + .smsQuotaMonthly(TierQuotaPolicy.smsQuotaFor(SubscriptionTier.FREE)) + .build()); + } catch (DataIntegrityViolationException race) { + // Same race-condition guard as getOrCreate above — + // duplicate-pkey on user_id means another tx beat us + // to it; just read the row that's now there. + log.debug("[Sub] race on getSubscriptionOrCreate userId={} — re-reading", userId); + return subsRepo.findById(userId) + .orElseThrow(() -> race); + } + }); + } + + @Transactional + public UserNotificationPreferences update(UUID userId, UpdatePreferencesRequest req) { + return update(userId, req, false, null, null); + } + + @Transactional + public UserNotificationPreferences update(UUID userId, UpdatePreferencesRequest req, + boolean tierExempt) { + return update(userId, req, tierExempt, null, null); + } + + /** + * Full update path — captures consent metadata (IP, UA, T&C version) + * for PDPL audit when {@code voiceConsentGranted} flips. Pass null + * for {@code clientIp} / {@code userAgent} from non-HTTP callers + * (Kafka listener, scheduled job). + */ + @Transactional + public UserNotificationPreferences update(UUID userId, UpdatePreferencesRequest req, + boolean tierExempt, + String clientIp, + String userAgent) { + UserNotificationPreferences p = getOrCreate(userId); + NotificationSubscription sub = getSubscriptionOrCreate(userId); + + if (req.language() != null) p.setLanguage(req.language()); + if (req.emailEnabled() != null) p.setEmailEnabled(req.emailEnabled()); + if (req.pushEnabled() != null) p.setPushEnabled(req.pushEnabled()); + if (req.smsEnabled() != null) p.setSmsEnabled(req.smsEnabled()); + + if (req.voiceEnabled() != null) { + // Gate voice on tier AND consent — silent rejection is worse than a 409, + // because the user would think it's on but never receives calls. + if (req.voiceEnabled()) { + if (!tierExempt) { + // Tenant path — paid subscription + explicit TCPA-style consent. + if (sub.getTier() != SubscriptionTier.PREMIUM) { + throw new ConflictException( + "Voice notifications require PREMIUM subscription. " + + "Upgrade at /api/notifications/subscriptions/upgrade"); + } + if (p.getVoiceConsentGivenAt() == null + && !Boolean.TRUE.equals(req.voiceConsentGranted())) { + throw new ConflictException( + "Voice calls require explicit consent. " + + "Pass voiceConsentGranted=true together with voiceEnabled=true."); + } + } else if (p.getVoiceConsentGivenAt() == null) { + // Landlord / manager — implicit consent via employment. + // Stamp the timestamp anyway so the audit trail records + // when voice was first activated for that user. + p.setVoiceConsentGivenAt(Instant.now()); + } + } + p.setVoiceEnabled(req.voiceEnabled()); + } + + if (req.quietHoursEnabled() != null) p.setQuietHoursEnabled(req.quietHoursEnabled()); + if (req.quietHoursStart() != null) p.setQuietHoursStart(req.quietHoursStart()); + if (req.quietHoursEnd() != null) p.setQuietHoursEnd(req.quietHoursEnd()); + if (req.quietHoursOverrideCritical() != null) p.setQuietHoursOverrideCritical(req.quietHoursOverrideCritical()); + + if (req.voiceMaxRetries() != null) { + int cap = TierQuotaPolicy.maxVoiceRetries(sub.getTier()); + p.setVoiceMaxRetries(Math.min(req.voiceMaxRetries(), cap)); + } + if (req.voiceRetryIntervalSec() != null) { + int min = TierQuotaPolicy.minRetryIntervalSec(sub.getTier()); + p.setVoiceRetryIntervalSec(Math.max(req.voiceRetryIntervalSec(), min)); + } + if (req.voiceRateLimitSec() != null) p.setVoiceRateLimitSec(req.voiceRateLimitSec()); + if (req.voiceGender() != null) p.setVoiceGender(req.voiceGender()); + if (req.voiceSpeed() != null) p.setVoiceSpeed(req.voiceSpeed()); + if (req.dtmfAckEnabled() != null) p.setDtmfAckEnabled(req.dtmfAckEnabled()); + if (req.escalationEnabled() != null) p.setEscalationEnabled(req.escalationEnabled()); + if (req.escalationTargetUserId() != null) p.setEscalationTargetUserId(req.escalationTargetUserId()); + + if (req.voiceConsentGranted() != null) { + if (req.voiceConsentGranted()) { + // GRANT path — first-time or re-grant after revoke. + if (p.getVoiceConsentGivenAt() == null) { + Instant now = Instant.now(); + p.setVoiceConsentGivenAt(now); + p.setVoiceConsentTextVersion(CURRENT_CONSENT_VERSION); + p.setVoiceConsentIp(clientIp); + p.setVoiceConsentUserAgent(userAgent); + consentHistoryRepo.save(VoiceConsentHistory.builder() + .userId(userId) + .action(VoiceConsentHistory.Action.GRANTED) + .textVersion(CURRENT_CONSENT_VERSION) + .ip(clientIp) + .userAgent(userAgent) + .initiatedBy(VoiceConsentHistory.InitiatedBy.USER) + .build()); + log.info("[Consent] GRANTED userId={} version={} ip={}", + userId, CURRENT_CONSENT_VERSION, clientIp); + } + } else { + // REVOKE path — clear stamp, append history row, force + // voice off (PDPL Điều 12: withdraw must be immediate). + p.setVoiceConsentGivenAt(null); + p.setVoiceEnabled(false); + consentHistoryRepo.save(VoiceConsentHistory.builder() + .userId(userId) + .action(VoiceConsentHistory.Action.REVOKED) + .textVersion(p.getVoiceConsentTextVersion()) + .ip(clientIp) + .userAgent(userAgent) + .initiatedBy(VoiceConsentHistory.InitiatedBy.USER) + .build()); + log.info("[Consent] REVOKED userId={} ip={}", userId, clientIp); + } + } + + return prefsRepo.save(p); + } + + public NotificationPreferencesDto toDto(UserNotificationPreferences p) { + return new NotificationPreferencesDto( + p.getUserId(), p.getLanguage(), + p.isEmailEnabled(), p.isPushEnabled(), p.isSmsEnabled(), p.isVoiceEnabled(), + p.isQuietHoursEnabled(), + p.getQuietHoursStart(), p.getQuietHoursEnd(), p.isQuietHoursOverrideCritical(), + p.getVoiceMaxRetries(), p.getVoiceRetryIntervalSec(), p.getVoiceRateLimitSec(), + p.getVoiceGender(), p.getVoiceSpeed(), p.isDtmfAckEnabled(), + p.isEscalationEnabled(), p.getEscalationTargetUserId(), + p.getVoiceConsentGivenAt() + ); + } + + public static LocaleType mapProtoLanguageToLocale(String protoLang) { + if (protoLang == null || protoLang.isBlank()) return LocaleType.vi_VN; + return switch (protoLang.toLowerCase()) { + case "en", "en_us", "en-us" -> LocaleType.en_US; + case "ja", "ja_jp", "ja-jp" -> LocaleType.ja_JP; + default -> LocaleType.vi_VN; + }; + } +} diff --git a/src/main/java/com/isums/notificationservice/services/NotificationQuotaService.java b/src/main/java/com/isums/notificationservice/services/NotificationQuotaService.java new file mode 100644 index 0000000..f779745 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/services/NotificationQuotaService.java @@ -0,0 +1,156 @@ +package com.isums.notificationservice.services; + +import com.isums.notificationservice.domains.entities.NotificationSubscription; +import com.isums.notificationservice.infrastructures.repositories.NotificationSubscriptionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.UUID; + +/** + * Redis-backed rate limit + monthly quota. Hot-path reads never touch + * Postgres — the {@code notification_subscriptions} counter is updated + * periodically from Redis (eventual consistency is fine for billing + * reports; enforcement happens in Redis). + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class NotificationQuotaService { + + private static final DateTimeFormatter MONTH_KEY = + DateTimeFormatter.ofPattern("yyyy-MM").withZone(ZoneId.of("Asia/Ho_Chi_Minh")); + + private final StringRedisTemplate redis; + private final NotificationSubscriptionRepository subsRepo; + + /** Returns true if we claimed a slot; false if the user is still in cool-down. */ + public boolean tryAcquireVoiceRateLimit(UUID userId, int cooldownSec) { + String key = "notif:voice:ratelimit:" + userId; + Boolean ok = redis.opsForValue().setIfAbsent(key, "1", Duration.ofSeconds(cooldownSec)); + return Boolean.TRUE.equals(ok); + } + + public long remainingRateLimitSec(UUID userId) { + Long ttl = redis.getExpire("notif:voice:ratelimit:" + userId); + return ttl == null || ttl < 0 ? 0 : ttl; + } + + /** Returns true + increments; false if the monthly quota is already used up. */ + @Transactional + public boolean tryConsumeVoiceQuota(UUID userId) { + NotificationSubscription sub = subsRepo.findById(userId).orElse(null); + if (sub == null) return false; + + int quota = sub.getVoiceQuotaMonthly(); + if (quota <= 0) return false; + + String month = MONTH_KEY.format(Instant.now()); + String key = "notif:voice:quota:" + userId + ":" + month; + + Long used = redis.opsForValue().increment(key); + if (used == null) return false; + + // First time this month — set a 40-day expiry so it auto-clears. + if (used == 1L) { + redis.expire(key, Duration.ofDays(40)); + } + + if (used > quota) { + // Over cap — decrement to avoid drift then reject. + redis.opsForValue().decrement(key); + log.info("[Quota] voice quota exceeded userId={} used={}/{}", userId, used - 1, quota); + return false; + } + + // Mirror into Postgres best-effort. A crash before commit means the + // DB counter lags Redis by one — acceptable; nightly reconciler + // can recover if needed. + sub.setVoiceUsedThisMonth(used.intValue()); + subsRepo.save(sub); + return true; + } + + @Transactional + public void refundVoiceQuota(UUID userId) { + String month = MONTH_KEY.format(Instant.now()); + redis.opsForValue().decrement("notif:voice:quota:" + userId + ":" + month); + subsRepo.findById(userId).ifPresent(sub -> { + if (sub.getVoiceUsedThisMonth() > 0) { + sub.setVoiceUsedThisMonth(sub.getVoiceUsedThisMonth() - 1); + subsRepo.save(sub); + } + }); + } + + /** Called by monthly scheduler on the 1st of every month (VN time). */ + @Transactional + public void resetAllUsageCounters() { + int updated = 0; + for (NotificationSubscription sub : subsRepo.findAll()) { + sub.setVoiceUsedThisMonth(0); + sub.setSmsUsedThisMonth(0); + sub.setQuotaResetAt(Instant.now()); + subsRepo.save(sub); + updated++; + } + log.info("[Quota] monthly reset complete, rows={}", updated); + } + + public int readVoiceUsedThisMonth(UUID userId) { + String month = MONTH_KEY.format(Instant.now()); + String v = redis.opsForValue().get("notif:voice:quota:" + userId + ":" + month); + try { + return v == null ? 0 : Integer.parseInt(v); + } catch (NumberFormatException e) { + return 0; + } + } + + public static String currentMonthKey() { + return MONTH_KEY.format(Instant.now()); + } + + public static LocalDate currentMonthDate() { + return LocalDate.now(ZoneId.of("Asia/Ho_Chi_Minh")).withDayOfMonth(1); + } + + // ─── Test-call daily quota ───────────────────────────────────────── + // PDPL + UX guard: tenant clicking "Gọi thử" repeatedly would burn + // their monthly voice quota and could be construed as harassment by + // an angry roommate. One test call per calendar day per user is more + // than enough to verify the phone is correct. + + private static final DateTimeFormatter DAY_KEY = + DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.of("Asia/Ho_Chi_Minh")); + private static final int TEST_VOICE_DAILY_LIMIT = 1; + + /** + * Returns true if we claimed today's test-voice slot. Once consumed, + * blocks further attempts until the next VN midnight. Counts against + * the monthly voice quota too — call {@link #tryConsumeVoiceQuota} + * separately after this check passes. + */ + public boolean tryAcquireTestVoiceDaily(UUID userId) { + String key = "notif:voice:testday:" + userId + ":" + DAY_KEY.format(Instant.now()); + Long count = redis.opsForValue().increment(key); + if (count == null) return false; + if (count == 1L) { + // ~25h TTL absorbs DST-style timezone edge cases. + redis.expire(key, Duration.ofHours(25)); + } + if (count > TEST_VOICE_DAILY_LIMIT) { + redis.opsForValue().decrement(key); + return false; + } + return true; + } +} diff --git a/src/main/java/com/isums/notificationservice/services/NotificationRecipientResolver.java b/src/main/java/com/isums/notificationservice/services/NotificationRecipientResolver.java new file mode 100644 index 0000000..b5a0289 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/services/NotificationRecipientResolver.java @@ -0,0 +1,44 @@ +package com.isums.notificationservice.services; + +import com.isums.notificationservice.infrastructures.grpcs.HouseGrpcClient; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class NotificationRecipientResolver { + + private final HouseGrpcClient houseGrpcClient; + + public List resolveLandlordAndManager(UUID houseId, UUID... extraRecipientIds) { + Set recipientIds = new LinkedHashSet<>(); + + if (houseId != null) { + UUID landlordId = houseGrpcClient.getLandlordIdByHouseId(houseId); + if (landlordId != null) { + recipientIds.add(landlordId); + } + + UUID managerId = houseGrpcClient.getManagerIdByHouseId(houseId); + if (managerId != null) { + recipientIds.add(managerId); + } + } + + if (extraRecipientIds != null) { + for (UUID extraRecipientId : extraRecipientIds) { + if (extraRecipientId != null) { + recipientIds.add(extraRecipientId); + } + } + } + + return new ArrayList<>(recipientIds); + } +} diff --git a/src/main/java/com/isums/notificationservice/services/NotificationSubscriptionService.java b/src/main/java/com/isums/notificationservice/services/NotificationSubscriptionService.java new file mode 100644 index 0000000..7eb0305 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/services/NotificationSubscriptionService.java @@ -0,0 +1,129 @@ +package com.isums.notificationservice.services; + +import com.isums.notificationservice.domains.dtos.SubscriptionDto; +import com.isums.notificationservice.domains.entities.NotificationSubscription; +import com.isums.notificationservice.domains.enums.SubscriptionTier; +import com.isums.notificationservice.infrastructures.repositories.NotificationSubscriptionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class NotificationSubscriptionService { + + private final NotificationSubscriptionRepository subsRepo; + + /** + * Month-based PREMIUM grant — kept for the admin "grant-premium" demo + * endpoint where months are the natural unit. Production payment flow + * goes through {@link #activatePremiumByDays} so a 7-day trial buys + * 7 days, not a rounded-up month. + */ + @Transactional + public NotificationSubscription activatePremium(UUID userId, int months) { + return activatePremiumByDays(userId, Math.max(1, months) * 30); + } + + /** + * Plan-driven PREMIUM grant. {@code durationDays} comes straight from + * the subscription_plans row that was paid for, so a 7-day trial gets + * 7 days and an annual plan gets 365 — no monthly rounding error. + * + *

Quotas default to {@link TierQuotaPolicy} (the legacy 20/30 + * floor) — callers with a curated plan should use the overload that + * takes plan quotas so a "Pro 1M" plan with 100 voice / 200 SMS + * doesn't get downgraded to the legacy ceiling on activation. + */ + @Transactional + public NotificationSubscription activatePremiumByDays(UUID userId, int durationDays) { + return activatePremiumByDays(userId, durationDays, + TierQuotaPolicy.voiceQuotaFor(SubscriptionTier.PREMIUM), + TierQuotaPolicy.smsQuotaFor(SubscriptionTier.PREMIUM)); + } + + /** + * Plan-driven PREMIUM grant with explicit quotas. Pass the values from + * {@code subscription_plans.voice_quota_monthly / sms_quota_monthly} + * so the user's monthly cap matches what they paid for. + * + *

Idempotent on top of itself: a Kafka redelivery hitting this with + * the same userId stacks days onto the existing premium_until (the + * "user paid twice, gets twice the time" semantics matches the legacy + * months path). Caller must guard against same-event redelivery via + * {@code IdempotencyService#isDuplicate}. + */ + @Transactional + public NotificationSubscription activatePremiumByDays(UUID userId, int durationDays, + int voiceQuotaMonthly, + int smsQuotaMonthly) { + int days = Math.max(1, durationDays); + // Floor at the tier policy minimum so a misconfigured plan can never + // shrink the user below the baseline they expect from PREMIUM — + // upper bound stays at whatever the plan says. + int voiceQuota = Math.max(voiceQuotaMonthly, + TierQuotaPolicy.voiceQuotaFor(SubscriptionTier.PREMIUM)); + int smsQuota = Math.max(smsQuotaMonthly, + TierQuotaPolicy.smsQuotaFor(SubscriptionTier.PREMIUM)); + + NotificationSubscription sub = subsRepo.findById(userId) + .orElseGet(() -> NotificationSubscription.builder().userId(userId).build()); + + Instant now = Instant.now(); + Instant newUntil; + if (sub.getTier() == SubscriptionTier.PREMIUM + && sub.getPremiumUntil() != null + && sub.getPremiumUntil().isAfter(now)) { + // Extend from existing end-date, not from now — user pays to stack. + newUntil = sub.getPremiumUntil().plus(days, ChronoUnit.DAYS); + } else { + sub.setPremiumStartedAt(now); + newUntil = now.plus(days, ChronoUnit.DAYS); + } + + sub.setTier(SubscriptionTier.PREMIUM); + sub.setPremiumUntil(newUntil); + // Plan quotas reset on activation (and on every renewal/extension) + // so a user upgrading from a smaller plan inherits the new quota + // immediately. Used-counters intentionally stay so we don't "free + // refill" by spamming activations within the same month. + sub.setVoiceQuotaMonthly(voiceQuota); + sub.setSmsQuotaMonthly(smsQuota); + + NotificationSubscription saved = subsRepo.save(sub); + log.info("[Subscription] PREMIUM activated userId={} days={} until={} voiceQuota={} smsQuota={}", + userId, days, newUntil, saved.getVoiceQuotaMonthly(), saved.getSmsQuotaMonthly()); + return saved; + } + + @Transactional + public NotificationSubscription downgradeToFree(UUID userId) { + NotificationSubscription sub = subsRepo.findById(userId).orElse(null); + if (sub == null) return null; + sub.setTier(SubscriptionTier.FREE); + sub.setPremiumUntil(null); + sub.setVoiceQuotaMonthly(TierQuotaPolicy.voiceQuotaFor(SubscriptionTier.FREE)); + sub.setSmsQuotaMonthly(TierQuotaPolicy.smsQuotaFor(SubscriptionTier.FREE)); + NotificationSubscription saved = subsRepo.save(sub); + log.info("[Subscription] downgraded userId={}", userId); + return saved; + } + + public SubscriptionDto toDto(NotificationSubscription s) { + int voiceRemaining = Math.max(0, s.getVoiceQuotaMonthly() - s.getVoiceUsedThisMonth()); + int smsRemaining = Math.max(0, s.getSmsQuotaMonthly() - s.getSmsUsedThisMonth()); + return new SubscriptionDto( + s.getUserId(), s.getTier(), + s.getPremiumStartedAt(), s.getPremiumUntil(), + s.getVoiceQuotaMonthly(), s.getVoiceUsedThisMonth(), voiceRemaining, + s.getSmsQuotaMonthly(), s.getSmsUsedThisMonth(), smsRemaining, + s.getQuotaResetAt() + ); + } +} diff --git a/src/main/java/com/isums/notificationservice/services/PollyTtsSynthesizer.java b/src/main/java/com/isums/notificationservice/services/PollyTtsSynthesizer.java new file mode 100644 index 0000000..dc20d82 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/services/PollyTtsSynthesizer.java @@ -0,0 +1,167 @@ +package com.isums.notificationservice.services; + +import com.isums.notificationservice.domains.entities.VoiceAudioCache; +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.domains.enums.VoiceGender; +import com.isums.notificationservice.infrastructures.abstracts.TtsAudioSynthesizer; +import com.isums.notificationservice.infrastructures.repositories.VoiceAudioCacheRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.polly.PollyClient; +import software.amazon.awssdk.services.polly.model.Engine; +import software.amazon.awssdk.services.polly.model.LanguageCode; +import software.amazon.awssdk.services.polly.model.OutputFormat; +import software.amazon.awssdk.services.polly.model.SynthesizeSpeechRequest; +import software.amazon.awssdk.services.polly.model.TextType; +import software.amazon.awssdk.services.polly.model.VoiceId; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.HexFormat; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PollyTtsSynthesizer implements TtsAudioSynthesizer { + + private final PollyClient pollyClient; + private final S3Client s3Client; + private final VoiceAudioCacheRepository audioCacheRepo; + + @Value("${app.notification.voice.audio-bucket:}") + private String audioBucket; + + @Value("${app.notification.voice.audio-public-base:https://%s.s3.ap-southeast-1.amazonaws.com}") + private String publicUrlBase; + + @Override + @Transactional + public String synthesizeAndCache(String text, LocaleType locale, + VoiceGender gender, BigDecimal speed) { + String cacheKey = hash(text, locale, gender, speed); + + var existing = audioCacheRepo.findByCacheKey(cacheKey); + if (existing.isPresent()) { + VoiceAudioCache hit = existing.get(); + hit.setLastUsedAt(Instant.now()); + hit.setHitCount(hit.getHitCount() + 1); + audioCacheRepo.save(hit); + return hit.getPublicUrl(); + } + + if (audioBucket == null || audioBucket.isBlank()) { + throw new IllegalStateException( + "app.notification.voice.audio-bucket not configured — " + + "cannot synthesize non-VN TTS"); + } + + VoiceId voice = pickVoice(locale, gender); + String ssml = wrapWithProsody(text, speed); + + SynthesizeSpeechRequest req = SynthesizeSpeechRequest.builder() + .text(ssml) + .textType(TextType.SSML) + .voiceId(voice) + .outputFormat(OutputFormat.MP3) + .engine(Engine.NEURAL) + .languageCode(toLanguageCode(locale)) + .build(); + + byte[] audio; + try (var audioStream = pollyClient.synthesizeSpeech(req)) { + audio = audioStream.readAllBytes(); + } catch (Exception e) { + throw new IllegalStateException("Polly synth failed: " + e.getMessage(), e); + } + + String s3Key = "voice-tts/" + locale.name() + "/" + cacheKey + ".mp3"; + s3Client.putObject( + PutObjectRequest.builder() + .bucket(audioBucket) + .key(s3Key) + .contentType("audio/mpeg") + .acl("public-read") + .build(), + software.amazon.awssdk.core.sync.RequestBody.fromBytes(audio)); + + String publicUrl = String.format(publicUrlBase, audioBucket) + "/" + s3Key; + + VoiceAudioCache entity = VoiceAudioCache.builder() + .cacheKey(cacheKey) + .locale(locale) + .voiceGender(gender) + .voiceSpeed(speed) + .renderedText(text) + .s3Bucket(audioBucket) + .s3Key(s3Key) + .publicUrl(publicUrl) + .bytes(audio.length) + .lastUsedAt(Instant.now()) + .hitCount(1) + .build(); + audioCacheRepo.save(entity); + + log.info("[PollyTTS] synthesized locale={} gender={} bytes={} url={}", + locale, gender, audio.length, publicUrl); + return publicUrl; + } + + private static VoiceId pickVoice(LocaleType locale, VoiceGender gender) { + return switch (locale) { + case ja_JP -> gender == VoiceGender.FEMALE ? VoiceId.TOMOKO : VoiceId.TAKUMI; + case en_US -> gender == VoiceGender.FEMALE ? VoiceId.JOANNA : VoiceId.MATTHEW; + // Polly has no native VN neural voice — fallback to a English voice + // that reads romaji reasonably. Callers really should use SpeedSMS + // native TTS for vi_VN; this branch is a safety net only. + case vi_VN -> gender == VoiceGender.FEMALE ? VoiceId.JOANNA : VoiceId.MATTHEW; + }; + } + + private static LanguageCode toLanguageCode(LocaleType locale) { + return switch (locale) { + case ja_JP -> LanguageCode.JA_JP; + case en_US -> LanguageCode.EN_US; + case vi_VN -> LanguageCode.EN_US; + }; + } + + private static String wrapWithProsody(String text, BigDecimal speed) { + // Convert 0.80-1.20 → "-20%" to "+20%" for SSML + int pct = speed.subtract(BigDecimal.ONE) + .multiply(BigDecimal.valueOf(100)) + .intValue(); + String rate = (pct >= 0 ? "+" : "") + pct + "%"; + return "" + escapeSsml(text) + ""; + } + + private static String escapeSsml(String s) { + return s.replace("&", "&") + .replace("<", "<") + .replace(">", ">"); + } + + private static String hash(String text, LocaleType locale, + VoiceGender gender, BigDecimal speed) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(text.getBytes(StandardCharsets.UTF_8)); + md.update((byte) 0); + md.update(locale.name().getBytes(StandardCharsets.UTF_8)); + md.update((byte) 0); + md.update(gender.name().getBytes(StandardCharsets.UTF_8)); + md.update((byte) 0); + md.update(speed.toPlainString().getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(md.digest()); + } catch (Exception e) { + throw new IllegalStateException("hash failed: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/isums/notificationservice/services/PremiumExpirationScheduler.java b/src/main/java/com/isums/notificationservice/services/PremiumExpirationScheduler.java new file mode 100644 index 0000000..60d1f5c --- /dev/null +++ b/src/main/java/com/isums/notificationservice/services/PremiumExpirationScheduler.java @@ -0,0 +1,46 @@ +package com.isums.notificationservice.services; + +import com.isums.notificationservice.domains.entities.NotificationSubscription; +import com.isums.notificationservice.domains.enums.SubscriptionTier; +import com.isums.notificationservice.infrastructures.repositories.NotificationSubscriptionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.List; + +/** + * Downgrades PREMIUM subscriptions whose {@code premium_until} has + * passed. Runs nightly — a one-day lag before the user's voice stops + * working is acceptable, and simpler than tracking precise expiry + * timestamps through Redis. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class PremiumExpirationScheduler { + + private final NotificationSubscriptionRepository subsRepo; + private final NotificationSubscriptionService subscriptionService; + + @Scheduled(cron = "0 15 2 * * *", zone = "Asia/Ho_Chi_Minh") + public void sweepExpired() { + Instant now = Instant.now(); + List expired = + subsRepo.findAllByTierAndPremiumUntilBefore(SubscriptionTier.PREMIUM, now); + + if (expired.isEmpty()) return; + log.info("[PremiumExpire] downgrading {} expired subscriptions", expired.size()); + + for (NotificationSubscription sub : expired) { + try { + subscriptionService.downgradeToFree(sub.getUserId()); + } catch (Exception e) { + log.error("[PremiumExpire] failed userId={}: {}", + sub.getUserId(), e.getMessage(), e); + } + } + } +} diff --git a/src/main/java/com/isums/notificationservice/services/QuietHoursPolicy.java b/src/main/java/com/isums/notificationservice/services/QuietHoursPolicy.java new file mode 100644 index 0000000..32f903a --- /dev/null +++ b/src/main/java/com/isums/notificationservice/services/QuietHoursPolicy.java @@ -0,0 +1,46 @@ +package com.isums.notificationservice.services; + +import com.isums.notificationservice.domains.entities.UserNotificationPreferences; +import com.isums.notificationservice.domains.enums.AlertEventType; + +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +public final class QuietHoursPolicy { + + private static final ZoneId VN = ZoneId.of("Asia/Ho_Chi_Minh"); + + private QuietHoursPolicy() {} + + /** + * Returns true if voice call should be SUPPRESSED right now because of + * quiet hours. CRITICAL events override when the user opted in (default). + */ + public static boolean shouldSuppress(UserNotificationPreferences prefs, AlertEventType event) { + // Master switch — when off, time-of-day window doesn't apply. + if (!prefs.isQuietHoursEnabled()) return false; + + LocalTime start = prefs.getQuietHoursStart(); + LocalTime end = prefs.getQuietHoursEnd(); + LocalTime now = ZonedDateTime.now(VN).toLocalTime(); + + boolean inWindow = inWindow(now, start, end); + if (!inWindow) return false; + + if (event != null && event.isCritical() && prefs.isQuietHoursOverrideCritical()) { + return false; + } + return true; + } + + private static boolean inWindow(LocalTime now, LocalTime start, LocalTime end) { + if (start.equals(end)) return false; // zero-width window = always off + if (start.isBefore(end)) { + // Same-day window (e.g. 13:00 → 15:00) + return !now.isBefore(start) && now.isBefore(end); + } + // Wraps midnight (e.g. 22:00 → 06:00) + return !now.isBefore(start) || now.isBefore(end); + } +} diff --git a/src/main/java/com/isums/notificationservice/services/StringeeClientImpl.java b/src/main/java/com/isums/notificationservice/services/StringeeClientImpl.java new file mode 100644 index 0000000..9bcfe2a --- /dev/null +++ b/src/main/java/com/isums/notificationservice/services/StringeeClientImpl.java @@ -0,0 +1,295 @@ +package com.isums.notificationservice.services; + +import com.isums.notificationservice.domains.dtos.SpeedSmsVoiceRequest; +import com.isums.notificationservice.domains.dtos.SpeedSmsVoiceResponse; +import com.isums.notificationservice.infrastructures.abstracts.VoiceProvider; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSObject; +import com.nimbusds.jose.Payload; +import com.nimbusds.jose.crypto.MACSigner; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Service +@Slf4j +public class StringeeClientImpl implements VoiceProvider { + + private final RestClient stringeeRestClient; + private final ObjectMapper objectMapper; + + @Value("${app.notification.stringee.api-key-sid:}") + private String apiKeySid; + + @Value("${app.notification.stringee.api-key-secret:}") + private String apiKeySecret; + + @Value("${app.notification.stringee.from-number:}") + private String fromNumber; + + @Value("${app.notification.stringee.callout-path:/v1/call2/callout}") + private String calloutPath; + + @Value("${app.notification.stringee.voice-name:vietnam_female}") + private String defaultVoiceName; + + @Value("${app.notification.stringee.voice-vi:vietnam_female}") + private String voiceVi; + + @Value("${app.notification.stringee.voice-en:english_female}") + private String voiceEn; + + @Value("${app.notification.stringee.voice-ja:japanese_female}") + private String voiceJa; + + @Value("${app.notification.stringee.answer-url-base:https://api-dev.isums.pro}") + private String answerUrlBase; + + @Value("${app.notification.stringee.sms-path:/v1/sms}") + private String smsPath; + + @Value("${app.notification.stringee.sms-from:ISUMS}") + private String smsFrom; + + @Value("${app.notification.voice.dry-run:false}") + private boolean dryRun; + + public StringeeClientImpl(RestClient stringeeRestClient, ObjectMapper objectMapper) { + this.stringeeRestClient = stringeeRestClient; + this.objectMapper = objectMapper; + } + + @jakarta.annotation.PostConstruct + void logConfig() { + log.info("[Stringee init] apiKeySid={} from={} calloutPath={} voice={} dryRun={}", + apiKeySid, fromNumber, calloutPath, defaultVoiceName, dryRun); + } + + @Override + public String providerId() { return "STRINGEE"; } + + @Override + public SpeedSmsVoiceResponse sendVoiceCall(SpeedSmsVoiceRequest request) { + if (dryRun) { + String fakeId = "dry-stringee-" + UUID.randomUUID(); + log.info("[Stringee DRY_RUN] callout to={} text=\n{}", request.phone(), request.tts()); + return new SpeedSmsVoiceResponse(true, fakeId, "DIALING", null); + } + + if (apiKeySid == null || apiKeySid.isBlank() + || apiKeySecret == null || apiKeySecret.isBlank()) { + return new SpeedSmsVoiceResponse(false, null, "FAILED", + "Stringee credentials missing (apiKeySid / apiKeySecret)"); + } + + try { + String jwt = buildRestJwt(); + Map body = buildSccoBody(request); + + String rawResponse = stringeeRestClient.post() + .uri(calloutPath) + .header("X-STRINGEE-AUTH", jwt) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .body(String.class); + + log.info("[Stringee] callout response phone={} raw={}", request.phone(), rawResponse); + + JsonNode json = objectMapper.readTree(rawResponse == null ? "{}" : rawResponse); + int r = json.path("r").asInt(-1); + String callId = json.path("call_id").asString(); + if (r != 0 && r != 13) { + String msg = json.path("message").asString(); + if (msg == null || msg.isBlank()) msg = rawResponse; + return new SpeedSmsVoiceResponse(false, null, "FAILED", msg); + } + return new SpeedSmsVoiceResponse(true, callId, "DIALING", null); + } catch (Exception e) { + log.error("[Stringee] callout failed phone={}: {}", + request.phone(), e.getMessage(), e); + return new SpeedSmsVoiceResponse(false, null, "FAILED", e.getMessage()); + } + } + + private Map buildSccoBody(SpeedSmsVoiceRequest request) { + Map from = new HashMap<>(); + from.put("type", "external"); + from.put("number", normalizeE164(fromNumber, "84")); + from.put("alias", "ISUMS"); + + Map to = new HashMap<>(); + to.put("type", "external"); + to.put("number", normalizeE164(request.phone(), "84")); + to.put("alias", "tenant"); + + Map body = new HashMap<>(); + body.put("from", from); + body.put("to", List.of(to)); + + String text = request.tts(); + if (text == null || text.isBlank()) text = "Cảnh báo từ hệ thống ISUMS."; + int loop = Math.max(1, request.loop()); + + String voice = request.voiceName(); + if (voice == null || voice.isBlank()) voice = defaultVoiceName; + + java.util.List> actions = new java.util.ArrayList<>(); + Map talk = new HashMap<>(); + talk.put("action", "talk"); + talk.put("text", text); + talk.put("voice", voice); + talk.put("loop", loop); // body repeats N times — see comment + talk.put("bargeIn", true); + talk.put("silenceTime", 0); + actions.add(talk); + // SCCO branches by `interactive` flag from caller: + // - TENANT path (interactive=true): full escalation flow with + // `input` action capturing DTMF + a trailing `talk` ack so + // the user hears "Đã chuyển cho quản lý" before hangup. + // Stringee trial DOES double the disclaimer here (once per + // talk action), trade-off accepted because tenant explicitly + // wanted audible confirmation that escalation succeeded. + // - MANAGER / LANDLORD path (interactive=false): alert + hangup. + // No DTMF prompts, no ack — manager IS the recipient, asking + // them to "press 2 to forward to manager" makes no sense; the + // escalation chain stops at manager (or chain step manager → + // landlord if the manager doesn't pick up — handled separately + // in VoiceWebhookHandler.scheduleRetryOrEscalate). + if (request.interactive()) { + // Short timeOut — TTS already played twice with bargeIn=true, + // so anyone who wants to press 2 has had ~60s of opportunity + // already. After TTS completes, give just 5 seconds for a + // late press, then move on to ack+hangup. Prevents the call + // from "hanging" silently after the message ends. + Map input = new HashMap<>(); + input.put("action", "input"); + input.put("maxDigits", 1); + input.put("timeOut", 5); + input.put("submitOnHash", false); + input.put("eventUrl", + answerUrlBase + "/api/notifications/voice/stringee-answer-url"); + actions.add(input); + + Map ack = new HashMap<>(); + ack.put("action", "talk"); + ack.put("text", "Đã chuyển cho quản lý."); + ack.put("voice", voice); + ack.put("silenceTime", 0); + actions.add(ack); + } + + actions.add(Map.of("action", "hangup")); + body.put("actions", actions); + + // customField is echoed back to eventUrl + answer_url callbacks + // (Stringee SCCO docs name it `customField`, not `custom_data`). + // Useful for joining Stringee call_id ↔ our voice_call_jobs row + // when the project-level Answer URL fires. + String jobId = request.jobId() == null ? "" : request.jobId().toString(); + body.put("customField", jobId); + body.put("custom_data", jobId); // legacy key kept for back-compat + + // answer_url retained as a Stringee fallback path; Project-level + // Answer URL on the Console points to the same endpoint. + body.put("answer_url", + answerUrlBase + "/api/notifications/voice/stringee-answer-url?jobId=" + jobId); + return body; + } + + /** + * Normalises a phone string to E.164 (no leading {@code +}) for Stringee. + * + *

Accepts the formats VN users actually type into the app: + *

    + *
  • {@code 0326336224} → {@code 84326336224} (drop leading 0, prefix country)
  • + *
  • {@code +84326336224} / {@code 0084326336224} → {@code 84326336224}
  • + *
  • {@code 84326336224} → unchanged
  • + *
  • foreign (eg {@code +14155551234}) → {@code 14155551234}
  • + *
+ * Whitespace, dashes and parentheses are stripped before parsing. + * Returns the input verbatim if normalisation can't be inferred — Stringee + * will then reject and we'll see the raw value in the failure log. + * + * @param phone raw phone string from User-Service / DB + * @param defaultCc country code to apply when input starts with {@code 0} + */ + static String normalizeE164(String phone, String defaultCc) { + if (phone == null) return ""; + String digits = phone.replaceAll("[\\s\\-()]+", "").trim(); + if (digits.isEmpty()) return ""; + if (digits.startsWith("+")) { + return digits.substring(1); + } + if (digits.startsWith("00")) { + return digits.substring(2); + } + if (digits.startsWith("0")) { + return defaultCc + digits.substring(1); + } + // Already CC-prefixed (or unknown shape — pass through best-effort). + return digits; + } + + private String buildRestJwt() throws JOSEException { + long nowEpoch = Instant.now().getEpochSecond(); + long expEpoch = nowEpoch + 3600; + String jti = apiKeySid + "-" + nowEpoch; + + Map claims = new HashMap<>(); + claims.put("jti", jti); + claims.put("iss", apiKeySid); + claims.put("exp", expEpoch); + claims.put("rest_api", true); + + // Stringee requires "cty":"stringee-api;v=1" in JWT header. Nimbus + // reserves "cty" as a registered claim → must use .contentType(). + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.HS256) + .type(new com.nimbusds.jose.JOSEObjectType("JWT")) + .contentType("stringee-api;v=1") + .build(); + JWSObject jws = new JWSObject(header, + new Payload(objectMapper.writeValueAsString(claims))); + jws.sign(new MACSigner(apiKeySecret.getBytes(StandardCharsets.UTF_8))); + return jws.serialize(); + } + + @Override + public boolean verifyWebhookSignature(String rawBody, String signature) { + // Stringee event webhooks are NOT signed — they rely on the + // event_url being a hard-to-guess HTTPS URL on your project. + // For thesis demo we accept all + log; production should verify + // by fetching the call detail back via REST and matching call_id. + if (dryRun) return true; + return true; + } + + /** + * Resolves the Stringee {@code voice} parameter for a given locale. + * Public so the orchestrator can stamp the right voice into the + * request DTO before calling. + */ + public String voiceForLocale(com.isums.notificationservice.domains.enums.LocaleType locale) { + if (locale == null) return defaultVoiceName; + return switch (locale) { + case vi_VN -> voiceVi; + case en_US -> voiceEn; + case ja_JP -> voiceJa; + }; + } + +} diff --git a/src/main/java/com/isums/notificationservice/services/SubscriptionPlanService.java b/src/main/java/com/isums/notificationservice/services/SubscriptionPlanService.java new file mode 100644 index 0000000..94f04a6 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/services/SubscriptionPlanService.java @@ -0,0 +1,116 @@ +package com.isums.notificationservice.services; + +import com.isums.notificationservice.domains.dtos.SubscriptionPlanDto; +import com.isums.notificationservice.domains.dtos.UpsertSubscriptionPlanRequest; +import com.isums.notificationservice.domains.entities.SubscriptionPlan; +import com.isums.notificationservice.exceptions.ConflictException; +import com.isums.notificationservice.infrastructures.repositories.SubscriptionPlanRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +/** + * CRUD for the landlord-managed plan catalogue. Public list (active + * only) is exposed to tenants for the upgrade picker; full CRUD is + * landlord/admin only — controller enforces the role gate. + * + *

Plans are referenced by stable {@code code} from payment intents, + * so we never delete rows: deactivation flips {@code is_active=false} + * but keeps the row for audit linkage. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class SubscriptionPlanService { + + private final SubscriptionPlanRepository repo; + + public List listActiveForCustomers() { + return repo.findByIsActiveTrueOrderBySortOrderAscPriceVndAsc() + .stream().map(this::toDto).toList(); + } + + public List listAllForAdmin() { + return repo.findAllByOrderBySortOrderAscPriceVndAsc() + .stream().map(this::toDto).toList(); + } + + public SubscriptionPlanDto getById(UUID id) { + return toDto(repo.findById(id) + .orElseThrow(() -> new ConflictException("Plan not found: " + id))); + } + + public SubscriptionPlan getEntity(UUID id) { + return repo.findById(id) + .orElseThrow(() -> new ConflictException("Plan not found: " + id)); + } + + @Transactional + public SubscriptionPlanDto create(UpsertSubscriptionPlanRequest req, UUID actorId) { + repo.findByCode(req.code()).ifPresent(p -> { + throw new ConflictException("Plan code already exists: " + req.code()); + }); + SubscriptionPlan plan = SubscriptionPlan.builder() + .code(req.code()) + .nameTranslations(req.nameTranslations()) + .durationDays(req.durationDays()) + .priceVnd(req.priceVnd()) + .voiceQuotaMonthly(req.voiceQuotaMonthly() != null ? req.voiceQuotaMonthly() : 100) + .smsQuotaMonthly(req.smsQuotaMonthly() != null ? req.smsQuotaMonthly() : 200) + .sortOrder(req.sortOrder() != null ? req.sortOrder() : 0) + .isActive(req.isActive() == null ? true : req.isActive()) + .isFeatured(Boolean.TRUE.equals(req.isFeatured())) + .createdBy(actorId) + .updatedBy(actorId) + .build(); + plan = repo.save(plan); + log.info("[Plans] CREATE actor={} code={} duration={}d price={} active={}", + actorId, plan.getCode(), plan.getDurationDays(), + plan.getPriceVnd(), plan.getIsActive()); + return toDto(plan); + } + + @Transactional + public SubscriptionPlanDto update(UUID id, UpsertSubscriptionPlanRequest req, UUID actorId) { + SubscriptionPlan p = getEntity(id); + // code is immutable — silently ignore attempts to change so we + // don't surprise the caller with a 400 when their form posts + // back the existing code unchanged. + if (req.nameTranslations() != null) p.setNameTranslations(req.nameTranslations()); + if (req.durationDays() != null) p.setDurationDays(req.durationDays()); + if (req.priceVnd() != null) p.setPriceVnd(req.priceVnd()); + if (req.voiceQuotaMonthly() != null) p.setVoiceQuotaMonthly(req.voiceQuotaMonthly()); + if (req.smsQuotaMonthly() != null) p.setSmsQuotaMonthly(req.smsQuotaMonthly()); + if (req.sortOrder() != null) p.setSortOrder(req.sortOrder()); + if (req.isActive() != null) p.setIsActive(req.isActive()); + if (req.isFeatured() != null) p.setIsFeatured(req.isFeatured()); + p.setUpdatedBy(actorId); + p = repo.save(p); + log.info("[Plans] UPDATE actor={} code={} price={} active={}", + actorId, p.getCode(), p.getPriceVnd(), p.getIsActive()); + return toDto(p); + } + + @Transactional + public void deactivate(UUID id, UUID actorId) { + SubscriptionPlan p = getEntity(id); + p.setIsActive(false); + p.setUpdatedBy(actorId); + repo.save(p); + log.info("[Plans] DEACTIVATE actor={} code={}", actorId, p.getCode()); + } + + private SubscriptionPlanDto toDto(SubscriptionPlan p) { + return new SubscriptionPlanDto( + p.getId(), p.getCode(), p.getNameTranslations(), + p.getDurationDays(), p.getPriceVnd(), + p.getVoiceQuotaMonthly(), p.getSmsQuotaMonthly(), + p.getSortOrder(), p.getIsActive(), p.getIsFeatured(), + p.getCreatedAt(), p.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/isums/notificationservice/services/TierQuotaPolicy.java b/src/main/java/com/isums/notificationservice/services/TierQuotaPolicy.java new file mode 100644 index 0000000..33acf2b --- /dev/null +++ b/src/main/java/com/isums/notificationservice/services/TierQuotaPolicy.java @@ -0,0 +1,37 @@ +package com.isums.notificationservice.services; + +import com.isums.notificationservice.domains.enums.SubscriptionTier; + +public final class TierQuotaPolicy { + + private TierQuotaPolicy() {} + + public static int voiceQuotaFor(SubscriptionTier tier) { + return switch (tier) { + case PREMIUM -> 20; + case FREE -> 0; + }; + } + + public static int smsQuotaFor(SubscriptionTier tier) { + return switch (tier) { + case PREMIUM -> 30; + case FREE -> 0; + }; + } + + public static int minRetryIntervalSec(SubscriptionTier tier) { + return switch (tier) { + case PREMIUM -> 30; + case FREE -> 120; + }; + } + + public static int maxVoiceRetries(SubscriptionTier tier) { + return switch (tier) { + case PREMIUM -> 3; + case FREE -> 0; + }; + } +} + diff --git a/src/main/java/com/isums/notificationservice/services/VoiceCallOrchestratorService.java b/src/main/java/com/isums/notificationservice/services/VoiceCallOrchestratorService.java new file mode 100644 index 0000000..87e4add --- /dev/null +++ b/src/main/java/com/isums/notificationservice/services/VoiceCallOrchestratorService.java @@ -0,0 +1,198 @@ +package com.isums.notificationservice.services; + +import com.isums.notificationservice.domains.dtos.AlertDispatchRequest; +import com.isums.notificationservice.domains.dtos.SpeedSmsVoiceRequest; +import com.isums.notificationservice.domains.dtos.SpeedSmsVoiceResponse; +import com.isums.notificationservice.domains.entities.UserNotificationPreferences; +import com.isums.notificationservice.domains.entities.VoiceCallJob; +import com.isums.notificationservice.domains.enums.LocaleType; +import com.isums.notificationservice.domains.enums.VoiceCallStatus; +import com.isums.notificationservice.infrastructures.abstracts.TtsAudioSynthesizer; +import com.isums.notificationservice.infrastructures.repositories.VoiceCallJobRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class VoiceCallOrchestratorService { + + private final ChannelTemplateRenderer templateRenderer; + private final VoiceProviderRouter providerRouter; + private final TtsAudioSynthesizer ttsAudioSynthesizer; + private final VoiceCallJobRepository voiceJobRepo; + + @Value("${app.notification.voice.webhook-base-url:https://api-dev.isums.pro}") + private String webhookBaseUrl; + + @Value("${app.notification.voice.caller-id-name:ISUMS}") + private String callerIdName; + + @Transactional + public VoiceCallJob enqueueFirstAttempt( + UUID userId, + String phone, + UserNotificationPreferences prefs, + AlertDispatchRequest alertReq, + Map templateVars, + com.isums.notificationservice.domains.enums.RecipientRole role) { + return enqueueFirstAttempt(userId, phone, prefs, alertReq, templateVars, role, null); + } + + @Transactional + public VoiceCallJob enqueueFirstAttempt( + UUID userId, + String phone, + UserNotificationPreferences prefs, + AlertDispatchRequest alertReq, + Map templateVars, + com.isums.notificationservice.domains.enums.RecipientRole role, + com.isums.notificationservice.domains.enums.EscalationReason reason) { + + String templateKey = voiceTemplateKey(alertReq.eventType().name(), role, reason); + String roleFallback = voiceTemplateKey(alertReq.eventType().name(), role, null); + String tenantFallback = voiceTemplateKey(alertReq.eventType().name(), + com.isums.notificationservice.domains.enums.RecipientRole.TENANT, null); + LocaleType locale = prefs.getLanguage(); + + var rendered = renderWithFallbackChain( + java.util.List.of(templateKey, roleFallback, tenantFallback), + locale, templateVars); + + java.math.BigDecimal alertValue = alertReq.value() == null ? null + : java.math.BigDecimal.valueOf(alertReq.value()); + + VoiceCallJob job = VoiceCallJob.builder() + .userId(userId) + .alertId(alertReq.alertId()) + .eventType(alertReq.eventType().name()) + .phone(phone) + .locale(locale) + .templateId(rendered.version().getTemplate().getId()) + .templateVersionId(rendered.version().getId()) + .renderedText(rendered.body()) + .maxAttempts(prefs.getVoiceMaxRetries() + 1) + .houseId(alertReq.houseId()) + .areaId(alertReq.areaId()) + .areaName(alertReq.areaName()) + .thing(alertReq.thing()) + .metric(alertReq.metric()) + .alertValue(alertValue) + .alertUnit(alertReq.unit()) + .build(); + job = voiceJobRepo.save(job); + + boolean interactive = role == com.isums.notificationservice.domains.enums.RecipientRole.TENANT; + dial(job, rendered.body(), locale, prefs, interactive); + return job; + } + + @Transactional + public void dial(VoiceCallJob job, String textBody, LocaleType locale, + UserNotificationPreferences prefs) { + dial(job, textBody, locale, prefs, true); + } + + @Transactional + public void dial(VoiceCallJob job, String textBody, LocaleType locale, + UserNotificationPreferences prefs, boolean interactive) { + var provider = providerRouter.voice(); + String audioUrl = null; + String tts = textBody; + + if (locale != LocaleType.vi_VN) { + try { + audioUrl = ttsAudioSynthesizer.synthesizeAndCache( + textBody, locale, + prefs.getVoiceGender(), prefs.getVoiceSpeed()); + } catch (Exception e) { + log.warn("[Voice] Polly synth failed, falling back to VN TTS: {}", e.getMessage()); + } + } + + String voiceName = null; + var voiceProvider = provider; + if (voiceProvider instanceof StringeeClientImpl s) { + voiceName = s.voiceForLocale(locale); + } + + SpeedSmsVoiceRequest req = new SpeedSmsVoiceRequest( + job.getPhone(), + tts, + audioUrl, + 2, + webhookBaseUrl + "/api/notifications/voice/webhook", + callerIdName, + job.getId(), + voiceName, + interactive + ); + + SpeedSmsVoiceResponse resp = provider.sendVoiceCall(req); + log.info("[Voice] dial provider={} jobId={} status={} callId={}", + provider.providerId(), job.getId(), resp.status(), resp.callId()); + + if (resp.ok()) { + job.setProviderCallId(resp.callId()); + job.setStatus(VoiceCallStatus.DIALING); + } else { + job.setStatus(VoiceCallStatus.FAILED); + job.setErrorMessage(resp.errorMessage()); + } + voiceJobRepo.save(job); + } + + private com.isums.notificationservice.services.ChannelTemplateRenderer.RenderedTemplate renderWithFallbackChain( + java.util.List keys, + LocaleType locale, + Map templateVars) { + RuntimeException lastError = null; + java.util.LinkedHashSet uniq = new java.util.LinkedHashSet<>(keys); + for (String key : uniq) { + try { + return templateRenderer.render( + key, + com.isums.notificationservice.domains.enums.NotificationChannel.VOICE, + locale, templateVars); + } catch (RuntimeException e) { + lastError = e; + log.warn("[Voice] template {} not renderable for locale={} ({}), trying next", + key, locale, e.getMessage()); + } + } + throw lastError != null ? lastError + : new IllegalStateException("no template keys resolved: " + keys); + } + + private static String voiceTemplateKey(String eventType) { + return voiceTemplateKey(eventType, + com.isums.notificationservice.domains.enums.RecipientRole.TENANT, null); + } + + private static String voiceTemplateKey(String eventType, + com.isums.notificationservice.domains.enums.RecipientRole role) { + return voiceTemplateKey(eventType, role, null); + } + + private static String voiceTemplateKey(String eventType, + com.isums.notificationservice.domains.enums.RecipientRole role, + com.isums.notificationservice.domains.enums.EscalationReason reason) { + String base = "voice_" + eventType.toLowerCase(); + String roleKey = switch (role) { + case MANAGER, LANDLORD -> base + "_manager"; + case TENANT -> base; + }; + if (reason == com.isums.notificationservice.domains.enums.EscalationReason.NO_ANSWER_MAX_RETRIES + && (role == com.isums.notificationservice.domains.enums.RecipientRole.MANAGER + || role == com.isums.notificationservice.domains.enums.RecipientRole.LANDLORD)) { + return roleKey + "_noanswer"; + } + return roleKey; + } +} diff --git a/src/main/java/com/isums/notificationservice/services/VoiceCallRetryScheduler.java b/src/main/java/com/isums/notificationservice/services/VoiceCallRetryScheduler.java new file mode 100644 index 0000000..a2095ab --- /dev/null +++ b/src/main/java/com/isums/notificationservice/services/VoiceCallRetryScheduler.java @@ -0,0 +1,79 @@ +package com.isums.notificationservice.services; + +import com.isums.notificationservice.domains.entities.UserNotificationPreferences; +import com.isums.notificationservice.domains.entities.VoiceCallJob; +import com.isums.notificationservice.domains.enums.VoiceCallStatus; +import com.isums.notificationservice.infrastructures.repositories.UserNotificationPreferencesRepository; +import com.isums.notificationservice.infrastructures.repositories.VoiceCallJobRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; + +/** + * Runs every minute, finds voice_call_jobs whose {@code next_retry_at} is + * past and whose attempt budget still allows another dial, and re-invokes + * the orchestrator with a fresh attempt counter. + * + *

Holds no state: safe to run across multiple JVM instances if that ever + * happens, but JPA row-level locking is not used — a duplicate dial is + * expensive and rare enough that we accept it as a trade-off. (Can be + * tightened with a pessimistic lock or Redis lease if needed.) + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class VoiceCallRetryScheduler { + + private final VoiceCallJobRepository voiceJobRepo; + private final UserNotificationPreferencesRepository prefsRepo; + private final VoiceCallOrchestratorService voiceOrchestrator; + + @Scheduled(cron = "0 * * * * *", zone = "Asia/Ho_Chi_Minh") + @Transactional + public void sweepRetries() { + Instant now = Instant.now(); + List candidates = voiceJobRepo.findAllByStatusInAndNextRetryAtBefore( + List.of(VoiceCallStatus.NO_ANSWER, VoiceCallStatus.BUSY), now); + + if (candidates.isEmpty()) return; + + log.info("[VoiceRetry] sweeping {} candidates", candidates.size()); + + for (VoiceCallJob job : candidates) { + try { + if (job.getAttemptNumber() >= job.getMaxAttempts()) { + job.setStatus(VoiceCallStatus.FAILED); + job.setNextRetryAt(null); + voiceJobRepo.save(job); + continue; + } + + UserNotificationPreferences prefs = + prefsRepo.findById(job.getUserId()).orElse(null); + if (prefs == null || !prefs.isVoiceEnabled()) { + job.setStatus(VoiceCallStatus.SKIPPED); + job.setNextRetryAt(null); + voiceJobRepo.save(job); + continue; + } + + // New attempt. Clear the retry timer + bump counter so the + // webhook path can distinguish a fresh dial from a stale one. + job.setAttemptNumber(job.getAttemptNumber() + 1); + job.setNextRetryAt(null); + job.setStatus(VoiceCallStatus.PENDING); + voiceJobRepo.save(job); + + voiceOrchestrator.dial(job, job.getRenderedText(), job.getLocale(), prefs); + } catch (Exception e) { + log.error("[VoiceRetry] failed to retry jobId={}: {}", + job.getId(), e.getMessage(), e); + } + } + } +} diff --git a/src/main/java/com/isums/notificationservice/services/VoiceProviderRouter.java b/src/main/java/com/isums/notificationservice/services/VoiceProviderRouter.java new file mode 100644 index 0000000..49198b6 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/services/VoiceProviderRouter.java @@ -0,0 +1,63 @@ +package com.isums.notificationservice.services; + +import com.isums.notificationservice.infrastructures.abstracts.VoiceProvider; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Picks a {@link VoiceProvider} based on + * {@code app.notification.voice.provider} (default {@code STRINGEE}). + * Stringee handles BOTH voice and SMS now — provider abstraction is + * kept so swapping vendors later only touches this router. + * + *

Routing is per-request, not per-bean — flip the property without + * a restart and the next dispatch picks up the new provider. + */ +@Component +@Slf4j +public class VoiceProviderRouter { + + private final List providers; + + @Value("${app.notification.voice.provider:STRINGEE}") + private String defaultProvider; + + public VoiceProviderRouter(List providers) { + this.providers = providers; + } + + @jakarta.annotation.PostConstruct + void logConfig() { + log.info("[VoiceProviderRouter] default={} available={}", + defaultProvider, + providers.stream().map(VoiceProvider::providerId).toList()); + } + + /** The voice provider for outbound TTS calls. */ + public VoiceProvider voice() { + return resolve(defaultProvider); + } + + /** The SMS provider — currently the same Stringee bean. */ + public VoiceProvider sms() { + return resolve(defaultProvider); + } + + private VoiceProvider resolve(String id) { + return providers.stream() + .filter(p -> p.providerId().equalsIgnoreCase(id)) + .findFirst() + .orElseGet(() -> { + if (providers.isEmpty()) { + throw new IllegalStateException( + "No VoiceProvider beans on the classpath — check StringeeClientImpl @Service"); + } + log.warn("[VoiceProviderRouter] unknown provider {} — using {}", + id, providers.get(0).providerId()); + return providers.get(0); + }); + } +} diff --git a/src/main/java/com/isums/notificationservice/services/VoiceWebhookHandler.java b/src/main/java/com/isums/notificationservice/services/VoiceWebhookHandler.java new file mode 100644 index 0000000..9dfbcb1 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/services/VoiceWebhookHandler.java @@ -0,0 +1,249 @@ +package com.isums.notificationservice.services; + +import com.isums.notificationservice.domains.dtos.AlertDispatchRequest; +import com.isums.notificationservice.domains.dtos.SpeedSmsWebhookPayload; +import com.isums.notificationservice.domains.entities.NotificationSubscription; +import com.isums.notificationservice.domains.entities.UserNotificationPreferences; +import com.isums.notificationservice.domains.entities.VoiceCallJob; +import com.isums.notificationservice.domains.enums.AlertEventType; +import com.isums.notificationservice.domains.enums.EscalationReason; +import com.isums.notificationservice.domains.enums.RecipientRole; +import com.isums.notificationservice.domains.enums.SubscriptionTier; +import com.isums.notificationservice.domains.enums.VoiceCallStatus; +import com.isums.notificationservice.infrastructures.repositories.NotificationSubscriptionRepository; +import com.isums.notificationservice.infrastructures.repositories.UserNotificationPreferencesRepository; +import com.isums.notificationservice.infrastructures.repositories.VoiceCallJobRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +/** + * Applies SpeedSMS webhook state to an existing voice_call_jobs row. + * Drives retry / escalation / DTMF opt-out accordingly. + */ +@Service +@Slf4j +public class VoiceWebhookHandler { + + private final VoiceCallJobRepository voiceJobRepo; + private final UserNotificationPreferencesRepository prefsRepo; + private final NotificationSubscriptionRepository subsRepo; + private final EscalationService escalationService; + private final NotificationDispatchService dispatchService; + + public VoiceWebhookHandler(VoiceCallJobRepository voiceJobRepo, + UserNotificationPreferencesRepository prefsRepo, + NotificationSubscriptionRepository subsRepo, + EscalationService escalationService, + // @Lazy breaks the circular DispatchService → VoiceCallOrchestrator + // → (eventually back to webhook handler) bean dependency. + @Lazy NotificationDispatchService dispatchService) { + this.voiceJobRepo = voiceJobRepo; + this.prefsRepo = prefsRepo; + this.subsRepo = subsRepo; + this.escalationService = escalationService; + this.dispatchService = dispatchService; + } + + @Transactional + public Optional handle(SpeedSmsWebhookPayload payload) { + if (payload == null || payload.callId() == null || payload.callId().isBlank()) { + log.warn("[Webhook] empty callId in payload"); + return Optional.empty(); + } + + VoiceCallJob job = voiceJobRepo.findByProviderCallId(payload.callId()).orElse(null); + if (job == null) { + log.warn("[Webhook] unknown callId={}", payload.callId()); + return Optional.empty(); + } + + String providerStatus = payload.status() == null ? "" : payload.status().toUpperCase(); + VoiceCallStatus newStatus = mapStatus(providerStatus); + + job.setStatus(newStatus); + if (payload.duration() != null) job.setDurationSec(payload.duration()); + if (payload.cost() != null) job.setCostVnd(payload.cost()); + if (payload.recordingUrl() != null) job.setRecordingUrl(payload.recordingUrl()); + if (payload.errorMessage() != null) job.setErrorMessage(payload.errorMessage()); + + if (payload.dtmf() != null && !payload.dtmf().isBlank()) { + job.setDtmfReceived(payload.dtmf()); + applyDtmf(job, payload.dtmf()); + } + + if (newStatus == VoiceCallStatus.ANSWERED && job.getDtmfReceived() == null) { + // Answered but nothing pressed → still counts as delivered. + // No retry needed, no escalation triggered. User heard it. + job.setAcknowledgedAt(Instant.now()); + job.setStatus(VoiceCallStatus.ACKNOWLEDGED); + } + + if (newStatus == VoiceCallStatus.NO_ANSWER || newStatus == VoiceCallStatus.BUSY) { + scheduleRetryOrEscalate(job); + } + + voiceJobRepo.save(job); + log.info("[Webhook] callId={} status={} dtmf={} duration={} cost={}", + payload.callId(), newStatus, payload.dtmf(), + payload.duration(), payload.cost()); + return Optional.of(job); + } + + private void applyDtmf(VoiceCallJob job, String dtmf) { + switch (dtmf.trim()) { + case "1" -> { + // Explicit acknowledgement — stop retries. + job.setAcknowledgedAt(Instant.now()); + job.setStatus(VoiceCallStatus.ACKNOWLEDGED); + } + case "2" -> { + // User explicitly asked to forward to landlord/manager. + UserNotificationPreferences prefs = prefsRepo.findById(job.getUserId()).orElse(null); + if (prefs == null) { + log.warn("[Webhook] escalation requested but no prefs for userId={}", job.getUserId()); + return; + } + UUID target = escalationService.resolveEscalationTarget( + job.getUserId(), prefs, job.getHouseId()); + if (target == null) { + log.warn("[Webhook] escalation requested but no target for userId={}", job.getUserId()); + return; + } + escalationService.record(job.getId(), null, target, EscalationReason.DTMF_KEY_2); + triggerEscalationDispatch(job, target, RecipientRole.MANAGER, EscalationReason.DTMF_KEY_2); + job.setStatus(VoiceCallStatus.ESCALATED); + } + case "9" -> { + // Opt-out — flip voice off in preferences. + prefsRepo.findById(job.getUserId()).ifPresent(p -> { + p.setVoiceEnabled(false); + prefsRepo.save(p); + }); + job.setStatus(VoiceCallStatus.ACKNOWLEDGED); + log.info("[Webhook] userId={} opted out via DTMF=9", job.getUserId()); + } + default -> { + // Unknown digit — treat as acknowledged but log for admin review. + job.setAcknowledgedAt(Instant.now()); + job.setStatus(VoiceCallStatus.ACKNOWLEDGED); + log.info("[Webhook] userId={} unknown dtmf={} → treated as ack", job.getUserId(), dtmf); + } + } + } + + private void scheduleRetryOrEscalate(VoiceCallJob job) { + UserNotificationPreferences prefs = prefsRepo.findById(job.getUserId()).orElse(null); + NotificationSubscription sub = subsRepo.findById(job.getUserId()).orElse(null); + + // Tier downgrade mid-retry: stop bothering — the user is no longer + // paying for voice. Already-used quota is a sunk cost. + if (sub != null && sub.getTier() != SubscriptionTier.PREMIUM) { + job.setStatus(VoiceCallStatus.SKIPPED); + return; + } + + int attemptNumber = job.getAttemptNumber(); + int maxAttempts = job.getMaxAttempts(); + + if (prefs != null && attemptNumber < maxAttempts) { + int delaySec = prefs.getVoiceRetryIntervalSec(); + job.setNextRetryAt(Instant.now().plusSeconds(delaySec)); + // Status stays NO_ANSWER until the retry scheduler picks it up. + log.info("[Webhook] will retry callId={} attempt={}/{} in {}s", + job.getProviderCallId(), attemptNumber, maxAttempts, delaySec); + return; + } + + // Max retries exhausted — escalate. + if (prefs != null && prefs.isEscalationEnabled()) { + UUID target = escalationService.resolveEscalationTarget( + job.getUserId(), prefs, job.getHouseId()); + if (target != null) { + escalationService.record(job.getId(), null, target, + EscalationReason.NO_ANSWER_MAX_RETRIES); + triggerEscalationDispatch(job, target, RecipientRole.MANAGER, + EscalationReason.NO_ANSWER_MAX_RETRIES); + job.setStatus(VoiceCallStatus.ESCALATED); + log.info("[Webhook] escalated to userId={} after {} attempts", target, attemptNumber); + return; + } + } + // Nothing more to do — mark FAILED so audit reports count it. + job.setStatus(VoiceCallStatus.FAILED); + } + + /** + * Re-dispatch the original alert to {@code targetUserId} (landlord + * or manager) using the channel matrix for that role. Reads the + * denormalised alert context off the original voice_call_jobs row + * so we don't need to hit DynamoDB. + * + *

Suffix the alertId with ".escalated" so the audit trail can + * tell tenant-vs-escalation calls apart. + */ + private void triggerEscalationDispatch(VoiceCallJob originalJob, + UUID targetUserId, + RecipientRole targetRole, + EscalationReason reason) { + AlertEventType eventType; + try { + eventType = AlertEventType.valueOf(originalJob.getEventType()); + } catch (IllegalArgumentException e) { + log.warn("[Webhook] unknown event_type={} on jobId={} — escalation aborted", + originalJob.getEventType(), originalJob.getId()); + return; + } + + Double value = originalJob.getAlertValue() == null ? null + : originalJob.getAlertValue().doubleValue(); + + Map escalationVars = new HashMap<>(); + escalationVars.put("escalated_from_user_id", originalJob.getUserId().toString()); + escalationVars.put("original_call_id", originalJob.getId().toString()); + + AlertDispatchRequest redispatch = new AlertDispatchRequest( + targetUserId, + originalJob.getAlertId() == null + ? "esc-" + originalJob.getId() + : originalJob.getAlertId() + ".escalated", + eventType, + originalJob.getHouseId(), + originalJob.getAreaId(), + originalJob.getAreaName(), + originalJob.getThing(), + originalJob.getMetric(), + value, + originalJob.getAlertUnit(), + escalationVars + ); + + try { + var resp = dispatchService.dispatchDirect(redispatch, targetUserId, targetRole, reason); + log.info("[Webhook] escalation re-dispatch ok target={} role={} reason={} channels={}", + targetUserId, targetRole, reason, + resp.results().stream().map(r -> r.channel() + "=" + r.status()).toList()); + } catch (Exception e) { + log.error("[Webhook] escalation re-dispatch failed target={}: {}", + targetUserId, e.getMessage(), e); + } + } + + private static VoiceCallStatus mapStatus(String providerStatus) { + return switch (providerStatus) { + case "ANSWERED", "answered" -> VoiceCallStatus.ANSWERED; + case "NO_ANSWER", "no_answer", "NOANSWER" -> VoiceCallStatus.NO_ANSWER; + case "BUSY", "busy" -> VoiceCallStatus.BUSY; + case "FAILED", "failed", "CANCELLED", "cancelled" -> VoiceCallStatus.FAILED; + default -> VoiceCallStatus.DIALING; + }; + } +} diff --git a/src/main/resources/db/migration/V20260417_1205__extend_manager_notification_category_check.sql b/src/main/resources/db/migration/V20260417_1205__extend_manager_notification_category_check.sql new file mode 100644 index 0000000..a5ef229 --- /dev/null +++ b/src/main/resources/db/migration/V20260417_1205__extend_manager_notification_category_check.sql @@ -0,0 +1,13 @@ +ALTER TABLE manager_notifications + DROP CONSTRAINT IF EXISTS manager_notifications_category_check; + +ALTER TABLE manager_notifications + ADD CONSTRAINT manager_notifications_category_check + CHECK (category IN ( + 'CONTRACT_EXPIRED', + 'INSPECTION_DONE', + 'CONTRACT_READY_FOR_LANDLORD_SIGNATURE', + 'RENEWAL_REQUEST', + 'PAYMENT_OVERDUE', + 'DEPOSIT_REFUND_CONFIRM' + )); diff --git a/src/main/resources/db/migration/V20260417_1335__add_contract_completed_manager_notification_category.sql b/src/main/resources/db/migration/V20260417_1335__add_contract_completed_manager_notification_category.sql new file mode 100644 index 0000000..1fe7af3 --- /dev/null +++ b/src/main/resources/db/migration/V20260417_1335__add_contract_completed_manager_notification_category.sql @@ -0,0 +1,14 @@ +ALTER TABLE manager_notifications + DROP CONSTRAINT IF EXISTS manager_notifications_category_check; + +ALTER TABLE manager_notifications + ADD CONSTRAINT manager_notifications_category_check + CHECK (category IN ( + 'CONTRACT_EXPIRED', + 'INSPECTION_DONE', + 'CONTRACT_READY_FOR_LANDLORD_SIGNATURE', + 'CONTRACT_COMPLETED', + 'RENEWAL_REQUEST', + 'PAYMENT_OVERDUE', + 'DEPOSIT_REFUND_CONFIRM' + )); diff --git a/src/main/resources/db/migration/V20260417_1415__extend_manager_notification_categories_for_contract_issue.sql b/src/main/resources/db/migration/V20260417_1415__extend_manager_notification_categories_for_contract_issue.sql new file mode 100644 index 0000000..5ed8aa4 --- /dev/null +++ b/src/main/resources/db/migration/V20260417_1415__extend_manager_notification_categories_for_contract_issue.sql @@ -0,0 +1,17 @@ +ALTER TABLE manager_notifications + DROP CONSTRAINT IF EXISTS manager_notifications_category_check; + +ALTER TABLE manager_notifications + ADD CONSTRAINT manager_notifications_category_check + CHECK (category IN ( + 'CONTRACT_EXPIRED', + 'INSPECTION_DONE', + 'CONTRACT_READY_FOR_LANDLORD_SIGNATURE', + 'CONTRACT_COMPLETED', + 'CONTRACT_CANCELLED_BY_TENANT', + 'ISSUE_WORK_SLOT_CREATED', + 'ISSUE_QUOTE_WAITING_MANAGER_APPROVAL', + 'RENEWAL_REQUEST', + 'PAYMENT_OVERDUE', + 'DEPOSIT_REFUND_CONFIRM' + )); diff --git a/src/main/resources/db/migration/V20260422_1600__extend_email_template_versions_locale_check_for_ja_JP.sql b/src/main/resources/db/migration/V20260422_1600__extend_email_template_versions_locale_check_for_ja_JP.sql new file mode 100644 index 0000000..66e56c4 --- /dev/null +++ b/src/main/resources/db/migration/V20260422_1600__extend_email_template_versions_locale_check_for_ja_JP.sql @@ -0,0 +1,27 @@ +-- Widen email_template_versions.locale CHECK to accept ja_JP alongside +-- the previous vi_VN / en_US values. +-- +-- Background: the column is @Enumerated(STRING) on EmailTemplateVersion, +-- and Hibernate 6+ DDL autogenerates a CHECK constraint from the enum's +-- values when the table was first created. Adding a new enum constant +-- (LocaleType.ja_JP) does NOT retroactively ALTER that constraint, so +-- the next insert of a ja_JP template row is rejected by Postgres with +-- "violates check constraint email_template_versions_locale_check". +-- +-- We replace the constraint rather than drop-and-recreate via JPA because +-- (1) RDS is treated as production (manual migrations only), and +-- (2) a Flyway migration is versioned + replayable across environments, +-- whereas relying on ddl-auto would diverge prod from dev. +-- +-- Matches: com.isums.notificationservice.domains.enums.LocaleType + +ALTER TABLE email_template_versions + DROP CONSTRAINT IF EXISTS email_template_versions_locale_check; + +ALTER TABLE email_template_versions + ADD CONSTRAINT email_template_versions_locale_check + CHECK (locale IN ( + 'vi_VN', + 'en_US', + 'ja_JP' + )); diff --git a/src/main/resources/db/migration/V20260425_0001__voice_notification_infra.sql b/src/main/resources/db/migration/V20260425_0001__voice_notification_infra.sql new file mode 100644 index 0000000..16b59af --- /dev/null +++ b/src/main/resources/db/migration/V20260425_0001__voice_notification_infra.sql @@ -0,0 +1,249 @@ +-- Voice / multi-channel notification infrastructure. +-- +-- Adds to the existing email-centric tables (email_templates / email_template_versions / +-- manager_notifications) a second family for: +-- - Per-user channel preferences (opt-in + quiet hours + retry / rate-limit knobs) +-- - Subscription tier gate (FREE vs PREMIUM 19k/month) +-- - Multi-channel template versions (VOICE / SMS / PUSH / ZNS) in vi_VN / en_US / ja_JP +-- - Voice-call job audit + escalation chain +-- - Pre-synthesised TTS audio cache (for Japanese, since SpeedSMS TTS is vi-only) +-- +-- RDS = prod data (project_isums_infra memory), so every schema change is +-- expressed as a Flyway migration rather than ddl-auto=update. + +-- ============================================================ +-- user_notification_preferences +-- ============================================================ +CREATE TABLE IF NOT EXISTS user_notification_preferences ( + user_id UUID PRIMARY KEY, + + language VARCHAR(20) NOT NULL DEFAULT 'vi_VN' + CHECK (language IN ('vi_VN', 'en_US', 'ja_JP')), + + -- Channel opt-in + email_enabled BOOLEAN NOT NULL DEFAULT TRUE, + push_enabled BOOLEAN NOT NULL DEFAULT TRUE, + sms_enabled BOOLEAN NOT NULL DEFAULT FALSE, + voice_enabled BOOLEAN NOT NULL DEFAULT FALSE, + + -- Quiet hours — stored as VN-local time (Asia/Ho_Chi_Minh). CRITICAL + -- alerts (gas, fire, power-lost) bypass quiet hours when the override + -- flag is TRUE. + quiet_hours_start TIME NOT NULL DEFAULT '22:00', + quiet_hours_end TIME NOT NULL DEFAULT '06:00', + quiet_hours_override_critical BOOLEAN NOT NULL DEFAULT TRUE, + + -- Voice knobs (user-configurable; service enforces tier-based caps) + voice_max_retries INT NOT NULL DEFAULT 2 CHECK (voice_max_retries BETWEEN 0 AND 5), + voice_retry_interval_sec INT NOT NULL DEFAULT 120 CHECK (voice_retry_interval_sec BETWEEN 30 AND 600), + voice_rate_limit_sec INT NOT NULL DEFAULT 300 CHECK (voice_rate_limit_sec BETWEEN 60 AND 3600), + voice_gender VARCHAR(10) NOT NULL DEFAULT 'FEMALE' + CHECK (voice_gender IN ('MALE', 'FEMALE')), + voice_speed NUMERIC(3,2) NOT NULL DEFAULT 1.00 + CHECK (voice_speed BETWEEN 0.80 AND 1.20), + dtmf_ack_enabled BOOLEAN NOT NULL DEFAULT TRUE, + + -- Escalation: when voice retry exhausts, dispatch to another user + -- (usually tenant → landlord). NULL = resolve via HouseGrpc landlord lookup. + escalation_enabled BOOLEAN NOT NULL DEFAULT TRUE, + escalation_target_user_id UUID, + + -- Opt-in timestamp for regulatory proof (voice calls need explicit consent) + voice_consent_given_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_prefs_escalation_target + ON user_notification_preferences(escalation_target_user_id); + + +-- ============================================================ +-- notification_subscriptions +-- ============================================================ +CREATE TABLE IF NOT EXISTS notification_subscriptions ( + user_id UUID PRIMARY KEY, + + tier VARCHAR(20) NOT NULL DEFAULT 'FREE' + CHECK (tier IN ('FREE', 'PREMIUM')), + + premium_started_at TIMESTAMPTZ, + premium_until TIMESTAMPTZ, + + -- Caps reset monthly; sourced from tier defaults but per-user override allowed + voice_quota_monthly INT NOT NULL DEFAULT 0, + voice_used_this_month INT NOT NULL DEFAULT 0, + + sms_quota_monthly INT NOT NULL DEFAULT 0, + sms_used_this_month INT NOT NULL DEFAULT 0, + + quota_reset_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_subs_premium_until + ON notification_subscriptions(premium_until) + WHERE tier = 'PREMIUM'; + + +-- ============================================================ +-- channel_templates (non-email channels — VOICE/SMS/PUSH/ZNS) +-- email_templates table is kept intact for email-specific bookkeeping. +-- ============================================================ +CREATE TABLE IF NOT EXISTS channel_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + template_key VARCHAR(100) NOT NULL, + channel VARCHAR(20) NOT NULL + CHECK (channel IN ('VOICE', 'SMS', 'PUSH', 'ZNS')), + event_type VARCHAR(80), + category VARCHAR(50), + recipient_type VARCHAR(50), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by VARCHAR(100), + updated_by VARCHAR(100), + CONSTRAINT uq_channel_tpl_key_channel UNIQUE (template_key, channel) +); + +CREATE INDEX IF NOT EXISTS ix_ch_tpl_event_type + ON channel_templates(event_type, channel); + + +-- ============================================================ +-- channel_template_versions +-- ============================================================ +CREATE TABLE IF NOT EXISTS channel_template_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + template_id UUID NOT NULL REFERENCES channel_templates(id) ON DELETE CASCADE, + + locale VARCHAR(20) NOT NULL + CHECK (locale IN ('vi_VN', 'en_US', 'ja_JP')), + + version INT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT' + CHECK (status IN ('DRAFT', 'APPROVED', 'ACTIVE', 'DEPRECATED')), + + -- body: rendered via Mustache, interpolated with alert vars + body TEXT NOT NULL, + + -- SSML alt for TTS channels — better pronunciation of numbers/units. + -- NULL for SMS/PUSH/ZNS. + ssml TEXT, + + -- Short title for PUSH/SMS channels (ignored for VOICE) + title VARCHAR(200), + + allowed_vars JSONB, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by VARCHAR(100), + updated_by VARCHAR(100), + + CONSTRAINT uq_ch_tpl_ver UNIQUE (template_id, locale, version) +); + +CREATE INDEX IF NOT EXISTS ix_ch_tplver_template_locale_status + ON channel_template_versions(template_id, locale, status); + + +-- ============================================================ +-- voice_call_jobs +-- ============================================================ +CREATE TABLE IF NOT EXISTS voice_call_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + user_id UUID NOT NULL, + alert_id VARCHAR(100), -- e.g. DynamoDB esp32_alerts.alertId + event_type VARCHAR(80) NOT NULL, + + phone VARCHAR(40) NOT NULL, -- E.164 formatted + locale VARCHAR(20) NOT NULL, + + template_id UUID, + template_version_id UUID, + + rendered_text TEXT NOT NULL, -- audit of what we asked provider to speak + + provider VARCHAR(20) NOT NULL DEFAULT 'SPEEDSMS', + provider_call_id VARCHAR(100), + + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' + CHECK (status IN ('PENDING', 'DIALING', 'ANSWERED', 'NO_ANSWER', + 'BUSY', 'FAILED', 'ACKNOWLEDGED', 'ESCALATED', 'SKIPPED')), + + dtmf_received VARCHAR(10), + acknowledged_at TIMESTAMPTZ, + + attempt_number INT NOT NULL DEFAULT 1, + max_attempts INT NOT NULL DEFAULT 3, + next_retry_at TIMESTAMPTZ, + + duration_sec INT, + cost_vnd INT, + recording_url TEXT, + + error_message TEXT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_voice_job_user_created ON voice_call_jobs(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS ix_voice_job_status_retry ON voice_call_jobs(status, next_retry_at); +CREATE INDEX IF NOT EXISTS ix_voice_job_provider_call ON voice_call_jobs(provider_call_id); +CREATE INDEX IF NOT EXISTS ix_voice_job_alert ON voice_call_jobs(alert_id); + + +-- ============================================================ +-- voice_call_escalations +-- ============================================================ +CREATE TABLE IF NOT EXISTS voice_call_escalations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + original_call_id UUID NOT NULL REFERENCES voice_call_jobs(id) ON DELETE CASCADE, + escalated_call_id UUID REFERENCES voice_call_jobs(id) ON DELETE SET NULL, + escalated_to_user_id UUID NOT NULL, + reason VARCHAR(40) NOT NULL + CHECK (reason IN ('NO_ANSWER_MAX_RETRIES', 'DTMF_KEY_2', 'MANUAL')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_esc_original ON voice_call_escalations(original_call_id); + + +-- ============================================================ +-- voice_audio_cache — pre-synthesised TTS (mainly for ja_JP via AWS Polly) +-- SpeedSMS native TTS only supports vi; for ja/en we pre-render to S3 and +-- pass the audio URL to SpeedSMS "play_url" mode. +-- ============================================================ +CREATE TABLE IF NOT EXISTS voice_audio_cache ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Cache key = hash of (template_version_id, locale, rendered_text, voice_gender, voice_speed) + cache_key VARCHAR(128) NOT NULL UNIQUE, + + locale VARCHAR(20) NOT NULL, + voice_gender VARCHAR(10) NOT NULL, + voice_speed NUMERIC(3,2) NOT NULL, + + rendered_text TEXT NOT NULL, + s3_bucket VARCHAR(200) NOT NULL, + s3_key VARCHAR(400) NOT NULL, + public_url TEXT NOT NULL, + + duration_sec INT, + bytes INT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_used_at TIMESTAMPTZ NOT NULL DEFAULT now(), + hit_count INT NOT NULL DEFAULT 0 +); + + +-- ============================================================ +-- Seed default preferences insert helper (for ApplicationRunner) +-- No rows here — seeder creates per-user lazily on first dispatch. +-- ============================================================ diff --git a/src/main/resources/db/migration/V20260425_1500__manager_notification_translations.sql b/src/main/resources/db/migration/V20260425_1500__manager_notification_translations.sql new file mode 100644 index 0000000..96922f6 --- /dev/null +++ b/src/main/resources/db/migration/V20260425_1500__manager_notification_translations.sql @@ -0,0 +1,12 @@ +-- Phase 3 i18n: store per-locale translations for manager notification title/body. +-- Source columns (title, body) keep authoring locale (default vi); translations +-- map populated asynchronously by AI-Service. + +ALTER TABLE manager_notifications + ADD COLUMN IF NOT EXISTS title_translations TEXT, + ADD COLUMN IF NOT EXISTS body_translations TEXT; + +COMMENT ON COLUMN manager_notifications.title_translations IS + 'JSON map of locale -> translated title. Reserved keys: _source, _auto.'; +COMMENT ON COLUMN manager_notifications.body_translations IS + 'JSON map of locale -> translated body. Reserved keys: _source, _auto.'; diff --git a/src/main/resources/db/migration/V20260428_2300__voice_call_jobs_alert_context.sql b/src/main/resources/db/migration/V20260428_2300__voice_call_jobs_alert_context.sql new file mode 100644 index 0000000..077e313 --- /dev/null +++ b/src/main/resources/db/migration/V20260428_2300__voice_call_jobs_alert_context.sql @@ -0,0 +1,14 @@ +-- Denormalize alert payload onto voice_call_jobs so the webhook handler +-- can re-dispatch the same alert to landlord / manager when the tenant +-- presses DTMF=2 or doesn't answer after retries. +-- +-- Without this we'd have to query DynamoDB esp32_alerts at escalation +-- time, which adds an external dependency to the webhook hot path. +ALTER TABLE voice_call_jobs + ADD COLUMN IF NOT EXISTS house_id VARCHAR(64), + ADD COLUMN IF NOT EXISTS area_id VARCHAR(64), + ADD COLUMN IF NOT EXISTS area_name VARCHAR(200), + ADD COLUMN IF NOT EXISTS thing VARCHAR(100), + ADD COLUMN IF NOT EXISTS metric VARCHAR(40), + ADD COLUMN IF NOT EXISTS alert_value NUMERIC(10, 2), + ADD COLUMN IF NOT EXISTS alert_unit VARCHAR(20); diff --git a/src/main/resources/db/migration/V20260429_0030__quiet_hours_enabled.sql b/src/main/resources/db/migration/V20260429_0030__quiet_hours_enabled.sql new file mode 100644 index 0000000..f06b67a --- /dev/null +++ b/src/main/resources/db/migration/V20260429_0030__quiet_hours_enabled.sql @@ -0,0 +1,5 @@ +-- Master on/off for the quiet-hours window. Existing rows default to +-- true so behaviour matches what the user already expected: window +-- enforced unless they explicitly turn it off. +ALTER TABLE user_notification_preferences + ADD COLUMN IF NOT EXISTS quiet_hours_enabled BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/src/main/resources/db/migration/V20260429_1100__voice_consent_compliance.sql b/src/main/resources/db/migration/V20260429_1100__voice_consent_compliance.sql new file mode 100644 index 0000000..5d718e2 --- /dev/null +++ b/src/main/resources/db/migration/V20260429_1100__voice_consent_compliance.sql @@ -0,0 +1,27 @@ +-- PDPL (Nghị định 13/2023/NĐ-CP) + Thông tư 22/2021/TT-BTTTT compliance: +-- voice consent must be auditable with version of T&C text, IP address +-- the user submitted from, and user agent. An immutable history table +-- preserves every grant/revoke for the 5-year retention window required +-- by Vietnamese telecom regulation. + +ALTER TABLE user_notification_preferences + ADD COLUMN IF NOT EXISTS voice_consent_text_version VARCHAR(20), + ADD COLUMN IF NOT EXISTS voice_consent_ip INET, + ADD COLUMN IF NOT EXISTS voice_consent_user_agent TEXT; + +-- Append-only audit log. Triggers fire on every grant or revoke; rows +-- never deleted (telecom audit may demand 5-year proof). +CREATE TABLE IF NOT EXISTS voice_consent_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + action VARCHAR(20) NOT NULL, -- GRANTED | REVOKED | EXPIRED + text_version VARCHAR(20), -- e.g. "v1.0-2026-04" + ip INET, + user_agent TEXT, + initiated_by VARCHAR(20) NOT NULL DEFAULT 'USER',-- USER | ADMIN | SYSTEM + initiated_by_id UUID, -- if ADMIN, who + note TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS ix_voice_consent_user + ON voice_consent_history(user_id, created_at DESC); diff --git a/src/main/resources/db/migration/V20260429_1300__subscription_plans.sql b/src/main/resources/db/migration/V20260429_1300__subscription_plans.sql new file mode 100644 index 0000000..8066b17 --- /dev/null +++ b/src/main/resources/db/migration/V20260429_1300__subscription_plans.sql @@ -0,0 +1,51 @@ +-- Landlord-managed subscription catalogue. Replaces the hard-coded +-- 1/3/6/12 month tiers in the FE so the business owner can adjust +-- pricing or roll new promotions without a code deploy. +-- +-- Duration is measured in DAYS so we can offer everything from a 7-day +-- trial (post-onboarding bait) to a multi-year discount tier without +-- forcing month-aligned arithmetic on the activation logic. + +CREATE TABLE IF NOT EXISTS subscription_plans ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(40) UNIQUE NOT NULL, + -- e.g. PREMIUM_1M, PREMIUM_TRIAL_7D, PREMIUM_ANNUAL + name_translations TEXT, + -- ISUMS i18n JSON blob: {"vi":"3 tháng","en":"3 months","ja":"3ヶ月"} + duration_days INT NOT NULL, + price_vnd INT NOT NULL, + voice_quota_monthly INT NOT NULL DEFAULT 100, + sms_quota_monthly INT NOT NULL DEFAULT 200, + sort_order INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + is_featured BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID, + updated_by UUID, + CONSTRAINT chk_duration_positive CHECK (duration_days > 0), + CONSTRAINT chk_price_nonneg CHECK (price_vnd >= 0) +); + +CREATE INDEX IF NOT EXISTS ix_plan_active_sort + ON subscription_plans(is_active, sort_order); + +-- Seed canonical defaults — landlord can edit / disable / supplement. +INSERT INTO subscription_plans + (code, name_translations, duration_days, price_vnd, + voice_quota_monthly, sms_quota_monthly, sort_order, is_featured) +VALUES + -- 12.000đ là ngưỡng an toàn — VNPay khuyến nghị >=10.000đ và một số + -- ngân hàng (Sacombank, ACB...) reject thẻ dưới 10k. Để 12k cho buffer + -- mà vẫn đủ rẻ để cảm giác như "trial". + ('PREMIUM_TRIAL_7D', '{"vi":"Dùng thử 7 ngày","en":"7-day trial","ja":"7日間のトライアル"}', + 7, 12000, 30, 60, 1, false), + ('PREMIUM_1M', '{"vi":"1 tháng","en":"1 month","ja":"1ヶ月"}', + 30, 19000, 100, 200, 10, false), + ('PREMIUM_3M', '{"vi":"3 tháng","en":"3 months","ja":"3ヶ月"}', + 90, 54000, 100, 200, 20, true), + ('PREMIUM_6M', '{"vi":"6 tháng","en":"6 months","ja":"6ヶ月"}', + 180, 102000, 100, 200, 30, false), + ('PREMIUM_12M', '{"vi":"12 tháng","en":"12 months","ja":"12ヶ月"}', + 365, 190000, 100, 200, 40, false) +ON CONFLICT (code) DO NOTHING; diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..ad97959 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,23 @@ + + + + + + + + true + true + true + true + true + true + true + true + true + + + + + + + \ No newline at end of file diff --git a/src/test/java/com/isums/notificationservice/controllers/ManagerNotificationControllerTest.java b/src/test/java/com/isums/notificationservice/controllers/ManagerNotificationControllerTest.java index 3815388..c82ea87 100644 --- a/src/test/java/com/isums/notificationservice/controllers/ManagerNotificationControllerTest.java +++ b/src/test/java/com/isums/notificationservice/controllers/ManagerNotificationControllerTest.java @@ -16,6 +16,8 @@ import org.springframework.core.MethodParameter; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; @@ -36,6 +38,8 @@ import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -137,7 +141,10 @@ void stream() throws Exception { when(service.countUnread(userId)).thenReturn(5L); mvc.perform(get("/api/notifications/manager/stream")) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM)) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-cache, no-transform")) + .andExpect(header().string("X-Accel-Buffering", "no")); verify(sseManager).subscribe(userId); verify(service).countUnread(userId); diff --git a/src/test/java/com/isums/notificationservice/infrastructures/Websockets/SseConnectionManagerTest.java b/src/test/java/com/isums/notificationservice/infrastructures/Websockets/SseConnectionManagerTest.java index 3ec0b30..a7ecf4e 100644 --- a/src/test/java/com/isums/notificationservice/infrastructures/Websockets/SseConnectionManagerTest.java +++ b/src/test/java/com/isums/notificationservice/infrastructures/Websockets/SseConnectionManagerTest.java @@ -53,4 +53,24 @@ void multipleSubscribers() { assertThat(e1).isNotSameAs(e2); manager.push(recipientId, notif(recipientId)); } + + @Test + @DisplayName("heartbeat removes emitters that can no longer be written") + void heartbeatRemovesFailedEmitter() { + UUID recipientId = UUID.randomUUID(); + manager.subscribe(recipientId, new FailingEmitter()); + + assertThat(manager.connectionCount(recipientId)).isEqualTo(1); + + manager.sendHeartbeats(); + + assertThat(manager.connectionCount(recipientId)).isZero(); + } + + private static final class FailingEmitter extends SseEmitter { + @Override + public void send(SseEventBuilder builder) throws IOException { + throw new IOException("closed"); + } + } } diff --git a/src/test/java/com/isums/notificationservice/infrastructures/kafka/ContractEventListenerTest.java b/src/test/java/com/isums/notificationservice/infrastructures/kafka/ContractEventListenerTest.java index c12b3cc..fe3f780 100644 --- a/src/test/java/com/isums/notificationservice/infrastructures/kafka/ContractEventListenerTest.java +++ b/src/test/java/com/isums/notificationservice/infrastructures/kafka/ContractEventListenerTest.java @@ -1,9 +1,13 @@ package com.isums.notificationservice.infrastructures.kafka; import com.isums.notificationservice.domains.enums.NotificationCategory; +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.InspectionDoneNotifyEvent; import com.isums.notificationservice.domains.events.InspectionScheduledEvent; import com.isums.notificationservice.infrastructures.abstracts.ManagerNotificationService; +import com.isums.notificationservice.services.NotificationRecipientResolver; import common.kafkas.IdempotencyService; import common.kafkas.KafkaListenerHelper; import org.apache.kafka.clients.consumer.ConsumerRecord; @@ -18,6 +22,8 @@ import org.springframework.kafka.support.Acknowledgment; import tools.jackson.databind.ObjectMapper; +import java.time.Instant; +import java.util.List; import java.util.Map; import java.util.UUID; @@ -27,6 +33,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -36,6 +43,7 @@ class ContractEventListenerTest { @Mock private ManagerNotificationService notificationService; + @Mock private NotificationRecipientResolver recipientResolver; @Mock private ObjectMapper objectMapper; @Mock private IdempotencyService idempotencyService; @Mock private KafkaListenerHelper kafkaHelper; @@ -55,16 +63,21 @@ class Scheduled { void happy() throws Exception { when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); when(idempotencyService.isDuplicate("m1")).thenReturn(false); + UUID houseId = UUID.randomUUID(); + UUID managerId = UUID.randomUUID(); + UUID landlordId = UUID.randomUUID(); InspectionScheduledEvent event = new InspectionScheduledEvent( - UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), "Alice", "m1"); + UUID.randomUUID(), UUID.randomUUID(), houseId, managerId, "Alice", "m1"); when(objectMapper.readValue("v", InspectionScheduledEvent.class)).thenReturn(event); + when(recipientResolver.resolveLandlordAndManager(houseId, managerId)) + .thenReturn(List.of(landlordId, managerId)); listener.handleInspectionScheduled(rec, ack); ArgumentCaptor cap = ArgumentCaptor.forClass(NotificationCategory.class); - verify(notificationService).send(eq(event.getManagerId()), cap.capture(), + verify(notificationService, times(2)).send(any(UUID.class), cap.capture(), anyString(), anyString(), anyString(), any(Map.class)); - assertThat(cap.getValue()).isEqualTo(NotificationCategory.CONTRACT_EXPIRED); + assertThat(cap.getAllValues()).containsOnly(NotificationCategory.CONTRACT_EXPIRED); verify(ack).acknowledge(); } @@ -118,4 +131,161 @@ void happy() throws Exception { verify(ack).acknowledge(); } } + + @Nested + @DisplayName("handleReadyForLandlordSignature") + class ReadyForLandlordSignature { + + private final ConsumerRecord rec = + new ConsumerRecord<>("contract.ready-for-landlord-signature", 0, 0L, "k", "v"); + + @Test + @DisplayName("sends CONTRACT_READY_FOR_LANDLORD_SIGNATURE notification on happy path") + void happy() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + + ContractReadyForLandlordSignatureEvent event = new ContractReadyForLandlordSignatureEvent( + "m1", UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), + "Alice", "Lease April", "doc-123"); + when(objectMapper.readValue("v", ContractReadyForLandlordSignatureEvent.class)).thenReturn(event); + + listener.handleReadyForLandlordSignature(rec, ack); + + ArgumentCaptor metadataCap = ArgumentCaptor.forClass(Map.class); + verify(notificationService).send( + eq(event.getRecipientUserId()), + eq(NotificationCategory.CONTRACT_READY_FOR_LANDLORD_SIGNATURE), + anyString(), + anyString(), + eq("/contracts/" + event.getContractId()), + metadataCap.capture() + ); + assertThat(metadataCap.getValue()) + .containsEntry("contractId", event.getContractId().toString()) + .containsEntry("tenantId", event.getTenantId().toString()) + .containsEntry("documentId", "doc-123") + .containsEntry("status", "READY"); + verify(ack).acknowledge(); + } + } + + @Nested + @DisplayName("handleContractCompleted") + class ContractCompleted { + + private final ConsumerRecord rec = + new ConsumerRecord<>("contract-completed-topic", 0, 0L, "k", "v"); + + @Test + @DisplayName("sends CONTRACT_COMPLETED notification on happy path") + void happy() throws Exception { + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + + UUID contractId = UUID.randomUUID(); + UUID tenantId = UUID.randomUUID(); + UUID houseId = UUID.randomUUID(); + UUID landlordId = UUID.randomUUID(); + UUID managerId = UUID.randomUUID(); + Instant completedAt = Instant.now(); + + ContractCompletedEvent event = new ContractCompletedEvent( + contractId, + tenantId, + "alice@example.com", + false, + houseId, + landlordId, + 1_000_000L, + 5_000_000L, + 5, + Instant.now(), + Instant.now().plusSeconds(86_400), + completedAt, + "https://signed-pdf" + ); + when(objectMapper.readValue("v", ContractCompletedEvent.class)).thenReturn(event); + when(recipientResolver.resolveLandlordAndManager(houseId, landlordId)) + .thenReturn(List.of(landlordId, managerId)); + + listener.handleContractCompleted(rec, ack); + + ArgumentCaptor metadataCap = ArgumentCaptor.forClass(Map.class); + verify(notificationService, times(2)).send( + any(UUID.class), + eq(NotificationCategory.CONTRACT_COMPLETED), + anyString(), + anyString(), + eq("/contracts/" + contractId), + metadataCap.capture() + ); + assertThat(metadataCap.getValue()) + .containsEntry("contractId", contractId.toString()) + .containsEntry("tenantId", tenantId.toString()) + .containsEntry("houseId", houseId.toString()) + .containsEntry("status", "COMPLETED") + .containsEntry("completedAt", completedAt.toString()) + .containsEntry("signedPdfUrl", "https://signed-pdf"); + verify(ack).acknowledge(); + } + } + + @Nested + @DisplayName("handleContractCancelledByTenant") + class ContractCancelledByTenant { + + private final ConsumerRecord rec = + new ConsumerRecord<>("contract.cancelled-by-tenant", 0, 0L, "k", "v"); + + @Test + @DisplayName("sends CONTRACT_CANCELLED_BY_TENANT 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 tenantId = UUID.randomUUID(); + UUID initiatorId = UUID.randomUUID(); + UUID landlordId = UUID.randomUUID(); + UUID managerId = UUID.randomUUID(); + Instant cancelledAt = Instant.now(); + + ContractCancelledByTenantEvent event = new ContractCancelledByTenantEvent( + "m1", + contractId, + houseId, + tenantId, + "Alice", + "Khong ky nua", + cancelledAt, + initiatorId + ); + + when(objectMapper.readValue("v", ContractCancelledByTenantEvent.class)).thenReturn(event); + when(recipientResolver.resolveLandlordAndManager(houseId, initiatorId)) + .thenReturn(List.of(landlordId, managerId)); + + listener.handleContractCancelledByTenant(rec, ack); + + ArgumentCaptor metadataCap = ArgumentCaptor.forClass(Map.class); + verify(notificationService, times(2)).send( + any(UUID.class), + eq(NotificationCategory.CONTRACT_CANCELLED_BY_TENANT), + anyString(), + anyString(), + eq("/contracts/" + contractId), + metadataCap.capture() + ); + assertThat(metadataCap.getValue()) + .containsEntry("contractId", contractId.toString()) + .containsEntry("houseId", houseId.toString()) + .containsEntry("tenantId", tenantId.toString()) + .containsEntry("status", "CANCELLED_BY_TENANT") + .containsEntry("cancelledAt", cancelledAt.toString()) + .containsEntry("reason", "Khong ky nua"); + verify(ack).acknowledge(); + } + } } diff --git a/src/test/java/com/isums/notificationservice/infrastructures/kafka/IssueNotificationEventListenerTest.java b/src/test/java/com/isums/notificationservice/infrastructures/kafka/IssueNotificationEventListenerTest.java new file mode 100644 index 0000000..f7f2252 --- /dev/null +++ b/src/test/java/com/isums/notificationservice/infrastructures/kafka/IssueNotificationEventListenerTest.java @@ -0,0 +1,188 @@ +package com.isums.notificationservice.infrastructures.kafka; + +import com.isums.notificationservice.domains.enums.NotificationCategory; +import com.isums.notificationservice.domains.events.IssueQuoteSubmittedEvent; +import com.isums.notificationservice.domains.events.IssueWorkSlotAssignedEvent; +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 org.apache.kafka.clients.consumer.ConsumerRecord; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.kafka.support.Acknowledgment; +import tools.jackson.databind.ObjectMapper; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("IssueNotificationEventListener") +class IssueNotificationEventListenerTest { + + @Mock private ManagerNotificationService notificationService; + @Mock private NotificationRecipientResolver recipientResolver; + @Mock private UserGrpcClient userGrpcClient; + @Mock private ObjectMapper objectMapper; + @Mock private IdempotencyService idempotencyService; + @Mock private KafkaListenerHelper kafkaHelper; + @Mock private Acknowledgment ack; + + @InjectMocks private IssueNotificationEventListener listener; + + @Nested + @DisplayName("handleIssueWorkSlotAssigned") + class IssueWorkSlotAssigned { + + private final ConsumerRecord rec = + new ConsumerRecord<>("job.assigned", 0, 0L, "k", "v"); + + @Test + @DisplayName("sends ISSUE_WORK_SLOT_CREATED to landlord and manager") + void happy() throws Exception { + UUID issueId = UUID.randomUUID(); + UUID houseId = UUID.randomUUID(); + UUID slotId = UUID.randomUUID(); + UUID staffId = UUID.randomUUID(); + UUID landlordId = UUID.randomUUID(); + UUID managerId = UUID.randomUUID(); + + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + when(recipientResolver.resolveLandlordAndManager(houseId)) + .thenReturn(List.of(landlordId, managerId)); + when(userGrpcClient.getUserById(staffId)).thenReturn( + UserResponse.newBuilder().setId(staffId.toString()).setName("Staff A").build()); + + IssueWorkSlotAssignedEvent event = new IssueWorkSlotAssignedEvent( + issueId, null, houseId, slotId, staffId, "ISSUE", null, null, "JOB_ASSIGNED"); + when(objectMapper.readValue("v", IssueWorkSlotAssignedEvent.class)).thenReturn(event); + + listener.handleIssueWorkSlotAssigned(rec, ack); + + ArgumentCaptor metadataCap = ArgumentCaptor.forClass(Map.class); + verify(notificationService, times(2)).send( + any(UUID.class), + eq(NotificationCategory.ISSUE_WORK_SLOT_CREATED), + any(), + any(), + eq("/issues/" + issueId), + metadataCap.capture()); + assertThat(metadataCap.getValue()) + .containsEntry("issueId", issueId.toString()) + .containsEntry("houseId", houseId.toString()) + .containsEntry("slotId", slotId.toString()) + .containsEntry("staffId", staffId.toString()) + .containsEntry("status", "SCHEDULED"); + verify(ack).acknowledge(); + } + } + + @Nested + @DisplayName("handleIssueCreated") + class IssueCreated { + + private final ConsumerRecord rec = + new ConsumerRecord<>("job.created", 0, 0L, "k", "v"); + + @Test + @DisplayName("sends ISSUE_WORK_SLOT_CREATED to tenant when issue is created") + void happy() throws Exception { + UUID issueId = UUID.randomUUID(); + UUID houseId = UUID.randomUUID(); + UUID tenantId = UUID.randomUUID(); + + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + when(userGrpcClient.getUserById(tenantId)).thenReturn( + UserResponse.newBuilder().setId(tenantId.toString()).setName("Tenant A").build()); + + IssueWorkSlotAssignedEvent event = new IssueWorkSlotAssignedEvent( + issueId, tenantId, houseId, null, null, "ISSUE", null, null, "JOB_CREATED"); + when(objectMapper.readValue("v", IssueWorkSlotAssignedEvent.class)).thenReturn(event); + + listener.handleIssueCreated(rec, ack); + + ArgumentCaptor metadataCap = ArgumentCaptor.forClass(Map.class); + verify(notificationService).send( + eq(tenantId), + eq(NotificationCategory.ISSUE_WORK_SLOT_CREATED), + any(), + any(), + eq("/issues/" + issueId), + metadataCap.capture()); + assertThat(metadataCap.getValue()) + .containsEntry("issueId", issueId.toString()) + .containsEntry("houseId", houseId.toString()) + .containsEntry("tenantId", tenantId.toString()) + .containsEntry("status", "CREATED"); + verify(ack).acknowledge(); + } + } + + @Nested + @DisplayName("handleIssueQuoteSubmitted") + class IssueQuoteSubmitted { + + private final ConsumerRecord rec = + new ConsumerRecord<>("issue.quote.submitted", 0, 0L, "k", "v"); + + @Test + @DisplayName("sends ISSUE_QUOTE_WAITING_MANAGER_APPROVAL to landlord and manager") + void happy() throws Exception { + UUID issueId = UUID.randomUUID(); + UUID quoteId = UUID.randomUUID(); + UUID houseId = UUID.randomUUID(); + UUID staffId = UUID.randomUUID(); + UUID landlordId = UUID.randomUUID(); + UUID managerId = UUID.randomUUID(); + + when(kafkaHelper.extractMessageId(rec)).thenReturn("m1"); + when(idempotencyService.isDuplicate("m1")).thenReturn(false); + when(recipientResolver.resolveLandlordAndManager(houseId)) + .thenReturn(List.of(landlordId, managerId)); + when(userGrpcClient.getUserById(staffId)).thenReturn( + UserResponse.newBuilder().setId(staffId.toString()).setName("Staff B").build()); + + IssueQuoteSubmittedEvent event = new IssueQuoteSubmittedEvent( + "m1", issueId, quoteId, houseId, staffId, BigDecimal.valueOf(550_000), null); + when(objectMapper.readValue("v", IssueQuoteSubmittedEvent.class)).thenReturn(event); + + listener.handleIssueQuoteSubmitted(rec, ack); + + ArgumentCaptor metadataCap = ArgumentCaptor.forClass(Map.class); + verify(notificationService, times(2)).send( + any(UUID.class), + eq(NotificationCategory.ISSUE_QUOTE_WAITING_MANAGER_APPROVAL), + any(), + any(), + eq("/issues/" + issueId), + metadataCap.capture()); + assertThat(metadataCap.getValue()) + .containsEntry("issueId", issueId.toString()) + .containsEntry("quoteId", quoteId.toString()) + .containsEntry("houseId", houseId.toString()) + .containsEntry("staffId", staffId.toString()) + .containsEntry("status", "WAITING_MANAGER_APPROVAL_QUOTE") + .containsEntry("totalPrice", "550000"); + 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 bfbec6e..65a6163 100644 --- a/src/test/java/com/isums/notificationservice/infrastructures/listeners/UserEventListenerTest.java +++ b/src/test/java/com/isums/notificationservice/infrastructures/listeners/UserEventListenerTest.java @@ -130,7 +130,7 @@ class HandleActivated { private UserActivatedEvent eventWithInvoice(String paymentUrl) { return UserActivatedEvent.builder() .userId(UUID.randomUUID()) - .email("bob@example.com").name("Bob").tempPassword("Temp@123") + .email("bob@example.com").name("Bob") .firstRentPaymentUrl(paymentUrl) .firstRentAmount(5_000_000L) .firstRentDueDate(Instant.now().plusSeconds(86400)) diff --git a/src/test/java/com/isums/notificationservice/services/ManagerNotificationServiceImplTest.java b/src/test/java/com/isums/notificationservice/services/ManagerNotificationServiceImplTest.java index 7db85f4..98b40d0 100644 --- a/src/test/java/com/isums/notificationservice/services/ManagerNotificationServiceImplTest.java +++ b/src/test/java/com/isums/notificationservice/services/ManagerNotificationServiceImplTest.java @@ -5,6 +5,7 @@ import com.isums.notificationservice.domains.enums.NotificationCategory; import com.isums.notificationservice.exceptions.NotFoundException; import com.isums.notificationservice.infrastructures.Websockets.SseConnectionManager; +import com.isums.notificationservice.infrastructures.kafka.NotificationTranslationRequester; import com.isums.notificationservice.infrastructures.repositories.ManagerNotificationRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -37,6 +38,7 @@ class ManagerNotificationServiceImplTest { @Mock private ManagerNotificationRepository repo; @Mock private SseConnectionManager sseManager; + @Mock private NotificationTranslationRequester translationRequester; @InjectMocks private ManagerNotificationServiceImpl service; @@ -62,10 +64,17 @@ void sends() { ManagerNotification saved = cap.getValue(); assertThat(saved.getRecipientId()).isEqualTo(recipientId); assertThat(saved.getTitle()).isEqualTo("Title"); + assertThat(saved.getTitleTranslations().asMap()).containsEntry("en", "Title"); + assertThat(saved.getTitleTranslations().asMap()).doesNotContainEntry("vi", "en"); + assertThat(saved.getTitleTranslations().asMap()).doesNotContainEntry("ja", "Title"); + assertThat(saved.getBodyTranslations().asMap()).containsEntry("en", "Body"); + assertThat(saved.getBodyTranslations().asMap()).doesNotContainEntry("vi", "en"); + assertThat(saved.getBodyTranslations().asMap()).doesNotContainEntry("ja", "Body"); assertThat(saved.getCategory()).isEqualTo(NotificationCategory.PAYMENT_OVERDUE); assertThat(saved.isRead()).isFalse(); verify(sseManager).push(recipientId, saved); + verify(translationRequester).requestMissing(saved, "en"); } } From d88e165f9fc56920f8832e60c49090b5bbfbd9f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20=C4=90=E1=BB=A9c=20Hi=E1=BB=87u?= Date: Fri, 8 May 2026 02:55:40 +0700 Subject: [PATCH 11/11] Integrate AWS Pinpoint Voice provider, runtime settings for voice configuration, and subscription per house logic. --- build.gradle | 1 + .../VoiceNotificationConfig.java | 11 ++ .../NotificationAdminSettingsController.java | 58 +++++++++ .../NotificationPreferencesController.java | 10 +- .../NotificationSubscriptionController.java | 11 +- .../domains/dtos/SubscriptionDto.java | 1 + .../entities/NotificationRuntimeSetting.java | 32 +++++ .../entities/NotificationSubscription.java | 5 + .../entities/NotificationSubscriptionId.java | 20 ++++ .../domains/enums/AlertEventType.java | 4 +- .../PaymentSubscriptionActivatedEvent.java | 1 + .../abstracts/ManagerNotificationService.java | 4 + .../kafka/ContractEventListener.java | 5 + .../PaymentSubscriptionListener.java | 15 ++- .../NotificationRuntimeSettingRepository.java | 10 ++ .../NotificationSubscriptionRepository.java | 8 +- .../seeders/EmailTemplateSeeder.java | 40 ++++++- .../seeders/VoiceAlertTemplateSeeder.java | 110 ++++++++++-------- .../services/AwsPinpointVoiceClient.java | 98 ++++++++++++++++ .../services/ChannelPolicy.java | 8 +- .../ManagerNotificationServiceImpl.java | 32 +++-- .../services/NotificationDispatchService.java | 37 +++++- .../NotificationPreferenceService.java | 34 ++++-- .../services/NotificationQuotaService.java | 28 +++-- .../NotificationRuntimeSettingService.java | 59 ++++++++++ .../NotificationSubscriptionService.java | 67 +++-------- .../services/PremiumExpirationScheduler.java | 6 +- .../services/VoiceProviderRouter.java | 35 +++--- .../services/VoiceWebhookHandler.java | 14 ++- ...07_2200__notification_runtime_settings.sql | 10 ++ ...0__notification_subscription_per_house.sql | 18 +++ 31 files changed, 612 insertions(+), 180 deletions(-) create mode 100644 src/main/java/com/isums/notificationservice/controllers/NotificationAdminSettingsController.java create mode 100644 src/main/java/com/isums/notificationservice/domains/entities/NotificationRuntimeSetting.java create mode 100644 src/main/java/com/isums/notificationservice/domains/entities/NotificationSubscriptionId.java create mode 100644 src/main/java/com/isums/notificationservice/infrastructures/repositories/NotificationRuntimeSettingRepository.java create mode 100644 src/main/java/com/isums/notificationservice/services/AwsPinpointVoiceClient.java create mode 100644 src/main/java/com/isums/notificationservice/services/NotificationRuntimeSettingService.java create mode 100644 src/main/resources/db/migration/V20260507_2200__notification_runtime_settings.sql create mode 100644 src/main/resources/db/migration/V20260508_1100__notification_subscription_per_house.sql diff --git a/build.gradle b/build.gradle index bd4285f..8614afe 100644 --- a/build.gradle +++ b/build.gradle @@ -86,6 +86,7 @@ dependencies { // phone-number verification, then auto-promoted out of Sandbox once // AWS approves the production access request). implementation 'software.amazon.awssdk:sns' + implementation 'software.amazon.awssdk:pinpointsmsvoicev2' // Stringee REST API JWT auth (HS256 signing). nimbus-jose-jwt already // pulled by spring-boot-starter-oauth2-resource-server, but pin here // for clarity. diff --git a/src/main/java/com/isums/notificationservice/configurations/VoiceNotificationConfig.java b/src/main/java/com/isums/notificationservice/configurations/VoiceNotificationConfig.java index 12b1424..66e102a 100644 --- a/src/main/java/com/isums/notificationservice/configurations/VoiceNotificationConfig.java +++ b/src/main/java/com/isums/notificationservice/configurations/VoiceNotificationConfig.java @@ -5,6 +5,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.web.client.RestClient; import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.pinpointsmsvoicev2.PinpointSmsVoiceV2Client; import software.amazon.awssdk.services.polly.PollyClient; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.sns.SnsClient; @@ -56,6 +57,16 @@ public S3Client voiceAudioS3Client() { .build(); } + @Value("${app.notification.aws.voice.region:us-east-1}") + private String pinpointVoiceRegion; + + @Bean + public PinpointSmsVoiceV2Client pinpointSmsVoiceV2Client() { + return PinpointSmsVoiceV2Client.builder() + .region(Region.of(pinpointVoiceRegion)) + .build(); + } + /** * AWS SNS client for transactional SMS. Uses the default credential * provider chain (env vars / instance profile / shared credentials), diff --git a/src/main/java/com/isums/notificationservice/controllers/NotificationAdminSettingsController.java b/src/main/java/com/isums/notificationservice/controllers/NotificationAdminSettingsController.java new file mode 100644 index 0000000..48a33b8 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/controllers/NotificationAdminSettingsController.java @@ -0,0 +1,58 @@ +package com.isums.notificationservice.controllers; + +import com.isums.notificationservice.domains.dtos.ApiResponse; +import com.isums.notificationservice.domains.dtos.ApiResponses; +import com.isums.notificationservice.services.NotificationRuntimeSettingService; +import com.isums.notificationservice.services.VoiceProviderRouter; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/notifications/admin/settings") +@RequiredArgsConstructor +public class NotificationAdminSettingsController { + + private final NotificationRuntimeSettingService settingService; + private final VoiceProviderRouter voiceRouter; + + @GetMapping("/voice-provider") + @PreAuthorize("hasAnyRole('LANDLORD', 'SYSTEM_ADMIN', 'ADMIN', 'MANAGER')") + public ResponseEntity>> getVoiceProvider() { + String active = voiceRouter.activeProviderId(); + List available = voiceRouter.availableProviderIds(); + return ResponseEntity.ok(ApiResponses.ok( + Map.of("active", active, "available", available), + "OK")); + } + + @PutMapping("/voice-provider") + @PreAuthorize("hasAnyRole('LANDLORD', 'SYSTEM_ADMIN', 'ADMIN', 'MANAGER')") + public ResponseEntity>> setVoiceProvider( + @AuthenticationPrincipal Jwt jwt, + @RequestBody VoiceProviderUpdateRequest req) { + String desired = req.provider() == null ? "" : req.provider().trim().toUpperCase(); + List available = voiceRouter.availableProviderIds(); + if (!available.contains(desired)) { + return ResponseEntity.badRequest().body(ApiResponses.fail( + HttpStatus.BAD_REQUEST, + "Unknown provider: " + desired + ". Available: " + available)); + } + UUID actor = UUID.fromString(jwt.getSubject()); + settingService.set(NotificationRuntimeSettingService.KEY_VOICE_PROVIDER, desired, actor); + return ResponseEntity.ok(ApiResponses.ok( + Map.of("active", desired, "available", available), + "Voice provider updated")); + } + + public record VoiceProviderUpdateRequest(@NotBlank String provider) {} +} diff --git a/src/main/java/com/isums/notificationservice/controllers/NotificationPreferencesController.java b/src/main/java/com/isums/notificationservice/controllers/NotificationPreferencesController.java index 5dc382a..47883e9 100644 --- a/src/main/java/com/isums/notificationservice/controllers/NotificationPreferencesController.java +++ b/src/main/java/com/isums/notificationservice/controllers/NotificationPreferencesController.java @@ -84,19 +84,21 @@ private static boolean hasAnyRealmRole(Jwt jwt, String... roles) { @GetMapping("/me/subscription") public ResponseEntity> getMySubscription( - @AuthenticationPrincipal Jwt jwt) { + @AuthenticationPrincipal Jwt jwt, + @RequestParam("houseId") UUID houseId) { UUID userId = UUID.fromString(jwt.getSubject()); SubscriptionDto dto = subscriptionService.toDto( - preferenceService.getSubscriptionOrCreate(userId)); + preferenceService.getSubscriptionOrCreate(userId, houseId)); return ResponseEntity.ok(ApiResponses.ok(dto, "OK")); } @GetMapping("/me/quota") public ResponseEntity>> getMyQuota( - @AuthenticationPrincipal Jwt jwt) { + @AuthenticationPrincipal Jwt jwt, + @RequestParam("houseId") UUID houseId) { UUID userId = UUID.fromString(jwt.getSubject()); SubscriptionDto sub = subscriptionService.toDto( - preferenceService.getSubscriptionOrCreate(userId)); + preferenceService.getSubscriptionOrCreate(userId, houseId)); long cooldown = quotaService.remainingRateLimitSec(userId); Map body = Map.of( "tier", sub.tier(), diff --git a/src/main/java/com/isums/notificationservice/controllers/NotificationSubscriptionController.java b/src/main/java/com/isums/notificationservice/controllers/NotificationSubscriptionController.java index 662e0e1..20d7be6 100644 --- a/src/main/java/com/isums/notificationservice/controllers/NotificationSubscriptionController.java +++ b/src/main/java/com/isums/notificationservice/controllers/NotificationSubscriptionController.java @@ -33,7 +33,7 @@ public class NotificationSubscriptionController { @PreAuthorize("hasAnyRole('LANDLORD', 'SYSTEM_ADMIN')") public ResponseEntity> adminGrant( @RequestBody AdminGrantRequest req) { - var sub = subscriptionService.activatePremium(req.userId(), req.months()); + var sub = subscriptionService.activatePremium(req.userId(), req.houseId(), req.months()); return ResponseEntity.ok(ApiResponses.ok( subscriptionService.toDto(sub), "Premium granted")); } @@ -42,9 +42,10 @@ public ResponseEntity> adminGrant( @PreAuthorize("hasAnyRole('LANDLORD', 'SYSTEM_ADMIN')") public ResponseEntity>> adminDowngrade( @RequestBody DowngradeRequest req) { - subscriptionService.downgradeToFree(req.userId()); + subscriptionService.downgradeToFree(req.userId(), req.houseId()); return ResponseEntity.ok(ApiResponses.ok( - Map.of("userId", req.userId(), "tier", "FREE"), "Downgraded")); + Map.of("userId", req.userId(), "houseId", req.houseId(), "tier", "FREE"), + "Downgraded")); } /** @@ -70,7 +71,7 @@ public ResponseEntity>> selfUpgrade( "Payment intent created")); } - public record AdminGrantRequest(UUID userId, int months) {} - public record DowngradeRequest(UUID userId) {} + public record AdminGrantRequest(UUID userId, UUID houseId, int months) {} + public record DowngradeRequest(UUID userId, UUID houseId) {} public record UpgradeRequest(int months) {} } diff --git a/src/main/java/com/isums/notificationservice/domains/dtos/SubscriptionDto.java b/src/main/java/com/isums/notificationservice/domains/dtos/SubscriptionDto.java index d03bbbe..9adf574 100644 --- a/src/main/java/com/isums/notificationservice/domains/dtos/SubscriptionDto.java +++ b/src/main/java/com/isums/notificationservice/domains/dtos/SubscriptionDto.java @@ -7,6 +7,7 @@ public record SubscriptionDto( UUID userId, + UUID houseId, SubscriptionTier tier, Instant premiumStartedAt, Instant premiumUntil, diff --git a/src/main/java/com/isums/notificationservice/domains/entities/NotificationRuntimeSetting.java b/src/main/java/com/isums/notificationservice/domains/entities/NotificationRuntimeSetting.java new file mode 100644 index 0000000..828042c --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/entities/NotificationRuntimeSetting.java @@ -0,0 +1,32 @@ +package com.isums.notificationservice.domains.entities; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "notification_runtime_settings") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class NotificationRuntimeSetting { + + @Id + @Column(name = "setting_key", length = 80) + private String settingKey; + + @Column(name = "setting_value", nullable = false, length = 500) + private String settingValue; + + @Column(name = "updated_by", columnDefinition = "uuid") + private UUID updatedBy; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/src/main/java/com/isums/notificationservice/domains/entities/NotificationSubscription.java b/src/main/java/com/isums/notificationservice/domains/entities/NotificationSubscription.java index 089fb5f..0312c74 100644 --- a/src/main/java/com/isums/notificationservice/domains/entities/NotificationSubscription.java +++ b/src/main/java/com/isums/notificationservice/domains/entities/NotificationSubscription.java @@ -16,12 +16,17 @@ @NoArgsConstructor @AllArgsConstructor @Builder +@IdClass(NotificationSubscriptionId.class) public class NotificationSubscription { @Id @Column(name = "user_id", columnDefinition = "uuid") private UUID userId; + @Id + @Column(name = "house_id", columnDefinition = "uuid") + private UUID houseId; + @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) @Builder.Default diff --git a/src/main/java/com/isums/notificationservice/domains/entities/NotificationSubscriptionId.java b/src/main/java/com/isums/notificationservice/domains/entities/NotificationSubscriptionId.java new file mode 100644 index 0000000..8670f81 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/domains/entities/NotificationSubscriptionId.java @@ -0,0 +1,20 @@ +package com.isums.notificationservice.domains.entities; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; +import java.util.UUID; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class NotificationSubscriptionId implements Serializable { + private UUID userId; + private UUID houseId; +} diff --git a/src/main/java/com/isums/notificationservice/domains/enums/AlertEventType.java b/src/main/java/com/isums/notificationservice/domains/enums/AlertEventType.java index cac26e5..70d2f63 100644 --- a/src/main/java/com/isums/notificationservice/domains/enums/AlertEventType.java +++ b/src/main/java/com/isums/notificationservice/domains/enums/AlertEventType.java @@ -12,10 +12,10 @@ public enum AlertEventType { // Critical — safety / immediate action GAS_CRITICAL, // MQ2 over 300 ppm FIRE_CRITICAL, // temperature > 55°C - POWER_LOST, // controller reports PZEM outage WATER_LEAK_SUSPECTED, // > 10 min continuous flow // Warning — attention needed but not immediate + POWER_LOST, // controller reports PZEM outage — email-only to avoid SMS cost GAS_WARNING, TEMPERATURE_HIGH, HUMIDITY_HIGH, @@ -49,7 +49,7 @@ public boolean isCritical() { */ public AlertSeverity severity() { return switch (this) { - case GAS_CRITICAL, FIRE_CRITICAL, POWER_LOST, WATER_LEAK_SUSPECTED, + case GAS_CRITICAL, FIRE_CRITICAL, WATER_LEAK_SUSPECTED, UTILITY_ELECTRICITY_CRITICAL, UTILITY_WATER_CRITICAL -> AlertSeverity.CRITICAL; case POWER_RESTORED -> AlertSeverity.INFO; default -> AlertSeverity.WARNING; diff --git a/src/main/java/com/isums/notificationservice/domains/events/PaymentSubscriptionActivatedEvent.java b/src/main/java/com/isums/notificationservice/domains/events/PaymentSubscriptionActivatedEvent.java index c4d64ab..5a9e245 100644 --- a/src/main/java/com/isums/notificationservice/domains/events/PaymentSubscriptionActivatedEvent.java +++ b/src/main/java/com/isums/notificationservice/domains/events/PaymentSubscriptionActivatedEvent.java @@ -18,6 +18,7 @@ public record PaymentSubscriptionActivatedEvent( String intentId, UUID userId, + UUID houseId, String purpose, Integer durationDays, String planCode, diff --git a/src/main/java/com/isums/notificationservice/infrastructures/abstracts/ManagerNotificationService.java b/src/main/java/com/isums/notificationservice/infrastructures/abstracts/ManagerNotificationService.java index ff06393..2ff987d 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/abstracts/ManagerNotificationService.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/abstracts/ManagerNotificationService.java @@ -14,6 +14,10 @@ void send(UUID recipientId, NotificationCategory category, String title, String body, String actionUrl, Map metadata); + void send(UUID recipientId, NotificationCategory category, + String title, String body, String sourceLang, + String actionUrl, Map metadata); + Page getByRecipient(UUID recipientId, Pageable pageable); long countUnread(UUID recipientId); 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 6dcdaed..64f5946 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/kafka/ContractEventListener.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/kafka/ContractEventListener.java @@ -67,6 +67,7 @@ public void handleInspectionScheduled( "Hợp đồng #" + event.getContractId().toString().substring(0, 8).toUpperCase() + " của khách thuê " + event.getTenantName() + " đã hết hạn. Đã phân công nhân viên đi kiểm tra.", + "vi", "/contracts/" + event.getContractId() + "/termination", metadata ); @@ -100,6 +101,7 @@ public void handleInspectionDone( "Nhân viên đã kiểm tra xong hợp đồng #" + event.getContractId().toString().substring(0, 8).toUpperCase() + ". Vui lòng xem lại và xác nhận số tiền cọc hoàn lại.", + "vi", "/contracts/" + event.getContractId() + "/deposit-refund", Map.of( "contractId", event.getContractId().toString(), @@ -154,6 +156,7 @@ public void handleReadyForLandlordSignature( 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 ); @@ -211,6 +214,7 @@ public void handleContractCompleted( NotificationCategory.CONTRACT_COMPLETED, "Hợp đồng được ký thành công", "Người thuê nhà " + tenantLabel + " đã hoàn tất việc ký hợp đồng " + contractLabel + ".", + "vi", "/contracts/" + event.getContractId(), metadata ); @@ -269,6 +273,7 @@ public void handleContractCancelledByTenant( "Khách thuê đã huỷ ký hợp đồng", "Khách thuê " + tenantLabel + " đã huỷ ký hợp đồng #" + event.getContractId().toString().substring(0, 8).toUpperCase() + ".", + "vi", "/contracts/" + event.getContractId(), metadata ); diff --git a/src/main/java/com/isums/notificationservice/infrastructures/listeners/PaymentSubscriptionListener.java b/src/main/java/com/isums/notificationservice/infrastructures/listeners/PaymentSubscriptionListener.java index 409a680..70daec9 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/listeners/PaymentSubscriptionListener.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/listeners/PaymentSubscriptionListener.java @@ -54,6 +54,12 @@ public void onActivated(ConsumerRecord record, Acknowledgment ac ack.acknowledge(); return; } + if (event.houseId() == null) { + log.error("[SubscriptionActivated] missing houseId messageId={} userId={} — cannot activate per-house PREMIUM, skip", + messageId, event.userId()); + ack.acknowledge(); + return; + } // Defensive defaults: Payment-Service guarantees durationDays in // the new shape, but a redelivery from before the schema change @@ -87,16 +93,15 @@ public void onActivated(ConsumerRecord record, Acknowledgment ac } if (voiceQuota >= 0 && smsQuota >= 0) { - subscriptionService.activatePremiumByDays(event.userId(), days, voiceQuota, smsQuota); + subscriptionService.activatePremiumByDays(event.userId(), event.houseId(), days, voiceQuota, smsQuota); } else { - // Legacy / missing plan path — service uses TierQuotaPolicy. - subscriptionService.activatePremiumByDays(event.userId(), days); + subscriptionService.activatePremiumByDays(event.userId(), event.houseId(), days); } idempotencyService.markProcessed(messageId); ack.acknowledge(); - log.info("[SubscriptionActivated] user={} plan={} days={} voice={} sms={} txnRef={}", - event.userId(), event.planCode(), days, voiceQuota, smsQuota, event.txnRef()); + log.info("[SubscriptionActivated] user={} house={} plan={} days={} voice={} sms={} txnRef={}", + event.userId(), event.houseId(), event.planCode(), days, voiceQuota, smsQuota, event.txnRef()); } catch (Exception e) { log.error("[SubscriptionActivated] failed messageId={}: {}", messageId, e.getMessage(), e); diff --git a/src/main/java/com/isums/notificationservice/infrastructures/repositories/NotificationRuntimeSettingRepository.java b/src/main/java/com/isums/notificationservice/infrastructures/repositories/NotificationRuntimeSettingRepository.java new file mode 100644 index 0000000..7be5303 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/infrastructures/repositories/NotificationRuntimeSettingRepository.java @@ -0,0 +1,10 @@ +package com.isums.notificationservice.infrastructures.repositories; + +import com.isums.notificationservice.domains.entities.NotificationRuntimeSetting; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface NotificationRuntimeSettingRepository + extends JpaRepository { +} diff --git a/src/main/java/com/isums/notificationservice/infrastructures/repositories/NotificationSubscriptionRepository.java b/src/main/java/com/isums/notificationservice/infrastructures/repositories/NotificationSubscriptionRepository.java index d98fe8a..6ae3ee5 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/repositories/NotificationSubscriptionRepository.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/repositories/NotificationSubscriptionRepository.java @@ -1,17 +1,23 @@ package com.isums.notificationservice.infrastructures.repositories; import com.isums.notificationservice.domains.entities.NotificationSubscription; +import com.isums.notificationservice.domains.entities.NotificationSubscriptionId; import com.isums.notificationservice.domains.enums.SubscriptionTier; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.time.Instant; import java.util.List; +import java.util.Optional; import java.util.UUID; @Repository public interface NotificationSubscriptionRepository - extends JpaRepository { + extends JpaRepository { + + Optional findByUserIdAndHouseId(UUID userId, UUID houseId); + + List findAllByUserId(UUID userId); List findAllByTierAndPremiumUntilBefore( SubscriptionTier tier, Instant cutoff); 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 64a9828..678edaa 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/seeders/EmailTemplateSeeder.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/seeders/EmailTemplateSeeder.java @@ -1933,6 +1933,42 @@ I agree (Confirm) ⏰ Hạn thanh toán: {{dueDate}} + {{#paymentUrl}} + + + + {{#appDeepLink}} + + {{/appDeepLink}} + +
+ + 💳 Thanh toán ngay + + + + Mở trên ứng dụng → + +
+

+ Nếu nút không hoạt động, copy link sau vào trình duyệt:
+ {{paymentUrl}} +
+ {{/paymentUrl}} + {{^paymentUrl}} +
+
+ Mở ứng dụng ISUMS → mục Hợp đồngHợp đồng #{{contractId}} để thanh toán phần chênh lệch. +
+
+ {{/paymentUrl}}
Trân trọng,
Đội ngũ ISUMS @@ -1956,8 +1992,10 @@ I agree (Confirm) Cọc gốc: {{originalAmount}} Đã chuyển: {{transferredAmount}} Cần nộp thêm: {{additionalAmount}} (hạn {{dueDate}}). + {{#paymentUrl}}Thanh toán: {{paymentUrl}}{{/paymentUrl}} + {{#appDeepLink}}Mở trên ứng dụng: {{appDeepLink}}{{/appDeepLink}} """, - List.of("contractId", "originalAmount", "transferredAmount", "additionalAmount", "dueDate"), + List.of("contractId", "originalAmount", "transferredAmount", "additionalAmount", "dueDate", "paymentUrl", "appDeepLink"), "system" ); diff --git a/src/main/java/com/isums/notificationservice/infrastructures/seeders/VoiceAlertTemplateSeeder.java b/src/main/java/com/isums/notificationservice/infrastructures/seeders/VoiceAlertTemplateSeeder.java index 85a5607..5d4c547 100644 --- a/src/main/java/com/isums/notificationservice/infrastructures/seeders/VoiceAlertTemplateSeeder.java +++ b/src/main/java/com/isums/notificationservice/infrastructures/seeders/VoiceAlertTemplateSeeder.java @@ -54,34 +54,31 @@ public void seedAll(ChannelTemplateRepository templateRepo, seedVoice(templateRepo, versionRepo, AlertEventType.GAS_CRITICAL, "Cảnh báo khẩn cấp. Phát hiện khí gas vượt ngưỡng nguy hiểm tại {{areaName}}, " + "{{value}} {{unit}}. Vui lòng kiểm tra ngay. " - + "Nhấn 1 để xác nhận đã nghe, nhấn 2 để chuyển cho chủ nhà, " - + "nhấn 9 để tắt cảnh báo gọi.", + + "Nhấn 2 để liên hệ quản lý. Nhấn 9 để tắt cảnh báo gọi.", "Emergency alert. Gas concentration has exceeded the critical threshold at " + "{{areaName}}, {{value}} {{unit}}. Please check immediately. " - + "Press 1 to acknowledge, press 2 to escalate to your landlord, " - + "or press 9 to opt out of voice alerts.", + + "Press 2 to contact your manager, or press 9 to opt out of voice alerts.", "緊急警報。{{areaName}}で検出されたガス濃度が危険な閾値を超えました。" + "{{value}}{{unit}}。直ちに確認してください。" - + "確認するには1を、大家さんに転送するには2を、音声通知を無効にするには9を押してください。" + + "管理者に連絡するには2を、音声通知を無効にするには9を押してください。" ); seedVoice(templateRepo, versionRepo, AlertEventType.FIRE_CRITICAL, "Cảnh báo khẩn cấp. Nhiệt độ tại {{areaName}} là {{value}} độ C, vượt ngưỡng an toàn. " + "Nghi ngờ có cháy. Vui lòng kiểm tra ngay. " - + "Nhấn 1 để xác nhận, nhấn 2 để chuyển cho chủ nhà.", + + "Nhấn 2 để liên hệ quản lý.", "Emergency alert. Temperature at {{areaName}} is {{value}} degrees Celsius, " + "exceeding the safety threshold. Possible fire. Please check immediately. " - + "Press 1 to acknowledge, press 2 to escalate.", + + "Press 2 to contact your manager.", "緊急警報。{{areaName}}の温度は{{value}}度で、安全閾値を超えています。" + "火災の可能性があります。直ちに確認してください。" - + "確認するには1を、転送するには2を押してください。" + + "管理者に連絡するには2を押してください。" ); seedVoice(templateRepo, versionRepo, AlertEventType.POWER_LOST, - "Thông báo. Khu vực {{areaName}} đã mất điện. " - + "Nhấn 1 để xác nhận.", - "Notification. Power has been lost at {{areaName}}. Press 1 to acknowledge.", - "お知らせ。{{areaName}}で停電が発生しました。確認するには1を押してください。" + "Thông báo. Khu vực {{areaName}} đã mất điện.", + "Notification. Power has been lost at {{areaName}}.", + "お知らせ。{{areaName}}で停電が発生しました。" ); seedVoice(templateRepo, versionRepo, AlertEventType.POWER_RESTORED, @@ -93,146 +90,146 @@ public void seedAll(ChannelTemplateRepository templateRepo, seedVoice(templateRepo, versionRepo, AlertEventType.WATER_LEAK_SUSPECTED, "Cảnh báo. Nghi ngờ rò rỉ nước tại {{areaName}}. " + "Dòng nước chảy liên tục {{value}} {{unit}}. " - + "Vui lòng kiểm tra. Nhấn 1 để xác nhận, nhấn 2 để chuyển cho chủ nhà.", + + "Vui lòng kiểm tra. Nhấn 2 để liên hệ quản lý.", "Warning. Suspected water leak at {{areaName}}. " + "Continuous flow {{value}} {{unit}}. Please check. " - + "Press 1 to acknowledge, press 2 to escalate.", + + "Press 2 to contact your manager.", "警告。{{areaName}}で水漏れの可能性があります。" + "連続流量{{value}}{{unit}}。ご確認ください。" - + "確認するには1を、転送するには2を押してください。" + + "管理者に連絡するには2を押してください。" ); seedVoice(templateRepo, versionRepo, AlertEventType.GAS_WARNING, "Cảnh báo. Nồng độ gas tại {{areaName}} là {{value}} {{unit}}, vượt ngưỡng khuyến nghị. " - + "Vui lòng thông gió khu vực. Nhấn 1 để xác nhận.", + + "Vui lòng thông gió khu vực.", "Warning. Gas concentration at {{areaName}} is {{value}} {{unit}}, " - + "above recommended level. Please ventilate. Press 1 to acknowledge.", + + "above recommended level. Please ventilate.", "警告。{{areaName}}のガス濃度は{{value}}{{unit}}で、推奨レベルを超えています。" - + "換気してください。確認するには1を押してください。" + + "換気してください。" ); seedVoice(templateRepo, versionRepo, AlertEventType.EIF_ANOMALY_POWER, "Thông báo từ hệ thống. Mức tiêu thụ điện bất thường tại {{areaName}}. " - + "Vui lòng kiểm tra thiết bị đang sử dụng. Nhấn 1 để xác nhận.", + + "Vui lòng kiểm tra thiết bị đang sử dụng.", "System notification. Abnormal power consumption at {{areaName}}. " - + "Please review running appliances. Press 1 to acknowledge.", + + "Please review running appliances.", "システム通知。{{areaName}}で異常な電力消費を検出しました。" - + "使用中の機器を確認してください。確認するには1を押してください。" + + "使用中の機器を確認してください。" ); seedVoice(templateRepo, versionRepo, AlertEventType.EIF_ANOMALY_WATER, "Thông báo từ hệ thống. Mức dùng nước bất thường tại {{areaName}}. " - + "Vui lòng kiểm tra đường ống. Nhấn 1 để xác nhận.", + + "Vui lòng kiểm tra đường ống.", "System notification. Abnormal water usage at {{areaName}}. " - + "Please check plumbing. Press 1 to acknowledge.", + + "Please check plumbing.", "システム通知。{{areaName}}で異常な水の使用を検出しました。" - + "配管を確認してください。確認するには1を押してください。" + + "配管を確認してください。" ); seedVoice(templateRepo, versionRepo, AlertEventType.UTILITY_ELECTRICITY_WARNING, "Cảnh báo. Nhà {{houseName}} đã dùng {{usagePercent}} phần trăm hạn mức điện tháng {{month}}, " - + "tương đương {{currentUsage}} trên {{monthlyLimit}} {{unit}}. Vui lòng kiểm tra. Nhấn 1 để xác nhận.", + + "tương đương {{currentUsage}} trên {{monthlyLimit}} {{unit}}. Vui lòng kiểm tra.", "Warning. {{houseName}} has used {{usagePercent}} percent of the electricity limit for {{month}}, " - + "{{currentUsage}} of {{monthlyLimit}} {{unit}}. Please review. Press 1 to acknowledge.", + + "{{currentUsage}} of {{monthlyLimit}} {{unit}}. Please review.", "警告。{{houseName}}の{{month}}電力使用量は上限の{{usagePercent}}パーセント、" - + "{{currentUsage}}/{{monthlyLimit}}{{unit}}です。確認するには1を押してください。" + + "{{currentUsage}}/{{monthlyLimit}}{{unit}}です。" ); seedVoice(templateRepo, versionRepo, AlertEventType.UTILITY_WATER_WARNING, "Cảnh báo. Nhà {{houseName}} đã dùng {{usagePercent}} phần trăm hạn mức nước tháng {{month}}, " - + "tương đương {{currentUsage}} trên {{monthlyLimit}} {{unit}}. Vui lòng kiểm tra. Nhấn 1 để xác nhận.", + + "tương đương {{currentUsage}} trên {{monthlyLimit}} {{unit}}. Vui lòng kiểm tra.", "Warning. {{houseName}} has used {{usagePercent}} percent of the water limit for {{month}}, " - + "{{currentUsage}} of {{monthlyLimit}} {{unit}}. Please review. Press 1 to acknowledge.", + + "{{currentUsage}} of {{monthlyLimit}} {{unit}}. Please review.", "警告。{{houseName}}の{{month}}水道使用量は上限の{{usagePercent}}パーセント、" - + "{{currentUsage}}/{{monthlyLimit}}{{unit}}です。確認するには1を押してください。" + + "{{currentUsage}}/{{monthlyLimit}}{{unit}}です。" ); seedVoice(templateRepo, versionRepo, AlertEventType.UTILITY_ELECTRICITY_CRITICAL, "Cảnh báo khẩn cấp. Nhà {{houseName}} đã vượt hạn mức điện tháng {{month}}, " + "{{currentUsage}} trên {{monthlyLimit}} {{unit}}, đạt {{usagePercent}} phần trăm. " - + "Vui lòng xử lý ngay. Nhấn 1 để xác nhận, nhấn 2 để chuyển cho chủ nhà.", + + "Vui lòng xử lý ngay. Nhấn 2 để liên hệ quản lý.", "Critical alert. {{houseName}} has exceeded the electricity limit for {{month}}, " + "{{currentUsage}} of {{monthlyLimit}} {{unit}}, reaching {{usagePercent}} percent. " - + "Please act now. Press 1 to acknowledge, press 2 to escalate.", + + "Please act now. Press 2 to contact your manager.", "緊急警報。{{houseName}}の{{month}}電力使用量が上限を超えました。" + "{{currentUsage}}/{{monthlyLimit}}{{unit}}、{{usagePercent}}パーセントです。" - + "確認するには1を、転送するには2を押してください。" + + "管理者に連絡するには2を押してください。" ); seedVoice(templateRepo, versionRepo, AlertEventType.UTILITY_WATER_CRITICAL, "Cảnh báo khẩn cấp. Nhà {{houseName}} đã vượt hạn mức nước tháng {{month}}, " + "{{currentUsage}} trên {{monthlyLimit}} {{unit}}, đạt {{usagePercent}} phần trăm. " - + "Vui lòng xử lý ngay. Nhấn 1 để xác nhận, nhấn 2 để chuyển cho chủ nhà.", + + "Vui lòng xử lý ngay. Nhấn 2 để liên hệ quản lý.", "Critical alert. {{houseName}} has exceeded the water limit for {{month}}, " + "{{currentUsage}} of {{monthlyLimit}} {{unit}}, reaching {{usagePercent}} percent. " - + "Please act now. Press 1 to acknowledge, press 2 to escalate.", + + "Please act now. Press 2 to contact your manager.", "緊急警報。{{houseName}}の{{month}}水道使用量が上限を超えました。" + "{{currentUsage}}/{{monthlyLimit}}{{unit}}、{{usagePercent}}パーセントです。" - + "確認するには1を、転送するには2を押してください。" + + "管理者に連絡するには2を押してください。" ); seedSms(templateRepo, versionRepo, AlertEventType.GAS_CRITICAL, - "[ISUMS] KHAN CAP: Gas vuot nguong nguy hiem tai {{areaName}} ({{value}} {{unit}}). Hay kiem tra ngay.", + "[ISUMS] KHẨN CẤP: Gas vượt ngưỡng nguy hiểm tại {{areaName}} ({{value}} {{unit}}). Hãy kiểm tra ngay.", "[ISUMS] EMERGENCY: Gas at {{areaName}} above critical ({{value}} {{unit}}). Check immediately.", "[ISUMS] 緊急: {{areaName}}のガス濃度が危険({{value}}{{unit}})。至急確認を。" ); seedSms(templateRepo, versionRepo, AlertEventType.FIRE_CRITICAL, - "[ISUMS] KHAN CAP: Nhiet do cao tai {{areaName}} ({{value}}C). Nghi co chay.", + "[ISUMS] KHẨN CẤP: Nhiệt độ cao tại {{areaName}} ({{value}}°C). Nghi có cháy.", "[ISUMS] EMERGENCY: High temperature at {{areaName}} ({{value}}C). Possible fire.", "[ISUMS] 緊急: {{areaName}}高温({{value}}C)。火災の可能性。" ); seedSms(templateRepo, versionRepo, AlertEventType.POWER_LOST, - "[ISUMS] Mat dien tai {{areaName}}.", + "[ISUMS] Mất điện tại {{areaName}}.", "[ISUMS] Power lost at {{areaName}}.", "[ISUMS] {{areaName}}で停電。" ); seedSms(templateRepo, versionRepo, AlertEventType.WATER_LEAK_SUSPECTED, - "[ISUMS] Nghi ro ri nuoc tai {{areaName}} ({{value}} {{unit}}). Kiem tra giup.", + "[ISUMS] Nghi rò rỉ nước tại {{areaName}} ({{value}} {{unit}}). Hãy kiểm tra giúp.", "[ISUMS] Suspected water leak at {{areaName}} ({{value}} {{unit}}). Please check.", "[ISUMS] {{areaName}}水漏れ疑い({{value}}{{unit}})。ご確認を。" ); seedSms(templateRepo, versionRepo, AlertEventType.GAS_WARNING, - "[ISUMS] Canh bao: Gas tai {{areaName}} dat {{value}} {{unit}}. Hay thong gio va kiem tra.", + "[ISUMS] Cảnh báo: Gas tại {{areaName}} đạt {{value}} {{unit}}. Hãy thông gió và kiểm tra.", "[ISUMS] Warning: Gas at {{areaName}} reached {{value}} {{unit}}. Ventilate and check.", "[ISUMS] 警告: {{areaName}}のガス濃度{{value}}{{unit}}。換気して確認してください。" ); seedSms(templateRepo, versionRepo, AlertEventType.EIF_ANOMALY_POWER, - "[ISUMS] Canh bao: Dien nang tieu thu bat thuong tai {{areaName}}. Hay kiem tra thiet bi.", + "[ISUMS] Cảnh báo: Điện năng tiêu thụ bất thường tại {{areaName}}. Hãy kiểm tra thiết bị.", "[ISUMS] Warning: Abnormal power usage at {{areaName}}. Please check appliances.", "[ISUMS] 警告: {{areaName}}で異常な電力使用。機器を確認してください。" ); seedSms(templateRepo, versionRepo, AlertEventType.EIF_ANOMALY_WATER, - "[ISUMS] Canh bao: Nuoc tieu thu bat thuong tai {{areaName}}. Hay kiem tra duong ong.", + "[ISUMS] Cảnh báo: Nước tiêu thụ bất thường tại {{areaName}}. Hãy kiểm tra đường ống.", "[ISUMS] Warning: Abnormal water usage at {{areaName}}. Please check plumbing.", "[ISUMS] 警告: {{areaName}}で異常な水使用。配管を確認してください。" ); seedSms(templateRepo, versionRepo, AlertEventType.UTILITY_ELECTRICITY_WARNING, - "[ISUMS] Canh bao dien: {{houseName}} da dung {{usagePercent}}% han muc thang {{month}} ({{currentUsage}}/{{monthlyLimit}} {{unit}}).", + "[ISUMS] Cảnh báo điện: {{houseName}} đã dùng {{usagePercent}}% hạn mức tháng {{month}} ({{currentUsage}}/{{monthlyLimit}} {{unit}}).", "[ISUMS] Electricity warning: {{houseName}} used {{usagePercent}}% of {{month}} limit ({{currentUsage}}/{{monthlyLimit}} {{unit}}).", "[ISUMS] 電力警告: {{houseName}}は{{month}}上限の{{usagePercent}}%を使用({{currentUsage}}/{{monthlyLimit}}{{unit}})。" ); seedSms(templateRepo, versionRepo, AlertEventType.UTILITY_WATER_WARNING, - "[ISUMS] Canh bao nuoc: {{houseName}} da dung {{usagePercent}}% han muc thang {{month}} ({{currentUsage}}/{{monthlyLimit}} {{unit}}).", + "[ISUMS] Cảnh báo nước: {{houseName}} đã dùng {{usagePercent}}% hạn mức tháng {{month}} ({{currentUsage}}/{{monthlyLimit}} {{unit}}).", "[ISUMS] Water warning: {{houseName}} used {{usagePercent}}% of {{month}} limit ({{currentUsage}}/{{monthlyLimit}} {{unit}}).", "[ISUMS] 水道警告: {{houseName}}は{{month}}上限の{{usagePercent}}%を使用({{currentUsage}}/{{monthlyLimit}}{{unit}})。" ); seedSms(templateRepo, versionRepo, AlertEventType.UTILITY_ELECTRICITY_CRITICAL, - "[ISUMS] KHAN CAP dien: {{houseName}} vuot han muc thang {{month}} ({{usagePercent}}%, {{currentUsage}}/{{monthlyLimit}} {{unit}}).", + "[ISUMS] KHẨN CẤP điện: {{houseName}} vượt hạn mức tháng {{month}} ({{usagePercent}}%, {{currentUsage}}/{{monthlyLimit}} {{unit}}).", "[ISUMS] CRITICAL electricity: {{houseName}} exceeded {{month}} limit ({{usagePercent}}%, {{currentUsage}}/{{monthlyLimit}} {{unit}}).", "[ISUMS] 緊急 電力: {{houseName}}は{{month}}上限超過({{usagePercent}}%, {{currentUsage}}/{{monthlyLimit}}{{unit}})。" ); seedSms(templateRepo, versionRepo, AlertEventType.UTILITY_WATER_CRITICAL, - "[ISUMS] KHAN CAP nuoc: {{houseName}} vuot han muc thang {{month}} ({{usagePercent}}%, {{currentUsage}}/{{monthlyLimit}} {{unit}}).", + "[ISUMS] KHẨN CẤP nước: {{houseName}} vượt hạn mức tháng {{month}} ({{usagePercent}}%, {{currentUsage}}/{{monthlyLimit}} {{unit}}).", "[ISUMS] CRITICAL water: {{houseName}} exceeded {{month}} limit ({{usagePercent}}%, {{currentUsage}}/{{monthlyLimit}} {{unit}}).", "[ISUMS] 緊急 水道: {{houseName}}は{{month}}上限超過({{usagePercent}}%, {{currentUsage}}/{{monthlyLimit}}{{unit}})。" ); @@ -289,7 +286,20 @@ private void upsertIfAbsent(ChannelTemplateRepository templateRepo, Optional existing = versionRepo .findFirstByTemplate_TemplateKeyAndTemplate_ChannelAndLocaleAndStatusOrderByVersionDesc( templateKey, channel, locale, TemplateStatus.ACTIVE); - if (existing.isPresent()) return; + + if (existing.isPresent()) { + ChannelTemplateVersion v = existing.get(); + boolean bodyChanged = !equalsSafe(v.getBody(), body); + boolean titleChanged = !equalsSafe(v.getTitle(), title); + if (!bodyChanged && !titleChanged) return; + v.setBody(body); + v.setTitle(title); + v.setAllowedVars(ALLOWED_VARS); + v.setUpdatedBy(ACTOR); + versionRepo.save(v); + log.info("[VoiceAlertSeed] updated {} channel={} locale={}", templateKey, channel, locale); + return; + } ChannelTemplateVersion v1 = ChannelTemplateVersion.builder() .template(tpl) @@ -305,4 +315,8 @@ private void upsertIfAbsent(ChannelTemplateRepository templateRepo, versionRepo.save(v1); log.info("[VoiceAlertSeed] seeded {} channel={} locale={}", templateKey, channel, locale); } + + private static boolean equalsSafe(String a, String b) { + return a == null ? b == null : a.equals(b); + } } diff --git a/src/main/java/com/isums/notificationservice/services/AwsPinpointVoiceClient.java b/src/main/java/com/isums/notificationservice/services/AwsPinpointVoiceClient.java new file mode 100644 index 0000000..e224550 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/services/AwsPinpointVoiceClient.java @@ -0,0 +1,98 @@ +package com.isums.notificationservice.services; + +import com.isums.notificationservice.domains.dtos.SpeedSmsVoiceRequest; +import com.isums.notificationservice.domains.dtos.SpeedSmsVoiceResponse; +import com.isums.notificationservice.infrastructures.abstracts.VoiceProvider; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.pinpointsmsvoicev2.PinpointSmsVoiceV2Client; +import software.amazon.awssdk.services.pinpointsmsvoicev2.model.SendVoiceMessageRequest; +import software.amazon.awssdk.services.pinpointsmsvoicev2.model.SendVoiceMessageResponse; +import software.amazon.awssdk.services.pinpointsmsvoicev2.model.VoiceMessageBodyTextType; + +import java.util.UUID; + +@Service +@Slf4j +public class AwsPinpointVoiceClient implements VoiceProvider { + + private final PinpointSmsVoiceV2Client client; + + @Value("${app.notification.aws.voice.origination-identity:}") + private String originationIdentity; + + @Value("${app.notification.aws.voice.voice-id:Lan}") + private String voiceId; + + @Value("${app.notification.aws.voice.body-type:TEXT}") + private String bodyType; + + @Value("${app.notification.aws.voice.configuration-set:}") + private String configurationSetName; + + @Value("${app.notification.aws.voice.max-price-per-minute:0.50}") + private String maxPricePerMinute; + + @Value("${app.notification.voice.dry-run:false}") + private boolean dryRun; + + public AwsPinpointVoiceClient(PinpointSmsVoiceV2Client client) { + this.client = client; + } + + @jakarta.annotation.PostConstruct + void logConfig() { + log.info("[AwsPinpointVoice init] origination={} voiceId={} bodyType={} dryRun={}", + originationIdentity, voiceId, bodyType, dryRun); + } + + @Override + public String providerId() { return "AWS_PINPOINT"; } + + @Override + public SpeedSmsVoiceResponse sendVoiceCall(SpeedSmsVoiceRequest request) { + if (dryRun) { + String fakeId = "dry-aws-" + UUID.randomUUID(); + log.info("[AwsPinpointVoice DRY_RUN] callout to={} text=\n{}", request.phone(), request.tts()); + return new SpeedSmsVoiceResponse(true, fakeId, "DIALING", null); + } + + if (originationIdentity == null || originationIdentity.isBlank()) { + return new SpeedSmsVoiceResponse(false, null, "FAILED", + "AWS Pinpoint origination-identity not configured"); + } + + try { + VoiceMessageBodyTextType bodyTypeEnum = "SSML".equalsIgnoreCase(bodyType) + ? VoiceMessageBodyTextType.SSML + : VoiceMessageBodyTextType.TEXT; + + SendVoiceMessageRequest.Builder reqBuilder = SendVoiceMessageRequest.builder() + .destinationPhoneNumber(request.phone()) + .originationIdentity(originationIdentity) + .messageBody(request.tts()) + .messageBodyTextType(bodyTypeEnum) + .voiceId(voiceId) + .maxPricePerMinute(maxPricePerMinute); + + if (configurationSetName != null && !configurationSetName.isBlank()) { + reqBuilder.configurationSetName(configurationSetName); + } + + SendVoiceMessageResponse resp = client.sendVoiceMessage(reqBuilder.build()); + String messageId = resp.messageId(); + log.info("[AwsPinpointVoice] sent phone={} messageId={}", request.phone(), messageId); + return new SpeedSmsVoiceResponse(true, messageId, "DIALING", null); + } catch (Exception e) { + log.error("[AwsPinpointVoice] failed phone={}: {}", request.phone(), e.getMessage(), e); + return new SpeedSmsVoiceResponse(false, null, "FAILED", e.getMessage()); + } + } + + @Override + public boolean verifyWebhookSignature(String rawBody, String signature) { + if (dryRun) return true; + return true; + } +} diff --git a/src/main/java/com/isums/notificationservice/services/ChannelPolicy.java b/src/main/java/com/isums/notificationservice/services/ChannelPolicy.java index 270df61..0baa18f 100644 --- a/src/main/java/com/isums/notificationservice/services/ChannelPolicy.java +++ b/src/main/java/com/isums/notificationservice/services/ChannelPolicy.java @@ -40,12 +40,12 @@ public static ChannelPolicy forSeverityRole(AlertSeverity severity, RecipientRol case MANAGER -> new ChannelPolicy(true, true, true, true); }; case WARNING -> switch (role) { - // Tenant: voice + SMS for actionable warnings when the user opted in. - case TENANT -> new ChannelPolicy(true, true, true, true); + // Tenant: in-app push + email only — no SMS / voice cost on warnings + case TENANT -> new ChannelPolicy(true, true, false, false); // Landlord: email only (no SMS noise) case LANDLORD -> new ChannelPolicy(false, true, false, false); - // Manager: SMS + push + email; voice reserved for CRITICAL - case MANAGER -> new ChannelPolicy(true, true, true, false); + // Manager: in-app push + email only — SMS/voice reserved for CRITICAL + case MANAGER -> new ChannelPolicy(true, true, false, false); }; case INFO -> switch (role) { // Tenant: lightweight push + email diff --git a/src/main/java/com/isums/notificationservice/services/ManagerNotificationServiceImpl.java b/src/main/java/com/isums/notificationservice/services/ManagerNotificationServiceImpl.java index d6ba639..1370c7d 100644 --- a/src/main/java/com/isums/notificationservice/services/ManagerNotificationServiceImpl.java +++ b/src/main/java/com/isums/notificationservice/services/ManagerNotificationServiceImpl.java @@ -35,18 +35,31 @@ public class ManagerNotificationServiceImpl implements ManagerNotificationServic private String sourceLanguage = "en"; @Override - @Transactional public void send(UUID recipientId, NotificationCategory category, String title, String body, String actionUrl, Map metadata) { + send(recipientId, category, title, body, sourceLanguage, actionUrl, metadata); + } + + @Override + @Transactional + public void send(UUID recipientId, NotificationCategory category, + String title, String body, String sourceLang, + String actionUrl, Map metadata) { + + String resolvedLang = TranslationMap.normalizeLanguage(sourceLang); + if (resolvedLang == null || resolvedLang.isBlank()) { + resolvedLang = TranslationMap.normalizeLanguage(sourceLanguage); + } + if (resolvedLang == null || resolvedLang.isBlank()) resolvedLang = "en"; ManagerNotification n = ManagerNotification.builder() .recipientId(recipientId) .category(category) .title(title) - .titleTranslations(sourceMap(title)) + .titleTranslations(sourceMap(title, resolvedLang)) .body(body) - .bodyTranslations(sourceMap(body)) + .bodyTranslations(sourceMap(body, resolvedLang)) .actionUrl(actionUrl) .metadata(metadata) .isRead(false) @@ -54,18 +67,17 @@ public void send(UUID recipientId, NotificationCategory category, repo.save(n); sseManager.push(recipientId, n); - translationRequester.requestMissing(n, sourceLanguage); + translationRequester.requestMissing(n, resolvedLang); - log.info("[Notification] Sent recipientId={} category={}", recipientId, category); + log.info("[Notification] Sent recipientId={} category={} sourceLang={}", + recipientId, category, resolvedLang); } - private TranslationMap sourceMap(String text) { - String code = TranslationMap.normalizeLanguage(sourceLanguage); - if (code == null || code.isBlank()) code = "en"; + private TranslationMap sourceMap(String text, String lang) { if (text == null || text.isBlank()) return TranslationMap.empty(); Map source = new LinkedHashMap<>(); - source.put(code, text); - source.put("_source", code); + source.put(lang, text); + source.put("_source", lang); return new TranslationMap(source); } diff --git a/src/main/java/com/isums/notificationservice/services/NotificationDispatchService.java b/src/main/java/com/isums/notificationservice/services/NotificationDispatchService.java index d8c9f09..d598771 100644 --- a/src/main/java/com/isums/notificationservice/services/NotificationDispatchService.java +++ b/src/main/java/com/isums/notificationservice/services/NotificationDispatchService.java @@ -210,7 +210,28 @@ private void dispatchToRecipient(AlertDispatchRequest req, } UserNotificationPreferences prefs = preferenceService.getOrCreate(keycloakUuid); - NotificationSubscription sub = preferenceService.getSubscriptionOrCreate(keycloakUuid); + NotificationSubscription sub; + if (nonBlank(req.houseId())) { + try { + UUID houseUuid = UUID.fromString(req.houseId()); + sub = preferenceService.getSubscriptionOrCreate(keycloakUuid, houseUuid); + } catch (IllegalArgumentException e) { + log.warn("[Dispatch] invalid houseId={} for subscription lookup, using FREE tier", req.houseId()); + sub = NotificationSubscription.builder() + .userId(keycloakUuid) + .tier(com.isums.notificationservice.domains.enums.SubscriptionTier.FREE) + .voiceQuotaMonthly(TierQuotaPolicy.voiceQuotaFor(com.isums.notificationservice.domains.enums.SubscriptionTier.FREE)) + .smsQuotaMonthly(TierQuotaPolicy.smsQuotaFor(com.isums.notificationservice.domains.enums.SubscriptionTier.FREE)) + .build(); + } + } else { + sub = NotificationSubscription.builder() + .userId(keycloakUuid) + .tier(com.isums.notificationservice.domains.enums.SubscriptionTier.FREE) + .voiceQuotaMonthly(TierQuotaPolicy.voiceQuotaFor(com.isums.notificationservice.domains.enums.SubscriptionTier.FREE)) + .smsQuotaMonthly(TierQuotaPolicy.smsQuotaFor(com.isums.notificationservice.domains.enums.SubscriptionTier.FREE)) + .build(); + } // Locale resolution: User-Service profile language is the system-wide // source of truth. Notification prefs used to carry a separate language, @@ -379,8 +400,16 @@ private ChannelDispatchResult deliverVoice(String prefix, // manager subscription row (tier=FREE, quota=0) silently swallows // every escalation call → user complaint "manager's phone never // rings". Tenant path still gates on quota above the tier check. + UUID dispatchHouseUuid = null; + if (nonBlank(req.houseId())) { + try { + dispatchHouseUuid = UUID.fromString(req.houseId()); + } catch (IllegalArgumentException ignored) { + dispatchHouseUuid = null; + } + } if (role == RecipientRole.TENANT - && !quotaService.tryConsumeVoiceQuota(keycloakUuid)) { + && !quotaService.tryConsumeVoiceQuota(keycloakUuid, dispatchHouseUuid)) { return new ChannelDispatchResult(prefix + "VOICE", "SKIPPED", "monthly_quota_exceeded", null); } @@ -394,10 +423,8 @@ private ChannelDispatchResult deliverVoice(String prefix, keycloakUuid, user.getPhoneNumber(), effectivePrefs, req, vars, role, reason); return new ChannelDispatchResult(prefix + "VOICE", "SENT", null, job.getId()); } catch (Exception e) { - // Only refund if we actually consumed (TENANT path). Non-TENANT - // bypassed the quota debit so refund would underflow the row. if (role == RecipientRole.TENANT) { - quotaService.refundVoiceQuota(keycloakUuid); + quotaService.refundVoiceQuota(keycloakUuid, dispatchHouseUuid); } log.error("[Dispatch] {}voice dispatch failed userId={}: {}", prefix, keycloakUuid, e.getMessage(), e); diff --git a/src/main/java/com/isums/notificationservice/services/NotificationPreferenceService.java b/src/main/java/com/isums/notificationservice/services/NotificationPreferenceService.java index d0e8c48..f4003ea 100644 --- a/src/main/java/com/isums/notificationservice/services/NotificationPreferenceService.java +++ b/src/main/java/com/isums/notificationservice/services/NotificationPreferenceService.java @@ -57,24 +57,36 @@ public UserNotificationPreferences getOrCreate(UUID userId) { }); } + public SubscriptionTier resolveEffectiveTier(UUID userId) { + Instant now = Instant.now(); + return subsRepo.findAllByUserId(userId).stream() + .anyMatch(s -> s.getTier() == SubscriptionTier.PREMIUM + && s.getPremiumUntil() != null + && s.getPremiumUntil().isAfter(now)) + ? SubscriptionTier.PREMIUM + : SubscriptionTier.FREE; + } + @Transactional - public NotificationSubscription getSubscriptionOrCreate(UUID userId) { - return subsRepo.findById(userId) + public NotificationSubscription getSubscriptionOrCreate(UUID userId, UUID houseId) { + if (houseId == null) { + throw new IllegalArgumentException("houseId is required"); + } + return subsRepo.findByUserIdAndHouseId(userId, houseId) .orElseGet(() -> { try { return subsRepo.saveAndFlush( NotificationSubscription.builder() .userId(userId) + .houseId(houseId) .tier(SubscriptionTier.FREE) .voiceQuotaMonthly(TierQuotaPolicy.voiceQuotaFor(SubscriptionTier.FREE)) .smsQuotaMonthly(TierQuotaPolicy.smsQuotaFor(SubscriptionTier.FREE)) .build()); } catch (DataIntegrityViolationException race) { - // Same race-condition guard as getOrCreate above — - // duplicate-pkey on user_id means another tx beat us - // to it; just read the row that's now there. - log.debug("[Sub] race on getSubscriptionOrCreate userId={} — re-reading", userId); - return subsRepo.findById(userId) + log.debug("[Sub] race on getSubscriptionOrCreate userId={} houseId={} — re-reading", + userId, houseId); + return subsRepo.findByUserIdAndHouseId(userId, houseId) .orElseThrow(() -> race); } }); @@ -103,7 +115,13 @@ public UserNotificationPreferences update(UUID userId, UpdatePreferencesRequest String clientIp, String userAgent) { UserNotificationPreferences p = getOrCreate(userId); - NotificationSubscription sub = getSubscriptionOrCreate(userId); + SubscriptionTier effectiveTier = resolveEffectiveTier(userId); + NotificationSubscription sub = NotificationSubscription.builder() + .userId(userId) + .tier(effectiveTier) + .voiceQuotaMonthly(TierQuotaPolicy.voiceQuotaFor(effectiveTier)) + .smsQuotaMonthly(TierQuotaPolicy.smsQuotaFor(effectiveTier)) + .build(); if (req.language() != null) p.setLanguage(req.language()); if (req.emailEnabled() != null) p.setEmailEnabled(req.emailEnabled()); diff --git a/src/main/java/com/isums/notificationservice/services/NotificationQuotaService.java b/src/main/java/com/isums/notificationservice/services/NotificationQuotaService.java index f779745..c9712a2 100644 --- a/src/main/java/com/isums/notificationservice/services/NotificationQuotaService.java +++ b/src/main/java/com/isums/notificationservice/services/NotificationQuotaService.java @@ -44,46 +44,43 @@ public long remainingRateLimitSec(UUID userId) { return ttl == null || ttl < 0 ? 0 : ttl; } - /** Returns true + increments; false if the monthly quota is already used up. */ @Transactional - public boolean tryConsumeVoiceQuota(UUID userId) { - NotificationSubscription sub = subsRepo.findById(userId).orElse(null); + public boolean tryConsumeVoiceQuota(UUID userId, UUID houseId) { + if (houseId == null) return false; + NotificationSubscription sub = subsRepo.findByUserIdAndHouseId(userId, houseId).orElse(null); if (sub == null) return false; int quota = sub.getVoiceQuotaMonthly(); if (quota <= 0) return false; String month = MONTH_KEY.format(Instant.now()); - String key = "notif:voice:quota:" + userId + ":" + month; + String key = "notif:voice:quota:" + userId + ":" + houseId + ":" + month; Long used = redis.opsForValue().increment(key); if (used == null) return false; - // First time this month — set a 40-day expiry so it auto-clears. if (used == 1L) { redis.expire(key, Duration.ofDays(40)); } if (used > quota) { - // Over cap — decrement to avoid drift then reject. redis.opsForValue().decrement(key); - log.info("[Quota] voice quota exceeded userId={} used={}/{}", userId, used - 1, quota); + log.info("[Quota] voice quota exceeded userId={} houseId={} used={}/{}", + userId, houseId, used - 1, quota); return false; } - // Mirror into Postgres best-effort. A crash before commit means the - // DB counter lags Redis by one — acceptable; nightly reconciler - // can recover if needed. sub.setVoiceUsedThisMonth(used.intValue()); subsRepo.save(sub); return true; } @Transactional - public void refundVoiceQuota(UUID userId) { + public void refundVoiceQuota(UUID userId, UUID houseId) { + if (houseId == null) return; String month = MONTH_KEY.format(Instant.now()); - redis.opsForValue().decrement("notif:voice:quota:" + userId + ":" + month); - subsRepo.findById(userId).ifPresent(sub -> { + redis.opsForValue().decrement("notif:voice:quota:" + userId + ":" + houseId + ":" + month); + subsRepo.findByUserIdAndHouseId(userId, houseId).ifPresent(sub -> { if (sub.getVoiceUsedThisMonth() > 0) { sub.setVoiceUsedThisMonth(sub.getVoiceUsedThisMonth() - 1); subsRepo.save(sub); @@ -105,9 +102,10 @@ public void resetAllUsageCounters() { log.info("[Quota] monthly reset complete, rows={}", updated); } - public int readVoiceUsedThisMonth(UUID userId) { + public int readVoiceUsedThisMonth(UUID userId, UUID houseId) { + if (houseId == null) return 0; String month = MONTH_KEY.format(Instant.now()); - String v = redis.opsForValue().get("notif:voice:quota:" + userId + ":" + month); + String v = redis.opsForValue().get("notif:voice:quota:" + userId + ":" + houseId + ":" + month); try { return v == null ? 0 : Integer.parseInt(v); } catch (NumberFormatException e) { diff --git a/src/main/java/com/isums/notificationservice/services/NotificationRuntimeSettingService.java b/src/main/java/com/isums/notificationservice/services/NotificationRuntimeSettingService.java new file mode 100644 index 0000000..42f7443 --- /dev/null +++ b/src/main/java/com/isums/notificationservice/services/NotificationRuntimeSettingService.java @@ -0,0 +1,59 @@ +package com.isums.notificationservice.services; + +import com.isums.notificationservice.domains.entities.NotificationRuntimeSetting; +import com.isums.notificationservice.infrastructures.repositories.NotificationRuntimeSettingRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class NotificationRuntimeSettingService { + + public static final String KEY_VOICE_PROVIDER = "voice.provider"; + + private static final String CACHE_PREFIX = "notif:setting:"; + private static final Duration CACHE_TTL = Duration.ofMinutes(5); + + private final NotificationRuntimeSettingRepository repo; + private final StringRedisTemplate redis; + + public Optional get(String key) { + String cacheKey = CACHE_PREFIX + key; + String cached = redis.opsForValue().get(cacheKey); + if (cached != null) { + return Optional.of(cached); + } + return repo.findById(key).map(s -> { + redis.opsForValue().set(cacheKey, s.getSettingValue(), CACHE_TTL); + return s.getSettingValue(); + }); + } + + public String getOrDefault(String key, String defaultValue) { + return get(key).orElse(defaultValue); + } + + @Transactional + public NotificationRuntimeSetting set(String key, String value, UUID updatedBy) { + NotificationRuntimeSetting entity = repo.findById(key) + .orElseGet(() -> NotificationRuntimeSetting.builder().settingKey(key).build()); + entity.setSettingValue(value); + entity.setUpdatedBy(updatedBy); + NotificationRuntimeSetting saved = repo.save(entity); + redis.delete(CACHE_PREFIX + key); + log.info("[RuntimeSetting] {} = {} by={}", key, value, updatedBy); + return saved; + } + + public void invalidate(String key) { + redis.delete(CACHE_PREFIX + key); + } +} diff --git a/src/main/java/com/isums/notificationservice/services/NotificationSubscriptionService.java b/src/main/java/com/isums/notificationservice/services/NotificationSubscriptionService.java index 7eb0305..a160463 100644 --- a/src/main/java/com/isums/notificationservice/services/NotificationSubscriptionService.java +++ b/src/main/java/com/isums/notificationservice/services/NotificationSubscriptionService.java @@ -20,67 +20,42 @@ public class NotificationSubscriptionService { private final NotificationSubscriptionRepository subsRepo; - /** - * Month-based PREMIUM grant — kept for the admin "grant-premium" demo - * endpoint where months are the natural unit. Production payment flow - * goes through {@link #activatePremiumByDays} so a 7-day trial buys - * 7 days, not a rounded-up month. - */ @Transactional - public NotificationSubscription activatePremium(UUID userId, int months) { - return activatePremiumByDays(userId, Math.max(1, months) * 30); + public NotificationSubscription activatePremium(UUID userId, UUID houseId, int months) { + return activatePremiumByDays(userId, houseId, Math.max(1, months) * 30); } - /** - * Plan-driven PREMIUM grant. {@code durationDays} comes straight from - * the subscription_plans row that was paid for, so a 7-day trial gets - * 7 days and an annual plan gets 365 — no monthly rounding error. - * - *

Quotas default to {@link TierQuotaPolicy} (the legacy 20/30 - * floor) — callers with a curated plan should use the overload that - * takes plan quotas so a "Pro 1M" plan with 100 voice / 200 SMS - * doesn't get downgraded to the legacy ceiling on activation. - */ @Transactional - public NotificationSubscription activatePremiumByDays(UUID userId, int durationDays) { - return activatePremiumByDays(userId, durationDays, + public NotificationSubscription activatePremiumByDays(UUID userId, UUID houseId, int durationDays) { + return activatePremiumByDays(userId, houseId, durationDays, TierQuotaPolicy.voiceQuotaFor(SubscriptionTier.PREMIUM), TierQuotaPolicy.smsQuotaFor(SubscriptionTier.PREMIUM)); } - /** - * Plan-driven PREMIUM grant with explicit quotas. Pass the values from - * {@code subscription_plans.voice_quota_monthly / sms_quota_monthly} - * so the user's monthly cap matches what they paid for. - * - *

Idempotent on top of itself: a Kafka redelivery hitting this with - * the same userId stacks days onto the existing premium_until (the - * "user paid twice, gets twice the time" semantics matches the legacy - * months path). Caller must guard against same-event redelivery via - * {@code IdempotencyService#isDuplicate}. - */ @Transactional - public NotificationSubscription activatePremiumByDays(UUID userId, int durationDays, + public NotificationSubscription activatePremiumByDays(UUID userId, UUID houseId, int durationDays, int voiceQuotaMonthly, int smsQuotaMonthly) { + if (houseId == null) { + throw new IllegalArgumentException("houseId is required for per-house PREMIUM activation"); + } int days = Math.max(1, durationDays); - // Floor at the tier policy minimum so a misconfigured plan can never - // shrink the user below the baseline they expect from PREMIUM — - // upper bound stays at whatever the plan says. int voiceQuota = Math.max(voiceQuotaMonthly, TierQuotaPolicy.voiceQuotaFor(SubscriptionTier.PREMIUM)); int smsQuota = Math.max(smsQuotaMonthly, TierQuotaPolicy.smsQuotaFor(SubscriptionTier.PREMIUM)); - NotificationSubscription sub = subsRepo.findById(userId) - .orElseGet(() -> NotificationSubscription.builder().userId(userId).build()); + NotificationSubscription sub = subsRepo.findByUserIdAndHouseId(userId, houseId) + .orElseGet(() -> NotificationSubscription.builder() + .userId(userId) + .houseId(houseId) + .build()); Instant now = Instant.now(); Instant newUntil; if (sub.getTier() == SubscriptionTier.PREMIUM && sub.getPremiumUntil() != null && sub.getPremiumUntil().isAfter(now)) { - // Extend from existing end-date, not from now — user pays to stack. newUntil = sub.getPremiumUntil().plus(days, ChronoUnit.DAYS); } else { sub.setPremiumStartedAt(now); @@ -89,29 +64,25 @@ public NotificationSubscription activatePremiumByDays(UUID userId, int durationD sub.setTier(SubscriptionTier.PREMIUM); sub.setPremiumUntil(newUntil); - // Plan quotas reset on activation (and on every renewal/extension) - // so a user upgrading from a smaller plan inherits the new quota - // immediately. Used-counters intentionally stay so we don't "free - // refill" by spamming activations within the same month. sub.setVoiceQuotaMonthly(voiceQuota); sub.setSmsQuotaMonthly(smsQuota); NotificationSubscription saved = subsRepo.save(sub); - log.info("[Subscription] PREMIUM activated userId={} days={} until={} voiceQuota={} smsQuota={}", - userId, days, newUntil, saved.getVoiceQuotaMonthly(), saved.getSmsQuotaMonthly()); + log.info("[Subscription] PREMIUM activated userId={} houseId={} days={} until={} voiceQuota={} smsQuota={}", + userId, houseId, days, newUntil, saved.getVoiceQuotaMonthly(), saved.getSmsQuotaMonthly()); return saved; } @Transactional - public NotificationSubscription downgradeToFree(UUID userId) { - NotificationSubscription sub = subsRepo.findById(userId).orElse(null); + public NotificationSubscription downgradeToFree(UUID userId, UUID houseId) { + NotificationSubscription sub = subsRepo.findByUserIdAndHouseId(userId, houseId).orElse(null); if (sub == null) return null; sub.setTier(SubscriptionTier.FREE); sub.setPremiumUntil(null); sub.setVoiceQuotaMonthly(TierQuotaPolicy.voiceQuotaFor(SubscriptionTier.FREE)); sub.setSmsQuotaMonthly(TierQuotaPolicy.smsQuotaFor(SubscriptionTier.FREE)); NotificationSubscription saved = subsRepo.save(sub); - log.info("[Subscription] downgraded userId={}", userId); + log.info("[Subscription] downgraded userId={} houseId={}", userId, houseId); return saved; } @@ -119,7 +90,7 @@ public SubscriptionDto toDto(NotificationSubscription s) { int voiceRemaining = Math.max(0, s.getVoiceQuotaMonthly() - s.getVoiceUsedThisMonth()); int smsRemaining = Math.max(0, s.getSmsQuotaMonthly() - s.getSmsUsedThisMonth()); return new SubscriptionDto( - s.getUserId(), s.getTier(), + s.getUserId(), s.getHouseId(), s.getTier(), s.getPremiumStartedAt(), s.getPremiumUntil(), s.getVoiceQuotaMonthly(), s.getVoiceUsedThisMonth(), voiceRemaining, s.getSmsQuotaMonthly(), s.getSmsUsedThisMonth(), smsRemaining, diff --git a/src/main/java/com/isums/notificationservice/services/PremiumExpirationScheduler.java b/src/main/java/com/isums/notificationservice/services/PremiumExpirationScheduler.java index 60d1f5c..71c58cc 100644 --- a/src/main/java/com/isums/notificationservice/services/PremiumExpirationScheduler.java +++ b/src/main/java/com/isums/notificationservice/services/PremiumExpirationScheduler.java @@ -36,10 +36,10 @@ public void sweepExpired() { for (NotificationSubscription sub : expired) { try { - subscriptionService.downgradeToFree(sub.getUserId()); + subscriptionService.downgradeToFree(sub.getUserId(), sub.getHouseId()); } catch (Exception e) { - log.error("[PremiumExpire] failed userId={}: {}", - sub.getUserId(), e.getMessage(), e); + log.error("[PremiumExpire] failed userId={} houseId={}: {}", + sub.getUserId(), sub.getHouseId(), e.getMessage(), e); } } } diff --git a/src/main/java/com/isums/notificationservice/services/VoiceProviderRouter.java b/src/main/java/com/isums/notificationservice/services/VoiceProviderRouter.java index 49198b6..965576a 100644 --- a/src/main/java/com/isums/notificationservice/services/VoiceProviderRouter.java +++ b/src/main/java/com/isums/notificationservice/services/VoiceProviderRouter.java @@ -7,43 +7,44 @@ import java.util.List; -/** - * Picks a {@link VoiceProvider} based on - * {@code app.notification.voice.provider} (default {@code STRINGEE}). - * Stringee handles BOTH voice and SMS now — provider abstraction is - * kept so swapping vendors later only touches this router. - * - *

Routing is per-request, not per-bean — flip the property without - * a restart and the next dispatch picks up the new provider. - */ @Component @Slf4j public class VoiceProviderRouter { private final List providers; + private final NotificationRuntimeSettingService settingService; @Value("${app.notification.voice.provider:STRINGEE}") - private String defaultProvider; + private String fallbackProvider; - public VoiceProviderRouter(List providers) { + public VoiceProviderRouter(List providers, + NotificationRuntimeSettingService settingService) { this.providers = providers; + this.settingService = settingService; } @jakarta.annotation.PostConstruct void logConfig() { - log.info("[VoiceProviderRouter] default={} available={}", - defaultProvider, + log.info("[VoiceProviderRouter] fallback={} available={}", + fallbackProvider, providers.stream().map(VoiceProvider::providerId).toList()); } - /** The voice provider for outbound TTS calls. */ public VoiceProvider voice() { - return resolve(defaultProvider); + return resolve(activeProviderId()); } - /** The SMS provider — currently the same Stringee bean. */ public VoiceProvider sms() { - return resolve(defaultProvider); + return resolve(activeProviderId()); + } + + public List availableProviderIds() { + return providers.stream().map(VoiceProvider::providerId).toList(); + } + + public String activeProviderId() { + return settingService.getOrDefault( + NotificationRuntimeSettingService.KEY_VOICE_PROVIDER, fallbackProvider); } private VoiceProvider resolve(String id) { diff --git a/src/main/java/com/isums/notificationservice/services/VoiceWebhookHandler.java b/src/main/java/com/isums/notificationservice/services/VoiceWebhookHandler.java index 9dfbcb1..1178937 100644 --- a/src/main/java/com/isums/notificationservice/services/VoiceWebhookHandler.java +++ b/src/main/java/com/isums/notificationservice/services/VoiceWebhookHandler.java @@ -101,12 +101,10 @@ public Optional handle(SpeedSmsWebhookPayload payload) { private void applyDtmf(VoiceCallJob job, String dtmf) { switch (dtmf.trim()) { case "1" -> { - // Explicit acknowledgement — stop retries. job.setAcknowledgedAt(Instant.now()); job.setStatus(VoiceCallStatus.ACKNOWLEDGED); } case "2" -> { - // User explicitly asked to forward to landlord/manager. UserNotificationPreferences prefs = prefsRepo.findById(job.getUserId()).orElse(null); if (prefs == null) { log.warn("[Webhook] escalation requested but no prefs for userId={}", job.getUserId()); @@ -115,7 +113,7 @@ private void applyDtmf(VoiceCallJob job, String dtmf) { UUID target = escalationService.resolveEscalationTarget( job.getUserId(), prefs, job.getHouseId()); if (target == null) { - log.warn("[Webhook] escalation requested but no target for userId={}", job.getUserId()); + log.warn("[Webhook] escalation requested but no manager target for userId={}", job.getUserId()); return; } escalationService.record(job.getId(), null, target, EscalationReason.DTMF_KEY_2); @@ -142,7 +140,15 @@ private void applyDtmf(VoiceCallJob job, String dtmf) { private void scheduleRetryOrEscalate(VoiceCallJob job) { UserNotificationPreferences prefs = prefsRepo.findById(job.getUserId()).orElse(null); - NotificationSubscription sub = subsRepo.findById(job.getUserId()).orElse(null); + NotificationSubscription sub = null; + if (job.getHouseId() != null && !job.getHouseId().isBlank()) { + try { + sub = subsRepo.findByUserIdAndHouseId(job.getUserId(), java.util.UUID.fromString(job.getHouseId())) + .orElse(null); + } catch (IllegalArgumentException ignored) { + sub = null; + } + } // Tier downgrade mid-retry: stop bothering — the user is no longer // paying for voice. Already-used quota is a sunk cost. diff --git a/src/main/resources/db/migration/V20260507_2200__notification_runtime_settings.sql b/src/main/resources/db/migration/V20260507_2200__notification_runtime_settings.sql new file mode 100644 index 0000000..526029a --- /dev/null +++ b/src/main/resources/db/migration/V20260507_2200__notification_runtime_settings.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS notification_runtime_settings ( + setting_key VARCHAR(80) PRIMARY KEY, + setting_value VARCHAR(500) NOT NULL, + updated_by UUID, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO notification_runtime_settings (setting_key, setting_value) +VALUES ('voice.provider', 'STRINGEE') +ON CONFLICT (setting_key) DO NOTHING; diff --git a/src/main/resources/db/migration/V20260508_1100__notification_subscription_per_house.sql b/src/main/resources/db/migration/V20260508_1100__notification_subscription_per_house.sql new file mode 100644 index 0000000..e666786 --- /dev/null +++ b/src/main/resources/db/migration/V20260508_1100__notification_subscription_per_house.sql @@ -0,0 +1,18 @@ +ALTER TABLE notification_subscriptions + ADD COLUMN IF NOT EXISTS house_id uuid; + +DELETE FROM notification_subscriptions WHERE tier = 'FREE'; + +DELETE FROM notification_subscriptions WHERE house_id IS NULL; + +ALTER TABLE notification_subscriptions + ALTER COLUMN house_id SET NOT NULL; + +ALTER TABLE notification_subscriptions + DROP CONSTRAINT IF EXISTS notification_subscriptions_pkey; + +ALTER TABLE notification_subscriptions + ADD PRIMARY KEY (user_id, house_id); + +CREATE INDEX IF NOT EXISTS idx_notification_subscriptions_user_id + ON notification_subscriptions(user_id);