Files
goodgo-platform/docs/explorations/from-desktop/NOTIFICATIONS_EXPLORATION.md
Ho Ngoc Hai 08b96f9c2d docs: consolidate exploration & audit reports under docs/ (TEC-3094)
- Move 8 stray .md (+5 .txt) from ~/Desktop into docs/explorations/from-desktop/
- Reorganize 27 .md/.txt at workspace root:
  - audit reports -> docs/audits/
  - exploration reports -> docs/explorations/
  - design system -> docs/design-system/
- Keep only README/CHANGELOG/CONTRIBUTING/CLAUDE at repo root
- Refresh docs/README.md as canonical index with links to all groups
- Note: pre-existing docs/audits/AUDIT_INDEX.md and AUDIT_SUMMARY.md were
  overwritten by the newer root-level versions during the move

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-21 16:29:24 +07:00

7.9 KiB

Notifications Module - Complete Exploration

📊 Summary

  • Total Files: 67 TypeScript files
  • Architecture: DDD + CQRS
  • Channels: EMAIL, SMS, PUSH (FCM), ZALO_OA
  • Event Listeners: 18 domain event listeners
  • Current SMS Provider: Stringee (fully implemented)

🎯 Main Interface: NotificationChannelPort

Location: domain/ports/notification-channel.port.ts

export interface SendChannelMessageDto {
  recipient: string;              // Email/phone/token
  subject: string;                // For EMAIL
  body: string;                   // HTML content
  templateKey: string;            // e.g., 'user.registered'
  metadata?: Record<string, unknown>;
}

export interface SendChannelMessageResult {
  messageId: string;
}

export interface NotificationChannelPort {
  readonly channel: NotificationChannel;  // 'EMAIL' | 'SMS' | 'PUSH' | 'ZALO_OA'
  readonly isAvailable: boolean;
  send(dto: SendChannelMessageDto): Promise<SendChannelMessageResult>;
}

export const SMS_NOTIFICATION_CHANNEL = Symbol('SMS_NOTIFICATION_CHANNEL');

📡 SMS Rate Limiting

Location: infrastructure/services/sms-rate-limiter.service.ts

  • Technology: Redis + Lua script (atomic, sliding window)
  • Buckets:
    • otp: 5/min, 10/hour (strict)
    • transactional: 20/min, 100/hour (lenient)
  • Graceful degradation: Redis error → allow request

🔧 Stringee SMS Service (Reference Implementation)

Location: infrastructure/services/stringee-sms.service.ts

Constructor Dependencies

  • LoggerService
  • SmsRateLimiterService

Environment Variables

  • STRINGEE_API_KEY (required)
  • STRINGEE_BRANDNAME (optional, default: "GoodGo")

Key Methods

  1. onModuleInit() - Read env, validate, initialize
  2. get isAvailable() - Check if initialized
  3. async send(dto) - NotificationChannelPort implementation
  4. async sendOTP(dto) - Specific OTP handling
  5. async dispatch(dto) - Main workflow
  6. async enforceRateLimit() - Check per-minute AND hourly
  7. async sendWithRetry() - 3 attempts with exponential backoff
  8. normalizePhone() - +84xxx format conversion
  9. stripHtml() - Remove HTML tags

Workflow

  1. Rate limit check (per-minute + hourly)
  2. HTML stripping
  3. Phone normalization (+84, 84, 0 formats)
  4. Retry logic (1s → 2s → 4s)
  5. API call to https://api.stringee.com/v1/sms
  6. Return messageId

🏗️ DI Configuration

Location: notifications.module.ts

@Module({
  providers: [
    // Repositories
    { provide: NOTIFICATION_REPOSITORY, useClass: PrismaNotificationRepository },
    { provide: NOTIFICATION_PREFERENCE_REPOSITORY, useClass: PrismaNotificationPreferenceRepository },

    // Services
    EmailService,
    FcmService,
    SmsRateLimiterService,
    StringeeSmsService,
    { provide: SMS_NOTIFICATION_CHANNEL, useExisting: StringeeSmsService },
    ZaloOaService,
    TemplateService,

    // Others
    NotificationsGateway,
    SendNotificationHandler,
    ...EventListeners,
  ],
  exports: [
    EmailService,
    FcmService,
    SmsRateLimiterService,
    StringeeSmsService,
    SMS_NOTIFICATION_CHANNEL,
    ZaloOaService,
    TemplateService,
    NotificationsGateway,
  ],
})
export class NotificationsModule {}

🔄 Message Flow

Domain Event (e.g., user.registered)
  ↓
@OnEvent('user.registered') Listener
  ↓
CommandBus.execute(SendNotificationCommand)
  ↓
SendNotificationHandler
  ├─ Check user preference (enabled?)
  ├─ Render template
  ├─ Persist notification log
  ├─ Dispatch to channel:
  │  ├─ EMAIL → EmailService.send()
  │  ├─ SMS   → StringeeSmsService.send() [with rate limit + retry]
  │  ├─ PUSH  → FcmService.send()
  │  └─ ZALO  → ZaloOaService.send()
  ├─ Update status (SENT/FAILED)
  └─ Publish NotificationSentEvent

💡 Key Patterns

Pattern 1: OnModuleInit + Lazy Initialization

  • Read env vars in onModuleInit()
  • Set initialized = true only if valid
  • Check isAvailable getter before operations

Pattern 2: DI Token Binding

export const MY_TOKEN = Symbol('MY_TOKEN');

providers: [
  MyService,
  { provide: MY_TOKEN, useExisting: MyService }
]

// Inject
@Inject(MY_TOKEN) myService: SomeInterface

Pattern 3: Rate Limiting

  • Always call rateLimiter.check(phone, bucket) first
  • Support OTP vs transactional buckets
  • Check BOTH per-minute and hourly limits
  • Throw DomainException on violation

Pattern 4: Error Handling

throw new DomainException(
  ErrorCode.TOO_MANY_REQUESTS,
  `SMS limit exceeded. Retry after ${retryAfterSeconds}s.`,
  HttpStatus.TOO_MANY_REQUESTS,
  { bucket, retryAfterSeconds }
);

Pattern 5: Retry Logic

  • MAX_RETRIES = 3
  • Exponential backoff: 1s → 2s → 4s
  • Log each attempt, fail silently on final retry

📁 Directory Structure

notifications/
├── domain/
│   ├── ports/
│   │   └── notification-channel.port.ts    ⭐ Main interface
│   ├── repositories/                        ← DI Tokens
│   ├── value-objects/
│   │   └── notification-channel.vo.ts      ← Channel enum
│   ├── entities/
│   └── events/
│       └── notification-sent.event.ts
├── infrastructure/
│   ├── services/
│   │   ├── stringee-sms.service.ts         ⭐ SMS provider example
│   │   ├── sms-rate-limiter.service.ts    ← Rate limiting
│   │   ├── email.service.ts
│   │   ├── fcm.service.ts
│   │   └── zalo-oa.service.ts
│   └── repositories/
│       ├── prisma-notification.repository.ts
│       └── prisma-notification-preference.repository.ts
├── application/
│   ├── commands/
│   │   └── send-notification/
│   │       ├── send-notification.command.ts
│   │       └── send-notification.handler.ts ⭐ Command dispatcher
│   └── listeners/                           ← 18 event listeners
├── presentation/
│   ├── controllers/
│   └── gateways/
│       └── notifications.gateway.ts         ← WebSocket
├── notifications.module.ts                  ⭐ DI setup
└── index.ts

🛠️ Implementation Checklist (for new SMS provider)

  • Create infrastructure/services/{provider}-sms.service.ts
  • Implement NotificationChannelPort interface
  • Implement OnModuleInit for env var reading
  • Inject SmsRateLimiterService
  • Implement rate limit enforcement
  • Implement phone normalization
  • Strip HTML from body
  • Implement retry logic (3 attempts, exponential backoff)
  • Register in notifications.module.ts
  • Integrate with SendNotificationHandler
  • Write unit tests

📊 SMS Rate Limits

Bucket Per-Minute Per-Hour Use Case
otp 5 10 OTP codes, verification
transactional 20 100 Regular notifications

OTP Templates (use strict limits):

  • user.phone_change_otp
  • auth.login_otp
  • auth.kyc_otp
  • auth.phone_verify_otp

🔗 Quick Reference

File Purpose
domain/ports/notification-channel.port.ts Interface + DI symbols
infrastructure/services/stringee-sms.service.ts SMS provider example
infrastructure/services/sms-rate-limiter.service.ts Rate limiting
application/commands/send-notification/send-notification.handler.ts Command handler
notifications.module.ts DI configuration
application/listeners/user-registered.listener.ts Event listener example

Everything You Need

  • Full directory structure
  • Main interface (NotificationChannelPort)
  • Existing SMS implementation (Stringee - complete reference)
  • Rate limiting system (Redis-based)
  • DI configuration patterns
  • Message flow understanding
  • Handler integration patterns
  • Error handling patterns
  • Testing patterns