Skip to content
Merged

Dev #21

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
0b64b4e
Implement manager notification system with Kafka integration, SSE str…
hoangtuzami Apr 8, 2026
47e63f8
Add renewal-related event handling, email templates, and Kafka listen…
hoangtuzami Apr 8, 2026
7520416
Merge pull request #17 from Management-System-for-Rental-SEP490/featu…
hoangtuzami Apr 9, 2026
1256bf8
Add Kafka listeners for payment-related events and overdue terminatio…
hoangtuzami Apr 10, 2026
13db660
Comment out the Kafka listener for `payment.power-cut-requested` in `…
hoangtuzami Apr 10, 2026
68c6998
Rename `ContractNotificationConsumer` to `ContractEventListener` for …
hoangtuzami Apr 10, 2026
7255a28
Merge pull request #18 from Management-System-for-Rental-SEP490/featu…
hoangtuzami Apr 10, 2026
f5dad93
Integrate OpenAPI with JWT security and configure Swagger UI.
hoangtuzami Apr 11, 2026
41ff164
Update security configuration to allow public access to Swagger UI an…
hoangtuzami Apr 11, 2026
17e3c9e
Merge pull request #19 from Management-System-for-Rental-SEP490/featu…
hoangtuzami Apr 11, 2026
817e704
Add extensive unit tests for Kafka listeners, email services, and glo…
hoangtuzami Apr 13, 2026
d13c60a
Merge pull request #20 from Management-System-for-Rental-SEP490/featu…
hoangtuzami Apr 13, 2026
2319777
Remove unused "user_activated" (vi_VN) email template from `EmailTemp…
hoangtuzami Apr 14, 2026
021c728
Add notification service domain models, repositories, and core logic …
hoangtuzami May 7, 2026
d88e165
Integrate AWS Pinpoint Voice provider, runtime settings for voice con…
hoangtuzami May 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 {
Expand All @@ -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'
Expand All @@ -48,8 +74,23 @@ 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'
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.
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"
Expand All @@ -63,8 +104,9 @@ 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'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.postgresql:postgresql'
Expand All @@ -89,3 +131,8 @@ tasks.named('bootBuildImage') {
tasks.named('test') {
useJUnitPlatform()
}


tasks.withType(JavaCompile).configureEach {
options.encoding = "UTF-8"
}
196 changes: 196 additions & 0 deletions docs/VOICE_NOTIFICATION_SETUP.md
Original file line number Diff line number Diff line change
@@ -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=<your-api-key>
SPEEDSMS_WEBHOOK_SECRET=<secret-for-HMAC-verify>
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=<long-random-string-same-as-esp32-router>

# 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=<same as BE>`

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 <admin-jwt>' \
-H 'Content-Type: application/json' \
-d '{"userId":"<your-keycloak-uuid>","months":1}'
```
3. Turn voice on + grant consent:
```bash
curl -X PUT \
http://localhost:8085/api/notifications/preferences/me \
-H 'Authorization: Bearer <your-jwt>' \
-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 <your-jwt>'
```
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 <your-jwt>'
```

## 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.
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down

This file was deleted.

This file was deleted.

Loading
Loading