Phase 1 steps 4–6 — cutover PaymentCompletedListener to the async
outbox path behind NOTIFICATIONS_ASYNC_ENABLED, and add the Redis
Streams consumer that picks up notification.requested events.
PaymentCompletedListener changes:
- Injects NotificationsAsyncConfig + NotificationsPublisher
- When asyncEnabled: builds NotificationRequestedPayload and calls
publisher.publishStandalone() instead of commandBus.execute()
- Legacy SendNotificationCommand path retained in else branch
- recipientEmail passed via params so the consumer can resolve it
NotificationsConsumer (new):
- XREADGROUP against `events:notification.requested` stream,
consumer group `notifications-workers`
- Idempotency via Redis SET NX EX 86400 keyed on
envelope.payload.dedupeKey ?? envelope.eventId
- Dispatches to existing SendNotificationHandler per channel via
CommandBus, mapping contract channels (email/sms/fcm/zalo/in_app)
to domain channels (EMAIL/SMS/PUSH/ZALO_OA)
- DLQ: after 3 failed deliveries, XADD to
events:notification.requested:dlq with original envelope + reason
- Consumer group created lazily with MKSTREAM; poll loop gated by
NOTIFICATIONS_ASYNC_ENABLED
- Registered in NotificationsModule providers
Tests (28 specs, all green):
- PaymentCompletedListener: legacy path, async path, skip-when-no-email
(4 specs, updated from 3 to match new 5-arg constructor)
- NotificationsConsumer: process message, dedupe skip, missing envelope
skip, DLQ after max retries, multi-channel dispatch, empty stream
(6 specs)
- NotificationsPublisher: 4 specs (unchanged)
- NotificationsAsyncConfig: 14 specs (unchanged)
Co-Authored-By: Paperclip <noreply@paperclip.ing>