- 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>
7.9 KiB
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
LoggerServiceSmsRateLimiterService
Environment Variables
STRINGEE_API_KEY(required)STRINGEE_BRANDNAME(optional, default: "GoodGo")
Key Methods
onModuleInit()- Read env, validate, initializeget isAvailable()- Check if initializedasync send(dto)- NotificationChannelPort implementationasync sendOTP(dto)- Specific OTP handlingasync dispatch(dto)- Main workflowasync enforceRateLimit()- Check per-minute AND hourlyasync sendWithRetry()- 3 attempts with exponential backoffnormalizePhone()- +84xxx format conversionstripHtml()- Remove HTML tags
Workflow
- Rate limit check (per-minute + hourly)
- HTML stripping
- Phone normalization (+84, 84, 0 formats)
- Retry logic (1s → 2s → 4s)
- API call to https://api.stringee.com/v1/sms
- 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 = trueonly if valid - Check
isAvailablegetter 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
DomainExceptionon 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
NotificationChannelPortinterface - Implement
OnModuleInitfor 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